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