OverscrollIndicator tracks horizontal drag input, etc (#5183)
This commit is contained in:
parent
64fa825ba1
commit
2656006c41
@ -56,7 +56,8 @@ class DragUpdateDetails {
|
||||
/// coordinates of [delta] and the other coordinate must be zero.
|
||||
DragUpdateDetails({
|
||||
this.delta: Offset.zero,
|
||||
this.primaryDelta
|
||||
this.primaryDelta,
|
||||
this.globalPosition
|
||||
}) {
|
||||
assert(primaryDelta == null
|
||||
|| (primaryDelta == delta.dx && delta.dy == 0.0)
|
||||
@ -79,6 +80,9 @@ class DragUpdateDetails {
|
||||
/// respectively). Otherwise, if the [GestureDragUpdateCallback] is for a
|
||||
/// two-dimensional drag (e.g., a pan), then this value is null.
|
||||
final double primaryDelta;
|
||||
|
||||
/// The pointer's global position.
|
||||
final Point globalPosition;
|
||||
}
|
||||
|
||||
/// Signature for when a pointer that is in contact with the screen and moving
|
||||
@ -184,7 +188,8 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
|
||||
if (onUpdate != null) {
|
||||
onUpdate(new DragUpdateDetails(
|
||||
delta: _getDeltaForDetails(delta),
|
||||
primaryDelta: _getPrimaryDeltaForDetails(delta)
|
||||
primaryDelta: _getPrimaryDeltaForDetails(delta),
|
||||
globalPosition: event.position
|
||||
));
|
||||
}
|
||||
} else {
|
||||
|
@ -3,6 +3,7 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async' show Timer;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@ -12,67 +13,66 @@ const double _kMinIndicatorExtent = 0.0;
|
||||
const double _kMaxIndicatorExtent = 64.0;
|
||||
const double _kMinIndicatorOpacity = 0.0;
|
||||
const double _kMaxIndicatorOpacity = 0.25;
|
||||
const Duration _kIndicatorHideDuration = const Duration(milliseconds: 200);
|
||||
const Duration _kIndicatorTimeoutDuration = const Duration(milliseconds: 500);
|
||||
|
||||
final Tween<double> _kIndicatorOpacity = new Tween<double>(begin: 0.0, end: 0.3);
|
||||
|
||||
// If an overscroll gesture lasts longer than this the hide timer will
|
||||
// cause the indicator to fade-out.
|
||||
const Duration _kTimeoutDuration = const Duration(milliseconds: 500);
|
||||
|
||||
// Fade-out duration if the fade-out was triggered by the timer.
|
||||
const Duration _kTimeoutHideDuration = const Duration(milliseconds: 2000);
|
||||
|
||||
// Fade-out duration if the fade-out was triggered by an input gesture.
|
||||
const Duration _kNormalHideDuration = const Duration(milliseconds: 600);
|
||||
|
||||
|
||||
class _Painter extends CustomPainter {
|
||||
_Painter({
|
||||
this.scrollDirection,
|
||||
this.extent, // Indicator width or height, per scrollDirection.
|
||||
this.dragPosition,
|
||||
this.isLeading, // Similarly true if the indicator appears at the top/left.
|
||||
this.color
|
||||
});
|
||||
|
||||
// See EdgeEffect setSize() in https://github.com/android
|
||||
static final double _kSizeToRadius = 0.75 / math.sin(math.PI / 6.0);
|
||||
|
||||
final Axis scrollDirection;
|
||||
final double extent;
|
||||
final bool isLeading;
|
||||
final Color color;
|
||||
final Point dragPosition;
|
||||
|
||||
void paintIndicator(Canvas canvas, Size size) {
|
||||
final double rectBias = extent / 2.0;
|
||||
final double arcBias = extent * 0.66;
|
||||
final Paint paint = new Paint()..color = color;
|
||||
final double width = size.width;
|
||||
final double height = size.height;
|
||||
|
||||
final Path path = new Path();
|
||||
switch(scrollDirection) {
|
||||
case Axis.vertical:
|
||||
final double width = size.width;
|
||||
if (isLeading) {
|
||||
path.moveTo(0.0, 0.0);
|
||||
path.relativeLineTo(width, 0.0);
|
||||
path.relativeLineTo(0.0, rectBias);
|
||||
path.relativeCubicTo(width * -0.25, arcBias, width * -0.75, arcBias, -width, 0.0);
|
||||
} else {
|
||||
path.moveTo(0.0, size.height);
|
||||
path.relativeLineTo(width, 0.0);
|
||||
path.relativeLineTo(0.0, -rectBias);
|
||||
path.relativeCubicTo(width * -0.25, -arcBias, width * -0.75, -arcBias, -width, 0.0);
|
||||
}
|
||||
final double radius = width * _kSizeToRadius;
|
||||
final double centerX = width / 2.0;
|
||||
final double centerY = isLeading ? extent - radius : height - extent + radius;
|
||||
final double eventX = dragPosition?.x ?? 0.0;
|
||||
final double biasX = (0.5 - (1.0 - eventX / width)) * centerX;
|
||||
canvas.drawCircle(new Point(centerX + biasX, centerY), radius, paint);
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
final double height = size.height;
|
||||
if (isLeading) {
|
||||
path.moveTo(0.0, 0.0);
|
||||
path.relativeLineTo(0.0, height);
|
||||
path.relativeLineTo(rectBias, 0.0);
|
||||
path.relativeCubicTo(arcBias, height * -0.25, arcBias, height * -0.75, 0.0, -height);
|
||||
} else {
|
||||
path.moveTo(size.width, 0.0);
|
||||
path.relativeLineTo(0.0, height);
|
||||
path.relativeLineTo(-rectBias, 0.0);
|
||||
path.relativeCubicTo(-arcBias, height * -0.25, -arcBias, height * -0.75, 0.0, -height);
|
||||
}
|
||||
final double radius = height * _kSizeToRadius;
|
||||
final double centerX = isLeading ? extent - radius : width - extent + radius;
|
||||
final double centerY = height / 2.0;
|
||||
final double eventY = dragPosition?.y ?? 0.0;
|
||||
final double biasY = (0.5 - (1.0 - eventY / height)) * centerY;
|
||||
canvas.drawCircle(new Point(centerX, centerY + biasY), radius, paint);
|
||||
break;
|
||||
}
|
||||
path.close();
|
||||
|
||||
final Paint paint = new Paint()..color = color;
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (color.alpha == 0)
|
||||
if (color.alpha == 0 || size.isEmpty)
|
||||
return;
|
||||
paintIndicator(canvas, size);
|
||||
}
|
||||
@ -120,19 +120,23 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
final AnimationController _extentAnimation = new AnimationController(
|
||||
lowerBound: _kMinIndicatorExtent,
|
||||
upperBound: _kMaxIndicatorExtent,
|
||||
duration: _kIndicatorHideDuration
|
||||
duration: _kNormalHideDuration
|
||||
);
|
||||
|
||||
bool _scrollUnderway = false;
|
||||
Timer _hideTimer;
|
||||
Axis _scrollDirection;
|
||||
double _scrollOffset;
|
||||
double _minScrollOffset;
|
||||
double _maxScrollOffset;
|
||||
Point _dragPosition;
|
||||
|
||||
void _hide() {
|
||||
void _hide([Duration duration=_kTimeoutHideDuration]) {
|
||||
_scrollUnderway = false;
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = null;
|
||||
if (!_extentAnimation.isAnimating) {
|
||||
_extentAnimation.duration = duration;
|
||||
_extentAnimation.reverse();
|
||||
}
|
||||
}
|
||||
@ -148,18 +152,24 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
}
|
||||
|
||||
void _onScrollStarted(ScrollableState scrollable) {
|
||||
assert(_scrollUnderway == false);
|
||||
_scrollUnderway = true;
|
||||
_updateState(scrollable);
|
||||
}
|
||||
|
||||
void _onScrollUpdated(ScrollableState scrollable) {
|
||||
void _onScrollUpdated(ScrollableState scrollable, DragUpdateDetails details) {
|
||||
if (!_scrollUnderway) // The hide timer has run.
|
||||
return;
|
||||
|
||||
final double value = scrollable.scrollOffset;
|
||||
if (_isOverscroll(value)) {
|
||||
_refreshHideTimer();
|
||||
// Hide the indicator as soon as user starts scrolling in the reverse direction of overscroll.
|
||||
if (_isReverseScroll(value)) {
|
||||
_hide();
|
||||
_hide(_kNormalHideDuration);
|
||||
} else {
|
||||
// Changing the animation's value causes an implicit setState().
|
||||
_dragPosition = details?.globalPosition ?? Point.origin;
|
||||
_extentAnimation.value = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset;
|
||||
}
|
||||
}
|
||||
@ -167,13 +177,16 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
}
|
||||
|
||||
void _onScrollEnded(ScrollableState scrollable) {
|
||||
if (!_scrollUnderway) // The hide timer has run.
|
||||
return;
|
||||
|
||||
_updateState(scrollable);
|
||||
_hide();
|
||||
_hide(_kNormalHideDuration);
|
||||
}
|
||||
|
||||
void _refreshHideTimer() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = new Timer(_kIndicatorTimeoutDuration, _hide);
|
||||
_hideTimer = new Timer(_kTimeoutDuration, _hide);
|
||||
}
|
||||
|
||||
bool _isOverscroll(double scrollOffset) {
|
||||
@ -183,7 +196,7 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
|
||||
bool _isReverseScroll(double scrollOffset) {
|
||||
final double delta = _scrollOffset - scrollOffset;
|
||||
return scrollOffset < _minScrollOffset ? delta < 0 : delta > 0;
|
||||
return scrollOffset < _minScrollOffset ? delta < 0.0 : delta > 0.0;
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
@ -200,7 +213,7 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
_onScrollStarted(scrollable);
|
||||
break;
|
||||
case ScrollNotificationKind.updated:
|
||||
_onScrollUpdated(scrollable);
|
||||
_onScrollUpdated(scrollable, notification.dragUpdateDetails);
|
||||
break;
|
||||
case ScrollNotificationKind.ended:
|
||||
_onScrollEnded(scrollable);
|
||||
@ -236,6 +249,7 @@ class _OverscrollIndicatorState extends State<OverscrollIndicator> {
|
||||
foregroundPainter: _scrollDirection == null ? null : new _Painter(
|
||||
scrollDirection: _scrollDirection,
|
||||
extent: _extentAnimation.value,
|
||||
dragPosition: _dragPosition,
|
||||
isLeading: _scrollOffset < _minScrollOffset,
|
||||
color: _indicatorColor
|
||||
),
|
||||
|
@ -365,7 +365,7 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
_setScrollOffset(_controller.value);
|
||||
}
|
||||
|
||||
void _setScrollOffset(double newScrollOffset) {
|
||||
void _setScrollOffset(double newScrollOffset, { DragUpdateDetails details }) {
|
||||
if (_scrollOffset == newScrollOffset)
|
||||
return;
|
||||
setState(() {
|
||||
@ -374,6 +374,11 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
PageStorage.of(context)?.writeState(context, _scrollOffset);
|
||||
_startScroll();
|
||||
dispatchOnScroll();
|
||||
new ScrollNotification(
|
||||
scrollable: this,
|
||||
kind: ScrollNotificationKind.updated,
|
||||
details: details
|
||||
).dispatch(context);
|
||||
_endScroll();
|
||||
}
|
||||
|
||||
@ -381,9 +386,13 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
///
|
||||
/// If a non-null [duration] is provided, the widget will animate to the new
|
||||
/// scroll offset over the given duration with the given curve.
|
||||
Future<Null> scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) {
|
||||
Future<Null> scrollBy(double scrollDelta, {
|
||||
Duration duration,
|
||||
Curve curve: Curves.ease,
|
||||
DragUpdateDetails details
|
||||
}) {
|
||||
double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
|
||||
return scrollTo(newScrollOffset, duration: duration, curve: curve);
|
||||
return scrollTo(newScrollOffset, duration: duration, curve: curve, details: details);
|
||||
}
|
||||
|
||||
/// Scroll this widget to the given scroll offset.
|
||||
@ -394,13 +403,17 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
/// This function does not accept a zero duration. To jump-scroll to
|
||||
/// the new offset, do not provide a duration, rather than providing
|
||||
/// a zero duration.
|
||||
Future<Null> scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) {
|
||||
Future<Null> scrollTo(double newScrollOffset, {
|
||||
Duration duration,
|
||||
Curve curve: Curves.ease,
|
||||
DragUpdateDetails details
|
||||
}) {
|
||||
if (newScrollOffset == _scrollOffset)
|
||||
return new Future<Null>.value();
|
||||
|
||||
if (duration == null) {
|
||||
_stop();
|
||||
_setScrollOffset(newScrollOffset);
|
||||
_setScrollOffset(newScrollOffset, details: details);
|
||||
return new Future<Null>.value();
|
||||
}
|
||||
|
||||
@ -412,7 +425,9 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
_stop();
|
||||
_controller.value = scrollOffset;
|
||||
_startScroll();
|
||||
return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then(_endScroll);
|
||||
return _controller.animateTo(newScrollOffset, duration: duration, curve: curve).then((Null _) {
|
||||
_endScroll();
|
||||
});
|
||||
}
|
||||
|
||||
/// Update any in-progress scrolling physics to account for new scroll behavior.
|
||||
@ -481,7 +496,9 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
if (_simulation == null)
|
||||
return new Future<Null>.value();
|
||||
_startScroll();
|
||||
return _controller.animateWith(_simulation).then(_endScroll);
|
||||
return _controller.animateWith(_simulation).then((Null _) {
|
||||
_endScroll();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether this scrollable should attempt to snap scroll offsets.
|
||||
@ -549,7 +566,6 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
assert(_numberOfInProgressScrolls > 0);
|
||||
if (config.onScroll != null)
|
||||
config.onScroll(_scrollOffset);
|
||||
new ScrollNotification(this, ScrollNotificationKind.updated).dispatch(context);
|
||||
}
|
||||
|
||||
void _handleDragDown(_) {
|
||||
@ -563,13 +579,19 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
_startScroll();
|
||||
_startScroll(details: details);
|
||||
}
|
||||
|
||||
void _startScroll() {
|
||||
void _startScroll({ DragStartDetails details }) {
|
||||
_numberOfInProgressScrolls += 1;
|
||||
if (_numberOfInProgressScrolls == 1)
|
||||
if (_numberOfInProgressScrolls == 1) {
|
||||
dispatchOnScrollStart();
|
||||
new ScrollNotification(
|
||||
scrollable: this,
|
||||
kind: ScrollNotificationKind.started,
|
||||
details: details
|
||||
).dispatch(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls the onScrollStart callback.
|
||||
@ -579,25 +601,32 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
assert(_numberOfInProgressScrolls == 1);
|
||||
if (config.onScrollStart != null)
|
||||
config.onScrollStart(_scrollOffset);
|
||||
new ScrollNotification(this, ScrollNotificationKind.started).dispatch(context);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
scrollBy(pixelOffsetToScrollOffset(details.primaryDelta));
|
||||
scrollBy(pixelOffsetToScrollOffset(details.primaryDelta), details: details);
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
final double scrollVelocity = pixelDeltaToScrollOffset(details.velocity.pixelsPerSecond);
|
||||
fling(scrollVelocity).then(_endScroll);
|
||||
fling(scrollVelocity).then((Null _) {
|
||||
_endScroll(details: details);
|
||||
});
|
||||
}
|
||||
|
||||
Null _endScroll([Null _]) {
|
||||
void _endScroll({ DragEndDetails details }) {
|
||||
_numberOfInProgressScrolls -= 1;
|
||||
if (_numberOfInProgressScrolls == 0) {
|
||||
_simulation = null;
|
||||
dispatchOnScrollEnd();
|
||||
if (mounted) {
|
||||
new ScrollNotification(
|
||||
scrollable: this,
|
||||
kind: ScrollNotificationKind.ended,
|
||||
details: details
|
||||
).dispatch(context);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calls the dispatchOnScrollEnd callback.
|
||||
@ -607,8 +636,6 @@ class ScrollableState<T extends Scrollable> extends State<T> {
|
||||
assert(_numberOfInProgressScrolls == 0);
|
||||
if (config.onScrollEnd != null)
|
||||
config.onScrollEnd(_scrollOffset);
|
||||
if (mounted)
|
||||
new ScrollNotification(this, ScrollNotificationKind.ended).dispatch(context);
|
||||
}
|
||||
|
||||
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = new GlobalKey<RawGestureDetectorState>();
|
||||
@ -716,7 +743,14 @@ enum ScrollNotificationKind {
|
||||
/// * [NotificationListener]
|
||||
class ScrollNotification extends Notification {
|
||||
/// Creates a notification about scrolling.
|
||||
ScrollNotification(this.scrollable, this.kind);
|
||||
ScrollNotification({ this.scrollable, this.kind, dynamic details }) : _details = details {
|
||||
assert(scrollable != null);
|
||||
assert(kind != null);
|
||||
assert(details == null
|
||||
|| (kind == ScrollNotificationKind.started && details is DragStartDetails)
|
||||
|| (kind == ScrollNotificationKind.updated && details is DragUpdateDetails)
|
||||
|| (kind == ScrollNotificationKind.ended && details is DragEndDetails));
|
||||
}
|
||||
|
||||
/// Indicates if we're at the start, middle, or end of a scroll.
|
||||
final ScrollNotificationKind kind;
|
||||
@ -724,6 +758,11 @@ class ScrollNotification extends Notification {
|
||||
/// The scrollable that scrolled.
|
||||
final ScrollableState scrollable;
|
||||
|
||||
DragStartDetails get dragStartDetails => kind == ScrollNotificationKind.started ? _details : null;
|
||||
DragUpdateDetails get dragUpdateDetails => kind == ScrollNotificationKind.updated ? _details : null;
|
||||
DragEndDetails get dragEndDetails => kind == ScrollNotificationKind.ended ? _details : null;
|
||||
final dynamic _details;
|
||||
|
||||
/// The number of scrollable widgets that have already received this
|
||||
/// notification. Typically listeners only respond to notifications
|
||||
/// with depth = 0.
|
||||
|
@ -23,16 +23,29 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(notification.kind, equals(ScrollNotificationKind.started));
|
||||
expect(notification.depth, equals(0));
|
||||
expect(notification.dragStartDetails, isNotNull);
|
||||
expect(notification.dragStartDetails.globalPosition, equals(new Point(100.0, 100.0)));
|
||||
expect(notification.dragUpdateDetails, isNull);
|
||||
expect(notification.dragEndDetails, isNull);
|
||||
|
||||
await gesture.moveBy(new Offset(-10.0, -10.0));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(notification.kind, equals(ScrollNotificationKind.updated));
|
||||
expect(notification.depth, equals(0));
|
||||
expect(notification.dragStartDetails, isNull);
|
||||
expect(notification.dragUpdateDetails, isNotNull);
|
||||
expect(notification.dragUpdateDetails.globalPosition, equals(new Point(90.0, 90.0)));
|
||||
expect(notification.dragUpdateDetails.delta, equals(new Offset(0.0, -10.0)));
|
||||
expect(notification.dragEndDetails, isNull);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(notification.kind, equals(ScrollNotificationKind.ended));
|
||||
expect(notification.depth, equals(0));
|
||||
expect(notification.dragStartDetails, isNull);
|
||||
expect(notification.dragUpdateDetails, isNull);
|
||||
expect(notification.dragEndDetails, isNotNull);
|
||||
expect(notification.dragEndDetails.velocity, equals(Velocity.zero));
|
||||
});
|
||||
|
||||
testWidgets('Scroll notifcation depth', (WidgetTester tester) async {
|
||||
|
Loading…
x
Reference in New Issue
Block a user