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
5import 'dart:async' show Timer;
6import 'dart:math' as math;
7
8import 'package:flutter/foundation.dart';
9import 'package:flutter/physics.dart' show Tolerance, nearEqual;
10import 'package:flutter/rendering.dart';
11import 'package:flutter/scheduler.dart';
12
13import 'basic.dart';
14import 'framework.dart';
15import 'media_query.dart';
16import 'notification_listener.dart';
17import 'scroll_notification.dart';
18import 'ticker_provider.dart';
19import '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.
76class 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
170class _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
302enum _GlowState { idle, absorb, pull, recede }
303
304class _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
524class _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
593enum _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].
626class 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
672class _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
806enum _StretchState {
807 idle,
808 absorb,
809 pull,
810 recede,
811}
812
813class _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.
947class 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