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