diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 6e7269af02..101d878472 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -922,12 +922,12 @@ class _TabBarViewState extends PageableListState implements TabBarSe controller.value = scrollOffset / 2.0; } - Future fling(Offset scrollVelocity) { + Future fling(double scrollVelocity) { if (_selection == null || _selection.valueIsChanging) return new Future.value(); - if (scrollVelocity.dx.abs() > _kMinFlingVelocity) { - final int selectionDelta = scrollVelocity.dx > 0 ? -1 : 1; + if (scrollVelocity.abs() > _kMinFlingVelocity) { + final int selectionDelta = scrollVelocity.sign.truncate(); final int targetIndex = (_selection.index + selectionDelta).clamp(0, _tabCount - 1); _selection.value = _selection.values[targetIndex]; return new Future.value(); diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index a8e64d8c3a..f348b3a59a 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -9,6 +9,11 @@ import 'package:vector_math/vector_math_64.dart'; import 'box.dart'; import 'object.dart'; +enum ViewportAnchor { + start, + end, +} + abstract class HasScrollDirection { Axis get scrollDirection; } diff --git a/packages/flutter/lib/src/widgets/pageable_list.dart b/packages/flutter/lib/src/widgets/pageable_list.dart index af657f4b94..e08a1554f4 100644 --- a/packages/flutter/lib/src/widgets/pageable_list.dart +++ b/packages/flutter/lib/src/widgets/pageable_list.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/animation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'basic.dart'; @@ -65,12 +64,25 @@ class PageableListState extends ScrollableState { int get itemCount => config.children?.length ?? 0; int _previousItemCount; - double pixelToScrollOffset(double value) { + double get _pixelsPerScrollUnit { final RenderBox box = context.findRenderObject(); if (box == null || !box.hasSize) return 0.0; - final double pixelScrollExtent = config.scrollDirection == Axis.vertical ? box.size.height : box.size.width; - return pixelScrollExtent == 0.0 ? 0.0 : value / pixelScrollExtent; + switch (config.scrollDirection) { + case Axis.horizontal: + return box.size.width; + case Axis.vertical: + return box.size.height; + } + } + + double pixelOffsetToScrollOffset(double pixelOffset) { + final double pixelsPerScrollUnit = _pixelsPerScrollUnit; + return super.pixelOffsetToScrollOffset(pixelsPerScrollUnit == 0.0 ? 0.0 : pixelOffset / pixelsPerScrollUnit); + } + + double scrollOffsetToPixelOffset(double scrollOffset) { + return super.scrollOffsetToPixelOffset(scrollOffset * _pixelsPerScrollUnit); } void initState() { @@ -143,7 +155,7 @@ class PageableListState extends ScrollableState { ScrollBehavior createScrollBehavior() => scrollBehavior; - bool get snapScrollOffsetChanges => config.itemsSnapAlignment == ItemsSnapAlignment.item; + bool get shouldSnapScrollOffset => config.itemsSnapAlignment == ItemsSnapAlignment.item; double snapScrollOffset(double newScrollOffset) { final double previousItemOffset = newScrollOffset.floorToDouble(); @@ -152,20 +164,19 @@ class PageableListState extends ScrollableState { .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); } - Future _flingToAdjacentItem(Offset velocity) { - final double scrollVelocity = scrollDirectionVelocity(velocity); + Future _flingToAdjacentItem(double scrollVelocity) { final double newScrollOffset = snapScrollOffset(scrollOffset + scrollVelocity.sign) .clamp(snapScrollOffset(scrollOffset - 0.5), snapScrollOffset(scrollOffset + 0.5)); return scrollTo(newScrollOffset, duration: config.duration, curve: config.curve) .then(_notifyPageChanged); } - Future fling(Offset velocity) { + Future fling(double scrollVelocity) { switch(config.itemsSnapAlignment) { case ItemsSnapAlignment.adjacentItem: - return _flingToAdjacentItem(velocity); + return _flingToAdjacentItem(scrollVelocity); default: - return super.fling(velocity).then(_notifyPageChanged); + return super.fling(scrollVelocity).then(_notifyPageChanged); } } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index e91717798e..2e9686fdbc 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -19,11 +19,6 @@ import 'notification_listener.dart'; import 'page_storage.dart'; import 'scroll_behavior.dart'; -// The gesture velocity properties are pixels/second, config min,max limits are pixels/ms -const double _kMillisecondsPerSecond = 1000.0; -const double _kMinFlingVelocity = -kMaxFlingVelocity * _kMillisecondsPerSecond; -const double _kMaxFlingVelocity = kMaxFlingVelocity * _kMillisecondsPerSecond; - /// The accuracy to which scrolling is computed. final Tolerance kPixelScrollTolerance = new Tolerance( velocity: 1.0 / (0.050 * ui.window.devicePixelRatio), // logical pixels per second @@ -45,14 +40,16 @@ abstract class Scrollable extends StatefulComponent { Key key, this.initialScrollOffset, this.scrollDirection: Axis.vertical, + this.scrollAnchor: ViewportAnchor.start, this.onScrollStart, this.onScroll, this.onScrollEnd, this.snapOffsetCallback, this.snapAlignmentOffset: 0.0 }) : super(key: key) { - assert(scrollDirection == Axis.vertical || - scrollDirection == Axis.horizontal); + assert(scrollDirection == Axis.vertical || scrollDirection == Axis.horizontal); + assert(scrollAnchor == ViewportAnchor.start || scrollAnchor == ViewportAnchor.end); + assert(snapAlignmentOffset != null); } /// The scroll offset this widget should use when first created. @@ -61,6 +58,8 @@ abstract class Scrollable extends StatefulComponent { /// The axis along which this widget should scroll. final Axis scrollDirection; + final ViewportAnchor scrollAnchor; + /// Called whenever this widget starts to scroll. final ScrollListener onScrollStart; @@ -172,13 +171,46 @@ abstract class ScrollableState extends State { /// Scrollable gesture handlers convert their incoming values with this method. /// Subclasses that define scrollOffset in units other than pixels must /// override this method. - double pixelToScrollOffset(double pixelValue) => pixelValue; + double pixelOffsetToScrollOffset(double pixelOffset) { + switch (config.scrollAnchor) { + case ViewportAnchor.start: + // We negate the delta here because a positive scroll offset moves the + // the content up (or to the left) rather than down (or the right). + return -pixelOffset; + case ViewportAnchor.end: + return pixelOffset; + } + } - /// Returns the component of the given velocity in the scroll direction. - double scrollDirectionVelocity(Offset scrollVelocity) { - return config.scrollDirection == Axis.horizontal - ? -scrollVelocity.dx - : -scrollVelocity.dy; + double scrollOffsetToPixelOffset(double scrollOffset) { + switch (config.scrollAnchor) { + case ViewportAnchor.start: + return -scrollOffset; + case ViewportAnchor.end: + return scrollOffset; + } + } + + /// Returns the scroll offset component of the given pixel delta, accounting + /// for the scroll direction and scroll anchor. + double pixelDeltaToScrollOffset(Offset pixelDelta) { + switch (config.scrollDirection) { + case Axis.horizontal: + return pixelOffsetToScrollOffset(pixelDelta.dx); + case Axis.vertical: + return pixelOffsetToScrollOffset(pixelDelta.dy); + } + } + + /// Returns a two-dimensional representation of the scroll offset, accounting + /// for the scroll direction and scroll anchor. + Offset scrollOffsetToPixelDelta(double scrollOffset) { + switch (config.scrollDirection) { + case Axis.horizontal: + return new Offset(scrollOffsetToPixelOffset(scrollOffset), 0.0); + case Axis.vertical: + return new Offset(0.0, scrollOffsetToPixelOffset(scrollOffset)); + } } ScrollBehavior _scrollBehavior; @@ -244,19 +276,19 @@ abstract class ScrollableState extends State { return _controller.animateTo(newScrollOffset, duration: duration, curve: curve); } - bool _scrollOffsetIsInBounds(double offset) { + bool _scrollOffsetIsInBounds(double scrollOffset) { if (scrollBehavior is! ExtentScrollBehavior) return false; ExtentScrollBehavior behavior = scrollBehavior; - return offset >= behavior.minScrollOffset && offset < behavior.maxScrollOffset; + return scrollOffset >= behavior.minScrollOffset && scrollOffset < behavior.maxScrollOffset; } - Simulation _createFlingSimulation(double velocity) { - final Simulation simulation = scrollBehavior.createFlingScrollSimulation(scrollOffset, velocity); + Simulation _createFlingSimulation(double scrollVelocity) { + final Simulation simulation = scrollBehavior.createFlingScrollSimulation(scrollOffset, scrollVelocity); if (simulation != null) { - final double endVelocity = pixelToScrollOffset(kPixelScrollTolerance.velocity); - final double endDistance = pixelToScrollOffset(kPixelScrollTolerance.distance); - simulation.tolerance = new Tolerance(velocity: endVelocity.abs(), distance: endDistance); + final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity) * scrollVelocity.sign; + final double endDistance = pixelOffsetToScrollOffset(kPixelScrollTolerance.distance).abs(); + simulation.tolerance = new Tolerance(velocity: endVelocity, distance: endDistance); } return simulation; } @@ -267,41 +299,40 @@ abstract class ScrollableState extends State { } /// Whether this scrollable should attempt to snap scroll offsets. - bool get snapScrollOffsetChanges => config.snapOffsetCallback != null; + bool get shouldSnapScrollOffset => config.snapOffsetCallback != null; - Simulation _createSnapSimulation(double velocity) { - if (!snapScrollOffsetChanges || velocity == 0.0 || !_scrollOffsetIsInBounds(scrollOffset)) + Simulation _createSnapSimulation(double scrollVelocity) { + if (!shouldSnapScrollOffset || scrollVelocity == 0.0 || !_scrollOffsetIsInBounds(scrollOffset)) return null; - Simulation simulation = _createFlingSimulation(velocity); + Simulation simulation = _createFlingSimulation(scrollVelocity); if (simulation == null) return null; - double endScrollOffset = simulation.x(double.INFINITY); + final double endScrollOffset = simulation.x(double.INFINITY); if (endScrollOffset.isNaN) return null; - double snappedScrollOffset = snapScrollOffset(endScrollOffset + config.snapAlignmentOffset); - double alignedScrollOffset = snappedScrollOffset - config.snapAlignmentOffset; + final double snappedScrollOffset = snapScrollOffset(endScrollOffset + config.snapAlignmentOffset); + final double alignedScrollOffset = snappedScrollOffset - config.snapAlignmentOffset; if (!_scrollOffsetIsInBounds(alignedScrollOffset)) return null; - double snapVelocity = velocity.abs() * (alignedScrollOffset - scrollOffset).sign; - double endVelocity = pixelToScrollOffset(kPixelScrollTolerance.velocity * velocity.sign); + final double snapVelocity = scrollVelocity.abs() * (alignedScrollOffset - scrollOffset).sign; + final double endVelocity = pixelOffsetToScrollOffset(kPixelScrollTolerance.velocity).abs() * scrollVelocity.sign; Simulation toSnapSimulation = scrollBehavior.createSnapScrollSimulation(scrollOffset, alignedScrollOffset, snapVelocity, endVelocity); if (toSnapSimulation == null) return null; - double offsetMin = math.min(scrollOffset, alignedScrollOffset); - double offsetMax = math.max(scrollOffset, alignedScrollOffset); - return new ClampedSimulation(toSnapSimulation, xMin: offsetMin, xMax: offsetMax); + final double scrollOffsetMin = math.min(scrollOffset, alignedScrollOffset); + final double scrollOffsetMax = math.max(scrollOffset, alignedScrollOffset); + return new ClampedSimulation(toSnapSimulation, xMin: scrollOffsetMin, xMax: scrollOffsetMax); } - Future _startToEndAnimation(Offset scrollVelocity) { - double velocity = scrollDirectionVelocity(scrollVelocity); + Future _startToEndAnimation(double scrollVelocity) { _controller.stop(); - Simulation simulation = _createSnapSimulation(velocity) ?? _createFlingSimulation(velocity); + Simulation simulation = _createSnapSimulation(scrollVelocity) ?? _createFlingSimulation(scrollVelocity); if (simulation == null) return new Future.value(); return _controller.animateWith(simulation); @@ -358,8 +389,8 @@ abstract class ScrollableState extends State { /// Calling this function starts a physics-based animation of the scroll /// offset with the given value as the initial velocity. The physics /// simulation used is determined by the scroll behavior. - Future fling(Offset scrollVelocity) { - if (scrollVelocity != Offset.zero) + Future fling(double scrollVelocity) { + if (scrollVelocity != 0.0) return _startToEndAnimation(scrollVelocity); if (!_controller.isAnimating) return settleScrollOffset(); @@ -372,7 +403,7 @@ abstract class ScrollableState extends State { /// offset either to a snap point or to within the scrolling bounds. The /// physics simulation used is determined by the scroll behavior. Future settleScrollOffset() { - return _startToEndAnimation(Offset.zero); + return _startToEndAnimation(0.0); } /// Calls the onScrollStart callback. @@ -408,19 +439,14 @@ abstract class ScrollableState extends State { } void _handleDragUpdate(double delta) { - // We negate the delta here because a positive scroll offset moves the - // the content up (or to the left) rather than down (or the right). - scrollBy(pixelToScrollOffset(-delta)); + scrollBy(pixelOffsetToScrollOffset(delta)); } - double _toScrollVelocity(double velocity) { - return pixelToScrollOffset(velocity.clamp(_kMinFlingVelocity, _kMaxFlingVelocity) / _kMillisecondsPerSecond); - } - - Future _handleDragEnd(Offset pixelScrollVelocity) { - final Offset scrollVelocity = new Offset(_toScrollVelocity(pixelScrollVelocity.dx), _toScrollVelocity(pixelScrollVelocity.dy)); - return fling(scrollVelocity).then((_) { - dispatchOnScrollEnd(); + Future _handleDragEnd(Offset velocity) { + double scrollVelocity = pixelDeltaToScrollOffset(velocity) / Duration.MILLISECONDS_PER_SECOND; + // The gesture velocity properties are pixels/second, config min,max limits are pixels/ms + return fling(scrollVelocity.clamp(-kMaxFlingVelocity, kMaxFlingVelocity)).then((_) { + dispatchOnScrollEnd(); }); } } diff --git a/packages/flutter/test/widget/snap_scrolling_test.dart b/packages/flutter/test/widget/snap_scrolling_test.dart index 7b17640a6e..e2e895f998 100644 --- a/packages/flutter/test/widget/snap_scrolling_test.dart +++ b/packages/flutter/test/widget/snap_scrolling_test.dart @@ -48,14 +48,11 @@ void set scrollOffset(double value) { } Future fling(double velocity) { - Offset velocityOffset = scrollDirection == Axis.vertical - ? new Offset(0.0, velocity) - : new Offset(velocity, 0.0); - return scrollableState.fling(velocityOffset); + return scrollableState.fling(velocity); } void main() { - test('ScrollableList snap scrolling, fling(-0.8)', () { + test('ScrollableList snap scrolling, fling(0.8)', () { testWidgets((WidgetTester tester) { tester.pumpWidget(buildFrame()); @@ -65,7 +62,7 @@ void main() { Duration dt = const Duration(seconds: 2); - fling(-0.8); + fling(0.8); tester.pump(); // Start the scheduler at 0.0 tester.pump(dt); expect(scrollOffset, closeTo(200.0, 1.0)); @@ -74,7 +71,7 @@ void main() { tester.pump(); expect(scrollOffset, 0.0); - fling(-2.0); + fling(2.0); tester.pump(); tester.pump(dt); expect(scrollOffset, closeTo(400.0, 1.0)); @@ -83,7 +80,7 @@ void main() { tester.pump(); expect(scrollOffset, 400.0); - fling(0.8); + fling(-0.8); tester.pump(); tester.pump(dt); expect(scrollOffset, closeTo(0.0, 1.0)); @@ -92,7 +89,7 @@ void main() { tester.pump(); expect(scrollOffset, 800.0); - fling(2.0); + fling(-2.0); tester.pump(); tester.pump(dt); expect(scrollOffset, closeTo(200.0, 1.0)); @@ -102,7 +99,7 @@ void main() { expect(scrollOffset, 800.0); bool completed = false; - fling(2.0).then((_) { + fling(-2.0).then((_) { completed = true; expect(scrollOffset, closeTo(200.0, 1.0)); });