diff --git a/packages/flutter/lib/src/gestures/drag.dart b/packages/flutter/lib/src/gestures/drag.dart index 97fdedc8e8..286a5973dd 100644 --- a/packages/flutter/lib/src/gestures/drag.dart +++ b/packages/flutter/lib/src/gestures/drag.dart @@ -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 { diff --git a/packages/flutter/lib/src/material/overscroll_indicator.dart b/packages/flutter/lib/src/material/overscroll_indicator.dart index 7836f2d1eb..90bfe8241e 100644 --- a/packages/flutter/lib/src/material/overscroll_indicator.dart +++ b/packages/flutter/lib/src/material/overscroll_indicator.dart @@ -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 _kIndicatorOpacity = new Tween(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 { 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 { } 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 { } 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 { 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 { _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 { foregroundPainter: _scrollDirection == null ? null : new _Painter( scrollDirection: _scrollDirection, extent: _extentAnimation.value, + dragPosition: _dragPosition, isLeading: _scrollOffset < _minScrollOffset, color: _indicatorColor ), diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index d923ed7af2..d2476d6a2b 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -365,7 +365,7 @@ class ScrollableState extends State { _setScrollOffset(_controller.value); } - void _setScrollOffset(double newScrollOffset) { + void _setScrollOffset(double newScrollOffset, { DragUpdateDetails details }) { if (_scrollOffset == newScrollOffset) return; setState(() { @@ -374,6 +374,11 @@ class ScrollableState extends State { 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 extends State { /// /// 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 scrollBy(double scrollDelta, { Duration duration, Curve curve: Curves.ease }) { + Future 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 extends State { /// 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 scrollTo(double newScrollOffset, { Duration duration, Curve curve: Curves.ease }) { + Future scrollTo(double newScrollOffset, { + Duration duration, + Curve curve: Curves.ease, + DragUpdateDetails details + }) { if (newScrollOffset == _scrollOffset) return new Future.value(); if (duration == null) { _stop(); - _setScrollOffset(newScrollOffset); + _setScrollOffset(newScrollOffset, details: details); return new Future.value(); } @@ -412,7 +425,9 @@ class ScrollableState extends State { _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 extends State { if (_simulation == null) return new Future.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 extends State { 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 extends State { } 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 extends State { 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 extends State { assert(_numberOfInProgressScrolls == 0); if (config.onScrollEnd != null) config.onScrollEnd(_scrollOffset); - if (mounted) - new ScrollNotification(this, ScrollNotificationKind.ended).dispatch(context); } final GlobalKey _gestureDetectorKey = new GlobalKey(); @@ -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. diff --git a/packages/flutter/test/widget/scroll_notification_test.dart b/packages/flutter/test/widget/scroll_notification_test.dart index a044f5811b..be1d41ffc9 100644 --- a/packages/flutter/test/widget/scroll_notification_test.dart +++ b/packages/flutter/test/widget/scroll_notification_test.dart @@ -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 {