blob: 148bfbc6543cd9ba2f9d09c5b5aa05c3a235e4b3 [file] [log] [blame]
// 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.
import 'dart:async' show Timer;
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart' show Tolerance, nearEqual;
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 '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.disallowGlow]
/// 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].
///
/// The [showLeading], [showTrailing], [axisDirection], [color], and
/// [notificationPredicate] arguments must not be null.
const GlowingOverscrollIndicator({
super.key,
this.showLeading = true,
this.showTrailing = true,
required this.axisDirection,
required this.color,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.child,
}) : assert(showLeading != null),
assert(showTrailing != null),
assert(axisDirection != null),
assert(color != null),
assert(notificationPredicate != null);
/// 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;
if (showLeading && showTrailing) {
showDescription = 'both sides';
} else if (showLeading) {
showDescription = 'leading side only';
} else if (showTrailing) {
showDescription = 'trailing side only';
} else {
showDescription = '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;
}
// 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 bool isLeading = controller == _leadingController;
if (_lastNotificationType is! OverscrollNotification) {
final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading);
confirmationNotification.dispatch(context);
_accepted[isLeading] = confirmationNotification.accepted;
if (_accepted[isLeading]!) {
controller!._paintOffset = confirmationNotification.paintOffset;
}
}
assert(controller != null);
assert(notification.metrics.axis == widget.axis);
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) {
assert(notification.dragDetails!.globalPosition != null);
final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox;
assert(renderer != null);
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);
break;
case Axis.vertical:
controller!.pull(notification.overscroll.abs(), size.height, clampDouble(position.dx, 0.0, size.width), size.width);
break;
}
}
}
}
} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
// Using dynamic here to avoid layer violations of importing
// drag_details.dart from gestures.
// ignore: avoid_dynamic_calls
if ((notification as dynamic).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/+/master/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,
}) : assert(vsync != null),
assert(color != null),
assert(axis != null),
_color = color,
_axis = axis {
_glowController = AnimationController(vsync: vsync)
..addStatusListener(_changePhase);
final Animation<double> 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
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) {
assert(color != null);
if (color == value) {
return;
}
_color = value;
notifyListeners();
}
Axis get axis => _axis;
Axis _axis;
set axis(Axis value) {
assert(axis != null);
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();
_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 != AnimationStatus.completed) {
return;
}
switch (_state) {
case _GlowState.absorb:
_recede(_recedeTime);
break;
case _GlowState.recede:
_state = _GlowState.idle;
_pullDistance = 0.0;
break;
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 = Rect.fromLTWH(0.0, 0.0, size.width, height);
final Offset center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius);
final Paint 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: ${describeEnum(axis)})';
}
}
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);
break;
case AxisDirection.down:
canvas.save();
canvas.translate(0.0, size.height);
canvas.scale(1.0, -1.0);
controller.paint(canvas, size);
canvas.restore();
break;
case AxisDirection.left:
canvas.save();
canvas.rotate(piOver2);
canvas.scale(1.0, -1.0);
controller.paint(canvas, Size(size.height, size.width));
canvas.restore();
break;
case AxisDirection.right:
canvas.save();
canvas.translate(size.width, 0.0);
canvas.rotate(piOver2);
controller.paint(canvas, Size(size.height, size.width));
canvas.restore();
break;
}
}
@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 [ScrollBehavior.buildOverscrollIndicator] on platforms
/// (e.g., Android) that commonly use this type of overscroll indication when
/// [ScrollBehavior.androidOverscrollIndicator] is
/// [AndroidOverscrollIndicator.stretch]. Otherwise, the default
/// [GlowingOverscrollIndicator] is applied.
/// [ScrollBehavior.androidOverscrollIndicator] is deprecated, use
/// [ThemeData.useMaterial3], or override
/// [ScrollBehavior.buildOverscrollIndicator] to choose the desired indicator.
///
/// 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].
///
/// The [axisDirection] and [notificationPredicate] arguments must not be null.
const StretchingOverscrollIndicator({
super.key,
required this.axisDirection,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.clipBehavior = Clip.hardEdge,
this.child,
}) : assert(axisDirection != null),
assert(notificationPredicate != null),
assert(clipBehavior != null);
/// {@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.
///
/// Typically a [StretchingOverscrollIndicator] is created by a
/// [ScrollBehavior.buildOverscrollIndicator] method when opted-in using the
/// [ScrollBehavior.androidOverscrollIndicator] flag. In this case
/// the child is usually the one provided as an argument to that method.
/// [ScrollBehavior.androidOverscrollIndicator] is deprecated, use
/// [ThemeData.useMaterial3], or override
/// [ScrollBehavior.buildOverscrollIndicator] to choose the desired indicator.
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;
bool _accepted = true;
bool _handleScrollNotification(ScrollNotification notification) {
if (!widget.notificationPredicate(notification)) {
return false;
}
if (notification is OverscrollNotification) {
_lastOverscrollNotification = notification;
if (_lastNotification.runtimeType is! OverscrollNotification) {
final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: notification.overscroll < 0.0);
confirmationNotification.dispatch(context);
_accepted = confirmationNotification.accepted;
}
assert(notification.metrics.axis == widget.axis);
if (_accepted) {
if (notification.velocity != 0.0) {
assert(notification.dragDetails == null);
_stretchController.absorbImpact(notification.velocity.abs());
} 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 =
(notification.overscroll.abs() / viewportDimension) + _stretchController.pullDistance;
final double clampedOverscroll = clampDouble(distanceForPull, 0, 1.0);
_stretchController.pull(clampedOverscroll);
}
}
}
} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
_stretchController.scrollEnd();
}
_lastNotification = notification;
return false;
}
AlignmentDirectional _getAlignmentForAxisDirection(double overscroll) {
// Accounts for reversed scrollables by checking the AxisDirection
switch (widget.axisDirection) {
case AxisDirection.up:
return overscroll > 0
? AlignmentDirectional.topCenter
: AlignmentDirectional.bottomCenter;
case AxisDirection.right:
return overscroll > 0
? AlignmentDirectional.centerEnd
: AlignmentDirectional.centerStart;
case AxisDirection.down:
return overscroll > 0
? AlignmentDirectional.bottomCenter
: AlignmentDirectional.topCenter;
case AxisDirection.left:
return overscroll > 0
? AlignmentDirectional.centerStart
: AlignmentDirectional.centerEnd;
}
}
@override
void dispose() {
_stretchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.of(context).size;
double mainAxisSize;
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: AnimatedBuilder(
animation: _stretchController,
builder: (BuildContext context, Widget? child) {
final double stretch = _stretchController.value;
double x = 1.0;
double y = 1.0;
switch (widget.axis) {
case Axis.horizontal:
x += stretch;
mainAxisSize = size.width;
break;
case Axis.vertical:
y += stretch;
mainAxisSize = size.height;
break;
}
final AlignmentDirectional alignment = _getAlignmentForAxisDirection(
_lastOverscrollNotification?.overscroll ?? 0.0
);
final double viewportDimension = _lastOverscrollNotification?.metrics.viewportDimension ?? mainAxisSize;
final Widget transform = Transform(
alignment: alignment,
transform: Matrix4.diagonal3Values(x, y, 1.0),
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,
);
},
),
);
}
}
enum _StretchState {
idle,
absorb,
pull,
recede,
}
class _StretchController extends ChangeNotifier {
_StretchController({ required TickerProvider vsync }) {
_stretchController = AnimationController(vsync: vsync)
..addStatusListener(_changePhase);
final Animation<double> decelerator = CurvedAnimation(
parent: _stretchController,
curve: Curves.decelerate,
)..addListener(notifyListeners);
_stretchSize = decelerator.drive(_stretchSizeTween);
}
late final AnimationController _stretchController;
late final Animation<double> _stretchSize;
final Tween<double> _stretchSizeTween = Tween<double>(begin: 0.0, end: 0.0);
_StretchState _state = _StretchState.idle;
double get pullDistance => _pullDistance;
double _pullDistance = 0.0;
// Constants from Android.
static const double _exponentialScalar = math.e / 0.33;
static const double _stretchIntensity = 0.016;
static const double _flingFriction = 1.01;
static const Duration _stretchDuration = Duration(milliseconds: 400);
double get value => _stretchSize.value;
/// Handle a fling to the edge of the viewport at a particular velocity.
///
/// The velocity must be positive.
void absorbImpact(double velocity) {
assert(velocity >= 0.0);
velocity = clampDouble(velocity, 1, 10000);
_stretchSizeTween.begin = _stretchSize.value;
_stretchSizeTween.end = math.min(_stretchIntensity + (_flingFriction / velocity), 1.0);
_stretchController.duration = Duration(milliseconds: (velocity * 0.02).round());
_stretchController.forward(from: 0.0);
_state = _StretchState.absorb;
}
/// Handle a user-driven overscroll.
///
/// The `normalizedOverscroll` argument should be the absolute value of the
/// scroll distance in logical pixels, divided by the extent of the viewport
/// in the main axis.
void pull(double normalizedOverscroll) {
assert(normalizedOverscroll >= 0.0);
_pullDistance = normalizedOverscroll;
_stretchSizeTween.begin = _stretchSize.value;
final double linearIntensity =_stretchIntensity * _pullDistance;
final double exponentialIntensity = _stretchIntensity * (1 - math.exp(-_pullDistance * _exponentialScalar));
_stretchSizeTween.end = linearIntensity + exponentialIntensity;
_stretchController.duration = _stretchDuration;
if (_state != _StretchState.pull) {
_stretchController.forward(from: 0.0);
_state = _StretchState.pull;
} else {
if (!_stretchController.isAnimating) {
assert(_stretchController.value == 1.0);
notifyListeners();
}
}
}
void scrollEnd() {
if (_state == _StretchState.pull) {
_recede(_stretchDuration);
}
}
void _changePhase(AnimationStatus status) {
if (status != AnimationStatus.completed) {
return;
}
switch (_state) {
case _StretchState.absorb:
_recede(_stretchDuration);
break;
case _StretchState.recede:
_state = _StretchState.idle;
_pullDistance = 0.0;
break;
case _StretchState.pull:
case _StretchState.idle:
break;
}
}
void _recede(Duration duration) {
if (_state == _StretchState.recede || _state == _StretchState.idle) {
return;
}
_stretchSizeTween.begin = _stretchSize.value;
_stretchSizeTween.end = 0.0;
_stretchController.duration = duration;
_stretchController.forward(from: 0.0);
_state = _StretchState.recede;
}
@override
void dispose() {
_stretchController.dispose();
super.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.
///
/// The [leading] argument must not be null.
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, cannot be null.
bool accepted = true;
/// Call this method if the glow should be prevented. This method is
/// deprecated in favor of [disallowIndicator].
@Deprecated(
'Use disallowIndicator instead. '
'This feature was deprecated after v2.5.0-6.0.pre.',
)
void disallowGlow() {
accepted = false;
}
/// 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"}');
}
}