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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com