Commit 7385641f authored by Hans Muller's avatar Hans Muller

SnapOffsets for fling Scrolling

Initial snap offset support for ScrollableWidgetList (and ScrollableList<T>) and ScrollableMixedWidgetList. If a ```toSnapOffset(scrollOffset)``` function is provided, fling Scrolls will coast to the returned value. If ```alignmentOffset``` is specified then fling scrolls conclude when toSnapOffset's value lines up with the Scrollable widget's origin + alignmentOffset. For example if the Scrollable widget's height was 200.0, and alignmentOffset:100.0 was specified, then fling scrolls would end with the value returned by toSnapOffset() lined up with the center of the Scrollable.

This approach to Scrollable snapping assumes that the layout of whatever the Scrollable contains is known at the outset. This is often true however a ScrollableMixedWidgetList may not know its items' sizes until they've been reached by scrolling.

This is a first cut at snapping support. Among the things that remain to be done:
- Scrolling limits trump snapping. Snapping should probably trump scrolling limits.
- Drag scrolls aren't snapped. This may be desirable so perhaps the feature should be controlled with a flag.
- Specifying alignmentOffset as a percentage would probably be more convenient.
- It would be nice if one could wrap items in a SnapOffset value like: ```new SnapOffset(0.5, child: myItem)``` to snap to the center of the item.

