1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
---|---|
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | /// @docImport 'package:flutter/cupertino.dart'; |
6 | /// @docImport 'package:flutter/material.dart'; |
7 | /// @docImport 'package:flutter_test/flutter_test.dart'; |
8 | /// |
9 | /// @docImport 'editable_text.dart'; |
10 | /// @docImport 'list_wheel_scroll_view.dart'; |
11 | /// @docImport 'nested_scroll_view.dart'; |
12 | /// @docImport 'page_view.dart'; |
13 | /// @docImport 'scroll_view.dart'; |
14 | /// @docImport 'widget_state.dart'; |
15 | library; |
16 | |
17 | import 'dart:async'; |
18 | import 'dart:math' as math; |
19 | |
20 | import 'package:flutter/foundation.dart'; |
21 | import 'package:flutter/gestures.dart'; |
22 | import 'package:flutter/rendering.dart'; |
23 | |
24 | import 'basic.dart'; |
25 | import 'binding.dart'; |
26 | import 'framework.dart'; |
27 | import 'gesture_detector.dart'; |
28 | import 'media_query.dart'; |
29 | import 'notification_listener.dart'; |
30 | import 'primary_scroll_controller.dart'; |
31 | import 'scroll_configuration.dart'; |
32 | import 'scroll_controller.dart'; |
33 | import 'scroll_metrics.dart'; |
34 | import 'scroll_notification.dart'; |
35 | import 'scroll_position.dart'; |
36 | import 'scrollable.dart'; |
37 | import 'scrollable_helpers.dart'; |
38 | import 'ticker_provider.dart'; |
39 | |
40 | const double _kMinThumbExtent = 18.0; |
41 | const double _kMinInteractiveSize = 48.0; |
42 | const double _kScrollbarThickness = 6.0; |
43 | const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); |
44 | const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); |
45 | |
46 | /// An orientation along either the horizontal or vertical [Axis]. |
47 | enum ScrollbarOrientation { |
48 | /// Place towards the left of the screen. |
49 | left, |
50 | |
51 | /// Place towards the right of the screen. |
52 | right, |
53 | |
54 | /// Place on top of the screen. |
55 | top, |
56 | |
57 | /// Place on the bottom of the screen. |
58 | bottom, |
59 | } |
60 | |
61 | /// Paints a scrollbar's track and thumb. |
62 | /// |
63 | /// The size of the scrollbar along its scroll direction is typically |
64 | /// proportional to the percentage of content completely visible on screen, |
65 | /// as long as its size isn't less than [minLength] and it isn't overscrolling. |
66 | /// |
67 | /// Unlike [CustomPainter]s that subclasses [CustomPainter] and only repaint |
68 | /// when [shouldRepaint] returns true (which requires this [CustomPainter] to |
69 | /// be rebuilt), this painter has the added optimization of repainting and not |
70 | /// rebuilding when: |
71 | /// |
72 | /// * the scroll position changes; and |
73 | /// * when the scrollbar fades away. |
74 | /// |
75 | /// Calling [update] with the new [ScrollMetrics] will repaint the new scrollbar |
76 | /// position. |
77 | /// |
78 | /// Updating the value on the provided [fadeoutOpacityAnimation] will repaint |
79 | /// with the new opacity. |
80 | /// |
81 | /// You must call [dispose] on this [ScrollbarPainter] when it's no longer used. |
82 | /// |
83 | /// See also: |
84 | /// |
85 | /// * [Scrollbar] for a widget showing a scrollbar around a [Scrollable] in the |
86 | /// Material Design style. |
87 | /// * [CupertinoScrollbar] for a widget showing a scrollbar around a |
88 | /// [Scrollable] in the iOS style. |
89 | class ScrollbarPainter extends ChangeNotifier implements CustomPainter { |
90 | /// Creates a scrollbar with customizations given by construction arguments. |
91 | ScrollbarPainter({ |
92 | required Color color, |
93 | required this.fadeoutOpacityAnimation, |
94 | Color trackColor = const Color(0x00000000), |
95 | Color trackBorderColor = const Color(0x00000000), |
96 | TextDirection? textDirection, |
97 | double thickness = _kScrollbarThickness, |
98 | EdgeInsets padding = EdgeInsets.zero, |
99 | double mainAxisMargin = 0.0, |
100 | double crossAxisMargin = 0.0, |
101 | Radius? radius, |
102 | Radius? trackRadius, |
103 | OutlinedBorder? shape, |
104 | double minLength = _kMinThumbExtent, |
105 | double? minOverscrollLength, |
106 | ScrollbarOrientation? scrollbarOrientation, |
107 | bool ignorePointer = false, |
108 | }) : assert(radius == null || shape == null), |
109 | assert(minLength >= 0), |
110 | assert(minOverscrollLength == null || minOverscrollLength <= minLength), |
111 | assert(minOverscrollLength == null || minOverscrollLength >= 0), |
112 | assert(padding.isNonNegative), |
113 | _color = color, |
114 | _textDirection = textDirection, |
115 | _thickness = thickness, |
116 | _radius = radius, |
117 | _shape = shape, |
118 | _padding = padding, |
119 | _mainAxisMargin = mainAxisMargin, |
120 | _crossAxisMargin = crossAxisMargin, |
121 | _minLength = minLength, |
122 | _trackColor = trackColor, |
123 | _trackBorderColor = trackBorderColor, |
124 | _trackRadius = trackRadius, |
125 | _scrollbarOrientation = scrollbarOrientation, |
126 | _minOverscrollLength = minOverscrollLength ?? minLength, |
127 | _ignorePointer = ignorePointer { |
128 | fadeoutOpacityAnimation.addListener(notifyListeners); |
129 | } |
130 | |
131 | /// [Color] of the thumb. Mustn't be null. |
132 | Color get color => _color; |
133 | Color _color; |
134 | set color(Color value) { |
135 | if (color == value) { |
136 | return; |
137 | } |
138 | |
139 | _color = value; |
140 | notifyListeners(); |
141 | } |
142 | |
143 | /// [Color] of the track. Mustn't be null. |
144 | Color get trackColor => _trackColor; |
145 | Color _trackColor; |
146 | set trackColor(Color value) { |
147 | if (trackColor == value) { |
148 | return; |
149 | } |
150 | |
151 | _trackColor = value; |
152 | notifyListeners(); |
153 | } |
154 | |
155 | /// [Color] of the track border. Mustn't be null. |
156 | Color get trackBorderColor => _trackBorderColor; |
157 | Color _trackBorderColor; |
158 | set trackBorderColor(Color value) { |
159 | if (trackBorderColor == value) { |
160 | return; |
161 | } |
162 | |
163 | _trackBorderColor = value; |
164 | notifyListeners(); |
165 | } |
166 | |
167 | /// [Radius] of corners of the Scrollbar's track. |
168 | /// |
169 | /// Scrollbar's track will be rectangular if [trackRadius] is null. |
170 | Radius? get trackRadius => _trackRadius; |
171 | Radius? _trackRadius; |
172 | set trackRadius(Radius? value) { |
173 | if (trackRadius == value) { |
174 | return; |
175 | } |
176 | |
177 | _trackRadius = value; |
178 | notifyListeners(); |
179 | } |
180 | |
181 | /// [TextDirection] of the [BuildContext] which dictates the side of the |
182 | /// screen the scrollbar appears in (the trailing side). Must be set prior to |
183 | /// calling paint. |
184 | TextDirection? get textDirection => _textDirection; |
185 | TextDirection? _textDirection; |
186 | set textDirection(TextDirection? value) { |
187 | assert(value != null); |
188 | if (textDirection == value) { |
189 | return; |
190 | } |
191 | |
192 | _textDirection = value; |
193 | notifyListeners(); |
194 | } |
195 | |
196 | /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. |
197 | double get thickness => _thickness; |
198 | double _thickness; |
199 | set thickness(double value) { |
200 | if (thickness == value) { |
201 | return; |
202 | } |
203 | |
204 | _thickness = value; |
205 | notifyListeners(); |
206 | } |
207 | |
208 | /// An opacity [Animation] that dictates the opacity of the thumb. |
209 | /// Changes in value of this [Listenable] will automatically trigger repaints. |
210 | /// Mustn't be null. |
211 | final Animation<double> fadeoutOpacityAnimation; |
212 | |
213 | /// Distance from the scrollbar thumb's start and end to the edge of the |
214 | /// viewport in logical pixels. It affects the amount of available paint area. |
215 | /// |
216 | /// The scrollbar track consumes this space. |
217 | /// |
218 | /// Mustn't be null and defaults to 0. |
219 | double get mainAxisMargin => _mainAxisMargin; |
220 | double _mainAxisMargin; |
221 | set mainAxisMargin(double value) { |
222 | if (mainAxisMargin == value) { |
223 | return; |
224 | } |
225 | |
226 | _mainAxisMargin = value; |
227 | notifyListeners(); |
228 | } |
229 | |
230 | /// Distance from the scrollbar thumb to the nearest cross axis edge |
231 | /// in logical pixels. |
232 | /// |
233 | /// The scrollbar track consumes this space. |
234 | /// |
235 | /// Defaults to zero. |
236 | double get crossAxisMargin => _crossAxisMargin; |
237 | double _crossAxisMargin; |
238 | set crossAxisMargin(double value) { |
239 | if (crossAxisMargin == value) { |
240 | return; |
241 | } |
242 | |
243 | _crossAxisMargin = value; |
244 | notifyListeners(); |
245 | } |
246 | |
247 | /// [Radius] of corners if the scrollbar should have rounded corners. |
248 | /// |
249 | /// Scrollbar will be rectangular if [radius] is null. |
250 | Radius? get radius => _radius; |
251 | Radius? _radius; |
252 | set radius(Radius? value) { |
253 | assert(shape == null || value == null); |
254 | if (radius == value) { |
255 | return; |
256 | } |
257 | |
258 | _radius = value; |
259 | notifyListeners(); |
260 | } |
261 | |
262 | /// The [OutlinedBorder] of the scrollbar's thumb. |
263 | /// |
264 | /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, |
265 | /// it's simplest to just specify [radius]. By default, the scrollbar thumb's |
266 | /// shape is a simple rectangle. |
267 | /// |
268 | /// If [shape] is specified, the thumb will take the shape of the passed |
269 | /// [OutlinedBorder] and fill itself with [color] (or grey if it |
270 | /// is unspecified). |
271 | /// |
272 | OutlinedBorder? get shape => _shape; |
273 | OutlinedBorder? _shape; |
274 | set shape(OutlinedBorder? value) { |
275 | assert(radius == null || value == null); |
276 | if (shape == value) { |
277 | return; |
278 | } |
279 | |
280 | _shape = value; |
281 | notifyListeners(); |
282 | } |
283 | |
284 | /// The amount of space by which to inset the scrollbar's start and end, as |
285 | /// well as its side to the nearest edge, in logical pixels. |
286 | /// |
287 | /// This is typically set to the current [MediaQueryData.padding] to avoid |
288 | /// partial obstructions such as display notches. If you only want additional |
289 | /// margins around the scrollbar, see [mainAxisMargin]. |
290 | /// |
291 | /// Defaults to [EdgeInsets.zero]. Offsets from all four directions must be |
292 | /// greater than or equal to zero. |
293 | EdgeInsets get padding => _padding; |
294 | EdgeInsets _padding; |
295 | set padding(EdgeInsets value) { |
296 | if (padding == value) { |
297 | return; |
298 | } |
299 | |
300 | _padding = value; |
301 | notifyListeners(); |
302 | } |
303 | |
304 | /// The preferred smallest size the scrollbar thumb can shrink to when the total |
305 | /// scrollable extent is large, the current visible viewport is small, and the |
306 | /// viewport is not overscrolled. |
307 | /// |
308 | /// The size of the scrollbar may shrink to a smaller size than [minLength] to |
309 | /// fit in the available paint area. E.g., when [minLength] is |
310 | /// `double.infinity`, it will not be respected if |
311 | /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. |
312 | /// |
313 | /// Mustn't be null and the value has to be greater or equal to |
314 | /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. |
315 | double get minLength => _minLength; |
316 | double _minLength; |
317 | set minLength(double value) { |
318 | if (minLength == value) { |
319 | return; |
320 | } |
321 | |
322 | _minLength = value; |
323 | notifyListeners(); |
324 | } |
325 | |
326 | /// The preferred smallest size the scrollbar thumb can shrink to when viewport is |
327 | /// overscrolled. |
328 | /// |
329 | /// When overscrolling, the size of the scrollbar may shrink to a smaller size |
330 | /// than [minOverscrollLength] to fit in the available paint area. E.g., when |
331 | /// [minOverscrollLength] is `double.infinity`, it will not be respected if |
332 | /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. |
333 | /// |
334 | /// The value is less than or equal to [minLength] and greater than or equal to 0. |
335 | /// When null, it will default to the value of [minLength]. |
336 | double get minOverscrollLength => _minOverscrollLength; |
337 | double _minOverscrollLength; |
338 | set minOverscrollLength(double value) { |
339 | if (minOverscrollLength == value) { |
340 | return; |
341 | } |
342 | |
343 | _minOverscrollLength = value; |
344 | notifyListeners(); |
345 | } |
346 | |
347 | /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} |
348 | /// Dictates the orientation of the scrollbar. |
349 | /// |
350 | /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. |
351 | /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. |
352 | /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. |
353 | /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. |
354 | /// |
355 | /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be |
356 | /// used with a vertical scroll. |
357 | /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be |
358 | /// used with a horizontal scroll. |
359 | /// |
360 | /// For a vertical scroll the orientation defaults to |
361 | /// [ScrollbarOrientation.right] for [TextDirection.ltr] and |
362 | /// [ScrollbarOrientation.left] for [TextDirection.rtl]. |
363 | /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. |
364 | /// {@endtemplate} |
365 | ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; |
366 | ScrollbarOrientation? _scrollbarOrientation; |
367 | set scrollbarOrientation(ScrollbarOrientation? value) { |
368 | if (scrollbarOrientation == value) { |
369 | return; |
370 | } |
371 | |
372 | _scrollbarOrientation = value; |
373 | notifyListeners(); |
374 | } |
375 | |
376 | /// Whether the painter will be ignored during hit testing. |
377 | bool get ignorePointer => _ignorePointer; |
378 | bool _ignorePointer; |
379 | set ignorePointer(bool value) { |
380 | if (ignorePointer == value) { |
381 | return; |
382 | } |
383 | |
384 | _ignorePointer = value; |
385 | notifyListeners(); |
386 | } |
387 | |
388 | // - Scrollbar Details |
389 | |
390 | Rect? _trackRect; |
391 | // The full painted length of the track |
392 | double get _trackExtent => _lastMetrics!.viewportDimension - _totalTrackMainAxisOffsets; |
393 | // The full length of the track that the thumb can travel |
394 | double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); |
395 | // Track Offsets |
396 | // The track is offset by only padding. |
397 | double get _totalTrackMainAxisOffsets => _isVertical ? padding.vertical : padding.horizontal; |
398 | |
399 | double get _leadingTrackMainAxisOffset => switch (_resolvedOrientation) { |
400 | ScrollbarOrientation.left || ScrollbarOrientation.right => padding.top, |
401 | ScrollbarOrientation.top || ScrollbarOrientation.bottom => padding.left, |
402 | }; |
403 | |
404 | Rect? _thumbRect; |
405 | // The current scroll position + _leadingThumbMainAxisOffset |
406 | late double _thumbOffset; |
407 | // The fraction visible in relation to the traversable length of the track. |
408 | late double _thumbExtent; |
409 | // Thumb Offsets |
410 | // The thumb is offset by padding and margins. |
411 | double get _leadingThumbMainAxisOffset => _leadingTrackMainAxisOffset + mainAxisMargin; |
412 | |
413 | void _setThumbExtent() { |
414 | // Thumb extent reflects fraction of content visible, as long as this |
415 | // isn't less than the absolute minimum size. |
416 | // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 |
417 | final double fractionVisible = clampDouble( |
418 | (_lastMetrics!.extentInside - _totalTrackMainAxisOffsets) / |
419 | (_totalContentExtent - _totalTrackMainAxisOffsets), |
420 | 0.0, |
421 | 1.0, |
422 | ); |
423 | |
424 | final double thumbExtent = math.max( |
425 | math.min(_traversableTrackExtent, minOverscrollLength), |
426 | _traversableTrackExtent * fractionVisible, |
427 | ); |
428 | |
429 | final double fractionOverscrolled = |
430 | 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; |
431 | final double safeMinLength = math.min(minLength, _traversableTrackExtent); |
432 | final double newMinLength = |
433 | (_beforeExtent > 0 && _afterExtent > 0) |
434 | // Thumb extent is no smaller than minLength if scrolling normally. |
435 | ? safeMinLength |
436 | // User is overscrolling. Thumb extent can be less than minLength |
437 | // but no smaller than minOverscrollLength. We can't use the |
438 | // fractionVisible to produce intermediate values between minLength and |
439 | // minOverscrollLength when the user is transitioning from regular |
440 | // scrolling to overscrolling, so we instead use the percentage of the |
441 | // content that is still in the viewport to determine the size of the |
442 | // thumb. iOS behavior appears to have the thumb reach its minimum size |
443 | // with ~20% of overscroll. We map the percentage of minLength from |
444 | // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce |
445 | // values for the thumb that range between minLength and the smallest |
446 | // possible value, minOverscrollLength. |
447 | : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); |
448 | |
449 | // The `thumbExtent` should be no greater than `trackSize`, otherwise |
450 | // the scrollbar may scroll towards the wrong direction. |
451 | _thumbExtent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); |
452 | } |
453 | |
454 | // - Scrollable Details |
455 | |
456 | ScrollMetrics? _lastMetrics; |
457 | bool get _lastMetricsAreScrollable => |
458 | _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; |
459 | AxisDirection? _lastAxisDirection; |
460 | |
461 | bool get _isVertical => |
462 | _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; |
463 | bool get _isReversed => |
464 | _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; |
465 | // The amount of scroll distance before and after the current position. |
466 | double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; |
467 | double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; |
468 | |
469 | // The total size of the scrollable content. |
470 | double get _totalContentExtent { |
471 | return _lastMetrics!.maxScrollExtent - |
472 | _lastMetrics!.minScrollExtent + |
473 | _lastMetrics!.viewportDimension; |
474 | } |
475 | |
476 | ScrollbarOrientation get _resolvedOrientation { |
477 | if (scrollbarOrientation == null) { |
478 | if (_isVertical) { |
479 | return textDirection == TextDirection.ltr |
480 | ? ScrollbarOrientation.right |
481 | : ScrollbarOrientation.left; |
482 | } |
483 | return ScrollbarOrientation.bottom; |
484 | } |
485 | return scrollbarOrientation!; |
486 | } |
487 | |
488 | void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { |
489 | assert( |
490 | () { |
491 | bool isVerticalOrientation(ScrollbarOrientation orientation) => |
492 | orientation == ScrollbarOrientation.left || orientation == ScrollbarOrientation.right; |
493 | return (_isVertical && isVerticalOrientation(orientation)) || |
494 | (!_isVertical && !isVerticalOrientation(orientation)); |
495 | }(), |
496 | 'The given ScrollbarOrientation:$orientation is incompatible with the ' |
497 | 'current AxisDirection:$_lastAxisDirection .', |
498 | ); |
499 | } |
500 | |
501 | // - Updating |
502 | |
503 | /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will |
504 | /// show and redraw itself based on these new metrics. |
505 | /// |
506 | /// The scrollbar will remain on screen. |
507 | void update(ScrollMetrics metrics, AxisDirection axisDirection) { |
508 | if (_lastMetrics != null && |
509 | _lastMetrics!.extentBefore == metrics.extentBefore && |
510 | _lastMetrics!.extentInside == metrics.extentInside && |
511 | _lastMetrics!.extentAfter == metrics.extentAfter && |
512 | _lastAxisDirection == axisDirection) { |
513 | return; |
514 | } |
515 | |
516 | final ScrollMetrics? oldMetrics = _lastMetrics; |
517 | _lastMetrics = metrics; |
518 | _lastAxisDirection = axisDirection; |
519 | |
520 | if (!_needPaint(oldMetrics) && !_needPaint(metrics)) { |
521 | return; |
522 | } |
523 | notifyListeners(); |
524 | } |
525 | |
526 | /// Update and redraw with new scrollbar thickness and radius. |
527 | void updateThickness(double nextThickness, Radius nextRadius) { |
528 | thickness = nextThickness; |
529 | radius = nextRadius; |
530 | } |
531 | |
532 | // - Painting |
533 | |
534 | Paint get _paintThumb { |
535 | return Paint()..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); |
536 | } |
537 | |
538 | bool _needPaint(ScrollMetrics? metrics) { |
539 | return metrics != null && |
540 | metrics.maxScrollExtent - metrics.minScrollExtent > precisionErrorTolerance; |
541 | } |
542 | |
543 | Paint _paintTrack({bool isBorder = false}) { |
544 | if (isBorder) { |
545 | return Paint() |
546 | ..color = trackBorderColor.withOpacity( |
547 | trackBorderColor.opacity * fadeoutOpacityAnimation.value, |
548 | ) |
549 | ..style = PaintingStyle.stroke |
550 | ..strokeWidth = 1.0; |
551 | } |
552 | return Paint() |
553 | ..color = trackColor.withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value); |
554 | } |
555 | |
556 | void _paintScrollbar(Canvas canvas, Size size) { |
557 | assert( |
558 | textDirection != null, |
559 | 'A TextDirection must be provided before a Scrollbar can be painted.', |
560 | ); |
561 | |
562 | final double x, y; |
563 | final Size thumbSize, trackSize; |
564 | final Offset trackOffset, borderStart, borderEnd; |
565 | _debugAssertIsValidOrientation(_resolvedOrientation); |
566 | switch (_resolvedOrientation) { |
567 | case ScrollbarOrientation.left: |
568 | thumbSize = Size(thickness, _thumbExtent); |
569 | trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); |
570 | x = crossAxisMargin + padding.left; |
571 | y = _thumbOffset; |
572 | trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); |
573 | borderStart = trackOffset + Offset(trackSize.width, 0.0); |
574 | borderEnd = Offset(trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); |
575 | case ScrollbarOrientation.right: |
576 | thumbSize = Size(thickness, _thumbExtent); |
577 | trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); |
578 | x = size.width - thickness - crossAxisMargin - padding.right; |
579 | y = _thumbOffset; |
580 | trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); |
581 | borderStart = trackOffset; |
582 | borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); |
583 | case ScrollbarOrientation.top: |
584 | thumbSize = Size(_thumbExtent, thickness); |
585 | trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); |
586 | x = _thumbOffset; |
587 | y = crossAxisMargin + padding.top; |
588 | trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); |
589 | borderStart = trackOffset + Offset(0.0, trackSize.height); |
590 | borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); |
591 | case ScrollbarOrientation.bottom: |
592 | thumbSize = Size(_thumbExtent, thickness); |
593 | trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); |
594 | x = _thumbOffset; |
595 | y = size.height - thickness - crossAxisMargin - padding.bottom; |
596 | trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); |
597 | borderStart = trackOffset; |
598 | borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); |
599 | } |
600 | |
601 | // Whether we paint or not, calculating these rects allows us to hit test |
602 | // when the scrollbar is transparent. |
603 | _trackRect = trackOffset & trackSize; |
604 | _thumbRect = Offset(x, y) & thumbSize; |
605 | |
606 | // Paint if the opacity dictates visibility |
607 | if (fadeoutOpacityAnimation.value != 0.0) { |
608 | // Track |
609 | if (trackRadius == null) { |
610 | canvas.drawRect(_trackRect!, _paintTrack()); |
611 | } else { |
612 | canvas.drawRRect(RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); |
613 | } |
614 | // Track Border |
615 | canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); |
616 | if (radius != null) { |
617 | // Rounded rect thumb |
618 | canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); |
619 | return; |
620 | } |
621 | if (shape == null) { |
622 | // Square thumb |
623 | canvas.drawRect(_thumbRect!, _paintThumb); |
624 | return; |
625 | } |
626 | // Custom-shaped thumb |
627 | final Path outerPath = shape!.getOuterPath(_thumbRect!); |
628 | canvas.drawPath(outerPath, _paintThumb); |
629 | shape!.paint(canvas, _thumbRect!); |
630 | } |
631 | } |
632 | |
633 | @override |
634 | void paint(Canvas canvas, Size size) { |
635 | if (_lastAxisDirection == null || !_needPaint(_lastMetrics)) { |
636 | return; |
637 | } |
638 | // Skip painting if there's not enough space. |
639 | if (_traversableTrackExtent <= 0) { |
640 | return; |
641 | } |
642 | // Do not paint a scrollbar if the scroll view is infinitely long. |
643 | // TODO(Piinks): Special handling for infinite scroll views, |
644 | // https://github.com/flutter/flutter/issues/41434 |
645 | if (_lastMetrics!.maxScrollExtent.isInfinite) { |
646 | return; |
647 | } |
648 | |
649 | _setThumbExtent(); |
650 | final double thumbPositionOffset = _getScrollToTrack(_lastMetrics!, _thumbExtent); |
651 | _thumbOffset = thumbPositionOffset + _leadingThumbMainAxisOffset; |
652 | |
653 | return _paintScrollbar(canvas, size); |
654 | } |
655 | |
656 | // - Scroll Position Conversion |
657 | |
658 | /// Convert between a thumb track position and the corresponding scroll |
659 | /// position. |
660 | /// |
661 | /// The `thumbOffsetLocal` argument is a position in the thumb track. |
662 | double getTrackToScroll(double thumbOffsetLocal) { |
663 | final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; |
664 | final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent; |
665 | |
666 | return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; |
667 | } |
668 | |
669 | /// The thumb's corresponding scroll offset in the track. |
670 | double getThumbScrollOffset() { |
671 | final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; |
672 | |
673 | final double fractionPast = |
674 | (scrollableExtent > 0) ? clampDouble(_lastMetrics!.pixels / scrollableExtent, 0.0, 1.0) : 0; |
675 | |
676 | return fractionPast * (_traversableTrackExtent - _thumbExtent); |
677 | } |
678 | |
679 | // Converts between a scroll position and the corresponding position in the |
680 | // thumb track. |
681 | double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { |
682 | final double scrollableExtent = metrics.maxScrollExtent - metrics.minScrollExtent; |
683 | |
684 | final double fractionPast = |
685 | (scrollableExtent > 0) |
686 | ? clampDouble((metrics.pixels - metrics.minScrollExtent) / scrollableExtent, 0.0, 1.0) |
687 | : 0; |
688 | |
689 | return (_isReversed ? 1 - fractionPast : fractionPast) * |
690 | (_traversableTrackExtent - thumbExtent); |
691 | } |
692 | |
693 | // - Hit Testing |
694 | |
695 | @override |
696 | bool? hitTest(Offset? position) { |
697 | // There is nothing painted to hit. |
698 | if (_thumbRect == null) { |
699 | return null; |
700 | } |
701 | |
702 | // Interaction disabled. |
703 | if (ignorePointer |
704 | // The thumb is not able to be hit when transparent. |
705 | || |
706 | fadeoutOpacityAnimation.value == 0.0 |
707 | // Not scrollable |
708 | || |
709 | !_lastMetricsAreScrollable) { |
710 | return false; |
711 | } |
712 | |
713 | return _trackRect!.contains(position!); |
714 | } |
715 | |
716 | /// Same as hitTest, but includes some padding when the [PointerEvent] is |
717 | /// caused by [PointerDeviceKind.touch] to make sure that the region |
718 | /// isn't too small to be interacted with by the user. |
719 | /// |
720 | /// The hit test area for hovering with [PointerDeviceKind.mouse] over the |
721 | /// scrollbar also uses this extra padding. This is to make it easier to |
722 | /// interact with the scrollbar by presenting it to the mouse for interaction |
723 | /// based on proximity. When `forHover` is true, the larger hit test area will |
724 | /// be used. |
725 | bool hitTestInteractive(Offset position, PointerDeviceKind kind, {bool forHover = false}) { |
726 | if (_trackRect == null) { |
727 | // We have not computed the scrollbar position yet. |
728 | return false; |
729 | } |
730 | if (ignorePointer) { |
731 | return false; |
732 | } |
733 | |
734 | if (!_lastMetricsAreScrollable) { |
735 | return false; |
736 | } |
737 | |
738 | final Rect interactiveRect = _trackRect!; |
739 | final Rect paddedRect = interactiveRect.expandToInclude( |
740 | Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), |
741 | ); |
742 | |
743 | // The scrollbar is not able to be hit when transparent - except when |
744 | // hovering with a mouse. This should bring the scrollbar into view so the |
745 | // mouse can interact with it. |
746 | if (fadeoutOpacityAnimation.value == 0.0) { |
747 | if (forHover && kind == PointerDeviceKind.mouse) { |
748 | return paddedRect.contains(position); |
749 | } |
750 | return false; |
751 | } |
752 | |
753 | switch (kind) { |
754 | case PointerDeviceKind.touch: |
755 | case PointerDeviceKind.trackpad: |
756 | return paddedRect.contains(position); |
757 | case PointerDeviceKind.mouse: |
758 | case PointerDeviceKind.stylus: |
759 | case PointerDeviceKind.invertedStylus: |
760 | case PointerDeviceKind.unknown: |
761 | return interactiveRect.contains(position); |
762 | } |
763 | } |
764 | |
765 | /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. |
766 | /// Used to evaluate interactions with only the scrollbar thumb. |
767 | bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { |
768 | if (_thumbRect == null) { |
769 | return false; |
770 | } |
771 | if (ignorePointer) { |
772 | return false; |
773 | } |
774 | // The thumb is not able to be hit when transparent. |
775 | if (fadeoutOpacityAnimation.value == 0.0) { |
776 | return false; |
777 | } |
778 | |
779 | if (!_lastMetricsAreScrollable) { |
780 | return false; |
781 | } |
782 | |
783 | switch (kind) { |
784 | case PointerDeviceKind.touch: |
785 | case PointerDeviceKind.trackpad: |
786 | final Rect touchThumbRect = _thumbRect!.expandToInclude( |
787 | Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), |
788 | ); |
789 | return touchThumbRect.contains(position); |
790 | case PointerDeviceKind.mouse: |
791 | case PointerDeviceKind.stylus: |
792 | case PointerDeviceKind.invertedStylus: |
793 | case PointerDeviceKind.unknown: |
794 | return _thumbRect!.contains(position); |
795 | } |
796 | } |
797 | |
798 | @override |
799 | bool shouldRepaint(ScrollbarPainter oldDelegate) { |
800 | // Should repaint if any properties changed. |
801 | return color != oldDelegate.color || |
802 | trackColor != oldDelegate.trackColor || |
803 | trackBorderColor != oldDelegate.trackBorderColor || |
804 | textDirection != oldDelegate.textDirection || |
805 | thickness != oldDelegate.thickness || |
806 | fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || |
807 | mainAxisMargin != oldDelegate.mainAxisMargin || |
808 | crossAxisMargin != oldDelegate.crossAxisMargin || |
809 | radius != oldDelegate.radius || |
810 | trackRadius != oldDelegate.trackRadius || |
811 | shape != oldDelegate.shape || |
812 | padding != oldDelegate.padding || |
813 | minLength != oldDelegate.minLength || |
814 | minOverscrollLength != oldDelegate.minOverscrollLength || |
815 | scrollbarOrientation != oldDelegate.scrollbarOrientation || |
816 | ignorePointer != oldDelegate.ignorePointer; |
817 | } |
818 | |
819 | @override |
820 | bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; |
821 | |
822 | @override |
823 | SemanticsBuilderCallback? get semanticsBuilder => null; |
824 | |
825 | @override |
826 | String toString() => describeIdentity(this); |
827 | |
828 | @override |
829 | void dispose() { |
830 | fadeoutOpacityAnimation.removeListener(notifyListeners); |
831 | super.dispose(); |
832 | } |
833 | } |
834 | |
835 | /// An extendable base class for building scrollbars that fade in and out. |
836 | /// |
837 | /// To add a scrollbar to a [ScrollView], like a [ListView] or a |
838 | /// [CustomScrollView], wrap the scroll view widget in a [RawScrollbar] widget. |
839 | /// |
840 | /// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc} |
841 | /// |
842 | /// {@template flutter.widgets.Scrollbar} |
843 | /// A scrollbar thumb indicates which portion of a [ScrollView] is actually |
844 | /// visible. |
845 | /// |
846 | /// By default, the thumb will fade in and out as the child scroll view |
847 | /// scrolls. When [thumbVisibility] is true, the scrollbar thumb will remain |
848 | /// visible without the fade animation. This requires that the [ScrollController] |
849 | /// associated with the Scrollable widget is provided to [controller], or that |
850 | /// the [PrimaryScrollController] is being used by that Scrollable widget. |
851 | /// |
852 | /// If the scrollbar is wrapped around multiple [ScrollView]s, it only responds to |
853 | /// the nearest ScrollView and shows the corresponding scrollbar thumb by default. |
854 | /// The [notificationPredicate] allows the ability to customize which |
855 | /// [ScrollNotification]s the Scrollbar should listen to. |
856 | /// |
857 | /// If the child [ScrollView] is infinitely long, the [RawScrollbar] will not be |
858 | /// painted. In this case, the scrollbar cannot accurately represent the |
859 | /// relative location of the visible area, or calculate the accurate delta to |
860 | /// apply when dragging on the thumb or tapping on the track. |
861 | /// |
862 | /// ### Interaction |
863 | /// |
864 | /// Scrollbars are interactive and can use the [PrimaryScrollController] if |
865 | /// a [controller] is not set. Interactive Scrollbar thumbs can be dragged along |
866 | /// the main axis of the [ScrollView] to change the [ScrollPosition]. Tapping |
867 | /// along the track exclusive of the thumb will trigger a |
868 | /// [ScrollIncrementType.page] based on the relative position to the thumb. |
869 | /// |
870 | /// When using the [PrimaryScrollController], it must not be attached to more |
871 | /// than one [ScrollPosition]. [ScrollView]s that have not been provided a |
872 | /// [ScrollController] and have a [ScrollView.scrollDirection] of |
873 | /// [Axis.vertical] will automatically attach their ScrollPosition to the |
874 | /// PrimaryScrollController. Provide a unique ScrollController to each |
875 | /// [Scrollable] in this case to prevent having multiple ScrollPositions |
876 | /// attached to the PrimaryScrollController. |
877 | /// |
878 | /// {@tool dartpad} |
879 | /// This sample shows an app with two scrollables in the same route. Since by |
880 | /// default, there is one [PrimaryScrollController] per route, and they both have a |
881 | /// scroll direction of [Axis.vertical], they would both try to attach to that |
882 | /// controller on mobile platforms. The [Scrollbar] cannot support multiple |
883 | /// positions attached to the same controller, so one [ListView], and its |
884 | /// [Scrollbar] have been provided a unique [ScrollController]. Desktop |
885 | /// platforms do not automatically attach to the PrimaryScrollController, |
886 | /// requiring [ScrollView.primary] to be true instead in order to use the |
887 | /// PrimaryScrollController. |
888 | /// |
889 | /// Alternatively, a new PrimaryScrollController could be created above one of |
890 | /// the [ListView]s. |
891 | /// |
892 | /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.0.dart ** |
893 | /// {@end-tool} |
894 | /// |
895 | /// ### Automatic Scrollbars on Desktop Platforms |
896 | /// |
897 | /// Scrollbars are added to most [Scrollable] widgets by default on |
898 | /// [TargetPlatformVariant.desktop] platforms. This is done through |
899 | /// [ScrollBehavior.buildScrollbar] as part of an app's |
900 | /// [ScrollConfiguration]. Scrollables that do not use the |
901 | /// [PrimaryScrollController] or have a [ScrollController] provided to them |
902 | /// will receive a unique ScrollController for use with the Scrollbar. In this |
903 | /// case, only one Scrollable can be using the PrimaryScrollController, unless |
904 | /// [interactive] is false. To prevent [Axis.vertical] Scrollables from using |
905 | /// the PrimaryScrollController, set [ScrollView.primary] to false. Scrollable |
906 | /// widgets that do not have automatically applied Scrollbars include |
907 | /// |
908 | /// * [EditableText] |
909 | /// * [ListWheelScrollView] |
910 | /// * [PageView] |
911 | /// * [NestedScrollView] |
912 | /// * [DropdownButton] |
913 | /// |
914 | /// Default Scrollbars can be disabled for the whole app by setting a |
915 | /// [ScrollBehavior] with `scrollbars` set to false. |
916 | /// |
917 | /// {@tool snippet} |
918 | /// ```dart |
919 | /// MaterialApp( |
920 | /// scrollBehavior: const MaterialScrollBehavior() |
921 | /// .copyWith(scrollbars: false), |
922 | /// home: Scaffold( |
923 | /// appBar: AppBar(title: const Text('Home')), |
924 | /// ), |
925 | /// ) |
926 | /// ``` |
927 | /// {@end-tool} |
928 | /// |
929 | /// {@tool dartpad} |
930 | /// This sample shows how to disable the default Scrollbar for a [Scrollable] |
931 | /// widget to avoid duplicate Scrollbars when running on desktop platforms. |
932 | /// |
933 | /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.desktop.0.dart ** |
934 | /// {@end-tool} |
935 | /// {@endtemplate} |
936 | /// |
937 | /// {@tool dartpad} |
938 | /// This sample shows a [RawScrollbar] that executes a fade animation as |
939 | /// scrolling occurs. The RawScrollbar will fade into view as the user scrolls, |
940 | /// and fade out when scrolling stops. The [GridView] uses the |
941 | /// [PrimaryScrollController] since it has an [Axis.vertical] scroll direction |
942 | /// and has not been provided a [ScrollController]. |
943 | /// |
944 | /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.1.dart ** |
945 | /// {@end-tool} |
946 | /// |
947 | /// {@tool dartpad} |
948 | /// When `thumbVisibility` is true, the scrollbar thumb will remain visible without |
949 | /// the fade animation. This requires that a [ScrollController] is provided to |
950 | /// `controller` for both the [RawScrollbar] and the [GridView]. |
951 | /// Alternatively, the [PrimaryScrollController] can be used automatically so long |
952 | /// as it is attached to the singular [ScrollPosition] associated with the GridView. |
953 | /// |
954 | /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.2.dart ** |
955 | /// {@end-tool} |
956 | /// |
957 | /// See also: |
958 | /// |
959 | /// * [ListView], which displays a linear, scrollable list of children. |
960 | /// * [GridView], which displays a 2 dimensional, scrollable array of children. |
961 | class RawScrollbar extends StatefulWidget { |
962 | /// Creates a basic raw scrollbar that wraps the given [child]. |
963 | /// |
964 | /// The [child], or a descendant of the [child], should be a source of |
965 | /// [ScrollNotification] notifications, typically a [Scrollable] widget. |
966 | const RawScrollbar({ |
967 | super.key, |
968 | required this.child, |
969 | this.controller, |
970 | this.thumbVisibility, |
971 | this.shape, |
972 | this.radius, |
973 | this.thickness, |
974 | this.thumbColor, |
975 | this.minThumbLength = _kMinThumbExtent, |
976 | this.minOverscrollLength, |
977 | this.trackVisibility, |
978 | this.trackRadius, |
979 | this.trackColor, |
980 | this.trackBorderColor, |
981 | this.fadeDuration = _kScrollbarFadeDuration, |
982 | this.timeToFade = _kScrollbarTimeToFade, |
983 | this.pressDuration = Duration.zero, |
984 | this.notificationPredicate = defaultScrollNotificationPredicate, |
985 | this.interactive, |
986 | this.scrollbarOrientation, |
987 | this.mainAxisMargin = 0.0, |
988 | this.crossAxisMargin = 0.0, |
989 | this.padding, |
990 | }) : assert( |
991 | !(thumbVisibility == false && (trackVisibility ?? false)), |
992 | 'A scrollbar track cannot be drawn without a scrollbar thumb.', |
993 | ), |
994 | assert(minThumbLength >= 0), |
995 | assert(minOverscrollLength == null || minOverscrollLength <= minThumbLength), |
996 | assert(minOverscrollLength == null || minOverscrollLength >= 0), |
997 | assert(radius == null || shape == null); |
998 | |
999 | /// {@template flutter.widgets.Scrollbar.child} |
1000 | /// The widget below this widget in the tree. |
1001 | /// |
1002 | /// The scrollbar will be stacked on top of this child. This child (and its |
1003 | /// subtree) should include a source of [ScrollNotification] notifications. |
1004 | /// Typically a [Scrollbar] is created on desktop platforms by a |
1005 | /// [ScrollBehavior.buildScrollbar] method, in which case the child is usually |
1006 | /// the one provided as an argument to that method. |
1007 | /// |
1008 | /// Typically a [ListView] or [CustomScrollView]. |
1009 | /// {@endtemplate} |
1010 | final Widget child; |
1011 | |
1012 | /// {@template flutter.widgets.Scrollbar.controller} |
1013 | /// The [ScrollController] used to implement Scrollbar dragging. |
1014 | /// |
1015 | /// If nothing is passed to controller, the default behavior is to automatically |
1016 | /// enable scrollbar dragging on the nearest ScrollController using |
1017 | /// [PrimaryScrollController.of]. |
1018 | /// |
1019 | /// If a ScrollController is passed, then dragging on the scrollbar thumb will |
1020 | /// update the [ScrollPosition] attached to the controller. A stateful ancestor |
1021 | /// of this widget needs to manage the ScrollController and either pass it to |
1022 | /// a scrollable descendant or use a PrimaryScrollController to share it. |
1023 | /// |
1024 | /// {@tool snippet} |
1025 | /// Here is an example of using the [controller] attribute to enable |
1026 | /// scrollbar dragging for multiple independent ListViews: |
1027 | /// |
1028 | /// ```dart |
1029 | /// // (e.g. in a stateful widget) |
1030 | /// |
1031 | /// final ScrollController controllerOne = ScrollController(); |
1032 | /// final ScrollController controllerTwo = ScrollController(); |
1033 | /// |
1034 | /// @override |
1035 | /// Widget build(BuildContext context) { |
1036 | /// return Column( |
1037 | /// children: <Widget>[ |
1038 | /// SizedBox( |
1039 | /// height: 200, |
1040 | /// child: CupertinoScrollbar( |
1041 | /// controller: controllerOne, |
1042 | /// child: ListView.builder( |
1043 | /// controller: controllerOne, |
1044 | /// itemCount: 120, |
1045 | /// itemBuilder: (BuildContext context, int index) => Text('item $index'), |
1046 | /// ), |
1047 | /// ), |
1048 | /// ), |
1049 | /// SizedBox( |
1050 | /// height: 200, |
1051 | /// child: CupertinoScrollbar( |
1052 | /// controller: controllerTwo, |
1053 | /// child: ListView.builder( |
1054 | /// controller: controllerTwo, |
1055 | /// itemCount: 120, |
1056 | /// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'), |
1057 | /// ), |
1058 | /// ), |
1059 | /// ), |
1060 | /// ], |
1061 | /// ); |
1062 | /// } |
1063 | /// ``` |
1064 | /// {@end-tool} |
1065 | /// {@endtemplate} |
1066 | final ScrollController? controller; |
1067 | |
1068 | /// {@template flutter.widgets.Scrollbar.thumbVisibility} |
1069 | /// Indicates that the scrollbar thumb should be visible, even when a scroll |
1070 | /// is not underway. |
1071 | /// |
1072 | /// When false, the scrollbar will be shown during scrolling |
1073 | /// and will fade out otherwise. |
1074 | /// |
1075 | /// When true, the scrollbar will always be visible and never fade out. This |
1076 | /// requires that the Scrollbar can access the [ScrollController] of the |
1077 | /// associated Scrollable widget. This can either be the provided [controller], |
1078 | /// or the [PrimaryScrollController] of the current context. |
1079 | /// |
1080 | /// * When providing a controller, the same ScrollController must also be |
1081 | /// provided to the associated Scrollable widget. |
1082 | /// * The [PrimaryScrollController] is used by default for a [ScrollView] |
1083 | /// that has not been provided a [ScrollController] and that has a |
1084 | /// [ScrollView.scrollDirection] of [Axis.vertical]. This automatic |
1085 | /// behavior does not apply to those with [Axis.horizontal]. To explicitly |
1086 | /// use the PrimaryScrollController, set [ScrollView.primary] to true. |
1087 | /// |
1088 | /// Defaults to false when null. |
1089 | /// |
1090 | /// {@tool snippet} |
1091 | /// |
1092 | /// ```dart |
1093 | /// // (e.g. in a stateful widget) |
1094 | /// |
1095 | /// final ScrollController controllerOne = ScrollController(); |
1096 | /// final ScrollController controllerTwo = ScrollController(); |
1097 | /// |
1098 | /// @override |
1099 | /// Widget build(BuildContext context) { |
1100 | /// return Column( |
1101 | /// children: <Widget>[ |
1102 | /// SizedBox( |
1103 | /// height: 200, |
1104 | /// child: Scrollbar( |
1105 | /// thumbVisibility: true, |
1106 | /// controller: controllerOne, |
1107 | /// child: ListView.builder( |
1108 | /// controller: controllerOne, |
1109 | /// itemCount: 120, |
1110 | /// itemBuilder: (BuildContext context, int index) { |
1111 | /// return Text('item $index'); |
1112 | /// }, |
1113 | /// ), |
1114 | /// ), |
1115 | /// ), |
1116 | /// SizedBox( |
1117 | /// height: 200, |
1118 | /// child: CupertinoScrollbar( |
1119 | /// thumbVisibility: true, |
1120 | /// controller: controllerTwo, |
1121 | /// child: SingleChildScrollView( |
1122 | /// controller: controllerTwo, |
1123 | /// child: const SizedBox( |
1124 | /// height: 2000, |
1125 | /// width: 500, |
1126 | /// child: Placeholder(), |
1127 | /// ), |
1128 | /// ), |
1129 | /// ), |
1130 | /// ), |
1131 | /// ], |
1132 | /// ); |
1133 | /// } |
1134 | /// ``` |
1135 | /// {@end-tool} |
1136 | /// |
1137 | /// See also: |
1138 | /// |
1139 | /// * [RawScrollbarState.showScrollbar], an overridable getter which uses |
1140 | /// this value to override the default behavior. |
1141 | /// * [ScrollView.primary], which indicates whether the ScrollView is the primary |
1142 | /// scroll view associated with the parent [PrimaryScrollController]. |
1143 | /// * [PrimaryScrollController], which associates a [ScrollController] with |
1144 | /// a subtree. |
1145 | /// {@endtemplate} |
1146 | /// |
1147 | /// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to |
1148 | /// [WidgetState]s by using [ScrollbarThemeData.thumbVisibility]. |
1149 | final bool? thumbVisibility; |
1150 | |
1151 | /// The [OutlinedBorder] of the scrollbar's thumb. |
1152 | /// |
1153 | /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, |
1154 | /// it's simplest to just specify [radius]. By default, the scrollbar thumb's |
1155 | /// shape is a simple rectangle. |
1156 | /// |
1157 | /// If [shape] is specified, the thumb will take the shape of the passed |
1158 | /// [OutlinedBorder] and fill itself with [thumbColor] (or grey if it |
1159 | /// is unspecified). |
1160 | /// |
1161 | /// {@tool dartpad} |
1162 | /// This is an example of using a [StadiumBorder] for drawing the [shape] of the |
1163 | /// thumb in a [RawScrollbar]. |
1164 | /// |
1165 | /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.shape.0.dart ** |
1166 | /// {@end-tool} |
1167 | final OutlinedBorder? shape; |
1168 | |
1169 | /// The [Radius] of the scrollbar thumb's rounded rectangle corners. |
1170 | /// |
1171 | /// Scrollbar will be rectangular if [radius] is null, which is the default |
1172 | /// behavior. |
1173 | final Radius? radius; |
1174 | |
1175 | /// The thickness of the scrollbar in the cross axis of the scrollable. |
1176 | /// |
1177 | /// If null, will default to 6.0 pixels. |
1178 | final double? thickness; |
1179 | |
1180 | /// The color of the scrollbar thumb. |
1181 | /// |
1182 | /// If null, defaults to Color(0x66BCBCBC). |
1183 | final Color? thumbColor; |
1184 | |
1185 | /// The preferred smallest size the scrollbar thumb can shrink to when the total |
1186 | /// scrollable extent is large, the current visible viewport is small, and the |
1187 | /// viewport is not overscrolled. |
1188 | /// |
1189 | /// The size of the scrollbar's thumb may shrink to a smaller size than [minThumbLength] |
1190 | /// to fit in the available paint area (e.g., when [minThumbLength] is greater |
1191 | /// than [ScrollMetrics.viewportDimension] and [mainAxisMargin] combined). |
1192 | /// |
1193 | /// Mustn't be null and the value has to be greater or equal to |
1194 | /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. |
1195 | final double minThumbLength; |
1196 | |
1197 | /// The preferred smallest size the scrollbar thumb can shrink to when viewport is |
1198 | /// overscrolled. |
1199 | /// |
1200 | /// When overscrolling, the size of the scrollbar's thumb may shrink to a smaller size |
1201 | /// than [minOverscrollLength] to fit in the available paint area (e.g., when |
1202 | /// [minOverscrollLength] is greater than [ScrollMetrics.viewportDimension] and |
1203 | /// [mainAxisMargin] combined). |
1204 | /// |
1205 | /// Overscrolling can be made possible by setting the `physics` property |
1206 | /// of the `child` Widget to a `BouncingScrollPhysics`, which is a special |
1207 | /// `ScrollPhysics` that allows overscrolling. |
1208 | /// |
1209 | /// The value is less than or equal to [minThumbLength] and greater than or equal to 0. |
1210 | /// When null, it will default to the value of [minThumbLength]. |
1211 | final double? minOverscrollLength; |
1212 | |
1213 | /// {@template flutter.widgets.Scrollbar.trackVisibility} |
1214 | /// Indicates that the scrollbar track should be visible. |
1215 | /// |
1216 | /// When true, the scrollbar track will always be visible so long as the thumb |
1217 | /// is visible. If the scrollbar thumb is not visible, the track will not be |
1218 | /// visible either. |
1219 | /// |
1220 | /// Defaults to false when null. |
1221 | /// {@endtemplate} |
1222 | /// |
1223 | /// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to |
1224 | /// [WidgetState]s by using [ScrollbarThemeData.trackVisibility]. |
1225 | final bool? trackVisibility; |
1226 | |
1227 | /// The [Radius] of the scrollbar track's rounded rectangle corners. |
1228 | /// |
1229 | /// Scrollbar's track will be rectangular if [trackRadius] is null, which is |
1230 | /// the default behavior. |
1231 | final Radius? trackRadius; |
1232 | |
1233 | /// The color of the scrollbar track. |
1234 | /// |
1235 | /// The scrollbar track will only be visible when [trackVisibility] and |
1236 | /// [thumbVisibility] are true. |
1237 | /// |
1238 | /// If null, defaults to Color(0x08000000). |
1239 | final Color? trackColor; |
1240 | |
1241 | /// The color of the scrollbar track's border. |
1242 | /// |
1243 | /// The scrollbar track will only be visible when [trackVisibility] and |
1244 | /// [thumbVisibility] are true. |
1245 | /// |
1246 | /// If null, defaults to Color(0x1a000000). |
1247 | final Color? trackBorderColor; |
1248 | |
1249 | /// The [Duration] of the fade animation. |
1250 | /// |
1251 | /// Defaults to a [Duration] of 300 milliseconds. |
1252 | final Duration fadeDuration; |
1253 | |
1254 | /// The [Duration] of time until the fade animation begins. |
1255 | /// |
1256 | /// Defaults to a [Duration] of 600 milliseconds. |
1257 | final Duration timeToFade; |
1258 | |
1259 | /// The [Duration] of time that a LongPress will trigger the drag gesture of |
1260 | /// the scrollbar thumb. |
1261 | /// |
1262 | /// Defaults to [Duration.zero]. |
1263 | final Duration pressDuration; |
1264 | |
1265 | /// {@template flutter.widgets.Scrollbar.notificationPredicate} |
1266 | /// A check that specifies whether a [ScrollNotification] should be |
1267 | /// handled by this widget. |
1268 | /// |
1269 | /// By default, checks whether `notification.depth == 0`. That means if the |
1270 | /// scrollbar is wrapped around multiple [ScrollView]s, it only responds to the |
1271 | /// nearest scrollView and shows the corresponding scrollbar thumb. |
1272 | /// {@endtemplate} |
1273 | final ScrollNotificationPredicate notificationPredicate; |
1274 | |
1275 | /// {@template flutter.widgets.Scrollbar.interactive} |
1276 | /// Whether the Scrollbar should be interactive and respond to dragging on the |
1277 | /// thumb, or tapping in the track area. |
1278 | /// |
1279 | /// Does not apply to the [CupertinoScrollbar], which is always interactive to |
1280 | /// match native behavior. On Android, the scrollbar is not interactive by |
1281 | /// default. |
1282 | /// |
1283 | /// When false, the scrollbar will not respond to gesture or hover events, |
1284 | /// and will allow to click through it. |
1285 | /// |
1286 | /// Defaults to true when null, unless on Android, which will default to false |
1287 | /// when null. |
1288 | /// |
1289 | /// See also: |
1290 | /// |
1291 | /// * [RawScrollbarState.enableGestures], an overridable getter which uses |
1292 | /// this value to override the default behavior. |
1293 | /// {@endtemplate} |
1294 | final bool? interactive; |
1295 | |
1296 | /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} |
1297 | final ScrollbarOrientation? scrollbarOrientation; |
1298 | |
1299 | /// Distance from the scrollbar thumb's start or end to the nearest edge of |
1300 | /// the viewport in logical pixels. It affects the amount of available |
1301 | /// paint area. |
1302 | /// |
1303 | /// The scrollbar track consumes this space. |
1304 | /// |
1305 | /// Mustn't be null and defaults to 0. |
1306 | final double mainAxisMargin; |
1307 | |
1308 | /// Distance from the scrollbar thumb's side to the nearest cross axis edge |
1309 | /// in logical pixels. |
1310 | /// |
1311 | /// The scrollbar track consumes this space. |
1312 | /// |
1313 | /// Defaults to zero. |
1314 | final double crossAxisMargin; |
1315 | |
1316 | /// The insets by which the scrollbar thumb and track should be padded. |
1317 | /// |
1318 | /// When null, the inherited [MediaQueryData.padding] is used. |
1319 | /// |
1320 | /// Defaults to null. |
1321 | final EdgeInsets? padding; |
1322 | |
1323 | @override |
1324 | RawScrollbarState<RawScrollbar> createState() => RawScrollbarState<RawScrollbar>(); |
1325 | } |
1326 | |
1327 | /// The state for a [RawScrollbar] widget, also shared by the [Scrollbar] and |
1328 | /// [CupertinoScrollbar] widgets. |
1329 | /// |
1330 | /// Controls the animation that fades a scrollbar's thumb in and out of view. |
1331 | /// |
1332 | /// Provides defaults gestures for dragging the scrollbar thumb and tapping on the |
1333 | /// scrollbar track. |
1334 | class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProviderStateMixin<T> { |
1335 | Offset? _startDragScrollbarAxisOffset; |
1336 | Offset? _lastDragUpdateOffset; |
1337 | double? _startDragThumbOffset; |
1338 | ScrollController? _cachedController; |
1339 | Timer? _fadeoutTimer; |
1340 | late AnimationController _fadeoutAnimationController; |
1341 | late CurvedAnimation _fadeoutOpacityAnimation; |
1342 | final GlobalKey _scrollbarPainterKey = GlobalKey(); |
1343 | bool _hoverIsActive = false; |
1344 | Drag? _thumbDrag; |
1345 | bool _maxScrollExtentPermitsScrolling = false; |
1346 | ScrollHoldController? _thumbHold; |
1347 | Axis? _axis; |
1348 | final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = |
1349 | GlobalKey<RawGestureDetectorState>(); |
1350 | |
1351 | ScrollController? get _effectiveScrollController => |
1352 | widget.controller ?? PrimaryScrollController.maybeOf(context); |
1353 | |
1354 | /// Used to paint the scrollbar. |
1355 | /// |
1356 | /// Can be customized by subclasses to change scrollbar behavior by overriding |
1357 | /// [updateScrollbarPainter]. |
1358 | @protected |
1359 | late final ScrollbarPainter scrollbarPainter; |
1360 | |
1361 | /// Overridable getter to indicate that the scrollbar should be visible, even |
1362 | /// when a scroll is not underway. |
1363 | /// |
1364 | /// Subclasses can override this getter to make its value depend on an inherited |
1365 | /// theme. |
1366 | /// |
1367 | /// Defaults to false when [RawScrollbar.thumbVisibility] is null. |
1368 | @protected |
1369 | bool get showScrollbar => widget.thumbVisibility ?? false; |
1370 | |
1371 | bool get _showTrack => showScrollbar && (widget.trackVisibility ?? false); |
1372 | |
1373 | /// Overridable getter to indicate is gestures should be enabled on the |
1374 | /// scrollbar. |
1375 | /// |
1376 | /// When false, the scrollbar will not respond to gesture or hover events, |
1377 | /// and will allow to click through it. |
1378 | /// |
1379 | /// Subclasses can override this getter to make its value depend on an inherited |
1380 | /// theme. |
1381 | /// |
1382 | /// Defaults to true when [RawScrollbar.interactive] is null. |
1383 | /// |
1384 | /// See also: |
1385 | /// |
1386 | /// * [RawScrollbar.interactive], which overrides the default behavior. |
1387 | @protected |
1388 | bool get enableGestures => widget.interactive ?? true; |
1389 | |
1390 | @protected |
1391 | @override |
1392 | void initState() { |
1393 | super.initState(); |
1394 | _fadeoutAnimationController = AnimationController(vsync: this, duration: widget.fadeDuration) |
1395 | ..addStatusListener(_validateInteractions); |
1396 | _fadeoutOpacityAnimation = CurvedAnimation( |
1397 | parent: _fadeoutAnimationController, |
1398 | curve: Curves.fastOutSlowIn, |
1399 | ); |
1400 | scrollbarPainter = ScrollbarPainter( |
1401 | color: widget.thumbColor ?? const Color(0x66BCBCBC), |
1402 | fadeoutOpacityAnimation: _fadeoutOpacityAnimation, |
1403 | thickness: widget.thickness ?? _kScrollbarThickness, |
1404 | radius: widget.radius, |
1405 | trackRadius: widget.trackRadius, |
1406 | scrollbarOrientation: widget.scrollbarOrientation, |
1407 | mainAxisMargin: widget.mainAxisMargin, |
1408 | shape: widget.shape, |
1409 | crossAxisMargin: widget.crossAxisMargin, |
1410 | minLength: widget.minThumbLength, |
1411 | minOverscrollLength: widget.minOverscrollLength ?? widget.minThumbLength, |
1412 | ); |
1413 | } |
1414 | |
1415 | @protected |
1416 | @override |
1417 | void didChangeDependencies() { |
1418 | super.didChangeDependencies(); |
1419 | assert(_debugScheduleCheckHasValidScrollPosition()); |
1420 | } |
1421 | |
1422 | bool _debugScheduleCheckHasValidScrollPosition() { |
1423 | if (!showScrollbar) { |
1424 | return true; |
1425 | } |
1426 | WidgetsBinding.instance.addPostFrameCallback((Duration duration) { |
1427 | assert(_debugCheckHasValidScrollPosition()); |
1428 | }, debugLabel: 'RawScrollbar.checkScrollPosition'); |
1429 | return true; |
1430 | } |
1431 | |
1432 | void _validateInteractions(AnimationStatus status) { |
1433 | if (status.isDismissed) { |
1434 | assert(_fadeoutOpacityAnimation.value == 0.0); |
1435 | // We do not check for a valid scroll position if the scrollbar is not |
1436 | // visible, because it cannot be interacted with. |
1437 | } else if (_effectiveScrollController != null && enableGestures) { |
1438 | // Interactive scrollbars need to be properly configured. If it is visible |
1439 | // for interaction, ensure we are set up properly. |
1440 | assert(_debugCheckHasValidScrollPosition()); |
1441 | } |
1442 | } |
1443 | |
1444 | bool _debugCheckHasValidScrollPosition() { |
1445 | if (!mounted) { |
1446 | return true; |
1447 | } |
1448 | final ScrollController? scrollController = _effectiveScrollController; |
1449 | final bool tryPrimary = widget.controller == null; |
1450 | final String controllerForError = |
1451 | tryPrimary ? 'PrimaryScrollController': 'provided ScrollController'; |
1452 | |
1453 | String when = ''; |
1454 | if (widget.thumbVisibility ?? false) { |
1455 | when = 'Scrollbar.thumbVisibility is true'; |
1456 | } else if (enableGestures) { |
1457 | when = 'the scrollbar is interactive'; |
1458 | } else { |
1459 | when = 'using the Scrollbar'; |
1460 | } |
1461 | |
1462 | assert( |
1463 | scrollController != null, |
1464 | 'A ScrollController is required when$when . ' |
1465 | '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, ' |
1466 | 'and attempted to use the PrimaryScrollController, but none was found.': ''} ', |
1467 | ); |
1468 | assert(() { |
1469 | if (!scrollController!.hasClients) { |
1470 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
1471 | ErrorSummary("The Scrollbar's ScrollController has no ScrollPosition attached."), |
1472 | ErrorDescription('A Scrollbar cannot be painted without a ScrollPosition. '), |
1473 | ErrorHint( |
1474 | 'The Scrollbar attempted to use the$controllerForError . This ' |
1475 | 'ScrollController should be associated with the ScrollView that ' |
1476 | 'the Scrollbar is being applied to.', |
1477 | ), |
1478 | if (tryPrimary) ...<ErrorHint>[ |
1479 | ErrorHint( |
1480 | 'If a ScrollController has not been provided, the ' |
1481 | 'PrimaryScrollController is used by default on mobile platforms ' |
1482 | 'for ScrollViews with an Axis.vertical scroll direction.', |
1483 | ), |
1484 | ErrorHint( |
1485 | 'To use the PrimaryScrollController explicitly, ' |
1486 | 'set ScrollView.primary to true on the Scrollable widget.', |
1487 | ), |
1488 | ] else |
1489 | ErrorHint( |
1490 | 'When providing your own ScrollController, ensure both the ' |
1491 | 'Scrollbar and the Scrollable widget use the same one.', |
1492 | ), |
1493 | ]); |
1494 | } |
1495 | return true; |
1496 | }()); |
1497 | assert(() { |
1498 | try { |
1499 | scrollController!.position; |
1500 | } catch (error) { |
1501 | if (scrollController == null || scrollController.positions.length <= 1) { |
1502 | rethrow; |
1503 | } |
1504 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
1505 | ErrorSummary('The$controllerForError is attached to more than one ScrollPosition.'), |
1506 | ErrorDescription( |
1507 | 'The Scrollbar requires a single ScrollPosition in order to be painted.', |
1508 | ), |
1509 | ErrorHint( |
1510 | 'When$when , the associated ScrollController must only have one ' |
1511 | 'ScrollPosition attached.', |
1512 | ), |
1513 | if (tryPrimary) ...<ErrorHint>[ |
1514 | ErrorHint( |
1515 | 'If a ScrollController has not been provided, the ' |
1516 | 'PrimaryScrollController is used by default on mobile platforms ' |
1517 | 'for ScrollViews with an Axis.vertical scroll direction.', |
1518 | ), |
1519 | ErrorHint( |
1520 | 'More than one ScrollView may have tried to use the ' |
1521 | 'PrimaryScrollController of the current context. ' |
1522 | 'ScrollView.primary can override this behavior.', |
1523 | ), |
1524 | ] else |
1525 | ErrorHint( |
1526 | 'The provided ScrollController cannot be shared by multiple ' |
1527 | 'ScrollView widgets.', |
1528 | ), |
1529 | ]); |
1530 | } |
1531 | return true; |
1532 | }()); |
1533 | return true; |
1534 | } |
1535 | |
1536 | /// This method is responsible for configuring the [scrollbarPainter] |
1537 | /// according to the [widget]'s properties and any inherited widgets the |
1538 | /// painter depends on, like [Directionality] and [MediaQuery]. |
1539 | /// |
1540 | /// Subclasses can override to configure the [scrollbarPainter]. |
1541 | @protected |
1542 | void updateScrollbarPainter() { |
1543 | scrollbarPainter |
1544 | ..color = widget.thumbColor ?? const Color(0x66BCBCBC) |
1545 | ..trackRadius = widget.trackRadius |
1546 | ..trackColor = |
1547 | _showTrack ? widget.trackColor ?? const Color(0x08000000) : const Color(0x00000000) |
1548 | ..trackBorderColor = |
1549 | _showTrack ? widget.trackBorderColor ?? const Color(0x1a000000) : const Color(0x00000000) |
1550 | ..textDirection = Directionality.of(context) |
1551 | ..thickness = widget.thickness ?? _kScrollbarThickness |
1552 | ..radius = widget.radius |
1553 | ..padding = widget.padding ?? MediaQuery.paddingOf(context) |
1554 | ..scrollbarOrientation = widget.scrollbarOrientation |
1555 | ..mainAxisMargin = widget.mainAxisMargin |
1556 | ..shape = widget.shape |
1557 | ..crossAxisMargin = widget.crossAxisMargin |
1558 | ..minLength = widget.minThumbLength |
1559 | ..minOverscrollLength = widget.minOverscrollLength ?? widget.minThumbLength |
1560 | ..ignorePointer = !enableGestures; |
1561 | } |
1562 | |
1563 | @protected |
1564 | @override |
1565 | void didUpdateWidget(T oldWidget) { |
1566 | super.didUpdateWidget(oldWidget); |
1567 | if (widget.thumbVisibility != oldWidget.thumbVisibility) { |
1568 | if (widget.thumbVisibility ?? false) { |
1569 | assert(_debugScheduleCheckHasValidScrollPosition()); |
1570 | _fadeoutTimer?.cancel(); |
1571 | _fadeoutAnimationController.animateTo(1.0); |
1572 | } else { |
1573 | _fadeoutAnimationController.reverse(); |
1574 | } |
1575 | } |
1576 | } |
1577 | |
1578 | void _maybeStartFadeoutTimer() { |
1579 | if (!showScrollbar) { |
1580 | _fadeoutTimer?.cancel(); |
1581 | _fadeoutTimer = Timer(widget.timeToFade, () { |
1582 | _fadeoutAnimationController.reverse(); |
1583 | _fadeoutTimer = null; |
1584 | }); |
1585 | } |
1586 | } |
1587 | |
1588 | /// Returns the [Axis] of the child scroll view, or null if the |
1589 | /// we haven't seen a ScrollMetrics notification yet. |
1590 | @protected |
1591 | Axis? getScrollbarDirection() => _axis; |
1592 | |
1593 | void _disposeThumbDrag() { |
1594 | _thumbDrag = null; |
1595 | } |
1596 | |
1597 | void _disposeThumbHold() { |
1598 | _thumbHold = null; |
1599 | } |
1600 | |
1601 | // Given the drag's localPosition (see handleThumbPressUpdate) compute the |
1602 | // scroll position delta in the scroll axis direction. Deal with the complications |
1603 | // arising from scroll metrics changes that have occurred since the last |
1604 | // drag update and the need to prevent overscrolling on some platforms. |
1605 | double? _getPrimaryDelta(Offset localPosition) { |
1606 | assert(_cachedController != null); |
1607 | assert(_startDragScrollbarAxisOffset != null); |
1608 | assert(_lastDragUpdateOffset != null); |
1609 | assert(_startDragThumbOffset != null); |
1610 | |
1611 | final ScrollPosition position = _cachedController!.position; |
1612 | late double primaryDeltaFromDragStart; |
1613 | late double primaryDeltaFromLastDragUpdate; |
1614 | switch (position.axisDirection) { |
1615 | case AxisDirection.up: |
1616 | primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - localPosition.dy; |
1617 | primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - localPosition.dy; |
1618 | case AxisDirection.right: |
1619 | primaryDeltaFromDragStart = localPosition.dx - _startDragScrollbarAxisOffset!.dx; |
1620 | primaryDeltaFromLastDragUpdate = localPosition.dx - _lastDragUpdateOffset!.dx; |
1621 | case AxisDirection.down: |
1622 | primaryDeltaFromDragStart = localPosition.dy - _startDragScrollbarAxisOffset!.dy; |
1623 | primaryDeltaFromLastDragUpdate = localPosition.dy - _lastDragUpdateOffset!.dy; |
1624 | case AxisDirection.left: |
1625 | primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - localPosition.dx; |
1626 | primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - localPosition.dx; |
1627 | } |
1628 | |
1629 | // Convert primaryDelta, the amount that the scrollbar moved since the last |
1630 | // time when drag started or last updated, into the coordinate space of the scroll |
1631 | // position. |
1632 | double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll( |
1633 | _startDragThumbOffset! + primaryDeltaFromDragStart, |
1634 | ); |
1635 | |
1636 | if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels || |
1637 | primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) { |
1638 | // Adjust the position value if the scrolling direction conflicts with |
1639 | // the dragging direction due to scroll metrics shrink. |
1640 | scrollOffsetGlobal = |
1641 | position.pixels + scrollbarPainter.getTrackToScroll(primaryDeltaFromLastDragUpdate); |
1642 | } |
1643 | if (scrollOffsetGlobal != position.pixels) { |
1644 | // Ensure we don't drag into overscroll if the physics do not allow it. |
1645 | final double physicsAdjustment = position.physics.applyBoundaryConditions( |
1646 | position, |
1647 | scrollOffsetGlobal, |
1648 | ); |
1649 | double newPosition = scrollOffsetGlobal - physicsAdjustment; |
1650 | |
1651 | // The physics may allow overscroll when actually *scrolling*, but |
1652 | // dragging on the scrollbar does not always allow us to enter overscroll. |
1653 | switch (ScrollConfiguration.of(context).getPlatform(context)) { |
1654 | case TargetPlatform.fuchsia: |
1655 | case TargetPlatform.linux: |
1656 | case TargetPlatform.macOS: |
1657 | case TargetPlatform.windows: |
1658 | newPosition = clampDouble( |
1659 | newPosition, |
1660 | position.minScrollExtent, |
1661 | position.maxScrollExtent, |
1662 | ); |
1663 | case TargetPlatform.iOS: |
1664 | case TargetPlatform.android: |
1665 | // We can only drag the scrollbar into overscroll on mobile |
1666 | // platforms, and only then if the physics allow it. |
1667 | } |
1668 | final bool isReversed = axisDirectionIsReversed(position.axisDirection); |
1669 | return isReversed ? newPosition - position.pixels : position.pixels - newPosition; |
1670 | } |
1671 | return null; |
1672 | } |
1673 | |
1674 | /// Handler called when a press on the scrollbar thumb has been recognized. |
1675 | /// |
1676 | /// Cancels the [Timer] associated with the fade animation of the scrollbar. |
1677 | @protected |
1678 | @mustCallSuper |
1679 | void handleThumbPress() { |
1680 | assert(_debugCheckHasValidScrollPosition()); |
1681 | _cachedController = _effectiveScrollController; |
1682 | if (getScrollbarDirection() == null) { |
1683 | return; |
1684 | } |
1685 | _fadeoutTimer?.cancel(); |
1686 | _thumbHold = _cachedController!.position.hold(_disposeThumbHold); |
1687 | } |
1688 | |
1689 | /// Handler called when a long press gesture has started. |
1690 | /// |
1691 | /// Begins the fade out animation and creates the thumb's DragScrollController. |
1692 | @protected |
1693 | @mustCallSuper |
1694 | void handleThumbPressStart(Offset localPosition) { |
1695 | assert(_debugCheckHasValidScrollPosition()); |
1696 | final Axis? direction = getScrollbarDirection(); |
1697 | if (direction == null) { |
1698 | return; |
1699 | } |
1700 | _fadeoutTimer?.cancel(); |
1701 | _fadeoutAnimationController.forward(); |
1702 | |
1703 | assert(_thumbDrag == null); |
1704 | final ScrollPosition position = _cachedController!.position; |
1705 | final RenderBox renderBox = |
1706 | _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; |
1707 | final DragStartDetails details = DragStartDetails( |
1708 | localPosition: localPosition, |
1709 | globalPosition: renderBox.localToGlobal(localPosition), |
1710 | ); |
1711 | _thumbDrag = position.drag(details, _disposeThumbDrag); |
1712 | assert(_thumbDrag != null); |
1713 | assert(_thumbHold == null); |
1714 | |
1715 | _startDragScrollbarAxisOffset = localPosition; |
1716 | _lastDragUpdateOffset = localPosition; |
1717 | _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset(); |
1718 | } |
1719 | |
1720 | /// Handler called when a currently active long press gesture moves. |
1721 | /// |
1722 | /// Updates the position of the child scrollable via the _drag ScrollDragController. |
1723 | @protected |
1724 | @mustCallSuper |
1725 | void handleThumbPressUpdate(Offset localPosition) { |
1726 | assert(_debugCheckHasValidScrollPosition()); |
1727 | if (_lastDragUpdateOffset == localPosition) { |
1728 | return; |
1729 | } |
1730 | final ScrollPosition position = _cachedController!.position; |
1731 | if (!position.physics.shouldAcceptUserOffset(position)) { |
1732 | return; |
1733 | } |
1734 | final Axis? direction = getScrollbarDirection(); |
1735 | if (direction == null) { |
1736 | return; |
1737 | } |
1738 | // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. |
1739 | assert(_thumbHold == null || _thumbDrag == null); |
1740 | if (_thumbDrag == null) { |
1741 | return; |
1742 | } |
1743 | |
1744 | final double? primaryDelta = _getPrimaryDelta(localPosition); |
1745 | if (primaryDelta == null) { |
1746 | return; |
1747 | } |
1748 | |
1749 | final Offset delta = switch (direction) { |
1750 | Axis.horizontal => Offset(primaryDelta, 0), |
1751 | Axis.vertical => Offset(0, primaryDelta), |
1752 | }; |
1753 | final RenderBox renderBox = |
1754 | _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; |
1755 | final DragUpdateDetails scrollDetails = DragUpdateDetails( |
1756 | delta: delta, |
1757 | primaryDelta: primaryDelta, |
1758 | globalPosition: renderBox.localToGlobal(localPosition), |
1759 | localPosition: localPosition, |
1760 | ); |
1761 | _thumbDrag!.update( |
1762 | scrollDetails, |
1763 | ); // Triggers updates to the ScrollPosition and ScrollbarPainter |
1764 | |
1765 | _lastDragUpdateOffset = localPosition; |
1766 | } |
1767 | |
1768 | /// Handler called when a long press has ended. |
1769 | @protected |
1770 | @mustCallSuper |
1771 | void handleThumbPressEnd(Offset localPosition, Velocity velocity) { |
1772 | assert(_debugCheckHasValidScrollPosition()); |
1773 | final Axis? direction = getScrollbarDirection(); |
1774 | if (direction == null) { |
1775 | return; |
1776 | } |
1777 | _maybeStartFadeoutTimer(); |
1778 | _cachedController = null; |
1779 | _lastDragUpdateOffset = null; |
1780 | |
1781 | // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. |
1782 | assert(_thumbHold == null || _thumbDrag == null); |
1783 | if (_thumbDrag == null) { |
1784 | return; |
1785 | } |
1786 | |
1787 | // On mobile platforms flinging the scrollbar thumb causes a ballistic |
1788 | // scroll, just like it does via a touch drag. Likewise for desktops when |
1789 | // dragging on the trackpad or with a stylus. |
1790 | final TargetPlatform platform = ScrollConfiguration.of(context).getPlatform(context); |
1791 | final Velocity adjustedVelocity = switch (platform) { |
1792 | TargetPlatform.iOS || TargetPlatform.android => -velocity, |
1793 | _ => Velocity.zero, |
1794 | }; |
1795 | final RenderBox renderBox = |
1796 | _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; |
1797 | final DragEndDetails details = DragEndDetails( |
1798 | localPosition: localPosition, |
1799 | globalPosition: renderBox.localToGlobal(localPosition), |
1800 | velocity: adjustedVelocity, |
1801 | primaryVelocity: switch (direction) { |
1802 | Axis.horizontal => adjustedVelocity.pixelsPerSecond.dx, |
1803 | Axis.vertical => adjustedVelocity.pixelsPerSecond.dy, |
1804 | }, |
1805 | ); |
1806 | |
1807 | _thumbDrag?.end(details); |
1808 | assert(_thumbDrag == null); |
1809 | |
1810 | _startDragScrollbarAxisOffset = null; |
1811 | _lastDragUpdateOffset = null; |
1812 | _startDragThumbOffset = null; |
1813 | _cachedController = null; |
1814 | } |
1815 | |
1816 | /// Handler called when the track is tapped in order to page in the tapped |
1817 | /// direction. |
1818 | @protected |
1819 | @mustCallSuper |
1820 | void handleTrackTapDown(TapDownDetails details) { |
1821 | // The Scrollbar should page towards the position of the tap on the track. |
1822 | assert(_debugCheckHasValidScrollPosition()); |
1823 | _cachedController = _effectiveScrollController; |
1824 | |
1825 | final ScrollPosition position = _cachedController!.position; |
1826 | if (!position.physics.shouldAcceptUserOffset(position)) { |
1827 | return; |
1828 | } |
1829 | |
1830 | // Determines the scroll direction. |
1831 | final AxisDirection scrollDirection; |
1832 | |
1833 | switch (axisDirectionToAxis(position.axisDirection)) { |
1834 | case Axis.vertical: |
1835 | if (details.localPosition.dy > scrollbarPainter._thumbOffset) { |
1836 | scrollDirection = AxisDirection.down; |
1837 | } else { |
1838 | scrollDirection = AxisDirection.up; |
1839 | } |
1840 | case Axis.horizontal: |
1841 | if (details.localPosition.dx > scrollbarPainter._thumbOffset) { |
1842 | scrollDirection = AxisDirection.right; |
1843 | } else { |
1844 | scrollDirection = AxisDirection.left; |
1845 | } |
1846 | } |
1847 | |
1848 | final ScrollableState? state = Scrollable.maybeOf(position.context.notificationContext!); |
1849 | final ScrollIntent intent = ScrollIntent( |
1850 | direction: scrollDirection, |
1851 | type: ScrollIncrementType.page, |
1852 | ); |
1853 | assert(state != null); |
1854 | final double scrollIncrement = ScrollAction.getDirectionalIncrement(state!, intent); |
1855 | |
1856 | _cachedController!.position.moveTo( |
1857 | _cachedController!.position.pixels + scrollIncrement, |
1858 | duration: const Duration(milliseconds: 100), |
1859 | curve: Curves.easeInOut, |
1860 | ); |
1861 | } |
1862 | |
1863 | // ScrollController takes precedence over ScrollNotification |
1864 | bool _shouldUpdatePainter(Axis notificationAxis) { |
1865 | final ScrollController? scrollController = _effectiveScrollController; |
1866 | // Only update the painter of this scrollbar if the notification |
1867 | // metrics do not conflict with the information we have from the scroll |
1868 | // controller. |
1869 | |
1870 | // We do not have a scroll controller dictating axis. |
1871 | if (scrollController == null) { |
1872 | return true; |
1873 | } |
1874 | // Has more than one attached positions. |
1875 | if (scrollController.positions.length > 1) { |
1876 | return false; |
1877 | } |
1878 | |
1879 | return // The scroll controller is not attached to a position. |
1880 | !scrollController.hasClients |
1881 | // The notification matches the scroll controller's axis. |
1882 | || |
1883 | scrollController.position.axis == notificationAxis; |
1884 | } |
1885 | |
1886 | bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) { |
1887 | if (!widget.notificationPredicate(notification.asScrollUpdate())) { |
1888 | return false; |
1889 | } |
1890 | |
1891 | if (showScrollbar && !_fadeoutAnimationController.isForwardOrCompleted) { |
1892 | _fadeoutAnimationController.forward(); |
1893 | } |
1894 | |
1895 | final ScrollMetrics metrics = notification.metrics; |
1896 | if (_shouldUpdatePainter(metrics.axis)) { |
1897 | scrollbarPainter.update(metrics, metrics.axisDirection); |
1898 | } |
1899 | if (metrics.axis != _axis) { |
1900 | setState(() { |
1901 | _axis = metrics.axis; |
1902 | }); |
1903 | } |
1904 | if (_maxScrollExtentPermitsScrolling != notification.metrics.maxScrollExtent > 0.0) { |
1905 | setState(() { |
1906 | _maxScrollExtentPermitsScrolling = !_maxScrollExtentPermitsScrolling; |
1907 | }); |
1908 | } |
1909 | |
1910 | return false; |
1911 | } |
1912 | |
1913 | bool _handleScrollNotification(ScrollNotification notification) { |
1914 | if (!widget.notificationPredicate(notification)) { |
1915 | return false; |
1916 | } |
1917 | |
1918 | final ScrollMetrics metrics = notification.metrics; |
1919 | if (metrics.maxScrollExtent <= metrics.minScrollExtent) { |
1920 | // Hide the bar when the Scrollable widget has no space to scroll. |
1921 | if (_fadeoutAnimationController.isForwardOrCompleted) { |
1922 | _fadeoutAnimationController.reverse(); |
1923 | } |
1924 | |
1925 | if (_shouldUpdatePainter(metrics.axis)) { |
1926 | scrollbarPainter.update(metrics, metrics.axisDirection); |
1927 | } |
1928 | return false; |
1929 | } |
1930 | |
1931 | if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { |
1932 | // Any movements always makes the scrollbar start showing up. |
1933 | if (!_fadeoutAnimationController.isForwardOrCompleted) { |
1934 | _fadeoutAnimationController.forward(); |
1935 | } |
1936 | |
1937 | _fadeoutTimer?.cancel(); |
1938 | |
1939 | if (_shouldUpdatePainter(metrics.axis)) { |
1940 | scrollbarPainter.update(metrics, metrics.axisDirection); |
1941 | } |
1942 | } else if (notification is ScrollEndNotification) { |
1943 | if (_thumbDrag == null) { |
1944 | _maybeStartFadeoutTimer(); |
1945 | } |
1946 | } |
1947 | return false; |
1948 | } |
1949 | |
1950 | void _handleThumbDragDown(DragDownDetails details) { |
1951 | handleThumbPress(); |
1952 | } |
1953 | |
1954 | // The protected RawScrollbar API methods - handleThumbPressStart, |
1955 | // handleThumbPressUpdate, handleThumbPressEnd - all depend on a |
1956 | // localPosition parameter that defines the event's location relative |
1957 | // to the scrollbar. Ensure that the localPosition is reported consistently, |
1958 | // even if the source of the event is a trackpad or a stylus. |
1959 | Offset _globalToScrollbar(Offset offset) { |
1960 | final RenderBox renderBox = |
1961 | _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; |
1962 | return renderBox.globalToLocal(offset); |
1963 | } |
1964 | |
1965 | void _handleThumbDragStart(DragStartDetails details) { |
1966 | handleThumbPressStart(_globalToScrollbar(details.globalPosition)); |
1967 | } |
1968 | |
1969 | void _handleThumbDragUpdate(DragUpdateDetails details) { |
1970 | handleThumbPressUpdate(_globalToScrollbar(details.globalPosition)); |
1971 | } |
1972 | |
1973 | void _handleThumbDragEnd(DragEndDetails details) { |
1974 | handleThumbPressEnd(_globalToScrollbar(details.globalPosition), details.velocity); |
1975 | } |
1976 | |
1977 | void _handleThumbDragCancel() { |
1978 | if (_gestureDetectorKey.currentContext == null) { |
1979 | // The cancel was caused by the GestureDetector getting disposed, which |
1980 | // means we will get disposed momentarily as well and shouldn't do |
1981 | // any work. |
1982 | return; |
1983 | } |
1984 | // _thumbHold might be null if the drag started. |
1985 | // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag. |
1986 | assert(_thumbHold == null || _thumbDrag == null); |
1987 | _thumbHold?.cancel(); |
1988 | _thumbDrag?.cancel(); |
1989 | assert(_thumbHold == null); |
1990 | assert(_thumbDrag == null); |
1991 | } |
1992 | |
1993 | void _initThumbDragGestureRecognizer(DragGestureRecognizer instance) { |
1994 | instance.onDown = _handleThumbDragDown; |
1995 | instance.onStart = _handleThumbDragStart; |
1996 | instance.onUpdate = _handleThumbDragUpdate; |
1997 | instance.onEnd = _handleThumbDragEnd; |
1998 | instance.onCancel = _handleThumbDragCancel; |
1999 | instance.gestureSettings = const DeviceGestureSettings(touchSlop: 0); |
2000 | instance.dragStartBehavior = DragStartBehavior.down; |
2001 | } |
2002 | |
2003 | bool _canHandleScrollGestures() { |
2004 | return enableGestures && |
2005 | _effectiveScrollController != null && |
2006 | _effectiveScrollController!.positions.length == 1 && |
2007 | _effectiveScrollController!.position.hasContentDimensions && |
2008 | _effectiveScrollController!.position.maxScrollExtent > 0.0; |
2009 | } |
2010 | |
2011 | Map<Type, GestureRecognizerFactory> get _gestures { |
2012 | final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; |
2013 | if (!_canHandleScrollGestures()) { |
2014 | return gestures; |
2015 | } |
2016 | |
2017 | switch (_effectiveScrollController!.position.axis) { |
2018 | case Axis.horizontal: |
2019 | gestures[_HorizontalThumbDragGestureRecognizer] = |
2020 | GestureRecognizerFactoryWithHandlers<_HorizontalThumbDragGestureRecognizer>( |
2021 | () => _HorizontalThumbDragGestureRecognizer( |
2022 | debugOwner: this, |
2023 | customPaintKey: _scrollbarPainterKey, |
2024 | ), |
2025 | _initThumbDragGestureRecognizer, |
2026 | ); |
2027 | case Axis.vertical: |
2028 | gestures[_VerticalThumbDragGestureRecognizer] = |
2029 | GestureRecognizerFactoryWithHandlers<_VerticalThumbDragGestureRecognizer>( |
2030 | () => _VerticalThumbDragGestureRecognizer( |
2031 | debugOwner: this, |
2032 | customPaintKey: _scrollbarPainterKey, |
2033 | ), |
2034 | _initThumbDragGestureRecognizer, |
2035 | ); |
2036 | } |
2037 | |
2038 | gestures[_TrackTapGestureRecognizer] = |
2039 | GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>( |
2040 | () => _TrackTapGestureRecognizer(debugOwner: this, customPaintKey: _scrollbarPainterKey), |
2041 | (_TrackTapGestureRecognizer instance) { |
2042 | instance.onTapDown = handleTrackTapDown; |
2043 | }, |
2044 | ); |
2045 | |
2046 | return gestures; |
2047 | } |
2048 | |
2049 | /// Returns true if the provided [Offset] is located over the track of the |
2050 | /// [RawScrollbar]. |
2051 | /// |
2052 | /// Excludes the [RawScrollbar] thumb. |
2053 | @protected |
2054 | bool isPointerOverTrack(Offset position, PointerDeviceKind kind) { |
2055 | if (_scrollbarPainterKey.currentContext == null) { |
2056 | return false; |
2057 | } |
2058 | final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); |
2059 | return scrollbarPainter.hitTestInteractive(localOffset, kind) && |
2060 | !scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind); |
2061 | } |
2062 | |
2063 | /// Returns true if the provided [Offset] is located over the thumb of the |
2064 | /// [RawScrollbar]. |
2065 | @protected |
2066 | bool isPointerOverThumb(Offset position, PointerDeviceKind kind) { |
2067 | if (_scrollbarPainterKey.currentContext == null) { |
2068 | return false; |
2069 | } |
2070 | final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); |
2071 | return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind); |
2072 | } |
2073 | |
2074 | /// Returns true if the provided [Offset] is located over the track or thumb |
2075 | /// of the [RawScrollbar]. |
2076 | /// |
2077 | /// The hit test area for mouse hovering over the scrollbar is larger than |
2078 | /// regular hit testing. This is to make it easier to interact with the |
2079 | /// scrollbar and present it to the mouse for interaction based on proximity. |
2080 | /// When `forHover` is true, the larger hit test area will be used. |
2081 | @protected |
2082 | bool isPointerOverScrollbar(Offset position, PointerDeviceKind kind, {bool forHover = false}) { |
2083 | if (_scrollbarPainterKey.currentContext == null) { |
2084 | return false; |
2085 | } |
2086 | final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); |
2087 | return scrollbarPainter.hitTestInteractive(localOffset, kind, forHover: true); |
2088 | } |
2089 | |
2090 | /// Cancels the fade out animation so the scrollbar will remain visible for |
2091 | /// interaction. |
2092 | /// |
2093 | /// Can be overridden by subclasses to respond to a [PointerHoverEvent]. |
2094 | /// |
2095 | /// Helper methods [isPointerOverScrollbar], [isPointerOverThumb], and |
2096 | /// [isPointerOverTrack] can be used to determine the location of the pointer |
2097 | /// relative to the painter scrollbar elements. |
2098 | @protected |
2099 | @mustCallSuper |
2100 | void handleHover(PointerHoverEvent event) { |
2101 | // Check if the position of the pointer falls over the painted scrollbar |
2102 | if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { |
2103 | _hoverIsActive = true; |
2104 | // Bring the scrollbar back into view if it has faded or started to fade |
2105 | // away. |
2106 | _fadeoutAnimationController.forward(); |
2107 | _fadeoutTimer?.cancel(); |
2108 | } else if (_hoverIsActive) { |
2109 | // Pointer is not over painted scrollbar. |
2110 | _hoverIsActive = false; |
2111 | _maybeStartFadeoutTimer(); |
2112 | } |
2113 | } |
2114 | |
2115 | /// Initiates the fade out animation. |
2116 | /// |
2117 | /// Can be overridden by subclasses to respond to a [PointerExitEvent]. |
2118 | @protected |
2119 | @mustCallSuper |
2120 | void handleHoverExit(PointerExitEvent event) { |
2121 | _hoverIsActive = false; |
2122 | _maybeStartFadeoutTimer(); |
2123 | } |
2124 | |
2125 | // Returns the delta that should result from applying [event] with axis and |
2126 | // direction taken into account. |
2127 | double _pointerSignalEventDelta(PointerScrollEvent event) { |
2128 | assert(_cachedController != null); |
2129 | double delta = |
2130 | _cachedController!.position.axis == Axis.horizontal |
2131 | ? event.scrollDelta.dx |
2132 | : event.scrollDelta.dy; |
2133 | |
2134 | if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) { |
2135 | delta *= -1; |
2136 | } |
2137 | return delta; |
2138 | } |
2139 | |
2140 | // Returns the offset that should result from applying [event] to the current |
2141 | // position, taking min/max scroll extent into account. |
2142 | double _targetScrollOffsetForPointerScroll(double delta) { |
2143 | assert(_cachedController != null); |
2144 | return math.min( |
2145 | math.max( |
2146 | _cachedController!.position.pixels + delta, |
2147 | _cachedController!.position.minScrollExtent, |
2148 | ), |
2149 | _cachedController!.position.maxScrollExtent, |
2150 | ); |
2151 | } |
2152 | |
2153 | void _handlePointerScroll(PointerEvent event) { |
2154 | assert(event is PointerScrollEvent); |
2155 | _cachedController = _effectiveScrollController; |
2156 | final double delta = _pointerSignalEventDelta(event as PointerScrollEvent); |
2157 | final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); |
2158 | if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) { |
2159 | _cachedController!.position.pointerScroll(delta); |
2160 | } |
2161 | } |
2162 | |
2163 | void _receivedPointerSignal(PointerSignalEvent event) { |
2164 | _cachedController = _effectiveScrollController; |
2165 | // Only try to scroll if the bar absorb the hit test. |
2166 | if ((scrollbarPainter.hitTest(event.localPosition) ?? false) && |
2167 | _cachedController != null && |
2168 | _cachedController!.hasClients && |
2169 | (_thumbDrag == null || kIsWeb)) { |
2170 | final ScrollPosition position = _cachedController!.position; |
2171 | if (event is PointerScrollEvent) { |
2172 | if (!position.physics.shouldAcceptUserOffset(position)) { |
2173 | return; |
2174 | } |
2175 | final double delta = _pointerSignalEventDelta(event); |
2176 | final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); |
2177 | if (delta != 0.0 && targetScrollOffset != position.pixels) { |
2178 | GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll); |
2179 | } |
2180 | } else if (event is PointerScrollInertiaCancelEvent) { |
2181 | position.jumpTo(position.pixels); |
2182 | // Don't use the pointer signal resolver, all hit-tested scrollables should stop. |
2183 | } |
2184 | } |
2185 | } |
2186 | |
2187 | @protected |
2188 | @override |
2189 | void dispose() { |
2190 | _fadeoutAnimationController.dispose(); |
2191 | _fadeoutTimer?.cancel(); |
2192 | scrollbarPainter.dispose(); |
2193 | _fadeoutOpacityAnimation.dispose(); |
2194 | super.dispose(); |
2195 | } |
2196 | |
2197 | @protected |
2198 | @override |
2199 | Widget build(BuildContext context) { |
2200 | updateScrollbarPainter(); |
2201 | |
2202 | return NotificationListener<ScrollMetricsNotification>( |
2203 | onNotification: _handleScrollMetricsNotification, |
2204 | child: NotificationListener<ScrollNotification>( |
2205 | onNotification: _handleScrollNotification, |
2206 | child: RepaintBoundary( |
2207 | child: Listener( |
2208 | onPointerSignal: _receivedPointerSignal, |
2209 | child: RawGestureDetector( |
2210 | key: _gestureDetectorKey, |
2211 | gestures: _gestures, |
2212 | child: MouseRegion( |
2213 | onExit: (PointerExitEvent event) { |
2214 | switch (event.kind) { |
2215 | case PointerDeviceKind.mouse: |
2216 | case PointerDeviceKind.trackpad: |
2217 | if (enableGestures) { |
2218 | handleHoverExit(event); |
2219 | } |
2220 | case PointerDeviceKind.stylus: |
2221 | case PointerDeviceKind.invertedStylus: |
2222 | case PointerDeviceKind.unknown: |
2223 | case PointerDeviceKind.touch: |
2224 | break; |
2225 | } |
2226 | }, |
2227 | onHover: (PointerHoverEvent event) { |
2228 | switch (event.kind) { |
2229 | case PointerDeviceKind.mouse: |
2230 | case PointerDeviceKind.trackpad: |
2231 | if (enableGestures) { |
2232 | handleHover(event); |
2233 | } |
2234 | case PointerDeviceKind.stylus: |
2235 | case PointerDeviceKind.invertedStylus: |
2236 | case PointerDeviceKind.unknown: |
2237 | case PointerDeviceKind.touch: |
2238 | break; |
2239 | } |
2240 | }, |
2241 | child: CustomPaint( |
2242 | key: _scrollbarPainterKey, |
2243 | foregroundPainter: scrollbarPainter, |
2244 | child: RepaintBoundary(child: widget.child), |
2245 | ), |
2246 | ), |
2247 | ), |
2248 | ), |
2249 | ), |
2250 | ), |
2251 | ); |
2252 | } |
2253 | } |
2254 | |
2255 | Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { |
2256 | final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; |
2257 | return renderBox.globalToLocal(position); |
2258 | } |
2259 | |
2260 | bool _isThumbEvent(GlobalKey customPaintKey, PointerEvent event) { |
2261 | if (customPaintKey.currentContext == null) { |
2262 | return false; |
2263 | } |
2264 | |
2265 | final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; |
2266 | final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; |
2267 | final Offset localOffset = _getLocalOffset(customPaintKey, event.position); |
2268 | return painter.hitTestOnlyThumbInteractive(localOffset, event.kind); |
2269 | } |
2270 | |
2271 | bool _isTrackEvent(GlobalKey customPaintKey, PointerEvent event) { |
2272 | if (customPaintKey.currentContext == null) { |
2273 | return false; |
2274 | } |
2275 | final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; |
2276 | final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; |
2277 | final Offset localOffset = _getLocalOffset(customPaintKey, event.position); |
2278 | final PointerDeviceKind kind = event.kind; |
2279 | return painter.hitTestInteractive(localOffset, kind) && |
2280 | !painter.hitTestOnlyThumbInteractive(localOffset, kind); |
2281 | } |
2282 | |
2283 | class _TrackTapGestureRecognizer extends TapGestureRecognizer { |
2284 | _TrackTapGestureRecognizer({required super.debugOwner, required GlobalKey customPaintKey}) |
2285 | : _customPaintKey = customPaintKey; |
2286 | |
2287 | final GlobalKey _customPaintKey; |
2288 | |
2289 | @override |
2290 | bool isPointerAllowed(PointerDownEvent event) { |
2291 | return _isTrackEvent(_customPaintKey, event) && super.isPointerAllowed(event); |
2292 | } |
2293 | } |
2294 | |
2295 | class _VerticalThumbDragGestureRecognizer extends VerticalDragGestureRecognizer { |
2296 | _VerticalThumbDragGestureRecognizer({ |
2297 | required Object super.debugOwner, |
2298 | required GlobalKey customPaintKey, |
2299 | }) : _customPaintKey = customPaintKey; |
2300 | |
2301 | final GlobalKey _customPaintKey; |
2302 | |
2303 | @override |
2304 | bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { |
2305 | return false; |
2306 | } |
2307 | |
2308 | @override |
2309 | bool isPointerAllowed(PointerEvent event) { |
2310 | return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event); |
2311 | } |
2312 | } |
2313 | |
2314 | class _HorizontalThumbDragGestureRecognizer extends HorizontalDragGestureRecognizer { |
2315 | _HorizontalThumbDragGestureRecognizer({ |
2316 | required Object super.debugOwner, |
2317 | required GlobalKey customPaintKey, |
2318 | }) : _customPaintKey = customPaintKey; |
2319 | |
2320 | final GlobalKey _customPaintKey; |
2321 | |
2322 | @override |
2323 | bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { |
2324 | return false; |
2325 | } |
2326 | |
2327 | @override |
2328 | bool isPointerAllowed(PointerEvent event) { |
2329 | return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event); |
2330 | } |
2331 | } |
2332 |
Definitions
- _kMinThumbExtent
- _kMinInteractiveSize
- _kScrollbarThickness
- _kScrollbarFadeDuration
- _kScrollbarTimeToFade
- ScrollbarOrientation
- ScrollbarPainter
- ScrollbarPainter
- color
- color
- trackColor
- trackColor
- trackBorderColor
- trackBorderColor
- trackRadius
- trackRadius
- textDirection
- textDirection
- thickness
- thickness
- mainAxisMargin
- mainAxisMargin
- crossAxisMargin
- crossAxisMargin
- radius
- radius
- shape
- shape
- padding
- padding
- minLength
- minLength
- minOverscrollLength
- minOverscrollLength
- scrollbarOrientation
- scrollbarOrientation
- ignorePointer
- ignorePointer
- _trackExtent
- _traversableTrackExtent
- _totalTrackMainAxisOffsets
- _leadingTrackMainAxisOffset
- _leadingThumbMainAxisOffset
- _setThumbExtent
- _lastMetricsAreScrollable
- _isVertical
- _isReversed
- _beforeExtent
- _afterExtent
- _totalContentExtent
- _resolvedOrientation
- _debugAssertIsValidOrientation
- isVerticalOrientation
- update
- updateThickness
- _paintThumb
- _needPaint
- _paintTrack
- _paintScrollbar
- paint
- getTrackToScroll
- getThumbScrollOffset
- _getScrollToTrack
- hitTest
- hitTestInteractive
- hitTestOnlyThumbInteractive
- shouldRepaint
- shouldRebuildSemantics
- semanticsBuilder
- toString
- dispose
- RawScrollbar
- RawScrollbar
- createState
- RawScrollbarState
- _effectiveScrollController
- showScrollbar
- _showTrack
- enableGestures
- initState
- didChangeDependencies
- _debugScheduleCheckHasValidScrollPosition
- _validateInteractions
- _debugCheckHasValidScrollPosition
- updateScrollbarPainter
- didUpdateWidget
- _maybeStartFadeoutTimer
- getScrollbarDirection
- _disposeThumbDrag
- _disposeThumbHold
- _getPrimaryDelta
- handleThumbPress
- handleThumbPressStart
- handleThumbPressUpdate
- handleThumbPressEnd
- handleTrackTapDown
- _shouldUpdatePainter
- _handleScrollMetricsNotification
- _handleScrollNotification
- _handleThumbDragDown
- _globalToScrollbar
- _handleThumbDragStart
- _handleThumbDragUpdate
- _handleThumbDragEnd
- _handleThumbDragCancel
- _initThumbDragGestureRecognizer
- _canHandleScrollGestures
- _gestures
- isPointerOverTrack
- isPointerOverThumb
- isPointerOverScrollbar
- handleHover
- handleHoverExit
- _pointerSignalEventDelta
- _targetScrollOffsetForPointerScroll
- _handlePointerScroll
- _receivedPointerSignal
- dispose
- build
- _getLocalOffset
- _isThumbEvent
- _isTrackEvent
- _TrackTapGestureRecognizer
- _TrackTapGestureRecognizer
- isPointerAllowed
- _VerticalThumbDragGestureRecognizer
- _VerticalThumbDragGestureRecognizer
- isPointerPanZoomAllowed
- isPointerAllowed
- _HorizontalThumbDragGestureRecognizer
- _HorizontalThumbDragGestureRecognizer
- isPointerPanZoomAllowed
Learn more about Flutter for embedded and desktop on industrialflutter.com