diff --git a/examples/widgets/card_collection.dart b/examples/widgets/card_collection.dart index d70d0e3eb43..22c2e2c39eb 100644 --- a/examples/widgets/card_collection.dart +++ b/examples/widgets/card_collection.dart @@ -30,12 +30,14 @@ class CardCollectionAppState extends State { List _cardModels; DismissDirection _dismissDirection = DismissDirection.horizontal; + bool _snapToCenter = false; + bool _fixedSizeCards = false; bool _drawerShowing = false; AnimationStatus _drawerStatus = AnimationStatus.dismissed; InvalidatorCallback _invalidator; + Size _cardCollectionSize = new Size(200.0, 200.0); - void initState(BuildContext context) { - super.initState(context); + void _initVariableSizedCardModels() { List cardHeights = [ 48.0, 63.0, 82.0, 146.0, 60.0, 55.0, 84.0, 96.0, 50.0, 48.0, 63.0, 82.0, 146.0, 60.0, 55.0, 84.0, 96.0, 50.0, @@ -47,6 +49,27 @@ class CardCollectionAppState extends State { }); } + void _initFixedSizedCardModels() { + const int cardCount = 27; + const double cardHeight = 100.0; + _cardModels = new List.generate(cardCount, (i) { + Color color = Color.lerp(Colors.red[300], Colors.blue[900], i / cardCount); + return new CardModel(i, cardHeight, color); + }); + } + + void _initCardModels() { + if (_fixedSizeCards) + _initFixedSizedCardModels(); + else + _initVariableSizedCardModels(); + } + + void initState(BuildContext context) { + super.initState(context); + _initCardModels(); + } + void dismissCard(CardModel card) { if (_cardModels.contains(card)) { setState(() { @@ -73,7 +96,20 @@ class CardCollectionAppState extends State { return "dismiss ${s.substring(s.indexOf('.') + 1)}"; } - void changeDismissDirection(DismissDirection newDismissDirection) { + void _toggleFixedSizeCards() { + setState(() { + _fixedSizeCards = !_fixedSizeCards; + _initCardModels(); + }); + } + + void _toggleSnapToCenter() { + setState(() { + _snapToCenter = !_snapToCenter; + }); + } + + _changeDismissDirection(DismissDirection newDismissDirection) { setState(() { _dismissDirection = newDismissDirection; _drawerStatus = AnimationStatus.dismissed; @@ -84,15 +120,25 @@ class CardCollectionAppState extends State { if (_drawerStatus == AnimationStatus.dismissed) return null; - Widget buildDrawerItem(DismissDirection direction, String icon) { + Widget buildDrawerCheckbox(String label, bool value, Function callback) { + return new DrawerItem( + onPressed: callback, + child: new Row([ + new Flexible(child: new Text(label)), + new Checkbox(value: value, onChanged: (_) { callback(); }) + ]) + ); + } + + Widget buildDrawerRadioItem(DismissDirection direction, String icon) { return new DrawerItem( icon: icon, - onPressed: () { changeDismissDirection(direction); }, + onPressed: () { _changeDismissDirection(direction); }, child: new Row([ new Flexible(child: new Text(_dismissDirectionText(direction))), new Radio( value: direction, - onChanged: changeDismissDirection, + onChanged: _changeDismissDirection, groupValue: _dismissDirection ) ]) @@ -106,10 +152,13 @@ class CardCollectionAppState extends State { showing: _drawerShowing, onDismissed: _handleDrawerDismissed, children: [ - new DrawerHeader(child: new Text('Dismiss Direction')), - buildDrawerItem(DismissDirection.horizontal, 'action/code'), - buildDrawerItem(DismissDirection.left, 'navigation/arrow_back'), - buildDrawerItem(DismissDirection.right, 'navigation/arrow_forward') + new DrawerHeader(child: new Text('Options')), + buildDrawerCheckbox("Snap fling scrolls to center", _snapToCenter, _toggleSnapToCenter), + buildDrawerCheckbox("Fixed size cards", _fixedSizeCards, _toggleFixedSizeCards), + new DrawerDivider(),//buildDrawerSpacerItem(), + buildDrawerRadioItem(DismissDirection.horizontal, 'action/code'), + buildDrawerRadioItem(DismissDirection.left, 'navigation/arrow_back'), + buildDrawerRadioItem(DismissDirection.right, 'navigation/arrow_forward'), ] ) ); @@ -200,17 +249,84 @@ class CardCollectionAppState extends State { ); } + void _updateCardCollectionSize(Size newSize) { + setState(() { + _cardCollectionSize = newSize; + }); + } + + double _variableSizeToSnapOffset(double scrollOffset) { + double cumulativeHeight = 0.0; + double margins = 8.0; + List cumulativeHeights = _cardModels.map((card) { + cumulativeHeight += card.height + margins; + return cumulativeHeight; + }) + .toList(); + + double offset; + for (int i = 0; i < cumulativeHeights.length; i++) { + if (cumulativeHeights[i] >= scrollOffset) + return 12.0 + (margins + _cardModels[i].height) / 2.0 + ((i == 0) ? 0.0 : cumulativeHeights[i - 1]); + } + + assert(false); + return 0.0; + } + + double _fixedSizeToSnapOffset(double scrollOffset) { + double cardHeight = _cardModels[0].height; + return 12.0 + (scrollOffset / cardHeight).floor() * cardHeight + cardHeight * 0.5; + } + + double _toSnapOffset(double scrollOffset) { + return _fixedSizeCards ? _fixedSizeToSnapOffset(scrollOffset) : _variableSizeToSnapOffset(scrollOffset); + } + Widget build(BuildContext context) { - Widget cardCollection = new Container( - padding: const EdgeDims.symmetric(vertical: 12.0, horizontal: 8.0), - decoration: new BoxDecoration(backgroundColor: Theme.of(context).primarySwatch[50]), - child: new ScrollableMixedWidgetList( + + Widget cardCollection; + if (_fixedSizeCards) { + cardCollection = new ScrollableList ( + snapOffsetCallback: _snapToCenter ? _toSnapOffset : null, + snapAlignmentOffset: _cardCollectionSize.height / 2.0, + items: _cardModels, + itemBuilder: (BuildContext context, CardModel card) => buildCard(context, card.value), + itemExtent: _cardModels[0].height + ); + } else { + cardCollection = new ScrollableMixedWidgetList( builder: buildCard, token: _cardModels.length, + snapOffsetCallback: _snapToCenter ? _toSnapOffset : null, + snapAlignmentOffset: _cardCollectionSize.height / 2.0, onInvalidatorAvailable: (InvalidatorCallback callback) { _invalidator = callback; } + ); + } + + Widget body = new SizeObserver( + callback: _updateCardCollectionSize, + child: new Container( + padding: const EdgeDims.symmetric(vertical: 12.0, horizontal: 8.0), + decoration: new BoxDecoration(backgroundColor: Theme.of(context).primarySwatch[50]), + child: cardCollection ) ); + if (_snapToCenter) { + Widget indicator = new IgnorePointer( + child: new Align( + horizontal: 0.0, + vertical: 0.5, + child: new Container( + height: 1.0, + decoration: new BoxDecoration(backgroundColor: const Color(0x80FFFFFF)) + ) + ) + ); + body = new Stack([body, indicator]); + } + return new Theme( data: new ThemeData( brightness: ThemeBrightness.light, @@ -222,7 +338,7 @@ class CardCollectionAppState extends State { child: new Scaffold( toolbar: buildToolBar(), drawer: buildDrawer(), - body: cardCollection + body: body ) ) ); diff --git a/sky/packages/sky/lib/src/animation/scroll_behavior.dart b/sky/packages/sky/lib/src/animation/scroll_behavior.dart index 337b3d48833..674c0005fb8 100644 --- a/sky/packages/sky/lib/src/animation/scroll_behavior.dart +++ b/sky/packages/sky/lib/src/animation/scroll_behavior.dart @@ -12,13 +12,16 @@ const double _kScrollDrag = 0.025; /// An interface for controlling the behavior of scrollable widgets abstract class ScrollBehavior { - /// A simulation to run to determine the scroll offset - /// - /// Called when the user stops scrolling at a given position with a given - /// instantaneous velocity. + /// Called when a drag gesture ends. Returns a simulation that + /// propels the scrollOffset. Simulation release(double position, double velocity) => null; - /// The new scroll offset to use when the user attempts to scroll from the given offset by the given delta + /// Called when a drag gesture ends and toSnapOffset is specified. + /// Returns an animation that ends at the snap offset. + Simulation createSnapScrollSimulation(double startOffset, double endOffset, double velocity) => null; + + /// Return the scroll offset to use when the user attempts to scroll + /// from the given offset by the given delta double applyCurve(double scrollOffset, double scrollDelta); /// Whether this scroll behavior currently permits scrolling @@ -87,6 +90,10 @@ class UnboundedBehavior extends ExtentScrollBehavior { ); } + Simulation createSnapScrollSimulation(double startOffset, double endOffset, double velocity) { + return _createSnapScrollSimulation(startOffset, endOffset, velocity); + } + double get minScrollOffset => double.NEGATIVE_INFINITY; double get maxScrollOffset => double.INFINITY; @@ -95,7 +102,7 @@ class UnboundedBehavior extends ExtentScrollBehavior { } } -Simulation _createDefaultScrollSimulation(double position, double velocity, double minScrollOffset, double maxScrollOffset) { +Simulation _createFlingScrollSimulation(double position, double velocity, double minScrollOffset, double maxScrollOffset) { double startVelocity = velocity * _kSecondsPerMillisecond; // Assume that we're rendering at atleast 15 FPS. Stop when we're @@ -111,17 +118,27 @@ Simulation _createDefaultScrollSimulation(double position, double velocity, doub SpringDescription spring = new SpringDescription.withDampingRatio(mass: 1.0, springConstant: 170.0, ratio: 1.1); ScrollSimulation simulation = new ScrollSimulation(position, startVelocity, minScrollOffset, maxScrollOffset, spring, _kScrollDrag) - ..tolerance = new Tolerance(velocity: endVelocity, distance: endDistance); + ..tolerance = new Tolerance(velocity: endVelocity.abs(), distance: endDistance); return simulation; } +Simulation _createSnapScrollSimulation(double startOffset, double endOffset, double velocity) { + double startVelocity = velocity * _kSecondsPerMillisecond; + double endVelocity = 15.0 * sky.view.devicePixelRatio * velocity.sign; + return new FrictionSimulation.through(startOffset, endOffset, startVelocity, endVelocity); +} + /// A scroll behavior that lets the user scroll beyond the scroll bounds with some resistance class OverscrollBehavior extends BoundedBehavior { OverscrollBehavior({ double contentExtent: 0.0, double containerExtent: 0.0 }) : super(contentExtent: contentExtent, containerExtent: containerExtent); Simulation release(double position, double velocity) { - return _createDefaultScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset); + return _createFlingScrollSimulation(position, velocity, minScrollOffset, maxScrollOffset); + } + + Simulation createSnapScrollSimulation(double startOffset, double endOffset, double velocity) { + return _createSnapScrollSimulation(startOffset, endOffset, velocity); } double applyCurve(double scrollOffset, double scrollDelta) { diff --git a/sky/packages/sky/lib/src/fn3/scrollable.dart b/sky/packages/sky/lib/src/fn3/scrollable.dart index f962d7ddc72..bf0705626bc 100644 --- a/sky/packages/sky/lib/src/fn3/scrollable.dart +++ b/sky/packages/sky/lib/src/fn3/scrollable.dart @@ -22,6 +22,7 @@ const double _kMinFlingVelocity = -kMaxFlingVelocity * _kMillisecondsPerSecond; const double _kMaxFlingVelocity = kMaxFlingVelocity * _kMillisecondsPerSecond; typedef void ScrollListener(double scrollOffset); +typedef double SnapOffsetCallback(double scrollOffset); /// A base class for scrollable widgets that reacts to user input and generates /// a scrollOffset. @@ -30,7 +31,9 @@ abstract class Scrollable extends StatefulComponent { Key key, this.initialScrollOffset, this.scrollDirection: ScrollDirection.vertical, - this.onScroll + this.onScroll, + this.snapOffsetCallback, + this.snapAlignmentOffset: 0.0 }) : super(key: key) { assert(scrollDirection == ScrollDirection.vertical || scrollDirection == ScrollDirection.horizontal); @@ -39,6 +42,8 @@ abstract class Scrollable extends StatefulComponent { final double initialScrollOffset; final ScrollDirection scrollDirection; final ScrollListener onScroll; + final SnapOffsetCallback snapOffsetCallback; + final double snapAlignmentOffset; } abstract class ScrollableState extends State { @@ -120,11 +125,37 @@ abstract class ScrollableState extends State { _toEndAnimation.stop(); } - void _startToEndAnimation({ double velocity: 0.0 }) { + bool _scrollOffsetIsInBounds(double offset) { + return offset >= scrollBehavior.minScrollOffset && offset < scrollBehavior.maxScrollOffset; + } + + double _alignedScrollSnapOffset(double offset) { + return config.snapOffsetCallback(offset + config.snapAlignmentOffset) - config.snapAlignmentOffset; + } + + void _startToEndAnimation({ double velocity }) { _stopAnimations(); - Simulation simulation = scrollBehavior.release(scrollOffset, velocity); - if (simulation != null) - _toEndAnimation.start(simulation); + + if (velocity != null && config.snapOffsetCallback != null && _scrollOffsetIsInBounds(scrollOffset)) { + Simulation simulation = scrollBehavior.release(scrollOffset, velocity); + if (simulation == null) + return; + double endScrollOffset = simulation.x(double.INFINITY); + if (!endScrollOffset.isNaN) { + double alignedScrollOffset = _alignedScrollSnapOffset(endScrollOffset); + if (_scrollOffsetIsInBounds(alignedScrollOffset)) { + Simulation toSnapSimulation = scrollBehavior.createSnapScrollSimulation( + scrollOffset, alignedScrollOffset, velocity); + _toEndAnimation.start(toSnapSimulation); + return; + } + } + } + + Simulation simulation = scrollBehavior.release(scrollOffset, velocity ?? 0.0); + if (simulation == null) + return; + _toEndAnimation.start(simulation); } void dispose() { @@ -357,6 +388,8 @@ abstract class ScrollableWidgetList extends Scrollable { double initialScrollOffset, ScrollDirection scrollDirection: ScrollDirection.vertical, ScrollListener onScroll, + SnapOffsetCallback snapOffsetCallback, + double snapAlignmentOffset: 0.0, this.itemsWrap: false, this.itemExtent, this.padding @@ -364,7 +397,9 @@ abstract class ScrollableWidgetList extends Scrollable { key: key, initialScrollOffset: initialScrollOffset, scrollDirection: scrollDirection, - onScroll: onScroll + onScroll: onScroll, + snapOffsetCallback: snapOffsetCallback, + snapAlignmentOffset: snapAlignmentOffset ) { assert(itemExtent != null); } @@ -498,6 +533,8 @@ class ScrollableList extends ScrollableWidgetList { double initialScrollOffset, ScrollDirection scrollDirection: ScrollDirection.vertical, ScrollListener onScroll, + SnapOffsetCallback snapOffsetCallback, + double snapAlignmentOffset: 0.0, this.items, this.itemBuilder, itemsWrap: false, @@ -508,6 +545,8 @@ class ScrollableList extends ScrollableWidgetList { initialScrollOffset: initialScrollOffset, scrollDirection: scrollDirection, onScroll: onScroll, + snapOffsetCallback: snapOffsetCallback, + snapAlignmentOffset: snapAlignmentOffset, itemsWrap: itemsWrap, itemExtent: itemExtent, padding: padding); @@ -609,13 +648,17 @@ class ScrollableMixedWidgetList extends Scrollable { Key key, double initialScrollOffset, ScrollListener onScroll, + SnapOffsetCallback snapOffsetCallback, + double snapAlignmentOffset: 0.0, this.builder, this.token, this.onInvalidatorAvailable }) : super( key: key, initialScrollOffset: initialScrollOffset, - onScroll: onScroll + onScroll: onScroll, + snapOffsetCallback: snapOffsetCallback, + snapAlignmentOffset: snapAlignmentOffset ); final IndexedBuilder builder; diff --git a/sky/unit/test/fn3/widget_tester.dart b/sky/unit/test/fn3/widget_tester.dart index 81a280135f5..c5aad4ba20b 100644 --- a/sky/unit/test/fn3/widget_tester.dart +++ b/sky/unit/test/fn3/widget_tester.dart @@ -1,5 +1,6 @@ import 'dart:sky' as sky; +import 'package:sky/animation.dart'; import 'package:sky/rendering.dart'; import 'package:sky/src/fn3.dart'; @@ -26,11 +27,11 @@ class WidgetTester { void pumpFrame(Widget widget, [ double frameTimeMs = 0.0 ]) { runApp(widget); - WidgetFlutterBinding.instance.beginFrame(frameTimeMs); // TODO(ianh): https://github.com/flutter/engine/issues/1084 + scheduler.beginFrame(frameTimeMs); // TODO(ianh): https://github.com/flutter/engine/issues/1084 } void pumpFrameWithoutChange([ double frameTimeMs = 0.0 ]) { - WidgetFlutterBinding.instance.beginFrame(frameTimeMs); // TODO(ianh): https://github.com/flutter/engine/issues/1084 + scheduler.beginFrame(frameTimeMs); // TODO(ianh): https://github.com/flutter/engine/issues/1084 }