
I don't know what I was thinking when I wrote this code back in 2017 but this is so trivially fixable...
1023 lines
38 KiB
Dart
1023 lines
38 KiB
Dart
// 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,
|
|
});
|
|
|
|
/// 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;
|
|
}
|
|
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 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);
|
|
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 RenderBox 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/+/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,
|
|
}) : _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) {
|
|
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();
|
|
_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);
|
|
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 = 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: ${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)';
|
|
}
|
|
}
|
|
|
|
enum _StretchDirection {
|
|
/// The [trailing] direction indicates that the content will be stretched toward
|
|
/// the trailing edge.
|
|
trailing,
|
|
/// The [leading] direction indicates that the content will be stretched toward
|
|
/// the leading edge.
|
|
leading,
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
|
|
/// {@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;
|
|
|
|
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 OverscrollIndicatorNotification 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.abs(), _totalOverscroll);
|
|
} 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.abs() / viewportDimension;
|
|
final double clampedOverscroll = clampDouble(distanceForPull, 0, 1.0);
|
|
_stretchController.pull(clampedOverscroll, _totalOverscroll);
|
|
}
|
|
}
|
|
}
|
|
} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) {
|
|
// Since the overscrolling ended, we reset the total overscroll amount.
|
|
_totalOverscroll = 0;
|
|
_stretchController.scrollEnd();
|
|
}
|
|
_lastNotification = notification;
|
|
return false;
|
|
}
|
|
|
|
AlignmentGeometry _getAlignmentForAxisDirection(_StretchDirection stretchDirection) {
|
|
// Accounts for reversed scrollables by checking the AxisDirection
|
|
switch (widget.axisDirection) {
|
|
case AxisDirection.up:
|
|
return stretchDirection == _StretchDirection.trailing
|
|
? AlignmentDirectional.topCenter
|
|
: AlignmentDirectional.bottomCenter;
|
|
case AxisDirection.right:
|
|
return stretchDirection == _StretchDirection.trailing
|
|
? Alignment.centerRight
|
|
: Alignment.centerLeft;
|
|
case AxisDirection.down:
|
|
return stretchDirection == _StretchDirection.trailing
|
|
? AlignmentDirectional.bottomCenter
|
|
: AlignmentDirectional.topCenter;
|
|
case AxisDirection.left:
|
|
return stretchDirection == _StretchDirection.trailing
|
|
? Alignment.centerLeft
|
|
: Alignment.centerRight;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stretchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Size size = MediaQuery.sizeOf(context);
|
|
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;
|
|
case Axis.vertical:
|
|
y += stretch;
|
|
mainAxisSize = size.height;
|
|
}
|
|
|
|
final AlignmentGeometry alignment = _getAlignmentForAxisDirection(
|
|
_stretchController.stretchDirection,
|
|
);
|
|
|
|
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;
|
|
|
|
_StretchDirection get stretchDirection => _stretchDirection;
|
|
_StretchDirection _stretchDirection = _StretchDirection.trailing;
|
|
|
|
// 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, double totalOverscroll) {
|
|
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;
|
|
_stretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
|
|
}
|
|
|
|
/// 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, double totalOverscroll) {
|
|
assert(normalizedOverscroll >= 0.0);
|
|
|
|
final _StretchDirection newStretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading;
|
|
if (_stretchDirection != newStretchDirection && _state == _StretchState.recede) {
|
|
// When the stretch direction changes while we are in the recede state, we need to ignore the change.
|
|
// If we don't, the stretch will instantly jump to the new direction with the recede animation still playing, which causes
|
|
// a unwanted visual abnormality (https://github.com/flutter/flutter/pull/116548#issuecomment-1414872567).
|
|
// By ignoring the directional change until the recede state is finished, we can avoid this.
|
|
return;
|
|
}
|
|
|
|
_stretchDirection = newStretchDirection;
|
|
_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);
|
|
case _StretchState.recede:
|
|
_state = _StretchState.idle;
|
|
_pullDistance = 0.0;
|
|
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"}');
|
|
}
|
|
}
|