| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /// @docImport 'package:flutter/material.dart'; |
| /// |
| /// @docImport 'nested_scroll_view.dart'; |
| /// @docImport 'scroll_configuration.dart'; |
| /// @docImport 'scroll_view.dart'; |
| /// @docImport 'scrollable.dart'; |
| library; |
| |
| import 'dart:async' show Timer; |
| import 'dart:math' as math; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/physics.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/scheduler.dart'; |
| |
| import 'basic.dart'; |
| import 'framework.dart'; |
| import 'media_query.dart'; |
| import 'notification_listener.dart'; |
| import 'scroll_notification.dart'; |
| import 'stretch_effect.dart'; |
| import 'ticker_provider.dart'; |
| import 'transitions.dart'; |
| |
| /// A visual indication that a scroll view has overscrolled. |
| /// |
| /// A [GlowingOverscrollIndicator] listens for [ScrollNotification]s in order |
| /// to control the overscroll indication. These notifications are typically |
| /// generated by a [ScrollView], such as a [ListView] or a [GridView]. |
| /// |
| /// [GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification] |
| /// before showing an overscroll indication. To prevent the indicator from |
| /// showing the indication, call |
| /// [OverscrollIndicatorNotification.disallowIndicator] on the notification. |
| /// |
| /// Created automatically by [ScrollBehavior.buildOverscrollIndicator] on platforms |
| /// (e.g., Android) that commonly use this type of overscroll indication. |
| /// |
| /// In a [MaterialApp], the edge glow color is the overall theme's |
| /// [ColorScheme.secondary] color. |
| /// |
| /// ## Customizing the Glow Position for Advanced Scroll Views |
| /// |
| /// When building a [CustomScrollView] with a [GlowingOverscrollIndicator], the |
| /// indicator will apply to the entire scrollable area, regardless of what |
| /// slivers the CustomScrollView contains. |
| /// |
| /// For example, if your CustomScrollView contains a SliverAppBar in the first |
| /// position, the GlowingOverscrollIndicator will overlay the SliverAppBar. To |
| /// manipulate the position of the GlowingOverscrollIndicator in this case, |
| /// you can either make use of a [NotificationListener] and provide a |
| /// [OverscrollIndicatorNotification.paintOffset] to the |
| /// notification, or use a [NestedScrollView]. |
| /// |
| /// {@tool dartpad} |
| /// This example demonstrates how to use a [NotificationListener] to manipulate |
| /// the placement of a [GlowingOverscrollIndicator] when building a |
| /// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll |
| /// indicator. |
| /// |
| /// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This example demonstrates how to use a [NestedScrollView] to manipulate the |
| /// placement of a [GlowingOverscrollIndicator] when building a |
| /// [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll |
| /// indicator. |
| /// |
| /// ** See code in examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.1.dart ** |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// * [OverscrollIndicatorNotification], which can be used to manipulate the |
| /// glow position or prevent the glow from being painted at all. |
| /// * [NotificationListener], to listen for the |
| /// [OverscrollIndicatorNotification]. |
| /// * [StretchingOverscrollIndicator], a Material Design overscroll indicator. |
| class GlowingOverscrollIndicator extends StatefulWidget { |
| /// Creates a visual indication that a scroll view has overscrolled. |
| /// |
| /// In order for this widget to display an overscroll indication, the [child] |
| /// widget must contain a widget that generates a [ScrollNotification], such |
| /// as a [ListView] or a [GridView]. |
| const GlowingOverscrollIndicator({ |
| super.key, |
| this.showLeading = true, |
| this.showTrailing = true, |
| required this.axisDirection, |
| required this.color, |
| this.notificationPredicate = defaultScrollNotificationPredicate, |
| this.child, |
| }); |
| |
| /// Whether to show the overscroll glow on the side with negative scroll |
| /// offsets. |
| /// |
| /// For a vertical downwards viewport, this is the top side. |
| /// |
| /// Defaults to true. |
| /// |
| /// See [showTrailing] for the corresponding control on the other side of the |
| /// viewport. |
| final bool showLeading; |
| |
| /// Whether to show the overscroll glow on the side with positive scroll |
| /// offsets. |
| /// |
| /// For a vertical downwards viewport, this is the bottom side. |
| /// |
| /// Defaults to true. |
| /// |
| /// See [showLeading] for the corresponding control on the other side of the |
| /// viewport. |
| final bool showTrailing; |
| |
| /// {@template flutter.overscroll.axisDirection} |
| /// The direction of positive scroll offsets in the [Scrollable] whose |
| /// overscrolls are to be visualized. |
| /// {@endtemplate} |
| final AxisDirection axisDirection; |
| |
| /// {@template flutter.overscroll.axis} |
| /// The axis along which scrolling occurs in the [Scrollable] whose |
| /// overscrolls are to be visualized. |
| /// {@endtemplate} |
| Axis get axis => axisDirectionToAxis(axisDirection); |
| |
| /// The color of the glow. The alpha channel is ignored. |
| final Color color; |
| |
| /// {@template flutter.overscroll.notificationPredicate} |
| /// A check that specifies whether a [ScrollNotification] should be |
| /// handled by this widget. |
| /// |
| /// By default, checks whether `notification.depth == 0`. Set it to something |
| /// else for more complicated layouts, such as nested [ScrollView]s. |
| /// {@endtemplate} |
| final ScrollNotificationPredicate notificationPredicate; |
| |
| /// The widget below this widget in the tree. |
| /// |
| /// The overscroll indicator will paint on top of this child. This child (and its |
| /// subtree) should include a source of [ScrollNotification] notifications. |
| /// |
| /// Typically a [GlowingOverscrollIndicator] is created by a |
| /// [ScrollBehavior.buildOverscrollIndicator] method, in which case |
| /// the child is usually the one provided as an argument to that method. |
| final Widget? child; |
| |
| @override |
| State<GlowingOverscrollIndicator> createState() => _GlowingOverscrollIndicatorState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection)); |
| final String showDescription = switch ((showLeading, showTrailing)) { |
| (true, true) => 'both sides', |
| (true, false) => 'leading side only', |
| (false, true) => 'trailing side only', |
| (false, false) => 'neither side (!)', |
| }; |
| properties.add(MessageProperty('show', showDescription)); |
| properties.add(ColorProperty('color', color, showName: false)); |
| } |
| } |
| |
| class _GlowingOverscrollIndicatorState extends State<GlowingOverscrollIndicator> |
| with TickerProviderStateMixin { |
| _GlowController? _leadingController; |
| _GlowController? _trailingController; |
| Listenable? _leadingAndTrailingListener; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _leadingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis); |
| _trailingController = _GlowController(vsync: this, color: widget.color, axis: widget.axis); |
| _leadingAndTrailingListener = Listenable.merge(<Listenable>[ |
| _leadingController!, |
| _trailingController!, |
| ]); |
| } |
| |
| @override |
| void didUpdateWidget(GlowingOverscrollIndicator oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (oldWidget.color != widget.color || oldWidget.axis != widget.axis) { |
| _leadingController!.color = widget.color; |
| _leadingController!.axis = widget.axis; |
| _trailingController!.color = widget.color; |
| _trailingController!.axis = widget.axis; |
| } |
| } |
| |
| Type? _lastNotificationType; |
| final Map<bool, bool> _accepted = <bool, bool>{false: true, true: true}; |
| |
| bool _handleScrollNotification(ScrollNotification notification) { |
| if (!widget.notificationPredicate(notification)) { |
| return false; |
| } |
| if (notification.metrics.axis != widget.axis) { |
| // This widget is explicitly configured to one axis. If a notification |
| // from a different axis bubbles up, do nothing. |
| return false; |
| } |
| |
| // Update the paint offset with the current scroll position. This makes |
| // sure that the glow effect correctly scrolls in line with the current |
| // scroll, e.g. when scrolling in the opposite direction again to hide |
| // the glow. Otherwise, the glow would always stay in a fixed position, |
| // even if the top of the content already scrolled away. |
| // For example (CustomScrollView with sliver before center), the scroll |
| // extent is [-200.0, 300.0], scroll in the opposite direction with 10.0 pixels |
| // before glow disappears, so the current pixels is -190.0, |
| // in this case, we should move the glow up 10.0 pixels and should not |
| // overflow the scrollable widget's edge. https://github.com/flutter/flutter/issues/64149. |
| _leadingController!._paintOffsetScrollPixels = -math.min( |
| notification.metrics.pixels - notification.metrics.minScrollExtent, |
| _leadingController!._paintOffset, |
| ); |
| _trailingController!._paintOffsetScrollPixels = -math.min( |
| notification.metrics.maxScrollExtent - notification.metrics.pixels, |
| _trailingController!._paintOffset, |
| ); |
| |
| if (notification is OverscrollNotification) { |
| _GlowController? controller; |
| if (notification.overscroll < 0.0) { |
| controller = _leadingController; |
| } else if (notification.overscroll > 0.0) { |
| controller = _trailingController; |
| } else { |
| assert(false); |
| } |
| final isLeading = controller == _leadingController; |
| if (_lastNotificationType is! OverscrollNotification) { |
| final confirmationNotification = OverscrollIndicatorNotification(leading: isLeading); |
| confirmationNotification.dispatch(context); |
| _accepted[isLeading] = confirmationNotification.accepted; |
| if (_accepted[isLeading]!) { |
| controller!._paintOffset = confirmationNotification.paintOffset; |
| } |
| } |
| assert(controller != null); |
| if (_accepted[isLeading]!) { |
| if (notification.velocity != 0.0) { |
| assert(notification.dragDetails == null); |
| controller!.absorbImpact(notification.velocity.abs()); |
| } else { |
| assert(notification.overscroll != 0.0); |
| if (notification.dragDetails != null) { |
| final renderer = notification.context!.findRenderObject()! as RenderBox; |
| assert(renderer.hasSize); |
| final Size size = renderer.size; |
| final Offset position = renderer.globalToLocal( |
| notification.dragDetails!.globalPosition, |
| ); |
| switch (notification.metrics.axis) { |
| case Axis.horizontal: |
| controller!.pull( |
| notification.overscroll.abs(), |
| size.width, |
| clampDouble(position.dy, 0.0, size.height), |
| size.height, |
| ); |
| case Axis.vertical: |
| controller!.pull( |
| notification.overscroll.abs(), |
| size.height, |
| clampDouble(position.dx, 0.0, size.width), |
| size.width, |
| ); |
| } |
| } |
| } |
| } |
| } else if ((notification is ScrollEndNotification && notification.dragDetails != null) || |
| (notification is ScrollUpdateNotification && notification.dragDetails != null)) { |
| _leadingController!.scrollEnd(); |
| _trailingController!.scrollEnd(); |
| } |
| _lastNotificationType = notification.runtimeType; |
| return false; |
| } |
| |
| @override |
| void dispose() { |
| _leadingController!.dispose(); |
| _trailingController!.dispose(); |
| super.dispose(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return NotificationListener<ScrollNotification>( |
| onNotification: _handleScrollNotification, |
| child: RepaintBoundary( |
| child: CustomPaint( |
| foregroundPainter: _GlowingOverscrollIndicatorPainter( |
| leadingController: widget.showLeading ? _leadingController : null, |
| trailingController: widget.showTrailing ? _trailingController : null, |
| axisDirection: widget.axisDirection, |
| repaint: _leadingAndTrailingListener, |
| ), |
| child: RepaintBoundary(child: widget.child), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| // The Glow logic is a port of the logic in the following file: |
| // https://android.googlesource.com/platform/frameworks/base/+/main/core/java/android/widget/EdgeEffect.java |
| // as of December 2016. |
| |
| enum _GlowState { idle, absorb, pull, recede } |
| |
| class _GlowController extends ChangeNotifier { |
| _GlowController({required TickerProvider vsync, required Color color, required Axis axis}) |
| : _color = color, |
| _axis = axis { |
| if (kFlutterMemoryAllocationsEnabled) { |
| ChangeNotifier.maybeDispatchObjectCreation(this); |
| } |
| _glowController = AnimationController(vsync: vsync)..addStatusListener(_changePhase); |
| _decelerator = CurvedAnimation(parent: _glowController, curve: Curves.decelerate) |
| ..addListener(notifyListeners); |
| _glowOpacity = _decelerator.drive(_glowOpacityTween); |
| _glowSize = _decelerator.drive(_glowSizeTween); |
| _displacementTicker = vsync.createTicker(_tickDisplacement); |
| } |
| |
| // animation of the main axis direction |
| _GlowState _state = _GlowState.idle; |
| late final AnimationController _glowController; |
| Timer? _pullRecedeTimer; |
| double _paintOffset = 0.0; |
| double _paintOffsetScrollPixels = 0.0; |
| |
| // animation values |
| late final CurvedAnimation _decelerator; |
| final Tween<double> _glowOpacityTween = Tween<double>(begin: 0.0, end: 0.0); |
| late final Animation<double> _glowOpacity; |
| final Tween<double> _glowSizeTween = Tween<double>(begin: 0.0, end: 0.0); |
| late final Animation<double> _glowSize; |
| |
| // animation of the cross axis position |
| late final Ticker _displacementTicker; |
| Duration? _displacementTickerLastElapsed; |
| double _displacementTarget = 0.5; |
| double _displacement = 0.5; |
| |
| // tracking the pull distance |
| double _pullDistance = 0.0; |
| |
| Color get color => _color; |
| Color _color; |
| set color(Color value) { |
| if (color == value) { |
| return; |
| } |
| _color = value; |
| notifyListeners(); |
| } |
| |
| Axis get axis => _axis; |
| Axis _axis; |
| set axis(Axis value) { |
| if (axis == value) { |
| return; |
| } |
| _axis = value; |
| notifyListeners(); |
| } |
| |
| static const Duration _recedeTime = Duration(milliseconds: 600); |
| static const Duration _pullTime = Duration(milliseconds: 167); |
| static const Duration _pullHoldTime = Duration(milliseconds: 167); |
| static const Duration _pullDecayTime = Duration(milliseconds: 2000); |
| static final Duration _crossAxisHalfTime = Duration( |
| microseconds: (Duration.microsecondsPerSecond / 60.0).round(), |
| ); |
| |
| static const double _maxOpacity = 0.5; |
| static const double _pullOpacityGlowFactor = 0.8; |
| static const double _velocityGlowFactor = 0.00006; |
| static const double _sqrt3 = 1.73205080757; // const math.sqrt(3) |
| static const double _widthToHeightFactor = (3.0 / 4.0) * (2.0 - _sqrt3); |
| |
| // absorbed velocities are clamped to the range _minVelocity.._maxVelocity |
| static const double _minVelocity = 100.0; // logical pixels per second |
| static const double _maxVelocity = 10000.0; // logical pixels per second |
| |
| @override |
| void dispose() { |
| _glowController.dispose(); |
| _decelerator.dispose(); |
| _displacementTicker.dispose(); |
| _pullRecedeTimer?.cancel(); |
| super.dispose(); |
| } |
| |
| /// Handle a scroll slamming into the edge at a particular velocity. |
| /// |
| /// The velocity must be positive. |
| void absorbImpact(double velocity) { |
| assert(velocity >= 0.0); |
| _pullRecedeTimer?.cancel(); |
| _pullRecedeTimer = null; |
| velocity = clampDouble(velocity, _minVelocity, _maxVelocity); |
| _glowOpacityTween.begin = _state == _GlowState.idle ? 0.3 : _glowOpacity.value; |
| _glowOpacityTween.end = clampDouble( |
| velocity * _velocityGlowFactor, |
| _glowOpacityTween.begin!, |
| _maxOpacity, |
| ); |
| _glowSizeTween.begin = _glowSize.value; |
| _glowSizeTween.end = math.min(0.025 + 7.5e-7 * velocity * velocity, 1.0); |
| _glowController.duration = Duration(milliseconds: (0.15 + velocity * 0.02).round()); |
| _glowController.forward(from: 0.0); |
| _displacement = 0.5; |
| _state = _GlowState.absorb; |
| } |
| |
| /// Handle a user-driven overscroll. |
| /// |
| /// The `overscroll` argument should be the scroll distance in logical pixels, |
| /// the `extent` argument should be the total dimension of the viewport in the |
| /// main axis in logical pixels, the `crossAxisOffset` argument should be the |
| /// distance from the leading (left or top) edge of the cross axis of the |
| /// viewport, and the `crossExtent` should be the size of the cross axis. For |
| /// example, a pull of 50 pixels up the middle of a 200 pixel high and 100 |
| /// pixel wide vertical viewport should result in a call of `pull(50.0, 200.0, |
| /// 50.0, 100.0)`. The `overscroll` value should be positive regardless of the |
| /// direction. |
| void pull(double overscroll, double extent, double crossAxisOffset, double crossExtent) { |
| _pullRecedeTimer?.cancel(); |
| _pullDistance += |
| overscroll / 200.0; // This factor is magic. Not clear why we need it to match Android. |
| _glowOpacityTween.begin = _glowOpacity.value; |
| _glowOpacityTween.end = math.min( |
| _glowOpacity.value + overscroll / extent * _pullOpacityGlowFactor, |
| _maxOpacity, |
| ); |
| final double height = math.min(extent, crossExtent * _widthToHeightFactor); |
| _glowSizeTween.begin = _glowSize.value; |
| _glowSizeTween.end = math.max( |
| 1.0 - 1.0 / (0.7 * math.sqrt(_pullDistance * height)), |
| _glowSize.value, |
| ); |
| _displacementTarget = crossAxisOffset / crossExtent; |
| if (_displacementTarget != _displacement) { |
| if (!_displacementTicker.isTicking) { |
| assert(_displacementTickerLastElapsed == null); |
| _displacementTicker.start(); |
| } |
| } else { |
| _displacementTicker.stop(); |
| _displacementTickerLastElapsed = null; |
| } |
| _glowController.duration = _pullTime; |
| if (_state != _GlowState.pull) { |
| _glowController.forward(from: 0.0); |
| _state = _GlowState.pull; |
| } else { |
| if (!_glowController.isAnimating) { |
| assert(_glowController.value == 1.0); |
| notifyListeners(); |
| } |
| } |
| _pullRecedeTimer = Timer(_pullHoldTime, () => _recede(_pullDecayTime)); |
| } |
| |
| void scrollEnd() { |
| if (_state == _GlowState.pull) { |
| _recede(_recedeTime); |
| } |
| } |
| |
| void _changePhase(AnimationStatus status) { |
| if (!status.isCompleted) { |
| return; |
| } |
| switch (_state) { |
| case _GlowState.absorb: |
| _recede(_recedeTime); |
| case _GlowState.recede: |
| _state = _GlowState.idle; |
| _pullDistance = 0.0; |
| case _GlowState.pull: |
| case _GlowState.idle: |
| break; |
| } |
| } |
| |
| void _recede(Duration duration) { |
| if (_state == _GlowState.recede || _state == _GlowState.idle) { |
| return; |
| } |
| _pullRecedeTimer?.cancel(); |
| _pullRecedeTimer = null; |
| _glowOpacityTween.begin = _glowOpacity.value; |
| _glowOpacityTween.end = 0.0; |
| _glowSizeTween.begin = _glowSize.value; |
| _glowSizeTween.end = 0.0; |
| _glowController.duration = duration; |
| _glowController.forward(from: 0.0); |
| _state = _GlowState.recede; |
| } |
| |
| void _tickDisplacement(Duration elapsed) { |
| if (_displacementTickerLastElapsed != null) { |
| final double t = (elapsed.inMicroseconds - _displacementTickerLastElapsed!.inMicroseconds) |
| .toDouble(); |
| _displacement = |
| _displacementTarget - |
| (_displacementTarget - _displacement) * |
| math.pow(2.0, -t / _crossAxisHalfTime.inMicroseconds); |
| notifyListeners(); |
| } |
| if (nearEqual(_displacementTarget, _displacement, Tolerance.defaultTolerance.distance)) { |
| _displacementTicker.stop(); |
| _displacementTickerLastElapsed = null; |
| } else { |
| _displacementTickerLastElapsed = elapsed; |
| } |
| } |
| |
| void paint(Canvas canvas, Size size) { |
| if (_glowOpacity.value == 0.0) { |
| return; |
| } |
| final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0; |
| final double radius = size.width * 3.0 / 2.0; |
| final double height = math.min(size.height, size.width * _widthToHeightFactor); |
| final double scaleY = _glowSize.value * baseGlowScale; |
| final rect = Rect.fromLTWH(0.0, 0.0, size.width, height); |
| final center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius); |
| final paint = Paint()..color = color.withOpacity(_glowOpacity.value); |
| canvas.save(); |
| canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels); |
| canvas.scale(1.0, scaleY); |
| canvas.clipRect(rect); |
| canvas.drawCircle(center, radius, paint); |
| canvas.restore(); |
| } |
| |
| @override |
| String toString() { |
| return '_GlowController(color: $color, axis: ${axis.name})'; |
| } |
| } |
| |
| class _GlowingOverscrollIndicatorPainter extends CustomPainter { |
| _GlowingOverscrollIndicatorPainter({ |
| this.leadingController, |
| this.trailingController, |
| required this.axisDirection, |
| super.repaint, |
| }); |
| |
| /// The controller for the overscroll glow on the side with negative scroll offsets. |
| /// |
| /// For a vertical downwards viewport, this is the top side. |
| final _GlowController? leadingController; |
| |
| /// The controller for the overscroll glow on the side with positive scroll offsets. |
| /// |
| /// For a vertical downwards viewport, this is the bottom side. |
| final _GlowController? trailingController; |
| |
| /// The direction of the viewport. |
| final AxisDirection axisDirection; |
| |
| static const double piOver2 = math.pi / 2.0; |
| |
| void _paintSide( |
| Canvas canvas, |
| Size size, |
| _GlowController? controller, |
| AxisDirection axisDirection, |
| GrowthDirection growthDirection, |
| ) { |
| if (controller == null) { |
| return; |
| } |
| switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) { |
| case AxisDirection.up: |
| controller.paint(canvas, size); |
| case AxisDirection.down: |
| canvas.save(); |
| canvas.translate(0.0, size.height); |
| canvas.scale(1.0, -1.0); |
| controller.paint(canvas, size); |
| canvas.restore(); |
| case AxisDirection.left: |
| canvas.save(); |
| canvas.rotate(piOver2); |
| canvas.scale(1.0, -1.0); |
| controller.paint(canvas, Size(size.height, size.width)); |
| canvas.restore(); |
| case AxisDirection.right: |
| canvas.save(); |
| canvas.translate(size.width, 0.0); |
| canvas.rotate(piOver2); |
| controller.paint(canvas, Size(size.height, size.width)); |
| canvas.restore(); |
| } |
| } |
| |
| @override |
| void paint(Canvas canvas, Size size) { |
| _paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse); |
| _paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward); |
| } |
| |
| @override |
| bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) { |
| return oldDelegate.leadingController != leadingController || |
| oldDelegate.trailingController != trailingController; |
| } |
| |
| @override |
| String toString() { |
| return '_GlowingOverscrollIndicatorPainter($leadingController, $trailingController)'; |
| } |
| } |
| |
| /// A Material Design visual indication that a scroll view has overscrolled. |
| /// |
| /// A [StretchingOverscrollIndicator] listens for [ScrollNotification]s in order |
| /// to stretch the content of the [Scrollable]. These notifications are typically |
| /// generated by a [ScrollView], such as a [ListView] or a [GridView]. |
| /// |
| /// When triggered, the [StretchingOverscrollIndicator] generates an |
| /// [OverscrollIndicatorNotification] before showing an overscroll indication. |
| /// To prevent the indicator from showing the indication, call |
| /// [OverscrollIndicatorNotification.disallowIndicator] on the notification. |
| /// |
| /// Created by [MaterialScrollBehavior.buildOverscrollIndicator] on platforms |
| /// (e.g., Android) that commonly use this type of overscroll indication when |
| /// [ThemeData.useMaterial3] is true. Otherwise, when [ThemeData.useMaterial3] |
| /// is false, a [GlowingOverscrollIndicator] is used instead.= |
| /// |
| /// See also: |
| /// |
| /// * [OverscrollIndicatorNotification], which can be used to prevent the |
| /// stretch effect from being applied at all. |
| /// * [NotificationListener], to listen for the |
| /// [OverscrollIndicatorNotification]. |
| /// * [GlowingOverscrollIndicator], the default overscroll indicator for |
| /// [TargetPlatform.android] and [TargetPlatform.fuchsia]. |
| class StretchingOverscrollIndicator extends StatefulWidget { |
| /// Creates a visual indication that a scroll view has overscrolled by |
| /// applying a stretch transformation to the content. |
| /// |
| /// In order for this widget to display an overscroll indication, the [child] |
| /// widget must contain a widget that generates a [ScrollNotification], such |
| /// as a [ListView] or a [GridView]. |
| const StretchingOverscrollIndicator({ |
| super.key, |
| required this.axisDirection, |
| this.notificationPredicate = defaultScrollNotificationPredicate, |
| this.clipBehavior = Clip.hardEdge, |
| this.child, |
| }); |
| |
| /// {@macro flutter.overscroll.axisDirection} |
| final AxisDirection axisDirection; |
| |
| /// {@macro flutter.overscroll.axis} |
| Axis get axis => axisDirectionToAxis(axisDirection); |
| |
| /// {@macro flutter.overscroll.notificationPredicate} |
| final ScrollNotificationPredicate notificationPredicate; |
| |
| /// {@macro flutter.material.Material.clipBehavior} |
| /// |
| /// Defaults to [Clip.hardEdge]. |
| final Clip clipBehavior; |
| |
| /// The widget below this widget in the tree. |
| /// |
| /// The overscroll indicator will apply a stretch effect to this child. This |
| /// child (and its subtree) should include a source of [ScrollNotification] |
| /// notifications. |
| final Widget? child; |
| |
| @override |
| State<StretchingOverscrollIndicator> createState() => _StretchingOverscrollIndicatorState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection)); |
| } |
| } |
| |
| class _StretchingOverscrollIndicatorState extends State<StretchingOverscrollIndicator> |
| with TickerProviderStateMixin { |
| late final _StretchController _stretchController = _StretchController(vsync: this); |
| ScrollNotification? _lastNotification; |
| OverscrollNotification? _lastOverscrollNotification; |
| |
| double _totalOverscroll = 0.0; |
| |
| bool _accepted = true; |
| |
| bool _handleScrollNotification(ScrollNotification notification) { |
| if (!widget.notificationPredicate(notification)) { |
| return false; |
| } |
| if (notification.metrics.axis != widget.axis) { |
| // This widget is explicitly configured to one axis. If a notification |
| // from a different axis bubbles up, do nothing. |
| return false; |
| } |
| if (notification is OverscrollNotification) { |
| _lastOverscrollNotification = notification; |
| if (_lastNotification.runtimeType is! OverscrollNotification) { |
| final confirmationNotification = OverscrollIndicatorNotification( |
| leading: notification.overscroll < 0.0, |
| ); |
| confirmationNotification.dispatch(context); |
| _accepted = confirmationNotification.accepted; |
| } |
| |
| if (_accepted) { |
| _totalOverscroll += notification.overscroll; |
| |
| if (notification.velocity != 0.0) { |
| assert(notification.dragDetails == null); |
| _stretchController.absorbImpact(notification.velocity); |
| } else { |
| assert(notification.overscroll != 0.0); |
| if (notification.dragDetails != null) { |
| // We clamp the overscroll amount relative to the length of the viewport, |
| // which is the furthest distance a single pointer could pull on the |
| // screen. This is because more than one pointer will multiply the |
| // amount of overscroll - https://github.com/flutter/flutter/issues/11884 |
| |
| final double viewportDimension = notification.metrics.viewportDimension; |
| final double distanceForPull = _totalOverscroll / viewportDimension; |
| final double clampedOverscroll = clampDouble(distanceForPull, -1.0, 1.0); |
| _stretchController.pull(clampedOverscroll); |
| } |
| } |
| } |
| } else if (notification is ScrollEndNotification) { |
| double velocity = switch (widget.axis) { |
| Axis.vertical => notification.dragDetails?.velocity.pixelsPerSecond.dy ?? 0.0, |
| Axis.horizontal => notification.dragDetails?.velocity.pixelsPerSecond.dx ?? 0.0, |
| }; |
| |
| // Reverse axis directions report dragDetails velocity in |
| // the opposite screen coordinate, so the value must be inverted. |
| if (notification.metrics.axisDirection == AxisDirection.left || |
| notification.metrics.axisDirection == AxisDirection.up) { |
| velocity = -velocity; |
| } |
| |
| // Since the overscrolling ended, we reset the total overscroll amount. |
| _totalOverscroll = 0.0; |
| _stretchController.scrollEnd(velocity); |
| } else if (notification is ScrollUpdateNotification) { |
| _totalOverscroll = 0.0; |
| _stretchController.scrollEnd(0.0); |
| } |
| _lastNotification = notification; |
| return false; |
| } |
| |
| @override |
| void dispose() { |
| _stretchController.dispose(); |
| super.dispose(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return NotificationListener<ScrollNotification>( |
| onNotification: _handleScrollNotification, |
| child: AnimatedBuilder( |
| animation: _stretchController, |
| builder: (BuildContext context, Widget? child) { |
| final double stretch = _stretchController.overscroll; |
| final double mainAxisSize; |
| |
| switch (widget.axis) { |
| case Axis.horizontal: |
| mainAxisSize = MediaQuery.widthOf(context); |
| case Axis.vertical: |
| mainAxisSize = MediaQuery.heightOf(context); |
| } |
| |
| final double viewportDimension = |
| _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize; |
| |
| double overscroll = -stretch; |
| |
| // Adjust overscroll for reverse scroll directions. |
| if (widget.axisDirection == AxisDirection.up || |
| widget.axisDirection == AxisDirection.left) { |
| overscroll = -overscroll; |
| } |
| |
| final Widget transform = StretchEffect( |
| stretchStrength: overscroll, |
| axis: widget.axis, |
| child: widget.child!, |
| ); |
| |
| // Only clip if the viewport dimension is smaller than that of the |
| // screen size in the main axis. If the viewport takes up the whole |
| // screen, overflow from transforming the viewport is irrelevant. |
| return ClipRect( |
| clipBehavior: stretch != 0.0 && viewportDimension != mainAxisSize |
| ? widget.clipBehavior |
| : Clip.none, |
| child: transform, |
| ); |
| }, |
| ), |
| ); |
| } |
| } |
| |
| class _StretchController extends Listenable { |
| _StretchController({required this.vsync}); |
| |
| final TickerProvider vsync; |
| AnimationController? _controller; |
| |
| /// Manages and notifies changes to the current [overscroll] value. |
| final ValueNotifier<double> _overscrollNotifier = ValueNotifier<double>(0.0); |
| double get overscroll => _overscrollNotifier.value; |
| set overscroll(double newValue) { |
| _overscrollNotifier.value = clampDouble(newValue, minOverscroll, maxOverscroll); |
| } |
| |
| /// Stores the `overscroll` value from an ongoing animation at the precise |
| /// moment it is interrupted by a new `pull()` gesture. |
| /// |
| /// When `pull()` is called while an animation (triggered by `absorbImpact` |
| /// or `scrollEnd`) is active, this field captures the animation's current |
| /// `_controller.value` (which represents the overscroll amount) immediately |
| /// before the animation controller is disposed. |
| /// |
| /// This captured value is then added to the overscroll amount calculated |
| /// by the `pull()` method. The primary purpose is to create a smoother |
| /// visual transition from an animated overscroll state to a direct, |
| /// user-driven pull, minimizing any abrupt visual "jumps" in the |
| /// stretch effect. |
| /// |
| /// It is reset to `0.0` when an animation completes naturally via `animate()` |
| /// or when a new pull starts without a preceding active animation. |
| double _interruptedOverscroll = 0.0; |
| |
| // Constants from Android. |
| static const double _exponentialScalar = math.e / 0.33; |
| static const double _stretchIntensity = 0.016; |
| |
| static const double minOverscroll = -1.0; |
| static const double maxOverscroll = 1.0; |
| |
| /// A fraction used to adjust the input velocity, measured in pixels, |
| /// to a value between -1 and 1 when gesture fling. |
| static const double _flingVelocityFriction = 1 / 6000; |
| |
| /// A fraction used to scale the absorbed impact velocity, |
| /// converting raw velocity into a normalized value for simulation. |
| static const double _absorbImpactVelocityFriction = 1 / 3000; |
| |
| /// The maximum velocity allowed for a fling after scaling |
| /// to prevent applying an excessive stretch effect. |
| static const double _maxFlingVelocity = 0.5; |
| |
| /// The maximum velocity allowed when absorbing an impact, |
| /// ensuring the stretch effect does not exceed a reasonable limit. |
| static const double _maxAbsorbImpactVelocity = 1.25; |
| |
| // Physical constants ported directly from Android's EdgeEffect.java. |
| // |
| // Android's EdgeEffect.java: |
| // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/widget/EdgeEffect.java |
| static const double kNaturalFrequency = 24.657; |
| static const double kDampingRatio = 0.98; |
| |
| /// A correction factor applied to the simulation time. |
| /// |
| /// The physical constants `kNaturalFrequency ` and `kDampingRatio ` were ported |
| /// directly from Android's `EdgeEffect.java` source. However, using these |
| /// constants as-is resulted in an animation that was noticeably faster than |
| /// the native Android behavior. The underlying reason for this discrepancy is |
| /// unknown. |
| /// |
| /// This factor, determined by visual comparison ("eyeballing") to match the |
| /// platform's timing, is applied to the elapsed time `t` to slow down the |
| /// simulation. |
| /// |
| /// Based on the damped harmonic oscillator equations, an alternative, |
| /// mathematically equivalent, approach would be to multiply both the |
| /// `kNaturalFrequency ` and the initial velocity by this same factor, |
| /// rather than scaling the time input. |
| static const double kTimeCorrectionFactor = 0.8; |
| |
| /// Stiffness coefficient for the spring, derived from the natural frequency. |
| /// |
| /// Calculated as `kStiffness = kNaturalFrequency^2`, this is the baseline |
| /// spring constant used in the damped harmonic oscillator model. |
| static const double kStiffness = kNaturalFrequency * kNaturalFrequency; |
| |
| /// Spring description representing the stretch behavior of the edge effect. |
| /// |
| /// This [SpringDescription] is used to simulate the overscroll stretch |
| /// in Flutter. The spring has a mass of 1, a stiffness adjusted by the |
| /// [kTimeCorrectionFactor] squared to match platform timing, and |
| /// a damping ratio corresponding to Android's native `EdgeEffect`. |
| static final SpringDescription _kStretchSpringDescription = SpringDescription.withDampingRatio( |
| mass: 1, |
| stiffness: kStiffness * kTimeCorrectionFactor * kTimeCorrectionFactor, |
| ratio: kDampingRatio, |
| ); |
| |
| @override |
| void addListener(VoidCallback listener) { |
| _overscrollNotifier.addListener(listener); |
| } |
| |
| @override |
| void removeListener(VoidCallback listener) { |
| _overscrollNotifier.removeListener(listener); |
| } |
| |
| /// Creates a stretching-only [Simulation] ported from Android 12. |
| SpringSimulation _createStretchSimulation(double velocity) { |
| return SpringSimulation( |
| _kStretchSpringDescription, |
| overscroll, |
| 0.0, |
| velocity * kTimeCorrectionFactor, |
| ); |
| } |
| |
| /// Handle a fling to the edge of the viewport at a particular velocity. |
| /// |
| /// The velocity must be positive. |
| void absorbImpact(double velocity) { |
| if (velocity == 0.0) { |
| return; |
| } |
| final double scaledVelocity = clampDouble( |
| velocity * _absorbImpactVelocityFriction, |
| -_maxAbsorbImpactVelocity, |
| _maxAbsorbImpactVelocity, |
| ); |
| |
| animate(_createStretchSimulation(scaledVelocity)); |
| } |
| |
| /// Called when the overscroll ends to trigger a fling animation if needed. |
| void scrollEnd(double velocity) { |
| if (velocity == 0.0 && overscroll == 0.0) { |
| return; |
| } |
| final double scaledVelocity = clampDouble( |
| -(velocity * _flingVelocityFriction), |
| -_maxFlingVelocity, |
| _maxFlingVelocity, |
| ); |
| |
| if (_controller == null) { |
| animate(_createStretchSimulation(scaledVelocity)); |
| } |
| } |
| |
| /// Starts a new animation using the given [simulation]. |
| /// |
| /// Disposes any existing animation controller before starting a new one. |
| /// Updates the [overscroll] value on each animation frame. |
| /// Automatically disposes the controller when the animation completes. |
| void animate(Simulation simulation) { |
| final controller = AnimationController.unbounded(vsync: vsync) |
| ..addListener(() { |
| final double newOverscroll = _controller?.value ?? 0.0; |
| overscroll = newOverscroll; |
| }) |
| ..animateWith(simulation).whenComplete(() { |
| overscroll = 0.0; |
| _interruptedOverscroll = 0.0; |
| _controller!.dispose(); |
| _controller = null; |
| }); |
| |
| _controller?.dispose(); |
| _controller = controller; |
| } |
| |
| /// Handle a user-driven overscroll. |
| /// |
| /// The `normalizedOverscroll` argument should be the scroll distance in |
| /// logical pixels, divided by the extent of the viewport in the main axis. |
| void pull(double normalizedOverscroll) { |
| if (_controller != null) { |
| _interruptedOverscroll = _controller!.value; |
| _controller!.dispose(); |
| _controller = null; |
| } |
| |
| final pullDistance = normalizedOverscroll; |
| final double absDistance = pullDistance.abs(); |
| final double linearIntensity = _stretchIntensity * absDistance; |
| final double exponentialIntensity = |
| _stretchIntensity * (1 - math.exp(-absDistance * _exponentialScalar)); |
| |
| // Maintain sign of overscroll for direction. |
| final double directionSign = pullDistance.sign; |
| final double newOverscroll = directionSign * (linearIntensity + exponentialIntensity); |
| overscroll = newOverscroll + _interruptedOverscroll; |
| } |
| |
| void dispose() { |
| _controller?.dispose(); |
| _overscrollNotifier.dispose(); |
| } |
| |
| @override |
| String toString() => '_StretchController()'; |
| } |
| |
| /// A notification that either a [GlowingOverscrollIndicator] or a |
| /// [StretchingOverscrollIndicator] will start showing an overscroll indication. |
| /// |
| /// To prevent the indicator from showing the indication, call |
| /// [disallowIndicator] on the notification. |
| /// |
| /// See also: |
| /// |
| /// * [GlowingOverscrollIndicator], which generates this type of notification |
| /// by painting an indicator over the child content. |
| /// * [StretchingOverscrollIndicator], which generates this type of |
| /// notification by applying a stretch transformation to the child content. |
| class OverscrollIndicatorNotification extends Notification with ViewportNotificationMixin { |
| /// Creates a notification that an [GlowingOverscrollIndicator] or a |
| /// [StretchingOverscrollIndicator] will start showing an overscroll indication. |
| OverscrollIndicatorNotification({required this.leading}); |
| |
| /// Whether the indication will be shown on the leading edge of the scroll |
| /// view. |
| final bool leading; |
| |
| /// Controls at which offset a [GlowingOverscrollIndicator] draws. |
| /// |
| /// A positive offset will move the glow away from its edge, |
| /// i.e. for a vertical, [leading] indicator, a [paintOffset] of 100.0 will |
| /// draw the indicator 100.0 pixels from the top of the edge. |
| /// For a vertical indicator with [leading] set to `false`, a [paintOffset] |
| /// of 100.0 will draw the indicator 100.0 pixels from the bottom instead. |
| /// |
| /// A negative [paintOffset] is generally not useful, since the glow will be |
| /// clipped. |
| /// |
| /// This has no effect on a [StretchingOverscrollIndicator]. |
| double paintOffset = 0.0; |
| |
| @protected |
| @visibleForTesting |
| /// Whether the current overscroll event will allow for the indicator to be |
| /// shown. |
| /// |
| /// Calling [disallowIndicator] sets this to false, preventing the over scroll |
| /// indicator from showing. |
| /// |
| /// Defaults to true. |
| bool accepted = true; |
| |
| /// Call this method if the overscroll indicator should be prevented. |
| void disallowIndicator() { |
| accepted = false; |
| } |
| |
| @override |
| void debugFillDescription(List<String> description) { |
| super.debugFillDescription(description); |
| description.add('side: ${leading ? "leading edge" : "trailing edge"}'); |
| } |
| } |