Updated the CardCollection example: snapping and fixed size items can be turned on/off with Drawer checkboxes.
parent ebd7fa3e
...@@ -30,12 +30,14 @@ class CardCollectionAppState extends State<CardCollectionApp> { ...@@ -30,12 +30,14 @@ class CardCollectionAppState extends State<CardCollectionApp> {
List<CardModel> _cardModels; List<CardModel> _cardModels;
DismissDirection _dismissDirection = DismissDirection.horizontal; DismissDirection _dismissDirection = DismissDirection.horizontal;
bool _snapToCenter = false;
bool _fixedSizeCards = false;
bool _drawerShowing = false; bool _drawerShowing = false;
AnimationStatus _drawerStatus = AnimationStatus.dismissed; AnimationStatus _drawerStatus = AnimationStatus.dismissed;
InvalidatorCallback _invalidator; InvalidatorCallback _invalidator;
Size _cardCollectionSize = new Size(200.0, 200.0);
void initState(BuildContext context) { void _initVariableSizedCardModels() {
super.initState(context);
List<double> cardHeights = <double>[ List<double> cardHeights = <double>[
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,
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<CardCollectionApp> { ...@@ -47,6 +49,27 @@ class CardCollectionAppState extends State<CardCollectionApp> {
}); });
} }
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) { void dismissCard(CardModel card) {
if (_cardModels.contains(card)) { if (_cardModels.contains(card)) {
setState(() { setState(() {
...@@ -73,7 +96,20 @@ class CardCollectionAppState extends State<CardCollectionApp> { ...@@ -73,7 +96,20 @@ class CardCollectionAppState extends State<CardCollectionApp> {
return "dismiss ${s.substring(s.indexOf('.') + 1)}"; 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(() { setState(() {
_dismissDirection = newDismissDirection; _dismissDirection = newDismissDirection;
_drawerStatus = AnimationStatus.dismissed; _drawerStatus = AnimationStatus.dismissed;
...@@ -84,15 +120,25 @@ class CardCollectionAppState extends State<CardCollectionApp> { ...@@ -84,15 +120,25 @@ class CardCollectionAppState extends State<CardCollectionApp> {
if (_drawerStatus == AnimationStatus.dismissed) if (_drawerStatus == AnimationStatus.dismissed)
return null; 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( return new DrawerItem(
icon: icon, icon: icon,
onPressed: () { changeDismissDirection(direction); }, onPressed: () { _changeDismissDirection(direction); },
child: new Row([ child: new Row([
new Flexible(child: new Text(_dismissDirectionText(direction))), new Flexible(child: new Text(_dismissDirectionText(direction))),
new Radio( new Radio(
value: direction, value: direction,
onChanged: changeDismissDirection, onChanged: _changeDismissDirection,
groupValue: _dismissDirection groupValue: _dismissDirection
) )
]) ])
...@@ -106,10 +152,13 @@ class CardCollectionAppState extends State<CardCollectionApp> { ...@@ -106,10 +152,13 @@ class CardCollectionAppState extends State<CardCollectionApp> {
showing: _drawerShowing, showing: _drawerShowing,
onDismissed: _handleDrawerDismissed, onDismissed: _handleDrawerDismissed,
children: [ children: [
new DrawerHeader(child: new Text('Dismiss Direction')), new DrawerHeader(child: new Text('Options')),
buildDrawerItem(DismissDirection.horizontal, 'action/code'), buildDrawerCheckbox("Snap fling scrolls to center", _snapToCenter, _toggleSnapToCenter),
buildDrawerItem(DismissDirection.left, 'navigation/arrow_back'), buildDrawerCheckbox("Fixed size cards", _fixedSizeCards, _toggleFixedSizeCards),
buildDrawerItem(DismissDirection.right, 'navigation/arrow_forward') 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<CardCollectionApp> { ...@@ -200,17 +249,84 @@ class CardCollectionAppState extends State<CardCollectionApp> {
); );
} }
void _updateCardCollectionSize(Size newSize) {
setState(() {
_cardCollectionSize = newSize;
});
}
double _variableSizeToSnapOffset(double scrollOffset) {
double cumulativeHeight = 0.0;
double margins = 8.0;
List<double> 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 build(BuildContext context) {
Widget cardCollection = new Container(
padding: const EdgeDims.symmetric(vertical: 12.0, horizontal: 8.0), Widget cardCollection;
decoration: new BoxDecoration(backgroundColor: Theme.of(context).primarySwatch[50]), if (_fixedSizeCards) {
child: new ScrollableMixedWidgetList( cardCollection = new ScrollableList<CardModel> (
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, builder: buildCard,
token: _cardModels.length, token: _cardModels.length,
snapOffsetCallback: _snapToCenter ? _toSnapOffset : null,
snapAlignmentOffset: _cardCollectionSize.height / 2.0,
onInvalidatorAvailable: (InvalidatorCallback callback) { _invalidator = callback; } 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( return new Theme(
data: new ThemeData( data: new ThemeData(
brightness: ThemeBrightness.light, brightness: ThemeBrightness.light,
...@@ -222,7 +338,7 @@ class CardCollectionAppState extends State<CardCollectionApp> { ...@@ -222,7 +338,7 @@ class CardCollectionAppState extends State<CardCollectionApp> {
child: new Scaffold( child: new Scaffold(
toolbar: buildToolBar(), toolbar: buildToolBar(),
drawer: buildDrawer(), drawer: buildDrawer(),
body: cardCollection body: body
) )
) )
); );
......
...@@ -12,13 +12,16 @@ const double _kScrollDrag = 0.025; ...@@ -12,13 +12,16 @@ const double _kScrollDrag = 0.025;
/// An interface for controlling the behavior of scrollable widgets /// An interface for controlling the behavior of scrollable widgets
abstract class ScrollBehavior { abstract class ScrollBehavior {
/// A simulation to run to determine the scroll offset /// Called when a drag gesture ends. Returns a simulation that
/// /// propels the scrollOffset.
/// Called when the user stops scrolling at a given position with a given
/// instantaneous velocity.
Simulation release(double position, double velocity) => null; 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); double applyCurve(double scrollOffset, double scrollDelta);
/// Whether this scroll behavior currently permits scrolling /// Whether this scroll behavior currently permits scrolling
...@@ -87,6 +90,10 @@ class UnboundedBehavior extends ExtentScrollBehavior { ...@@ -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 minScrollOffset => double.NEGATIVE_INFINITY;
double get maxScrollOffset => double.INFINITY; double get maxScrollOffset => double.INFINITY;
...@@ -95,7 +102,7 @@ class UnboundedBehavior extends ExtentScrollBehavior { ...@@ -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; double startVelocity = velocity * _kSecondsPerMillisecond;
// Assume that we're rendering at atleast 15 FPS. Stop when we're // Assume that we're rendering at atleast 15 FPS. Stop when we're
...@@ -111,17 +118,27 @@ Simulation _createDefaultScrollSimulation(double position, double velocity, doub ...@@ -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); SpringDescription spring = new SpringDescription.withDampingRatio(mass: 1.0, springConstant: 170.0, ratio: 1.1);
ScrollSimulation simulation = ScrollSimulation simulation =
new ScrollSimulation(position, startVelocity, minScrollOffset, maxScrollOffset, spring, _kScrollDrag) 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; 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 /// A scroll behavior that lets the user scroll beyond the scroll bounds with some resistance
class OverscrollBehavior extends BoundedBehavior { class OverscrollBehavior extends BoundedBehavior {
OverscrollBehavior({ double contentExtent: 0.0, double containerExtent: 0.0 }) OverscrollBehavior({ double contentExtent: 0.0, double containerExtent: 0.0 })
: super(contentExtent: contentExtent, containerExtent: containerExtent); : super(contentExtent: contentExtent, containerExtent: containerExtent);
Simulation release(double position, double velocity) { 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) { double applyCurve(double scrollOffset, double scrollDelta) {
......
...@@ -22,6 +22,7 @@ const double _kMinFlingVelocity = -kMaxFlingVelocity * _kMillisecondsPerSecond; ...@@ -22,6 +22,7 @@ const double _kMinFlingVelocity = -kMaxFlingVelocity * _kMillisecondsPerSecond;
const double _kMaxFlingVelocity = kMaxFlingVelocity * _kMillisecondsPerSecond; const double _kMaxFlingVelocity = kMaxFlingVelocity * _kMillisecondsPerSecond;
typedef void ScrollListener(double scrollOffset); typedef void ScrollListener(double scrollOffset);
typedef double SnapOffsetCallback(double scrollOffset);
/// A base class for scrollable widgets that reacts to user input and generates /// A base class for scrollable widgets that reacts to user input and generates
/// a scrollOffset. /// a scrollOffset.
...@@ -30,7 +31,9 @@ abstract class Scrollable extends StatefulComponent { ...@@ -30,7 +31,9 @@ abstract class Scrollable extends StatefulComponent {
Key key, Key key,
this.initialScrollOffset, this.initialScrollOffset,
this.scrollDirection: ScrollDirection.vertical, this.scrollDirection: ScrollDirection.vertical,
this.onScroll this.onScroll,
this.snapOffsetCallback,
this.snapAlignmentOffset: 0.0
}) : super(key: key) { }) : super(key: key) {
assert(scrollDirection == ScrollDirection.vertical || assert(scrollDirection == ScrollDirection.vertical ||
scrollDirection == ScrollDirection.horizontal); scrollDirection == ScrollDirection.horizontal);
...@@ -39,6 +42,8 @@ abstract class Scrollable extends StatefulComponent { ...@@ -39,6 +42,8 @@ abstract class Scrollable extends StatefulComponent {
final double initialScrollOffset; final double initialScrollOffset;
final ScrollDirection scrollDirection; final ScrollDirection scrollDirection;
final ScrollListener onScroll; final ScrollListener onScroll;
final SnapOffsetCallback snapOffsetCallback;
final double snapAlignmentOffset;
} }
abstract class ScrollableState<T extends Scrollable> extends State<T> { abstract class ScrollableState<T extends Scrollable> extends State<T> {
...@@ -120,11 +125,37 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> { ...@@ -120,11 +125,37 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> {
_toEndAnimation.stop(); _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(); _stopAnimations();
Simulation simulation = scrollBehavior.release(scrollOffset, velocity);
if (simulation != null) if (velocity != null && config.snapOffsetCallback != null && _scrollOffsetIsInBounds(scrollOffset)) {
_toEndAnimation.start(simulation); 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() { void dispose() {
...@@ -357,6 +388,8 @@ abstract class ScrollableWidgetList extends Scrollable { ...@@ -357,6 +388,8 @@ abstract class ScrollableWidgetList extends Scrollable {
double initialScrollOffset, double initialScrollOffset,
ScrollDirection scrollDirection: ScrollDirection.vertical, ScrollDirection scrollDirection: ScrollDirection.vertical,
ScrollListener onScroll, ScrollListener onScroll,
SnapOffsetCallback snapOffsetCallback,
double snapAlignmentOffset: 0.0,
this.itemsWrap: false, this.itemsWrap: false,
this.itemExtent, this.itemExtent,
this.padding this.padding
...@@ -364,7 +397,9 @@ abstract class ScrollableWidgetList extends Scrollable { ...@@ -364,7 +397,9 @@ abstract class ScrollableWidgetList extends Scrollable {
key: key, key: key,
initialScrollOffset: initialScrollOffset, initialScrollOffset: initialScrollOffset,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
onScroll: onScroll onScroll: onScroll,
snapOffsetCallback: snapOffsetCallback,
snapAlignmentOffset: snapAlignmentOffset
) { ) {
assert(itemExtent != null); assert(itemExtent != null);
} }
...@@ -498,6 +533,8 @@ class ScrollableList<T> extends ScrollableWidgetList { ...@@ -498,6 +533,8 @@ class ScrollableList<T> extends ScrollableWidgetList {
double initialScrollOffset, double initialScrollOffset,
ScrollDirection scrollDirection: ScrollDirection.vertical, ScrollDirection scrollDirection: ScrollDirection.vertical,
ScrollListener onScroll, ScrollListener onScroll,
SnapOffsetCallback snapOffsetCallback,
double snapAlignmentOffset: 0.0,
this.items, this.items,
this.itemBuilder, this.itemBuilder,
itemsWrap: false, itemsWrap: false,
...@@ -508,6 +545,8 @@ class ScrollableList<T> extends ScrollableWidgetList { ...@@ -508,6 +545,8 @@ class ScrollableList<T> extends ScrollableWidgetList {
initialScrollOffset: initialScrollOffset, initialScrollOffset: initialScrollOffset,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
onScroll: onScroll, onScroll: onScroll,
snapOffsetCallback: snapOffsetCallback,
snapAlignmentOffset: snapAlignmentOffset,
itemsWrap: itemsWrap, itemsWrap: itemsWrap,
itemExtent: itemExtent, itemExtent: itemExtent,
padding: padding); padding: padding);
...@@ -609,13 +648,17 @@ class ScrollableMixedWidgetList extends Scrollable { ...@@ -609,13 +648,17 @@ class ScrollableMixedWidgetList extends Scrollable {
Key key, Key key,
double initialScrollOffset, double initialScrollOffset,
ScrollListener onScroll, ScrollListener onScroll,
SnapOffsetCallback snapOffsetCallback,
double snapAlignmentOffset: 0.0,
this.builder, this.builder,
this.token, this.token,
this.onInvalidatorAvailable this.onInvalidatorAvailable
}) : super( }) : super(
key: key, key: key,
initialScrollOffset: initialScrollOffset, initialScrollOffset: initialScrollOffset,
onScroll: onScroll onScroll: onScroll,
snapOffsetCallback: snapOffsetCallback,
snapAlignmentOffset: snapAlignmentOffset
); );
final IndexedBuilder builder; final IndexedBuilder builder;
......
import 'dart:sky' as sky; import 'dart:sky' as sky;
import 'package:sky/animation.dart';
import 'package:sky/rendering.dart'; import 'package:sky/rendering.dart';
import 'package:sky/src/fn3.dart'; import 'package:sky/src/fn3.dart';
...@@ -26,11 +27,11 @@ class WidgetTester { ...@@ -26,11 +27,11 @@ class WidgetTester {
void pumpFrame(Widget widget, [ double frameTimeMs = 0.0 ]) { void pumpFrame(Widget widget, [ double frameTimeMs = 0.0 ]) {
runApp(widget); 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 ]) { 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
} }
......
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