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