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