Commit 2656006c authored by Hans Muller's avatar Hans Muller Committed by GitHub

OverscrollIndicator tracks horizontal drag input, etc (#5183)

parent 64fa825b
......@@ -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 {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment