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