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