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 double get _leadingTrackMainAxisOffset {
399 return switch (_resolvedOrientation) {
400 ScrollbarOrientation.left || ScrollbarOrientation.right => padding.top,
401 ScrollbarOrientation.top || ScrollbarOrientation.bottom => padding.left,
402 };
403 }
404
405 Rect? _thumbRect;
406 // The current scroll position + _leadingThumbMainAxisOffset
407 late double _thumbOffset;
408 // The fraction visible in relation to the traversable length of the track.
409 late double _thumbExtent;
410 // Thumb Offsets
411 // The thumb is offset by padding and margins.
412 double get _leadingThumbMainAxisOffset => _leadingTrackMainAxisOffset + mainAxisMargin;
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 /// [WidgetState]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 /// [WidgetState]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 CurvedAnimation _fadeoutOpacityAnimation;
1336 final GlobalKey _scrollbarPainterKey = GlobalKey();
1337 bool _hoverIsActive = false;
1338 Drag? _thumbDrag;
1339 bool _maxScrollExtentPermitsScrolling = false;
1340 ScrollHoldController? _thumbHold;
1341 Axis? _axis;
1342 final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>();
1343
1344 ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context);
1345
1346 /// Used to paint the scrollbar.
1347 ///
1348 /// Can be customized by subclasses to change scrollbar behavior by overriding
1349 /// [updateScrollbarPainter].
1350 @protected
1351 late final ScrollbarPainter scrollbarPainter;
1352
1353 /// Overridable getter to indicate that the scrollbar should be visible, even
1354 /// when a scroll is not underway.
1355 ///
1356 /// Subclasses can override this getter to make its value depend on an inherited
1357 /// theme.
1358 ///
1359 /// Defaults to false when [RawScrollbar.thumbVisibility] is null.
1360 @protected
1361 bool get showScrollbar => widget.thumbVisibility ?? false;
1362
1363 bool get _showTrack => showScrollbar && (widget.trackVisibility ?? false);
1364
1365 /// Overridable getter to indicate is gestures should be enabled on the
1366 /// scrollbar.
1367 ///
1368 /// When false, the scrollbar will not respond to gesture or hover events,
1369 /// and will allow to click through it.
1370 ///
1371 /// Subclasses can override this getter to make its value depend on an inherited
1372 /// theme.
1373 ///
1374 /// Defaults to true when [RawScrollbar.interactive] is null.
1375 ///
1376 /// See also:
1377 ///
1378 /// * [RawScrollbar.interactive], which overrides the default behavior.
1379 @protected
1380 bool get enableGestures => widget.interactive ?? true;
1381
1382 @override
1383 void initState() {
1384 super.initState();
1385 _fadeoutAnimationController = AnimationController(
1386 vsync: this,
1387 duration: widget.fadeDuration,
1388 )..addStatusListener(_validateInteractions);
1389 _fadeoutOpacityAnimation = CurvedAnimation(
1390 parent: _fadeoutAnimationController,
1391 curve: Curves.fastOutSlowIn,
1392 );
1393 scrollbarPainter = ScrollbarPainter(
1394 color: widget.thumbColor ?? const Color(0x66BCBCBC),
1395 fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
1396 thickness: widget.thickness ?? _kScrollbarThickness,
1397 radius: widget.radius,
1398 trackRadius: widget.trackRadius,
1399 scrollbarOrientation: widget.scrollbarOrientation,
1400 mainAxisMargin: widget.mainAxisMargin,
1401 shape: widget.shape,
1402 crossAxisMargin: widget.crossAxisMargin,
1403 minLength: widget.minThumbLength,
1404 minOverscrollLength: widget.minOverscrollLength ?? widget.minThumbLength,
1405 );
1406 }
1407
1408 @override
1409 void didChangeDependencies() {
1410 super.didChangeDependencies();
1411 assert(_debugScheduleCheckHasValidScrollPosition());
1412 }
1413
1414 bool _debugScheduleCheckHasValidScrollPosition() {
1415 if (!showScrollbar) {
1416 return true;
1417 }
1418 WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
1419 assert(_debugCheckHasValidScrollPosition());
1420 }, debugLabel: 'RawScrollbar.checkScrollPosition');
1421 return true;
1422 }
1423
1424 void _validateInteractions(AnimationStatus status) {
1425 if (status.isDismissed) {
1426 assert(_fadeoutOpacityAnimation.value == 0.0);
1427 // We do not check for a valid scroll position if the scrollbar is not
1428 // visible, because it cannot be interacted with.
1429 } else if (_effectiveScrollController != null && enableGestures) {
1430 // Interactive scrollbars need to be properly configured. If it is visible
1431 // for interaction, ensure we are set up properly.
1432 assert(_debugCheckHasValidScrollPosition());
1433 }
1434 }
1435
1436 bool _debugCheckHasValidScrollPosition() {
1437 if (!mounted) {
1438 return true;
1439 }
1440 final ScrollController? scrollController = _effectiveScrollController;
1441 final bool tryPrimary = widget.controller == null;
1442 final String controllerForError = tryPrimary
1443 ? 'PrimaryScrollController'
1444 : 'provided ScrollController';
1445
1446 String when = '';
1447 if (widget.thumbVisibility ?? false) {
1448 when = 'Scrollbar.thumbVisibility is true';
1449 } else if (enableGestures) {
1450 when = 'the scrollbar is interactive';
1451 } else {
1452 when = 'using the Scrollbar';
1453 }
1454
1455 assert(
1456 scrollController != null,
1457 'A ScrollController is required when $when. '
1458 '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, '
1459 'and attempted to use the PrimaryScrollController, but none was found.' :''}',
1460 );
1461 assert (() {
1462 if (!scrollController!.hasClients) {
1463 throw FlutterError.fromParts(<DiagnosticsNode>[
1464 ErrorSummary(
1465 "The Scrollbar's ScrollController has no ScrollPosition attached.",
1466 ),
1467 ErrorDescription(
1468 'A Scrollbar cannot be painted without a ScrollPosition. ',
1469 ),
1470 ErrorHint(
1471 'The Scrollbar attempted to use the $controllerForError. This '
1472 'ScrollController should be associated with the ScrollView that '
1473 'the Scrollbar is being applied to.',
1474 ),
1475 if (tryPrimary) ...<ErrorHint>[
1476 ErrorHint(
1477 'If a ScrollController has not been provided, the '
1478 'PrimaryScrollController is used by default on mobile platforms '
1479 'for ScrollViews with an Axis.vertical scroll direction.',
1480 ),
1481 ErrorHint(
1482 'To use the PrimaryScrollController explicitly, '
1483 'set ScrollView.primary to true on the Scrollable widget.',
1484 ),
1485 ]
1486 else
1487 ErrorHint(
1488 'When providing your own ScrollController, ensure both the '
1489 'Scrollbar and the Scrollable widget use the same one.',
1490 ),
1491 ]);
1492 }
1493 return true;
1494 }());
1495 assert (() {
1496 try {
1497 scrollController!.position;
1498 } catch (error) {
1499 if (scrollController == null || scrollController.positions.length <= 1) {
1500 rethrow;
1501 }
1502 throw FlutterError.fromParts(<DiagnosticsNode>[
1503 ErrorSummary(
1504 'The $controllerForError is attached to more than one ScrollPosition.',
1505 ),
1506 ErrorDescription(
1507 'The Scrollbar requires a single ScrollPosition in order to be painted.',
1508 ),
1509 ErrorHint(
1510 'When $when, the associated ScrollController must only have one '
1511 'ScrollPosition attached.',
1512 ),
1513 if (tryPrimary) ...<ErrorHint>[
1514 ErrorHint(
1515 'If a ScrollController has not been provided, the '
1516 'PrimaryScrollController is used by default on mobile platforms '
1517 'for ScrollViews with an Axis.vertical scroll direction.'
1518 ),
1519 ErrorHint(
1520 'More than one ScrollView may have tried to use the '
1521 'PrimaryScrollController of the current context. '
1522 'ScrollView.primary can override this behavior.'
1523 ),
1524 ]
1525 else
1526 ErrorHint(
1527 'The provided ScrollController cannot be shared by multiple '
1528 'ScrollView widgets.'
1529 ),
1530 ]);
1531 }
1532 return true;
1533 }());
1534 return true;
1535 }
1536
1537 /// This method is responsible for configuring the [scrollbarPainter]
1538 /// according to the [widget]'s properties and any inherited widgets the
1539 /// painter depends on, like [Directionality] and [MediaQuery].
1540 ///
1541 /// Subclasses can override to configure the [scrollbarPainter].
1542 @protected
1543 void updateScrollbarPainter() {
1544 scrollbarPainter
1545 ..color = widget.thumbColor ?? const Color(0x66BCBCBC)
1546 ..trackRadius = widget.trackRadius
1547 ..trackColor = _showTrack
1548 ? widget.trackColor ?? const Color(0x08000000)
1549 : const Color(0x00000000)
1550 ..trackBorderColor = _showTrack
1551 ? widget.trackBorderColor ?? const Color(0x1a000000)
1552 : const Color(0x00000000)
1553 ..textDirection = Directionality.of(context)
1554 ..thickness = widget.thickness ?? _kScrollbarThickness
1555 ..radius = widget.radius
1556 ..padding = widget.padding ?? MediaQuery.paddingOf(context)
1557 ..scrollbarOrientation = widget.scrollbarOrientation
1558 ..mainAxisMargin = widget.mainAxisMargin
1559 ..shape = widget.shape
1560 ..crossAxisMargin = widget.crossAxisMargin
1561 ..minLength = widget.minThumbLength
1562 ..minOverscrollLength = widget.minOverscrollLength ?? widget.minThumbLength
1563 ..ignorePointer = !enableGestures;
1564 }
1565
1566 @override
1567 void didUpdateWidget(T oldWidget) {
1568 super.didUpdateWidget(oldWidget);
1569 if (widget.thumbVisibility != oldWidget.thumbVisibility) {
1570 if (widget.thumbVisibility ?? false) {
1571 assert(_debugScheduleCheckHasValidScrollPosition());
1572 _fadeoutTimer?.cancel();
1573 _fadeoutAnimationController.animateTo(1.0);
1574 } else {
1575 _fadeoutAnimationController.reverse();
1576 }
1577 }
1578 }
1579
1580 void _maybeStartFadeoutTimer() {
1581 if (!showScrollbar) {
1582 _fadeoutTimer?.cancel();
1583 _fadeoutTimer = Timer(widget.timeToFade, () {
1584 _fadeoutAnimationController.reverse();
1585 _fadeoutTimer = null;
1586 });
1587 }
1588 }
1589
1590 /// Returns the [Axis] of the child scroll view, or null if the
1591 /// we haven't seen a ScrollMetrics notification yet.
1592 @protected
1593 Axis? getScrollbarDirection() => _axis;
1594
1595 void _disposeThumbDrag() {
1596 _thumbDrag = null;
1597 }
1598
1599 void _disposeThumbHold() {
1600 _thumbHold = null;
1601 }
1602
1603 // Given the drag's localPosition (see handleThumbPressUpdate) compute the
1604 // scroll position delta in the scroll axis direction. Deal with the complications
1605 // arising from scroll metrics changes that have occurred since the last
1606 // drag update and the need to prevent overscrolling on some platforms.
1607 double? _getPrimaryDelta(Offset localPosition) {
1608 assert(_cachedController != null);
1609 assert(_startDragScrollbarAxisOffset != null);
1610 assert(_lastDragUpdateOffset != null);
1611 assert(_startDragThumbOffset != null);
1612
1613 final ScrollPosition position = _cachedController!.position;
1614 late double primaryDeltaFromDragStart;
1615 late double primaryDeltaFromLastDragUpdate;
1616 switch (position.axisDirection) {
1617 case AxisDirection.up:
1618 primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - localPosition.dy;
1619 primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - localPosition.dy;
1620 case AxisDirection.right:
1621 primaryDeltaFromDragStart = localPosition.dx -_startDragScrollbarAxisOffset!.dx;
1622 primaryDeltaFromLastDragUpdate = localPosition.dx -_lastDragUpdateOffset!.dx;
1623 case AxisDirection.down:
1624 primaryDeltaFromDragStart = localPosition.dy -_startDragScrollbarAxisOffset!.dy;
1625 primaryDeltaFromLastDragUpdate = localPosition.dy -_lastDragUpdateOffset!.dy;
1626 case AxisDirection.left:
1627 primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - localPosition.dx;
1628 primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - localPosition.dx;
1629 }
1630
1631 // Convert primaryDelta, the amount that the scrollbar moved since the last
1632 // time when drag started or last updated, into the coordinate space of the scroll
1633 // position.
1634 double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(_startDragThumbOffset! + primaryDeltaFromDragStart);
1635
1636 if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels
1637 || primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) {
1638 // Adjust the position value if the scrolling direction conflicts with
1639 // the dragging direction due to scroll metrics shrink.
1640 scrollOffsetGlobal = position.pixels + scrollbarPainter.getTrackToScroll(primaryDeltaFromLastDragUpdate);
1641 }
1642 if (scrollOffsetGlobal != position.pixels) {
1643 // Ensure we don't drag into overscroll if the physics do not allow it.
1644 final double physicsAdjustment = position.physics.applyBoundaryConditions(position, scrollOffsetGlobal);
1645 double newPosition = scrollOffsetGlobal - physicsAdjustment;
1646
1647 // The physics may allow overscroll when actually *scrolling*, but
1648 // dragging on the scrollbar does not always allow us to enter overscroll.
1649 switch (ScrollConfiguration.of(context).getPlatform(context)) {
1650 case TargetPlatform.fuchsia:
1651 case TargetPlatform.linux:
1652 case TargetPlatform.macOS:
1653 case TargetPlatform.windows:
1654 newPosition = clampDouble(newPosition, position.minScrollExtent, position.maxScrollExtent);
1655 case TargetPlatform.iOS:
1656 case TargetPlatform.android:
1657 // We can only drag the scrollbar into overscroll on mobile
1658 // platforms, and only then if the physics allow it.
1659 }
1660 final bool isReversed = axisDirectionIsReversed(position.axisDirection);
1661 return isReversed ? newPosition - position.pixels : position.pixels - newPosition;
1662 }
1663 return null;
1664 }
1665
1666 /// Handler called when a press on the scrollbar thumb has been recognized.
1667 ///
1668 /// Cancels the [Timer] associated with the fade animation of the scrollbar.
1669 @protected
1670 @mustCallSuper
1671 void handleThumbPress() {
1672 assert(_debugCheckHasValidScrollPosition());
1673 _cachedController = _effectiveScrollController;
1674 if (getScrollbarDirection() == null) {
1675 return;
1676 }
1677 _fadeoutTimer?.cancel();
1678 _thumbHold = _cachedController!.position.hold(_disposeThumbHold);
1679 }
1680
1681 /// Handler called when a long press gesture has started.
1682 ///
1683 /// Begins the fade out animation and creates the thumb's DragScrollController.
1684 @protected
1685 @mustCallSuper
1686 void handleThumbPressStart(Offset localPosition) {
1687 assert(_debugCheckHasValidScrollPosition());
1688 final Axis? direction = getScrollbarDirection();
1689 if (direction == null) {
1690 return;
1691 }
1692 _fadeoutTimer?.cancel();
1693 _fadeoutAnimationController.forward();
1694
1695 assert(_thumbDrag == null);
1696 final ScrollPosition position = _cachedController!.position;
1697 final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
1698 final DragStartDetails details = DragStartDetails(
1699 localPosition: localPosition,
1700 globalPosition: renderBox.localToGlobal(localPosition),
1701 );
1702 _thumbDrag = position.drag(details, _disposeThumbDrag);
1703 assert(_thumbDrag != null);
1704 assert(_thumbHold == null);
1705
1706 _startDragScrollbarAxisOffset = localPosition;
1707 _lastDragUpdateOffset = localPosition;
1708 _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset();
1709 }
1710
1711 /// Handler called when a currently active long press gesture moves.
1712 ///
1713 /// Updates the position of the child scrollable via the _drag ScrollDragController.
1714 @protected
1715 @mustCallSuper
1716 void handleThumbPressUpdate(Offset localPosition) {
1717 assert(_debugCheckHasValidScrollPosition());
1718 if (_lastDragUpdateOffset == localPosition) {
1719 return;
1720 }
1721 final ScrollPosition position = _cachedController!.position;
1722 if (!position.physics.shouldAcceptUserOffset(position)) {
1723 return;
1724 }
1725 final Axis? direction = getScrollbarDirection();
1726 if (direction == null) {
1727 return;
1728 }
1729 // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
1730 assert(_thumbHold == null || _thumbDrag == null);
1731 if (_thumbDrag == null) {
1732 return;
1733 }
1734
1735 final double? primaryDelta = _getPrimaryDelta(localPosition);
1736 if (primaryDelta == null) {
1737 return;
1738 }
1739
1740 final Offset delta = switch (direction) {
1741 Axis.horizontal => Offset(primaryDelta, 0),
1742 Axis.vertical => Offset(0, primaryDelta),
1743 };
1744 final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
1745 final DragUpdateDetails scrollDetails = DragUpdateDetails(
1746 delta: delta,
1747 primaryDelta: primaryDelta,
1748 globalPosition: renderBox.localToGlobal(localPosition),
1749 localPosition: localPosition,
1750 );
1751 _thumbDrag!.update(scrollDetails); // Triggers updates to the ScrollPosition and ScrollbarPainter
1752
1753 _lastDragUpdateOffset = localPosition;
1754 }
1755
1756 /// Handler called when a long press has ended.
1757 @protected
1758 @mustCallSuper
1759 void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
1760 assert(_debugCheckHasValidScrollPosition());
1761 final Axis? direction = getScrollbarDirection();
1762 if (direction == null) {
1763 return;
1764 }
1765 _maybeStartFadeoutTimer();
1766 _cachedController = null;
1767 _lastDragUpdateOffset = null;
1768
1769 // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
1770 assert(_thumbHold == null || _thumbDrag == null);
1771 if (_thumbDrag == null) {
1772 return;
1773 }
1774
1775 // On mobile platforms flinging the scrollbar thumb causes a ballistic
1776 // scroll, just like it does via a touch drag. Likewise for desktops when
1777 // dragging on the trackpad or with a stylus.
1778 final TargetPlatform platform = ScrollConfiguration.of(context).getPlatform(context);
1779 final Velocity adjustedVelocity = switch (platform) {
1780 TargetPlatform.iOS || TargetPlatform.android => -velocity,
1781 _ => Velocity.zero,
1782 };
1783 final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
1784 final DragEndDetails details = DragEndDetails(
1785 localPosition: localPosition,
1786 globalPosition: renderBox.localToGlobal(localPosition),
1787 velocity: adjustedVelocity,
1788 primaryVelocity: switch (direction) {
1789 Axis.horizontal => adjustedVelocity.pixelsPerSecond.dx,
1790 Axis.vertical => adjustedVelocity.pixelsPerSecond.dy,
1791 },
1792 );
1793
1794 _thumbDrag?.end(details);
1795 assert(_thumbDrag == null);
1796
1797 _startDragScrollbarAxisOffset = null;
1798 _lastDragUpdateOffset = null;
1799 _startDragThumbOffset = null;
1800 _cachedController = null;
1801 }
1802
1803 /// Handler called when the track is tapped in order to page in the tapped
1804 /// direction.
1805 @protected
1806 @mustCallSuper
1807 void handleTrackTapDown(TapDownDetails details) {
1808 // The Scrollbar should page towards the position of the tap on the track.
1809 assert(_debugCheckHasValidScrollPosition());
1810 _cachedController = _effectiveScrollController;
1811
1812 final ScrollPosition position = _cachedController!.position;
1813 if (!position.physics.shouldAcceptUserOffset(position)) {
1814 return;
1815 }
1816
1817 // Determines the scroll direction.
1818 final AxisDirection scrollDirection;
1819
1820 switch (axisDirectionToAxis(position.axisDirection)) {
1821 case Axis.vertical:
1822 if (details.localPosition.dy > scrollbarPainter._thumbOffset) {
1823 scrollDirection = AxisDirection.down;
1824 } else {
1825 scrollDirection = AxisDirection.up;
1826 }
1827 case Axis.horizontal:
1828 if (details.localPosition.dx > scrollbarPainter._thumbOffset) {
1829 scrollDirection = AxisDirection.right;
1830 } else {
1831 scrollDirection = AxisDirection.left;
1832 }
1833 }
1834
1835 final ScrollableState? state = Scrollable.maybeOf(position.context.notificationContext!);
1836 final ScrollIntent intent = ScrollIntent(direction: scrollDirection, type: ScrollIncrementType.page);
1837 assert(state != null);
1838 final double scrollIncrement = ScrollAction.getDirectionalIncrement(state!, intent);
1839
1840 _cachedController!.position.moveTo(
1841 _cachedController!.position.pixels + scrollIncrement,
1842 duration: const Duration(milliseconds: 100),
1843 curve: Curves.easeInOut,
1844 );
1845 }
1846
1847 // ScrollController takes precedence over ScrollNotification
1848 bool _shouldUpdatePainter(Axis notificationAxis) {
1849 final ScrollController? scrollController = _effectiveScrollController;
1850 // Only update the painter of this scrollbar if the notification
1851 // metrics do not conflict with the information we have from the scroll
1852 // controller.
1853
1854 // We do not have a scroll controller dictating axis.
1855 if (scrollController == null) {
1856 return true;
1857 }
1858 // Has more than one attached positions.
1859 if (scrollController.positions.length > 1) {
1860 return false;
1861 }
1862
1863 return
1864 // The scroll controller is not attached to a position.
1865 !scrollController.hasClients
1866 // The notification matches the scroll controller's axis.
1867 || scrollController.position.axis == notificationAxis;
1868 }
1869
1870 bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) {
1871 if (!widget.notificationPredicate(notification.asScrollUpdate())) {
1872 return false;
1873 }
1874
1875 if (showScrollbar && !_fadeoutAnimationController.isForwardOrCompleted) {
1876 _fadeoutAnimationController.forward();
1877 }
1878
1879 final ScrollMetrics metrics = notification.metrics;
1880 if (_shouldUpdatePainter(metrics.axis)) {
1881 scrollbarPainter.update(metrics, metrics.axisDirection);
1882 }
1883 if (metrics.axis != _axis) {
1884 setState(() { _axis = metrics.axis; });
1885 }
1886 if (_maxScrollExtentPermitsScrolling != notification.metrics.maxScrollExtent > 0.0) {
1887 setState(() { _maxScrollExtentPermitsScrolling = !_maxScrollExtentPermitsScrolling; });
1888 }
1889
1890 return false;
1891 }
1892
1893 bool _handleScrollNotification(ScrollNotification notification) {
1894 if (!widget.notificationPredicate(notification)) {
1895 return false;
1896 }
1897
1898 final ScrollMetrics metrics = notification.metrics;
1899 if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
1900 // Hide the bar when the Scrollable widget has no space to scroll.
1901 if (_fadeoutAnimationController.isForwardOrCompleted) {
1902 _fadeoutAnimationController.reverse();
1903 }
1904
1905 if (_shouldUpdatePainter(metrics.axis)) {
1906 scrollbarPainter.update(metrics, metrics.axisDirection);
1907 }
1908 return false;
1909 }
1910
1911 if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
1912 // Any movements always makes the scrollbar start showing up.
1913 if (!_fadeoutAnimationController.isForwardOrCompleted) {
1914 _fadeoutAnimationController.forward();
1915 }
1916
1917 _fadeoutTimer?.cancel();
1918
1919 if (_shouldUpdatePainter(metrics.axis)) {
1920 scrollbarPainter.update(metrics, metrics.axisDirection);
1921 }
1922 } else if (notification is ScrollEndNotification) {
1923 if (_thumbDrag == null) {
1924 _maybeStartFadeoutTimer();
1925 }
1926 }
1927 return false;
1928 }
1929
1930 void _handleThumbDragDown(DragDownDetails details) {
1931 handleThumbPress();
1932 }
1933
1934 // The protected RawScrollbar API methods - handleThumbPressStart,
1935 // handleThumbPressUpdate, handleThumbPressEnd - all depend on a
1936 // localPosition parameter that defines the event's location relative
1937 // to the scrollbar. Ensure that the localPosition is reported consistently,
1938 // even if the source of the event is a trackpad or a stylus.
1939 Offset _globalToScrollbar(Offset offset) {
1940 final RenderBox renderBox = _scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
1941 return renderBox.globalToLocal(offset);
1942 }
1943
1944 void _handleThumbDragStart(DragStartDetails details) {
1945 handleThumbPressStart(_globalToScrollbar(details.globalPosition));
1946 }
1947
1948 void _handleThumbDragUpdate(DragUpdateDetails details) {
1949 handleThumbPressUpdate(_globalToScrollbar(details.globalPosition));
1950 }
1951
1952 void _handleThumbDragEnd(DragEndDetails details) {
1953 handleThumbPressEnd(_globalToScrollbar(details.globalPosition), details.velocity);
1954 }
1955
1956 void _handleThumbDragCancel() {
1957 if (_gestureDetectorKey.currentContext == null) {
1958 // The cancel was caused by the GestureDetector getting disposed, which
1959 // means we will get disposed momentarily as well and shouldn't do
1960 // any work.
1961 return;
1962 }
1963 // _thumbHold might be null if the drag started.
1964 // _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
1965 assert(_thumbHold == null || _thumbDrag == null);
1966 _thumbHold?.cancel();
1967 _thumbDrag?.cancel();
1968 assert(_thumbHold == null);
1969 assert(_thumbDrag == null);
1970 }
1971
1972 void _initThumbDragGestureRecognizer(DragGestureRecognizer instance) {
1973 instance.onDown = _handleThumbDragDown;
1974 instance.onStart = _handleThumbDragStart;
1975 instance.onUpdate = _handleThumbDragUpdate;
1976 instance.onEnd = _handleThumbDragEnd;
1977 instance.onCancel = _handleThumbDragCancel;
1978 instance.gestureSettings = const DeviceGestureSettings(touchSlop: 0);
1979 instance.dragStartBehavior = DragStartBehavior.down;
1980 }
1981
1982 bool _canHandleScrollGestures() {
1983 return enableGestures
1984 && _effectiveScrollController != null
1985 && _effectiveScrollController!.positions.length == 1
1986 && _effectiveScrollController!.position.hasContentDimensions
1987 && _effectiveScrollController!.position.maxScrollExtent > 0.0;
1988 }
1989
1990 Map<Type, GestureRecognizerFactory> get _gestures {
1991 final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
1992 if (!_canHandleScrollGestures()) {
1993 return gestures;
1994 }
1995
1996 switch (_axis) {
1997 case Axis.horizontal:
1998 gestures[_HorizontalThumbDragGestureRecognizer] =
1999 GestureRecognizerFactoryWithHandlers<_HorizontalThumbDragGestureRecognizer>(
2000 () => _HorizontalThumbDragGestureRecognizer(
2001 debugOwner: this,
2002 customPaintKey: _scrollbarPainterKey,
2003 ),
2004 _initThumbDragGestureRecognizer,
2005 );
2006 case Axis.vertical:
2007 gestures[_VerticalThumbDragGestureRecognizer] =
2008 GestureRecognizerFactoryWithHandlers<_VerticalThumbDragGestureRecognizer>(
2009 () => _VerticalThumbDragGestureRecognizer(
2010 debugOwner: this,
2011 customPaintKey: _scrollbarPainterKey,
2012 ),
2013 _initThumbDragGestureRecognizer,
2014 );
2015 case null:
2016 return gestures;
2017 }
2018
2019 gestures[_TrackTapGestureRecognizer] =
2020 GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>(
2021 () => _TrackTapGestureRecognizer(
2022 debugOwner: this,
2023 customPaintKey: _scrollbarPainterKey,
2024 ),
2025 (_TrackTapGestureRecognizer instance) {
2026 instance.onTapDown = handleTrackTapDown;
2027 },
2028 );
2029
2030 return gestures;
2031 }
2032
2033 /// Returns true if the provided [Offset] is located over the track of the
2034 /// [RawScrollbar].
2035 ///
2036 /// Excludes the [RawScrollbar] thumb.
2037 @protected
2038 bool isPointerOverTrack(Offset position, PointerDeviceKind kind) {
2039 if (_scrollbarPainterKey.currentContext == null) {
2040 return false;
2041 }
2042 final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
2043 return scrollbarPainter.hitTestInteractive(localOffset, kind)
2044 && !scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind);
2045 }
2046 /// Returns true if the provided [Offset] is located over the thumb of the
2047 /// [RawScrollbar].
2048 @protected
2049 bool isPointerOverThumb(Offset position, PointerDeviceKind kind) {
2050 if (_scrollbarPainterKey.currentContext == null) {
2051 return false;
2052 }
2053 final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
2054 return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind);
2055 }
2056 /// Returns true if the provided [Offset] is located over the track or thumb
2057 /// of the [RawScrollbar].
2058 ///
2059 /// The hit test area for mouse hovering over the scrollbar is larger than
2060 /// regular hit testing. This is to make it easier to interact with the
2061 /// scrollbar and present it to the mouse for interaction based on proximity.
2062 /// When `forHover` is true, the larger hit test area will be used.
2063 @protected
2064 bool isPointerOverScrollbar(Offset position, PointerDeviceKind kind, { bool forHover = false }) {
2065 if (_scrollbarPainterKey.currentContext == null) {
2066 return false;
2067 }
2068 final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
2069 return scrollbarPainter.hitTestInteractive(localOffset, kind, forHover: true);
2070 }
2071
2072 /// Cancels the fade out animation so the scrollbar will remain visible for
2073 /// interaction.
2074 ///
2075 /// Can be overridden by subclasses to respond to a [PointerHoverEvent].
2076 ///
2077 /// Helper methods [isPointerOverScrollbar], [isPointerOverThumb], and
2078 /// [isPointerOverTrack] can be used to determine the location of the pointer
2079 /// relative to the painter scrollbar elements.
2080 @protected
2081 @mustCallSuper
2082 void handleHover(PointerHoverEvent event) {
2083 // Check if the position of the pointer falls over the painted scrollbar
2084 if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) {
2085 _hoverIsActive = true;
2086 // Bring the scrollbar back into view if it has faded or started to fade
2087 // away.
2088 _fadeoutAnimationController.forward();
2089 _fadeoutTimer?.cancel();
2090 } else if (_hoverIsActive) {
2091 // Pointer is not over painted scrollbar.
2092 _hoverIsActive = false;
2093 _maybeStartFadeoutTimer();
2094 }
2095 }
2096
2097 /// Initiates the fade out animation.
2098 ///
2099 /// Can be overridden by subclasses to respond to a [PointerExitEvent].
2100 @protected
2101 @mustCallSuper
2102 void handleHoverExit(PointerExitEvent event) {
2103 _hoverIsActive = false;
2104 _maybeStartFadeoutTimer();
2105 }
2106
2107 // Returns the delta that should result from applying [event] with axis and
2108 // direction taken into account.
2109 double _pointerSignalEventDelta(PointerScrollEvent event) {
2110 assert(_cachedController != null);
2111 double delta = _cachedController!.position.axis == Axis.horizontal
2112 ? event.scrollDelta.dx
2113 : event.scrollDelta.dy;
2114
2115 if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) {
2116 delta *= -1;
2117 }
2118 return delta;
2119 }
2120
2121 // Returns the offset that should result from applying [event] to the current
2122 // position, taking min/max scroll extent into account.
2123 double _targetScrollOffsetForPointerScroll(double delta) {
2124 assert(_cachedController != null);
2125 return math.min(
2126 math.max(_cachedController!.position.pixels + delta, _cachedController!.position.minScrollExtent),
2127 _cachedController!.position.maxScrollExtent,
2128 );
2129 }
2130
2131 void _handlePointerScroll(PointerEvent event) {
2132 assert(event is PointerScrollEvent);
2133 _cachedController = _effectiveScrollController;
2134 final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
2135 final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
2136 if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) {
2137 _cachedController!.position.pointerScroll(delta);
2138 }
2139 }
2140
2141 void _receivedPointerSignal(PointerSignalEvent event) {
2142 _cachedController = _effectiveScrollController;
2143 // Only try to scroll if the bar absorb the hit test.
2144 if ((scrollbarPainter.hitTest(event.localPosition) ?? false) &&
2145 _cachedController != null &&
2146 _cachedController!.hasClients &&
2147 (_thumbDrag == null || kIsWeb)) {
2148 final ScrollPosition position = _cachedController!.position;
2149 if (event is PointerScrollEvent) {
2150 if (!position.physics.shouldAcceptUserOffset(position)) {
2151 return;
2152 }
2153 final double delta = _pointerSignalEventDelta(event);
2154 final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
2155 if (delta != 0.0 && targetScrollOffset != position.pixels) {
2156 GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
2157 }
2158 } else if (event is PointerScrollInertiaCancelEvent) {
2159 position.jumpTo(position.pixels);
2160 // Don't use the pointer signal resolver, all hit-tested scrollables should stop.
2161 }
2162 }
2163 }
2164
2165 @override
2166 void dispose() {
2167 _fadeoutAnimationController.dispose();
2168 _fadeoutTimer?.cancel();
2169 scrollbarPainter.dispose();
2170 _fadeoutOpacityAnimation.dispose();
2171 super.dispose();
2172 }
2173
2174 @override
2175 Widget build(BuildContext context) {
2176 updateScrollbarPainter();
2177
2178 return NotificationListener<ScrollMetricsNotification>(
2179 onNotification: _handleScrollMetricsNotification,
2180 child: NotificationListener<ScrollNotification>(
2181 onNotification: _handleScrollNotification,
2182 child: RepaintBoundary(
2183 child: Listener(
2184 onPointerSignal: _receivedPointerSignal,
2185 child: RawGestureDetector(
2186 key: _gestureDetectorKey,
2187 gestures: _gestures,
2188 child: MouseRegion(
2189 onExit: (PointerExitEvent event) {
2190 switch (event.kind) {
2191 case PointerDeviceKind.mouse:
2192 case PointerDeviceKind.trackpad:
2193 if (enableGestures) {
2194 handleHoverExit(event);
2195 }
2196 case PointerDeviceKind.stylus:
2197 case PointerDeviceKind.invertedStylus:
2198 case PointerDeviceKind.unknown:
2199 case PointerDeviceKind.touch:
2200 break;
2201 }
2202 },
2203 onHover: (PointerHoverEvent event) {
2204 switch (event.kind) {
2205 case PointerDeviceKind.mouse:
2206 case PointerDeviceKind.trackpad:
2207 if (enableGestures) {
2208 handleHover(event);
2209 }
2210 case PointerDeviceKind.stylus:
2211 case PointerDeviceKind.invertedStylus:
2212 case PointerDeviceKind.unknown:
2213 case PointerDeviceKind.touch:
2214 break;
2215 }
2216 },
2217 child: CustomPaint(
2218 key: _scrollbarPainterKey,
2219 foregroundPainter: scrollbarPainter,
2220 child: RepaintBoundary(child: widget.child),
2221 ),
2222 ),
2223 ),
2224 ),
2225 ),
2226 ),
2227 );
2228 }
2229}
2230
2231Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) {
2232 final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
2233 return renderBox.globalToLocal(position);
2234}
2235
2236bool _isThumbEvent(GlobalKey customPaintKey, PointerEvent event) {
2237 if (customPaintKey.currentContext == null) {
2238 return false;
2239 }
2240
2241 final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
2242 final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
2243 final Offset localOffset = _getLocalOffset(customPaintKey, event.position);
2244 return painter.hitTestOnlyThumbInteractive(localOffset, event.kind);
2245}
2246
2247bool _isTrackEvent(GlobalKey customPaintKey, PointerEvent event) {
2248 if (customPaintKey.currentContext == null) {
2249 return false;
2250 }
2251 final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
2252 final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
2253 final Offset localOffset = _getLocalOffset(customPaintKey, event.position);
2254 final PointerDeviceKind kind = event.kind;
2255 return painter.hitTestInteractive(localOffset, kind) && !painter.hitTestOnlyThumbInteractive(localOffset, kind);
2256}
2257
2258class _TrackTapGestureRecognizer extends TapGestureRecognizer {
2259 _TrackTapGestureRecognizer({
2260 required super.debugOwner,
2261 required GlobalKey customPaintKey,
2262 }) : _customPaintKey = customPaintKey;
2263
2264 final GlobalKey _customPaintKey;
2265
2266 @override
2267 bool isPointerAllowed(PointerDownEvent event) {
2268 return _isTrackEvent(_customPaintKey, event) && super.isPointerAllowed(event);
2269 }
2270}
2271
2272class _VerticalThumbDragGestureRecognizer extends VerticalDragGestureRecognizer {
2273 _VerticalThumbDragGestureRecognizer({
2274 required Object super.debugOwner,
2275 required GlobalKey customPaintKey,
2276 }) : _customPaintKey = customPaintKey;
2277
2278 final GlobalKey _customPaintKey;
2279
2280 @override
2281 bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
2282 return false;
2283 }
2284
2285 @override
2286 bool isPointerAllowed(PointerEvent event) {
2287 return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
2288 }
2289}
2290
2291class _HorizontalThumbDragGestureRecognizer extends HorizontalDragGestureRecognizer {
2292 _HorizontalThumbDragGestureRecognizer({
2293 required Object super.debugOwner,
2294 required GlobalKey customPaintKey,
2295 }) : _customPaintKey = customPaintKey;
2296
2297 final GlobalKey _customPaintKey;
2298
2299 @override
2300 bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
2301 return false;
2302 }
2303
2304 @override
2305 bool isPointerAllowed(PointerEvent event) {
2306 return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
2307 }
2308}
2309