1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:async' show Timer; |
6 | import 'dart:math' as math; |
7 | |
8 | import 'package:flutter/foundation.dart'; |
9 | import 'package:flutter/physics.dart' show Tolerance, nearEqual; |
10 | import 'package:flutter/rendering.dart'; |
11 | import 'package:flutter/scheduler.dart'; |
12 | |
13 | import 'basic.dart'; |
14 | import 'framework.dart'; |
15 | import 'media_query.dart'; |
16 | import 'notification_listener.dart'; |
17 | import 'scroll_notification.dart'; |
18 | import 'ticker_provider.dart'; |
19 | import 'transitions.dart'; |
20 | |
21 | /// A visual indication that a scroll view has overscrolled. |
22 | /// |
23 | /// A [GlowingOverscrollIndicator] listens for [ScrollNotification]s in order |
24 | /// to control the overscroll indication. These notifications are typically |
25 | /// generated by a [ScrollView], such as a [ListView] or a [GridView]. |
26 | /// |
27 | /// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification] |
28 | /// before showing an overscroll indication. To prevent the indicator from |
29 | /// showing the indication, call |
30 | /// [OverscrollIndicatorNotification.disallowIndicator] on the notification. |
31 | /// |
32 | /// Created automatically by [ScrollBehavior.buildOverscrollIndicator] on platforms |
33 | /// (e.g., Android) that commonly use this type of overscroll indication. |
34 | /// |
35 | /// In a [MaterialApp], the edge glow color is the overall theme's |
36 | /// [ColorScheme.secondary] color. |
37 | /// |
38 | /// ## Customizing the Glow Position for Advanced Scroll Views |
39 | /// |
40 | /// When building a [CustomScrollView] with a [GlowingOverscrollIndicator], the |
41 | /// indicator will apply to the entire scrollable area, regardless of what |
42 | /// slivers the CustomScrollView contains. |
43 | /// |
44 | /// For example, if your CustomScrollView contains a SliverAppBar in the first |
45 | /// position, the GlowingOverscrollIndicator will overlay the SliverAppBar. To |
46 | /// manipulate the position of the GlowingOverscrollIndicator in this case, |
47 | /// you can either make use of a [NotificationListener] and provide a |
48 | /// [OverscrollIndicatorNotification.paintOffset] to the |
49 | /// notification, or use a [NestedScrollView]. |
50 | /// |
51 | /// {@tool dartpad} |
52 | /// This example demonstrates how to use a [NotificationListener] to manipulate |
53 | /// the placement of a [GlowingOverscrollIndicator] when building a |
54 | /// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll |
55 | /// indicator. |
56 | /// |
57 | /// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.0.dart ** |
58 | /// {@end-tool} |
59 | /// |
60 | /// {@tool dartpad} |
61 | /// This example demonstrates how to use a [NestedScrollView] to manipulate the |
62 | /// placement of a [GlowingOverscrollIndicator] when building a |
63 | /// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll |
64 | /// indicator. |
65 | /// |
66 | /// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.1.dart ** |
67 | /// {@end-tool} |
68 | /// |
69 | /// See also: |
70 | /// |
71 | /// * [OverscrollIndicatorNotification], which can be used to manipulate the |
72 | /// glow position or prevent the glow from being painted at all. |
73 | /// * [NotificationListener], to listen for the |
74 | /// [OverscrollIndicatorNotification]. |
75 | /// * [StretchingOverscrollIndicator], a Material Design overscroll indicator. |
76 | class GlowingOverscrollIndicator extends StatefulWidget { |
77 | /// Creates a visual indication that a scroll view has overscrolled. |
78 | /// |
79 | /// In order for this widget to display an overscroll indication, the [child] |
80 | /// widget must contain a widget that generates a [ScrollNotification], such |
81 | /// as a [ListView] or a [GridView]. |
82 | const GlowingOverscrollIndicator({ |
83 | super.key, |
84 | this.showLeading = true, |
85 | this.showTrailing = true, |
86 | required this.axisDirection, |
87 | required this.color, |
88 | this.notificationPredicate = defaultScrollNotificationPredicate, |
89 | this.child, |
90 | }); |
91 | |
92 | /// Whether to show the overscroll glow on the side with negative scroll |
93 | /// offsets. |
94 | /// |
95 | /// For a vertical downwards viewport, this is the top side. |
96 | /// |
97 | /// Defaults to true. |
98 | /// |
99 | /// See [showTrailing] for the corresponding control on the other side of the |
100 | /// viewport. |
101 | final bool showLeading; |
102 | |
103 | /// Whether to show the overscroll glow on the side with positive scroll |
104 | /// offsets. |
105 | /// |
106 | /// For a vertical downwards viewport, this is the bottom side. |
107 | /// |
108 | /// Defaults to true. |
109 | /// |
110 | /// See [showLeading] for the corresponding control on the other side of the |
111 | /// viewport. |
112 | final bool showTrailing; |
113 | |
114 | /// {@template flutter.overscroll.axisDirection} |
115 | /// The direction of positive scroll offsets in the [Scrollable] whose |
116 | /// overscrolls are to be visualized. |
117 | /// {@endtemplate} |
118 | final AxisDirection axisDirection; |
119 | |
120 | /// {@template flutter.overscroll.axis} |
121 | /// The axis along which scrolling occurs in the [Scrollable] whose |
122 | /// overscrolls are to be visualized. |
123 | /// {@endtemplate} |
124 | Axis get axis => axisDirectionToAxis(axisDirection); |
125 | |
126 | /// The color of the glow. The alpha channel is ignored. |
127 | final Color color; |
128 | |
129 | /// {@template flutter.overscroll.notificationPredicate} |
130 | /// A check that specifies whether a [ScrollNotification] should be |
131 | /// handled by this widget. |
132 | /// |
133 | /// By default, checks whether `notification.depth == 0`. Set it to something |
134 | /// else for more complicated layouts, such as nested [ScrollView]s. |
135 | /// {@endtemplate} |
136 | final ScrollNotificationPredicate notificationPredicate; |
137 | |
138 | /// The widget below this widget in the tree. |
139 | /// |
140 | /// The overscroll indicator will paint on top of this child. This child (and its |
141 | /// subtree) should include a source of [ScrollNotification] notifications. |
142 | /// |
143 | /// Typically a [GlowingOverscrollIndicator] is created by a |
144 | /// [ScrollBehavior.buildOverscrollIndicator] method, in which case |
145 | /// the child is usually the one provided as an argument to that method. |
146 | final Widget? child; |
147 | |
148 | @override |
149 | State<GlowingOverscrollIndicator> createState() => _GlowingOverscrollIndicatorState(); |
150 | |
151 | @override |
152 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
153 | super.debugFillProperties(properties); |
154 | properties.add(EnumProperty<AxisDirection>('axisDirection' , axisDirection)); |
155 | final String showDescription; |
156 | if (showLeading && showTrailing) { |
157 | showDescription = 'both sides' ; |
158 | } else if (showLeading) { |
159 | showDescription = 'leading side only' ; |
160 | } else if (showTrailing) { |
161 | showDescription = 'trailing side only' ; |
162 | } else { |
163 | showDescription = 'neither side (!)' ; |
164 | } |
165 | properties.add(MessageProperty('show' , showDescription)); |
166 | properties.add(ColorProperty('color' , color, showName: false)); |
167 | } |
168 | } |
169 | |
170 | class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> with TickerProviderStateMixin { |
171 | _GlowController? _leadingController; |
172 | _GlowController? _trailingController; |
173 | Listenable? _leadingAndTrailingListener; |
174 | |
175 | @override |
176 | void initState() { |
177 | super.initState(); |
178 | _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis); |
179 | _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis); |
180 | _leadingAndTrailingListener = Listenable.merge(<Listenable>[_leadingController!, _trailingController!]); |
181 | } |
182 | |
183 | @override |
184 | void didUpdateWidget(GlowingOverscrollIndicator oldWidget) { |
185 | super.didUpdateWidget(oldWidget); |
186 | if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) { |
187 | _leadingController!.color = widget.color; |
188 | _leadingController!.axis = widget.axis; |
189 | _trailingController!.color = widget.color; |
190 | _trailingController!.axis = widget.axis; |
191 | } |
192 | } |
193 | |
194 | Type? _lastNotificationType; |
195 | final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true}; |
196 | |
197 | bool _handleScrollNotification(ScrollNotification notification) { |
198 | if (!widget.notificationPredicate(notification)) { |
199 | return false; |
200 | } |
201 | if (notification.metrics.axis != widget.axis) { |
202 | // This widget is explicitly configured to one axis. If a notification |
203 | // from a different axis bubbles up, do nothing. |
204 | return false; |
205 | } |
206 | |
207 | // Update the paint offset with the current scroll position. This makes |
208 | // sure that the glow effect correctly scrolls in line with the current |
209 | // scroll, e.g. when scrolling in the opposite direction again to hide |
210 | // the glow. Otherwise, the glow would always stay in a fixed position, |
211 | // even if the top of the content already scrolled away. |
212 | // For example (CustomScrollView with sliver before center), the scroll |
213 | // extent is [-200.0, 300.0], scroll in the opposite direction with 10.0 pixels |
214 | // before glow disappears, so the current pixels is -190.0, |
215 | // in this case, we should move the glow up 10.0 pixels and should not |
216 | // overflow the scrollable widget's edge. https://github.com/flutter/flutter/issues/64149. |
217 | _leadingController!._paintOffsetScrollPixels = |
218 | -math.min(notification.metrics.pixels - notification.metrics.minScrollExtent, _leadingController!._paintOffset); |
219 | _trailingController!._paintOffsetScrollPixels = |
220 | -math.min(notification.metrics.maxScrollExtent - notification.metrics.pixels, _trailingController!._paintOffset); |
221 | |
222 | if (notification is OverscrollNotification) { |
223 | _GlowController? controller; |
224 | if (notification.overscroll < 0.0) { |
225 | controller = _leadingController; |
226 | } else if (notification.overscroll > 0.0) { |
227 | controller = _trailingController; |
228 | } else { |
229 | assert(false); |
230 | } |
231 | final bool isLeading = controller == _leadingController; |
232 | if (_lastNotificationType is! OverscrollNotification) { |
233 | final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading); |
234 | confirmationNotification.dispatch(context); |
235 | _accepted[isLeading] = confirmationNotification.accepted; |
236 | if (_accepted[isLeading]!) { |
237 | controller!._paintOffset = confirmationNotification.paintOffset; |
238 | } |
239 | } |
240 | assert(controller != null); |
241 | if (_accepted[isLeading]!) { |
242 | if (notification.velocity != 0.0) { |
243 | assert(notification.dragDetails == null); |
244 | controller!.absorbImpact(notification.velocity.abs()); |
245 | } else { |
246 | assert(notification.overscroll != 0.0); |
247 | if (notification.dragDetails != null) { |
248 | final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox; |
249 | assert(renderer.hasSize); |
250 | final Size size = renderer.size; |
251 | final Offset position = renderer.globalToLocal(notification.dragDetails!.globalPosition); |
252 | switch (notification.metrics.axis) { |
253 | case Axis.horizontal: |
254 | controller!.pull(notification.overscroll.abs(), size.width, clampDouble(position.dy, 0.0, size.height), size.height); |
255 | case Axis.vertical: |
256 | controller!.pull(notification.overscroll.abs(), size.height, clampDouble(position.dx, 0.0, size.width), size.width); |
257 | } |
258 | } |
259 | } |
260 | } |
261 | } else if ((notification is ScrollEndNotification && notification.dragDetails != null) || |
262 | (notification is ScrollUpdateNotification && notification.dragDetails != null)) { |
263 | _leadingController!.scrollEnd(); |
264 | _trailingController!.scrollEnd(); |
265 | } |
266 | _lastNotificationType = notification.runtimeType; |
267 | return false; |
268 | } |
269 | |
270 | @override |
271 | void dispose() { |
272 | _leadingController!.dispose(); |
273 | _trailingController!.dispose(); |
274 | super.dispose(); |
275 | } |
276 | |
277 | @override |
278 | Widget build(BuildContext context) { |
279 | return NotificationListener<ScrollNotification>( |
280 | onNotification: _handleScrollNotification, |
281 | child: RepaintBoundary( |
282 | child: CustomPaint( |
283 | foregroundPainter: _GlowingOverscrollIndicatorPainter( |
284 | leadingController: widget.showLeading ? _leadingController : null, |
285 | trailingController: widget.showTrailing ? _trailingController : null, |
286 | axisDirection: widget.axisDirection, |
287 | repaint: _leadingAndTrailingListener, |
288 | ), |
289 | child: RepaintBoundary( |
290 | child: widget.child, |
291 | ), |
292 | ), |
293 | ), |
294 | ); |
295 | } |
296 | } |
297 | |
298 | // The Glow logic is a port of the logic in the following file: |
299 | // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/EdgeEffect.java |
300 | // as of December 2016. |
301 | |
302 | enum _GlowState { idle, absorb, pull, recede } |
303 | |
304 | class _GlowController extends ChangeNotifier { |
305 | _GlowController({ |
306 | required TickerProvider vsync, |
307 | required Color color, |
308 | required Axis axis, |
309 | }) : _color = color, |
310 | _axis = axis { |
311 | _glowController = AnimationController(vsync: vsync) |
312 | ..addStatusListener(_changePhase); |
313 | final Animation<double> decelerator = CurvedAnimation( |
314 | parent: _glowController, |
315 | curve: Curves.decelerate, |
316 | )..addListener(notifyListeners); |
317 | _glowOpacity = decelerator.drive(_glowOpacityTween); |
318 | _glowSize = decelerator.drive(_glowSizeTween); |
319 | _displacementTicker = vsync.createTicker(_tickDisplacement); |
320 | } |
321 | |
322 | // animation of the main axis direction |
323 | _GlowState _state = _GlowState.idle; |
324 | late final AnimationController _glowController; |
325 | Timer? _pullRecedeTimer; |
326 | double _paintOffset = 0.0; |
327 | double _paintOffsetScrollPixels = 0.0; |
328 | |
329 | // animation values |
330 | final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0); |
331 | late final Animation<double> _glowOpacity; |
332 | final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0); |
333 | late final Animation<double> _glowSize; |
334 | |
335 | // animation of the cross axis position |
336 | late final Ticker _displacementTicker; |
337 | Duration? _displacementTickerLastElapsed; |
338 | double _displacementTarget = 0.5; |
339 | double _displacement = 0.5; |
340 | |
341 | // tracking the pull distance |
342 | double _pullDistance = 0.0; |
343 | |
344 | Color get color => _color; |
345 | Color _color; |
346 | set color(Color value) { |
347 | if (color == value) { |
348 | return; |
349 | } |
350 | _color = value; |
351 | notifyListeners(); |
352 | } |
353 | |
354 | Axis get axis => _axis; |
355 | Axis _axis; |
356 | set axis(Axis value) { |
357 | if (axis == value) { |
358 | return; |
359 | } |
360 | _axis = value; |
361 | notifyListeners(); |
362 | } |
363 | |
364 | static const Duration _recedeTime = Duration(milliseconds: 600); |
365 | static const Duration _pullTime = Duration(milliseconds: 167); |
366 | static const Duration _pullHoldTime = Duration(milliseconds: 167); |
367 | static const Duration _pullDecayTime = Duration(milliseconds: 2000); |
368 | static final Duration _crossAxisHalfTime = Duration(microseconds: (Duration.microsecondsPerSecond / 60.0).round()); |
369 | |
370 | static const double _maxOpacity = 0.5; |
371 | static const double _pullOpacityGlowFactor = 0.8; |
372 | static const double _velocityGlowFactor = 0.00006; |
373 | static const double _sqrt3 = 1.73205080757; // const math.sqrt(3) |
374 | static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3); |
375 | |
376 | // absorbed velocities are clamped to the range _minVelocity.._maxVelocity |
377 | static const double _minVelocity = 100.0; // logical pixels per second |
378 | static const double _maxVelocity = 10000.0; // logical pixels per second |
379 | |
380 | @override |
381 | void dispose() { |
382 | _glowController.dispose(); |
383 | _displacementTicker.dispose(); |
384 | _pullRecedeTimer?.cancel(); |
385 | super.dispose(); |
386 | } |
387 | |
388 | /// Handle a scroll slamming into the edge at a particular velocity. |
389 | /// |
390 | /// The velocity must be positive. |
391 | void absorbImpact(double velocity) { |
392 | assert(velocity >= 0.0); |
393 | _pullRecedeTimer?.cancel(); |
394 | _pullRecedeTimer = null; |
395 | velocity = clampDouble(velocity, _minVelocity, _maxVelocity); |
396 | _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value; |
397 | _glowOpacityTween.end = clampDouble(velocity * _velocityGlowFactor, _glowOpacityTween.begin!, _maxOpacity); |
398 | _glowSizeTween.begin = _glowSize.value; |
399 | _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0); |
400 | _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round()); |
401 | _glowController.forward(from: 0.0); |
402 | _displacement = 0.5; |
403 | _state = _GlowState.absorb; |
404 | } |
405 | |
406 | /// Handle a user-driven overscroll. |
407 | /// |
408 | /// The `overscroll` argument should be the scroll distance in logical pixels, |
409 | /// the `extent` argument should be the total dimension of the viewport in the |
410 | /// main axis in logical pixels, the `crossAxisOffset` argument should be the |
411 | /// distance from the leading (left or top) edge of the cross axis of the |
412 | /// viewport, and the `crossExtent` should be the size of the cross axis. For |
413 | /// example, a pull of 50 pixels up the middle of a 200 pixel high and 100 |
414 | /// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0, |
415 | /// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the |
416 | /// direction. |
417 | void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) { |
418 | _pullRecedeTimer?.cancel(); |
419 | _pullDistance += overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android. |
420 | _glowOpacityTween.begin = _glowOpacity.value; |
421 | _glowOpacityTween.end = math.min(_glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor, _maxOpacity); |
422 | final double height = math.min(extent, crossExtent * _widthToHeightFactor); |
423 | _glowSizeTween.begin = _glowSize.value; |
424 | _glowSizeTween.end = math.max(1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), _glowSize.value); |
425 | _displacementTarget = crossAxisOffset / crossExtent; |
426 | if (_displacementTarget != _displacement) { |
427 | if (!_displacementTicker.isTicking) { |
428 | assert(_displacementTickerLastElapsed == null); |
429 | _displacementTicker.start(); |
430 | } |
431 | } else { |
432 | _displacementTicker.stop(); |
433 | _displacementTickerLastElapsed = null; |
434 | } |
435 | _glowController.duration = _pullTime; |
436 | if (_state != _GlowState.pull) { |
437 | _glowController.forward(from: 0.0); |
438 | _state = _GlowState.pull; |
439 | } else { |
440 | if (!_glowController.isAnimating) { |
441 | assert(_glowController.value == 1.0); |
442 | notifyListeners(); |
443 | } |
444 | } |
445 | _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime)); |
446 | } |
447 | |
448 | void scrollEnd() { |
449 | if (_state == _GlowState.pull) { |
450 | _recede(_recedeTime); |
451 | } |
452 | } |
453 | |
454 | void _changePhase(AnimationStatus status) { |
455 | if (status != AnimationStatus.completed) { |
456 | return; |
457 | } |
458 | switch (_state) { |
459 | case _GlowState.absorb: |
460 | _recede(_recedeTime); |
461 | case _GlowState.recede: |
462 | _state = _GlowState.idle; |
463 | _pullDistance = 0.0; |
464 | case _GlowState.pull: |
465 | case _GlowState.idle: |
466 | break; |
467 | } |
468 | } |
469 | |
470 | void _recede(Duration duration) { |
471 | if (_state == _GlowState.recede || _state == _GlowState.idle) { |
472 | return; |
473 | } |
474 | _pullRecedeTimer?.cancel(); |
475 | _pullRecedeTimer = null; |
476 | _glowOpacityTween.begin = _glowOpacity.value; |
477 | _glowOpacityTween.end = 0.0; |
478 | _glowSizeTween.begin = _glowSize.value; |
479 | _glowSizeTween.end = 0.0; |
480 | _glowController.duration = duration; |
481 | _glowController.forward(from: 0.0); |
482 | _state = _GlowState.recede; |
483 | } |
484 | |
485 | void _tickDisplacement(Duration elapsed) { |
486 | if (_displacementTickerLastElapsed != null) { |
487 | final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed!.inMicroseconds).toDouble(); |
488 | _displacement = _displacementTarget - (_displacementTarget - _displacement) * math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds); |
489 | notifyListeners(); |
490 | } |
491 | if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) { |
492 | _displacementTicker.stop(); |
493 | _displacementTickerLastElapsed = null; |
494 | } else { |
495 | _displacementTickerLastElapsed = elapsed; |
496 | } |
497 | } |
498 | |
499 | void paint(Canvas canvas, Size size) { |
500 | if (_glowOpacity.value == 0.0) { |
501 | return; |
502 | } |
503 | final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0; |
504 | final double radius = size.width * 3.0 / 2.0; |
505 | final double height = math.min(size.height, size.width * _widthToHeightFactor); |
506 | final double scaleY = _glowSize.value * baseGlowScale; |
507 | final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, height); |
508 | final Offset center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius); |
509 | final Paint paint = Paint()..color = color.withOpacity(_glowOpacity.value); |
510 | canvas.save(); |
511 | canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels); |
512 | canvas.scale(1.0, scaleY); |
513 | canvas.clipRect(rect); |
514 | canvas.drawCircle(center, radius, paint); |
515 | canvas.restore(); |
516 | } |
517 | |
518 | @override |
519 | String toString() { |
520 | return '_GlowController(color: $color, axis: ${axis.name})' ; |
521 | } |
522 | } |
523 | |
524 | class _GlowingOverscrollIndicatorPainter extends CustomPainter { |
525 | _GlowingOverscrollIndicatorPainter({ |
526 | this.leadingController, |
527 | this.trailingController, |
528 | required this.axisDirection, |
529 | super.repaint, |
530 | }); |
531 | |
532 | /// The controller for the overscroll glow on the side with negative scroll offsets. |
533 | /// |
534 | /// For a vertical downwards viewport, this is the top side. |
535 | final _GlowController? leadingController; |
536 | |
537 | /// The controller for the overscroll glow on the side with positive scroll offsets. |
538 | /// |
539 | /// For a vertical downwards viewport, this is the bottom side. |
540 | final _GlowController? trailingController; |
541 | |
542 | /// The direction of the viewport. |
543 | final AxisDirection axisDirection; |
544 | |
545 | static const double piOver2 = math.pi / 2.0; |
546 | |
547 | void _paintSide(Canvas canvas, Size size, _GlowController? controller, AxisDirection axisDirection, GrowthDirection growthDirection) { |
548 | if (controller == null) { |
549 | return; |
550 | } |
551 | switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) { |
552 | case AxisDirection.up: |
553 | controller.paint(canvas, size); |
554 | case AxisDirection.down: |
555 | canvas.save(); |
556 | canvas.translate(0.0, size.height); |
557 | canvas.scale(1.0, -1.0); |
558 | controller.paint(canvas, size); |
559 | canvas.restore(); |
560 | case AxisDirection.left: |
561 | canvas.save(); |
562 | canvas.rotate(piOver2); |
563 | canvas.scale(1.0, -1.0); |
564 | controller.paint(canvas, Size(size.height, size.width)); |
565 | canvas.restore(); |
566 | case AxisDirection.right: |
567 | canvas.save(); |
568 | canvas.translate(size.width, 0.0); |
569 | canvas.rotate(piOver2); |
570 | controller.paint(canvas, Size(size.height, size.width)); |
571 | canvas.restore(); |
572 | } |
573 | } |
574 | |
575 | @override |
576 | void paint(Canvas canvas, Size size) { |
577 | _paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse); |
578 | _paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward); |
579 | } |
580 | |
581 | @override |
582 | bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) { |
583 | return oldDelegate.leadingController != leadingController |
584 | || oldDelegate.trailingController != trailingController; |
585 | } |
586 | |
587 | @override |
588 | String toString() { |
589 | return '_GlowingOverscrollIndicatorPainter( $leadingController, $trailingController)' ; |
590 | } |
591 | } |
592 | |
593 | enum _StretchDirection { |
594 | /// The [trailing] direction indicates that the content will be stretched toward |
595 | /// the trailing edge. |
596 | trailing, |
597 | /// The [leading] direction indicates that the content will be stretched toward |
598 | /// the leading edge. |
599 | leading, |
600 | } |
601 | |
602 | /// A Material Design visual indication that a scroll view has overscrolled. |
603 | /// |
604 | /// A [StretchingOverscrollIndicator] listens for [ScrollNotification]s in order |
605 | /// to stretch the content of the [Scrollable]. These notifications are typically |
606 | /// generated by a [ScrollView], such as a [ListView] or a [GridView]. |
607 | /// |
608 | /// When triggered, the [StretchingOverscrollIndicator] generates an |
609 | /// [OverscrollIndicatorNotification] before showing an overscroll indication. |
610 | /// To prevent the indicator from showing the indication, call |
611 | /// [OverscrollIndicatorNotification.disallowIndicator] on the notification. |
612 | /// |
613 | /// Created by [MaterialScrollBehavior.buildOverscrollIndicator] on platforms |
614 | /// (e.g., Android) that commonly use this type of overscroll indication when |
615 | /// [ThemeData.useMaterial3] is true. Otherwise, when [ThemeData.useMaterial3] |
616 | /// is false, a [GlowingOverscrollIndicator] is used instead.= |
617 | /// |
618 | /// See also: |
619 | /// |
620 | /// * [OverscrollIndicatorNotification], which can be used to prevent the |
621 | /// stretch effect from being applied at all. |
622 | /// * [NotificationListener], to listen for the |
623 | /// [OverscrollIndicatorNotification]. |
624 | /// * [GlowingOverscrollIndicator], the default overscroll indicator for |
625 | /// [TargetPlatform.android] and [TargetPlatform.fuchsia]. |
626 | class StretchingOverscrollIndicator extends StatefulWidget { |
627 | /// Creates a visual indication that a scroll view has overscrolled by |
628 | /// applying a stretch transformation to the content. |
629 | /// |
630 | /// In order for this widget to display an overscroll indication, the [child] |
631 | /// widget must contain a widget that generates a [ScrollNotification], such |
632 | /// as a [ListView] or a [GridView]. |
633 | const StretchingOverscrollIndicator({ |
634 | super.key, |
635 | required this.axisDirection, |
636 | this.notificationPredicate = defaultScrollNotificationPredicate, |
637 | this.clipBehavior = Clip.hardEdge, |
638 | this.child, |
639 | }); |
640 | |
641 | /// {@macro flutter.overscroll.axisDirection} |
642 | final AxisDirection axisDirection; |
643 | |
644 | /// {@macro flutter.overscroll.axis} |
645 | Axis get axis => axisDirectionToAxis(axisDirection); |
646 | |
647 | /// {@macro flutter.overscroll.notificationPredicate} |
648 | final ScrollNotificationPredicate notificationPredicate; |
649 | |
650 | /// {@macro flutter.material.Material.clipBehavior} |
651 | /// |
652 | /// Defaults to [Clip.hardEdge]. |
653 | final Clip clipBehavior; |
654 | |
655 | /// The widget below this widget in the tree. |
656 | /// |
657 | /// The overscroll indicator will apply a stretch effect to this child. This |
658 | /// child (and its subtree) should include a source of [ScrollNotification] |
659 | /// notifications. |
660 | final Widget? child; |
661 | |
662 | @override |
663 | State<StretchingOverscrollIndicator> createState() => _StretchingOverscrollIndicatorState(); |
664 | |
665 | @override |
666 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
667 | super.debugFillProperties(properties); |
668 | properties.add(EnumProperty<AxisDirection>('axisDirection' , axisDirection)); |
669 | } |
670 | } |
671 | |
672 | class _StretchingOverscrollIndicatorState extends State<StretchingOverscrollIndicator> with TickerProviderStateMixin { |
673 | late final _StretchController _stretchController = _StretchController(vsync: this); |
674 | ScrollNotification? _lastNotification; |
675 | OverscrollNotification? _lastOverscrollNotification; |
676 | |
677 | double _totalOverscroll = 0.0; |
678 | |
679 | bool _accepted = true; |
680 | |
681 | bool _handleScrollNotification(ScrollNotification notification) { |
682 | if (!widget.notificationPredicate(notification)) { |
683 | return false; |
684 | } |
685 | if (notification.metrics.axis != widget.axis) { |
686 | // This widget is explicitly configured to one axis. If a notification |
687 | // from a different axis bubbles up, do nothing. |
688 | return false; |
689 | } |
690 | |
691 | if (notification is OverscrollNotification) { |
692 | _lastOverscrollNotification = notification; |
693 | if (_lastNotification.runtimeType is! OverscrollNotification) { |
694 | final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: notification.overscroll < 0.0); |
695 | confirmationNotification.dispatch(context); |
696 | _accepted = confirmationNotification.accepted; |
697 | } |
698 | |
699 | if (_accepted) { |
700 | _totalOverscroll += notification.overscroll; |
701 | |
702 | if (notification.velocity != 0.0) { |
703 | assert(notification.dragDetails == null); |
704 | _stretchController.absorbImpact(notification.velocity.abs(), _totalOverscroll); |
705 | } else { |
706 | assert(notification.overscroll != 0.0); |
707 | if (notification.dragDetails != null) { |
708 | // We clamp the overscroll amount relative to the length of the viewport, |
709 | // which is the furthest distance a single pointer could pull on the |
710 | // screen. This is because more than one pointer will multiply the |
711 | // amount of overscroll - https://github.com/flutter/flutter/issues/11884 |
712 | |
713 | final double viewportDimension = notification.metrics.viewportDimension; |
714 | final double distanceForPull = _totalOverscroll.abs() / viewportDimension; |
715 | final double clampedOverscroll = clampDouble(distanceForPull, 0, 1.0); |
716 | _stretchController.pull(clampedOverscroll, _totalOverscroll); |
717 | } |
718 | } |
719 | } |
720 | } else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) { |
721 | // Since the overscrolling ended, we reset the total overscroll amount. |
722 | _totalOverscroll = 0; |
723 | _stretchController.scrollEnd(); |
724 | } |
725 | _lastNotification = notification; |
726 | return false; |
727 | } |
728 | |
729 | AlignmentGeometry _getAlignmentForAxisDirection(_StretchDirection stretchDirection) { |
730 | // Accounts for reversed scrollables by checking the AxisDirection |
731 | switch (widget.axisDirection) { |
732 | case AxisDirection.up: |
733 | return stretchDirection == _StretchDirection.trailing |
734 | ? AlignmentDirectional.topCenter |
735 | : AlignmentDirectional.bottomCenter; |
736 | case AxisDirection.right: |
737 | return stretchDirection == _StretchDirection.trailing |
738 | ? Alignment.centerRight |
739 | : Alignment.centerLeft; |
740 | case AxisDirection.down: |
741 | return stretchDirection == _StretchDirection.trailing |
742 | ? AlignmentDirectional.bottomCenter |
743 | : AlignmentDirectional.topCenter; |
744 | case AxisDirection.left: |
745 | return stretchDirection == _StretchDirection.trailing |
746 | ? Alignment.centerLeft |
747 | : Alignment.centerRight; |
748 | } |
749 | } |
750 | |
751 | @override |
752 | void dispose() { |
753 | _stretchController.dispose(); |
754 | super.dispose(); |
755 | } |
756 | |
757 | @override |
758 | Widget build(BuildContext context) { |
759 | final Size size = MediaQuery.sizeOf(context); |
760 | double mainAxisSize; |
761 | return NotificationListener<ScrollNotification>( |
762 | onNotification: _handleScrollNotification, |
763 | child: AnimatedBuilder( |
764 | animation: _stretchController, |
765 | builder: (BuildContext context, Widget? child) { |
766 | final double stretch = _stretchController.value; |
767 | double x = 1.0; |
768 | double y = 1.0; |
769 | |
770 | switch (widget.axis) { |
771 | case Axis.horizontal: |
772 | x += stretch; |
773 | mainAxisSize = size.width; |
774 | case Axis.vertical: |
775 | y += stretch; |
776 | mainAxisSize = size.height; |
777 | } |
778 | |
779 | final AlignmentGeometry alignment = _getAlignmentForAxisDirection( |
780 | _stretchController.stretchDirection, |
781 | ); |
782 | |
783 | final double viewportDimension = _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize; |
784 | final Widget transform = Transform( |
785 | alignment: alignment, |
786 | transform: Matrix4.diagonal3Values(x, y, 1.0), |
787 | filterQuality: stretch == 0 ? null : FilterQuality.low, |
788 | child: widget.child, |
789 | ); |
790 | |
791 | // Only clip if the viewport dimension is smaller than that of the |
792 | // screen size in the main axis. If the viewport takes up the whole |
793 | // screen, overflow from transforming the viewport is irrelevant. |
794 | return ClipRect( |
795 | clipBehavior: stretch != 0.0 && viewportDimension != mainAxisSize |
796 | ? widget.clipBehavior |
797 | : Clip.none, |
798 | child: transform, |
799 | ); |
800 | }, |
801 | ), |
802 | ); |
803 | } |
804 | } |
805 | |
806 | enum _StretchState { |
807 | idle, |
808 | absorb, |
809 | pull, |
810 | recede, |
811 | } |
812 | |
813 | class _StretchController extends ChangeNotifier { |
814 | _StretchController({ required TickerProvider vsync }) { |
815 | _stretchController = AnimationController(vsync: vsync) |
816 | ..addStatusListener(_changePhase); |
817 | final Animation<double> decelerator = CurvedAnimation( |
818 | parent: _stretchController, |
819 | curve: Curves.decelerate, |
820 | )..addListener(notifyListeners); |
821 | _stretchSize = decelerator.drive(_stretchSizeTween); |
822 | } |
823 | |
824 | late final AnimationController _stretchController; |
825 | late final Animation<double> _stretchSize; |
826 | final Tween<double> _stretchSizeTween = Tween<double>(begin: 0.0, end: 0.0); |
827 | _StretchState _state = _StretchState.idle; |
828 | |
829 | double get pullDistance => _pullDistance; |
830 | double _pullDistance = 0.0; |
831 | |
832 | _StretchDirection get stretchDirection => _stretchDirection; |
833 | _StretchDirection _stretchDirection = _StretchDirection.trailing; |
834 | |
835 | // Constants from Android. |
836 | static const double _exponentialScalar = math.e / 0.33; |
837 | static const double _stretchIntensity = 0.016; |
838 | static const double _flingFriction = 1.01; |
839 | static const Duration _stretchDuration = Duration(milliseconds: 400); |
840 | |
841 | double get value => _stretchSize.value; |
842 | |
843 | /// Handle a fling to the edge of the viewport at a particular velocity. |
844 | /// |
845 | /// The velocity must be positive. |
846 | void absorbImpact(double velocity, double totalOverscroll) { |
847 | assert(velocity >= 0.0); |
848 | velocity = clampDouble(velocity, 1, 10000); |
849 | _stretchSizeTween.begin = _stretchSize.value; |
850 | _stretchSizeTween.end = math.min(_stretchIntensity + (_flingFriction / velocity), 1.0); |
851 | _stretchController.duration = Duration(milliseconds: (velocity * 0.02).round()); |
852 | _stretchController.forward(from: 0.0); |
853 | _state = _StretchState.absorb; |
854 | _stretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading; |
855 | } |
856 | |
857 | /// Handle a user-driven overscroll. |
858 | /// |
859 | /// The `normalizedOverscroll` argument should be the absolute value of the |
860 | /// scroll distance in logical pixels, divided by the extent of the viewport |
861 | /// in the main axis. |
862 | void pull(double normalizedOverscroll, double totalOverscroll) { |
863 | assert(normalizedOverscroll >= 0.0); |
864 | |
865 | final _StretchDirection newStretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading; |
866 | if (_stretchDirection != newStretchDirection && _state == _StretchState.recede) { |
867 | // When the stretch direction changes while we are in the recede state, we need to ignore the change. |
868 | // If we don't, the stretch will instantly jump to the new direction with the recede animation still playing, which causes |
869 | // a unwanted visual abnormality (https://github.com/flutter/flutter/pull/116548#issuecomment-1414872567). |
870 | // By ignoring the directional change until the recede state is finished, we can avoid this. |
871 | return; |
872 | } |
873 | |
874 | _stretchDirection = newStretchDirection; |
875 | _pullDistance = normalizedOverscroll; |
876 | _stretchSizeTween.begin = _stretchSize.value; |
877 | final double linearIntensity =_stretchIntensity * _pullDistance; |
878 | final double exponentialIntensity = _stretchIntensity * (1 - math.exp(-_pullDistance * _exponentialScalar)); |
879 | _stretchSizeTween.end = linearIntensity + exponentialIntensity; |
880 | _stretchController.duration = _stretchDuration; |
881 | if (_state != _StretchState.pull) { |
882 | _stretchController.forward(from: 0.0); |
883 | _state = _StretchState.pull; |
884 | } else { |
885 | if (!_stretchController.isAnimating) { |
886 | assert(_stretchController.value == 1.0); |
887 | notifyListeners(); |
888 | } |
889 | } |
890 | } |
891 | |
892 | void scrollEnd() { |
893 | if (_state == _StretchState.pull) { |
894 | _recede(_stretchDuration); |
895 | } |
896 | } |
897 | |
898 | void _changePhase(AnimationStatus status) { |
899 | if (status != AnimationStatus.completed) { |
900 | return; |
901 | } |
902 | switch (_state) { |
903 | case _StretchState.absorb: |
904 | _recede(_stretchDuration); |
905 | case _StretchState.recede: |
906 | _state = _StretchState.idle; |
907 | _pullDistance = 0.0; |
908 | case _StretchState.pull: |
909 | case _StretchState.idle: |
910 | break; |
911 | } |
912 | } |
913 | |
914 | void _recede(Duration duration) { |
915 | if (_state == _StretchState.recede || _state == _StretchState.idle) { |
916 | return; |
917 | } |
918 | _stretchSizeTween.begin = _stretchSize.value; |
919 | _stretchSizeTween.end = 0.0; |
920 | _stretchController.duration = duration; |
921 | _stretchController.forward(from: 0.0); |
922 | _state = _StretchState.recede; |
923 | } |
924 | |
925 | @override |
926 | void dispose() { |
927 | _stretchController.dispose(); |
928 | super.dispose(); |
929 | } |
930 | |
931 | @override |
932 | String toString() => '_StretchController()' ; |
933 | } |
934 | |
935 | /// A notification that either a [GlowingOverscrollIndicator] or a |
936 | /// [StretchingOverscrollIndicator] will start showing an overscroll indication. |
937 | /// |
938 | /// To prevent the indicator from showing the indication, call |
939 | /// [disallowIndicator] on the notification. |
940 | /// |
941 | /// See also: |
942 | /// |
943 | /// * [GlowingOverscrollIndicator], which generates this type of notification |
944 | /// by painting an indicator over the child content. |
945 | /// * [StretchingOverscrollIndicator], which generates this type of |
946 | /// notification by applying a stretch transformation to the child content. |
947 | class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin { |
948 | /// Creates a notification that an [GlowingOverscrollIndicator] or a |
949 | /// [StretchingOverscrollIndicator] will start showing an overscroll indication. |
950 | OverscrollIndicatorNotification({ |
951 | required this.leading, |
952 | }); |
953 | |
954 | /// Whether the indication will be shown on the leading edge of the scroll |
955 | /// view. |
956 | final bool leading; |
957 | |
958 | /// Controls at which offset a [GlowingOverscrollIndicator] draws. |
959 | /// |
960 | /// A positive offset will move the glow away from its edge, |
961 | /// i.e. for a vertical, [leading] indicator, a [paintOffset] of 100.0 will |
962 | /// draw the indicator 100.0 pixels from the top of the edge. |
963 | /// For a vertical indicator with [leading] set to `false`, a [paintOffset] |
964 | /// of 100.0 will draw the indicator 100.0 pixels from the bottom instead. |
965 | /// |
966 | /// A negative [paintOffset] is generally not useful, since the glow will be |
967 | /// clipped. |
968 | /// |
969 | /// This has no effect on a [StretchingOverscrollIndicator]. |
970 | double paintOffset = 0.0; |
971 | |
972 | @protected |
973 | @visibleForTesting |
974 | /// Whether the current overscroll event will allow for the indicator to be |
975 | /// shown. |
976 | /// |
977 | /// Calling [disallowIndicator] sets this to false, preventing the over scroll |
978 | /// indicator from showing. |
979 | /// |
980 | /// Defaults to true. |
981 | bool accepted = true; |
982 | |
983 | /// Call this method if the overscroll indicator should be prevented. |
984 | void disallowIndicator() { |
985 | accepted = false; |
986 | } |
987 | |
988 | @override |
989 | void debugFillDescription(List<String> description) { |
990 | super.debugFillDescription(description); |
991 | description.add('side: ${leading ? "leading edge" : "trailing edge" }' ); |
992 | } |
993 | } |
994 | |