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> {
List<CardModel> _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<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,
......@@ -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) {
if (_cardModels.contains(card)) {
setState(() {
......@@ -73,7 +96,20 @@ class CardCollectionAppState extends State<CardCollectionApp> {
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<CardCollectionApp> {
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<CardCollectionApp> {
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<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 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<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,
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<CardCollectionApp> {
child: new Scaffold(
toolbar: buildToolBar(),
drawer: buildDrawer(),
body: cardCollection
body: body
)
)
);
......
......@@ -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) {
......
......@@ -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<T extends Scrollable> extends State<T> {
......@@ -120,11 +125,37 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> {
_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<T> 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<T> 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;
......
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
}
......
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