diff --git a/examples/demo_launcher/lib/main.dart b/examples/demo_launcher/lib/main.dart index 853932eea98..39f3f7fc9f4 100644 --- a/examples/demo_launcher/lib/main.dart +++ b/examples/demo_launcher/lib/main.dart @@ -177,7 +177,7 @@ class DemoList extends Component { Widget build() { return new ScrollableList( items: demos, - itemHeight: kCardHeight, + itemExtent: kCardHeight, itemBuilder: buildDemo, padding: kListPadding ); diff --git a/examples/fitness/lib/feed.dart b/examples/fitness/lib/feed.dart index c3ffb585c2b..b39b7d1850b 100644 --- a/examples/fitness/lib/feed.dart +++ b/examples/fitness/lib/feed.dart @@ -19,7 +19,7 @@ class FitnessItemList extends Component { child: new ScrollableList( padding: const EdgeDims.all(4.0), items: items, - itemHeight: kFitnessItemHeight, + itemExtent: kFitnessItemHeight, itemBuilder: (item) => item.toRow(onDismissed: onDismissed) ) ); diff --git a/examples/stocks/lib/stock_list.dart b/examples/stocks/lib/stock_list.dart index 91fbb42d99e..516564c4ef3 100644 --- a/examples/stocks/lib/stock_list.dart +++ b/examples/stocks/lib/stock_list.dart @@ -14,7 +14,7 @@ class Stocklist extends Component { type: MaterialType.canvas, child: new ScrollableList( items: stocks, - itemHeight: StockRow.kHeight, + itemExtent: StockRow.kHeight, itemBuilder: (stock) => new StockRow(stock: stock) ) ); diff --git a/examples/widgets/ensure_visible.dart b/examples/widgets/ensure_visible.dart index 6e9e9eae796..78aab2e68eb 100644 --- a/examples/widgets/ensure_visible.dart +++ b/examples/widgets/ensure_visible.dart @@ -2,9 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:sky/animation/animated_value.dart'; -import 'package:sky/animation/animation_performance.dart'; -import 'package:sky/animation/curves.dart'; import 'package:sky/base/lerp.dart'; import 'package:sky/theme/colors.dart' as colors; import 'package:sky/widgets.dart'; @@ -23,10 +20,12 @@ class EnsureVisibleApp extends App { static const TextStyle cardLabelStyle = const TextStyle(color: colors.white, fontSize: 18.0, fontWeight: bold); + static const TextStyle selectedCardLabelStyle = + const TextStyle(color: white, fontSize: 24.0, fontWeight: bold); + List cardModels; BlockViewportLayoutState layoutState = new BlockViewportLayoutState(); - ScrollListener scrollListener; - ValueAnimation scrollAnimation; + CardModel selectedCardModel; void initState() { List cardHeights = [ @@ -39,15 +38,15 @@ class EnsureVisibleApp extends App { return new CardModel(i, cardHeights[i], color); }); - scrollAnimation = new ValueAnimation() - ..duration = const Duration(milliseconds: 200) - ..variable = new AnimatedValue(0.0, curve: ease); - super.initState(); } - EventDisposition handleTap(Widget target) { - ensureWidgetIsVisible(target, animation: scrollAnimation); + EventDisposition handleTap(Widget card, CardModel cardModel) { + ensureWidgetIsVisible(card, duration: const Duration(milliseconds: 200)) + .then((_) { + setState(() { selectedCardModel = cardModel; }); + }); + return EventDisposition.processed; } @@ -55,17 +54,18 @@ class EnsureVisibleApp extends App { if (index >= cardModels.length) return null; CardModel cardModel = cardModels[index]; + TextStyle style = (cardModel == selectedCardModel) ? selectedCardLabelStyle : cardLabelStyle; Widget card = new Card( color: cardModel.color, child: new Container( height: cardModel.height, padding: const EdgeDims.all(8.0), - child: new Center(child: new Text(cardModel.label, style: cardLabelStyle)) + child: new Center(child: new Text(cardModel.label, style: style)) ) ); return new Listener( key: cardModel.key, - onGestureTap: (_) { return handleTap(card); }, + onGestureTap: (_) { return handleTap(card, cardModel); }, child: card ); } diff --git a/examples/widgets/page_scrollable.dart b/examples/widgets/page_scrollable.dart new file mode 100644 index 00000000000..3297169079c --- /dev/null +++ b/examples/widgets/page_scrollable.dart @@ -0,0 +1,112 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:sky/base/lerp.dart'; +import 'package:sky/painting/text_style.dart'; +import 'package:sky/theme/colors.dart'; +import 'package:sky/widgets/basic.dart'; +import 'package:sky/widgets/card.dart'; +import 'package:sky/widgets/icon.dart'; +import 'package:sky/widgets/scrollable.dart'; +import 'package:sky/widgets/scaffold.dart'; +import 'package:sky/widgets/theme.dart'; +import 'package:sky/widgets/tool_bar.dart'; +import 'package:sky/widgets/framework.dart'; +import 'package:sky/widgets/task_description.dart'; + +class CardModel { + CardModel(this.value, this.size, this.color); + int value; + Size size; + Color color; + String get label => "Card $value"; + Key get key => new Key.fromObjectIdentity(this); +} + +class TestApp extends App { + + static const TextStyle cardLabelStyle = + const TextStyle(color: white, fontSize: 18.0, fontWeight: bold); + + List cardModels; + Size pageSize = new Size(200.0, 200.0); + + void initState() { + List cardSizes = [ + [100.0, 300.0], [300.0, 100.0], [200.0, 400.0], [400.0, 400.0], [300.0, 400.0], + [100.0, 300.0], [300.0, 100.0], [200.0, 400.0], [400.0, 400.0], [300.0, 400.0], + [100.0, 300.0], [300.0, 100.0], [200.0, 400.0], [400.0, 400.0], [300.0, 400.0] + ] + .map((args) => new Size(args[0], args[1])) + .toList(); + + cardModels = new List.generate(cardSizes.length, (i) { + Color color = lerpColor(Red[300], Blue[900], i / cardSizes.length); + return new CardModel(i, cardSizes[i], color); + }); + + super.initState(); + } + + void updatePageSize(Size newSize) { + setState(() { + pageSize = newSize; + }); + } + + Widget buildCard(CardModel cardModel) { + print("SKY buildCard ${cardModel.label}"); + Widget card = new Card( + color: cardModel.color, + child: new Container( + width: cardModel.size.width, + height: cardModel.size.height, + padding: const EdgeDims.all(8.0), + child: new Center(child: new Text(cardModel.label, style: cardLabelStyle)) + ) + ); + return new Container( + key: cardModel.key, + width: pageSize.width, + child: new Center(child: card) + ); + } + + Widget build() { + Widget list = new PageableList( + items: cardModels, + itemBuilder: buildCard, + scrollDirection: ScrollDirection.horizontal, + itemExtent: pageSize.width + ); + + return new IconTheme( + data: const IconThemeData(color: IconThemeColor.white), + child: new Theme( + data: new ThemeData( + brightness: ThemeBrightness.light, + primarySwatch: Blue, + accentColor: RedAccent[200] + ), + child: new TaskDescription( + label: 'PageableList', + child: new Scaffold( + toolbar: new ToolBar(center: new Text('PageableList Demo')), + body: new SizeObserver( + callback: updatePageSize, + child: new Container( + child: list, + decoration: new BoxDecoration(backgroundColor: Theme.of(this).primarySwatch[50]) + ) + ) + ) + ) + ) + ); + } +} + +void main() { + runApp(new TestApp()); +} diff --git a/sky/packages/sky/lib/widgets/framework.dart b/sky/packages/sky/lib/widgets/framework.dart index eac66d4e284..7b7ae06bf19 100644 --- a/sky/packages/sky/lib/widgets/framework.dart +++ b/sky/packages/sky/lib/widgets/framework.dart @@ -767,6 +767,8 @@ abstract class StatefulComponent extends Component { return super.syncChild(node, oldNode, slot); } + // Calls function fn immediately and then schedules another build + // for this Component. void setState(void fn()) { assert(!_disqualifiedFromEverAppearingAgain); fn(); diff --git a/sky/packages/sky/lib/widgets/scrollable.dart b/sky/packages/sky/lib/widgets/scrollable.dart index 1b52d4d7079..e594845e69b 100644 --- a/sky/packages/sky/lib/widgets/scrollable.dart +++ b/sky/packages/sky/lib/widgets/scrollable.dart @@ -2,12 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:math' as math; import 'dart:sky' as sky; import 'package:newton/newton.dart'; import 'package:sky/animation/animated_simulation.dart'; import 'package:sky/animation/animation_performance.dart'; +import 'package:sky/animation/animated_value.dart'; +import 'package:sky/animation/curves.dart'; import 'package:sky/animation/scroll_behavior.dart'; import 'package:sky/rendering/box.dart'; import 'package:sky/rendering/viewport.dart'; @@ -19,13 +22,11 @@ import 'package:sky/widgets/framework.dart'; export 'package:sky/widgets/block_viewport.dart' show BlockViewportLayoutState; -const double _kMillisecondsPerSecond = 1000.0; -double _velocityForFlingGesture(double eventVelocity) { - // eventVelocity is pixels/second, config min,max limits are pixels/ms - return eventVelocity.clamp(-config.kMaxFlingVelocity, config.kMaxFlingVelocity) / - _kMillisecondsPerSecond; -} +// The GestureEvent velocity properties are pixels/second, config min,max limits are pixels/ms +const double _kMillisecondsPerSecond = 1000.0; +const double _kMinFlingVelocity = -config.kMaxFlingVelocity * _kMillisecondsPerSecond; +const double _kMaxFlingVelocity = config.kMaxFlingVelocity * _kMillisecondsPerSecond; typedef void ScrollListener(); @@ -48,6 +49,11 @@ abstract class Scrollable extends StatefulComponent { void initState() { _toEndAnimation = new AnimatedSimulation(_tickScrollOffset); + _toOffsetAnimation = new ValueAnimation() + ..addListener(() { + AnimatedValue offset = _toOffsetAnimation.variable; + scrollTo(offset.value); + }); } void syncFields(Scrollable source) { @@ -86,38 +92,22 @@ abstract class Scrollable extends StatefulComponent { ); } - void _startToOffsetAnimation(double newScrollOffset, ValueAnimation animation) { + Future _startToOffsetAnimation(double newScrollOffset, Duration duration, Curve curve) { _stopToEndAnimation(); _stopToOffsetAnimation(); - - animation.variable - ..begin = scrollOffset - ..end = newScrollOffset; - - _toOffsetAnimation = animation + _toOffsetAnimation + ..variable = new AnimatedValue(scrollOffset, + end: newScrollOffset, + curve: curve + ) ..progress = 0.0 - ..addListener(_updateToOffsetAnimation) - ..addStatusListener(_updateToOffsetAnimationStatus) - ..play(); - } - - void _updateToOffsetAnimation() { - scrollTo(_toOffsetAnimation.value); - } - - void _updateToOffsetAnimationStatus(AnimationStatus status) { - if (status == AnimationStatus.dismissed || status == AnimationStatus.completed) - _stopToOffsetAnimation(); + ..duration = duration; + return _toOffsetAnimation.play(); } void _stopToOffsetAnimation() { - if (_toOffsetAnimation != null) { - _toOffsetAnimation - ..removeStatusListener(_updateToOffsetAnimationStatus) - ..removeListener(_updateToOffsetAnimation) - ..stop(); - _toOffsetAnimation = null; - } + if (_toOffsetAnimation.isAnimating) + _toOffsetAnimation.stop(); } void _startToEndAnimation({ double velocity: 0.0 }) { @@ -138,27 +128,29 @@ abstract class Scrollable extends StatefulComponent { super.didUnmount(); } - bool scrollTo(double newScrollOffset, { ValueAnimation animation }) { + Future scrollTo(double newScrollOffset, { Duration duration, Curve curve: ease }) { if (newScrollOffset == _scrollOffset) - return false; + return new Future.value(); - if (animation == null) { + Future result; + if (duration == null) { setState(() { _scrollOffset = newScrollOffset; }); + result = new Future.value(); } else { - _startToOffsetAnimation(newScrollOffset, animation); + result = _startToOffsetAnimation(newScrollOffset, duration, curve); } if (_listeners.length > 0) _notifyListeners(); - return true; + return result; } - bool scrollBy(double scrollDelta) { + Future scrollBy(double scrollDelta, { Duration duration, Curve curve }) { double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta); - return scrollTo(newScrollOffset); + return scrollTo(newScrollOffset, duration: duration, curve: curve); } void settleScrollOffset() { @@ -169,6 +161,14 @@ abstract class Scrollable extends StatefulComponent { scrollTo(value); } + // Return the event's velocity in pixels/second. + double _eventVelocity(sky.GestureEvent event) { + double velocity = scrollDirection == ScrollDirection.horizontal + ? -event.velocityX + : -event.velocityY; + return velocity.clamp(_kMinFlingVelocity, _kMaxFlingVelocity) / _kMillisecondsPerSecond; + } + EventDisposition _handlePointerDown(_) { _stopToEndAnimation(); _stopToOffsetAnimation(); @@ -181,10 +181,7 @@ abstract class Scrollable extends StatefulComponent { } EventDisposition _handleFlingStart(sky.GestureEvent event) { - double eventVelocity = scrollDirection == ScrollDirection.horizontal - ? -event.velocityX - : -event.velocityY; - _startToEndAnimation(velocity: _velocityForFlingGesture(eventVelocity)); + _startToEndAnimation(velocity: _eventVelocity(event)); return EventDisposition.processed; } @@ -232,13 +229,13 @@ Scrollable findScrollableAncestor({ Widget target }) { return ancestor; } -bool ensureWidgetIsVisible(Widget target, { ValueAnimation animation }) { +Future ensureWidgetIsVisible(Widget target, { Duration duration, Curve curve }) { assert(target.mounted); assert(target.renderObject is RenderBox); Scrollable scrollable = findScrollableAncestor(target: target); if (scrollable == null) - return false; + return new Future.value(); Size targetSize = (target.renderObject as RenderBox).size; Point targetCenter = target.localToGlobal( @@ -260,12 +257,10 @@ bool ensureWidgetIsVisible(Widget target, { ValueAnimation animation }) double scrollOffset = (scrollable.scrollOffset + scrollOffsetDelta) .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); - if (scrollOffset != scrollable.scrollOffset) { - scrollable.scrollTo(scrollOffset, animation: animation); - return true; - } + if (scrollOffset != scrollable.scrollOffset) + return scrollable.scrollTo(scrollOffset, duration: duration, curve: curve); - return false; + return new Future.value(); } /// A simple scrollable widget that has a single child. Use this component if @@ -346,17 +341,19 @@ class ScrollableBlock extends Component { } /// An optimized scrollable widget for a large number of children that are all -/// of the same height. Use this widget when you have a large number of children -/// or when you are concerned about offscreen widgets consuming resources. +/// the same size (extent) in the scrollDirection. For example for +/// ScrollDirection.vertical itemExtent is the height of each item. Use this +/// widget when you have a large number of children or when you are concerned +// about offscreen widgets consuming resources. abstract class FixedHeightScrollable extends Scrollable { - FixedHeightScrollable({ Key key, this.itemHeight, this.padding }) - : super(key: key) { - assert(itemHeight != null); + FixedHeightScrollable({ Key key, ScrollDirection scrollDirection, this.itemExtent, this.padding }) + : super(key: key, scrollDirection: scrollDirection) { + assert(itemExtent != null); } EdgeDims padding; - double itemHeight; + double itemExtent; /// Subclasses must implement `get itemCount` to tell FixedHeightScrollable /// how many items there are in the list. @@ -364,27 +361,34 @@ abstract class FixedHeightScrollable extends Scrollable { int _previousItemCount; void syncFields(FixedHeightScrollable source) { - padding = source.padding; - itemHeight = source.itemHeight; + if (padding != source.padding || itemExtent != source.itemExtent) { + padding = source.padding; + itemExtent = source.itemExtent; + _updateContentsExtent(); + } super.syncFields(source); } ScrollBehavior createScrollBehavior() => new OverscrollBehavior(); OverscrollBehavior get scrollBehavior => super.scrollBehavior; - double _height; + double _containerExtent; void _handleSizeChanged(Size newSize) { setState(() { - _height = newSize.height; - scrollBehavior.containerSize = _height; + _containerExtent = scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width; + scrollBehavior.containerSize = _containerExtent; }); } - void _updateContentsHeight() { - double contentsHeight = itemHeight * itemCount; - if (padding != null) - contentsHeight += padding.top + padding.bottom; - scrollBehavior.contentsSize = contentsHeight; + void _updateContentsExtent() { + double contentsExtent = itemExtent * itemCount; + if (padding != null) { + if (scrollDirection == ScrollDirection.vertical) + contentsExtent += padding.top + padding.bottom; + else + contentsExtent += padding.left + padding.right; + } + scrollBehavior.contentsSize = contentsExtent; } void _updateScrollOffset() { @@ -392,50 +396,61 @@ abstract class FixedHeightScrollable extends Scrollable { settleScrollOffset(); } + Offset _toOffset(double value) { + return scrollDirection == ScrollDirection.vertical + ? new Offset(0.0, value) + : new Offset(value, 0.0); + } + Widget buildContent() { if (itemCount != _previousItemCount) { _previousItemCount = itemCount; - _updateContentsHeight(); + _updateContentsExtent(); _updateScrollOffset(); } int itemShowIndex = 0; int itemShowCount = 0; - double offsetY = 0.0; - if (_height != null && _height > 0.0) { + Offset viewportOffset = Offset.zero; + if (_containerExtent != null && _containerExtent > 0.0) { if (scrollOffset < 0.0) { - double visibleHeight = _height + scrollOffset; - itemShowCount = (visibleHeight / itemHeight).round() + 1; - offsetY = scrollOffset; + double visibleHeight = _containerExtent + scrollOffset; + itemShowCount = (visibleHeight / itemExtent).round() + 1; + viewportOffset = _toOffset(scrollOffset); } else { - itemShowCount = (_height / itemHeight).ceil(); - double alignmentDelta = -scrollOffset % itemHeight; + itemShowCount = (_containerExtent / itemExtent).ceil(); + double alignmentDelta = -scrollOffset % itemExtent; double drawStart; if (alignmentDelta != 0.0) { - alignmentDelta -= itemHeight; + alignmentDelta -= itemExtent; itemShowCount += 1; drawStart = scrollOffset + alignmentDelta; - offsetY = -alignmentDelta; + viewportOffset = _toOffset(-alignmentDelta); } else { drawStart = scrollOffset; } - itemShowIndex = math.max(0, (drawStart / itemHeight).floor()); + itemShowIndex = math.max(0, (drawStart / itemExtent).floor()); } } List items = buildItems(itemShowIndex, itemShowCount); assert(items.every((item) => item.key != null)); + BlockDirection blockDirection = scrollDirection == ScrollDirection.vertical + ? BlockDirection.vertical + : BlockDirection.horizontal; + // TODO(ianh): Refactor this so that it does the building in the // same frame as the size observing, similar to BlockViewport, but // keeping the fixed-height optimisations. return new SizeObserver( callback: _handleSizeChanged, child: new Viewport( - scrollOffset: new Offset(0.0, offsetY), + scrollDirection: scrollDirection, + scrollOffset: viewportOffset, child: new Container( padding: padding, - child: new Block(items) + child: new Block(items, direction: blockDirection) ) ) ); @@ -453,11 +468,12 @@ typedef Widget ItemBuilder(T item); class ScrollableList extends FixedHeightScrollable { ScrollableList({ Key key, + ScrollDirection scrollDirection, this.items, this.itemBuilder, - double itemHeight, + double itemExtent, EdgeDims padding - }) : super(key: key, itemHeight: itemHeight, padding: padding); + }) : super(key: key, scrollDirection: scrollDirection, itemExtent: itemExtent, padding: padding); List items; ItemBuilder itemBuilder; @@ -479,6 +495,61 @@ class ScrollableList extends FixedHeightScrollable { } } +class PageableList extends ScrollableList { + PageableList({ + Key key, + ScrollDirection scrollDirection, + List items, + ItemBuilder itemBuilder, + double itemExtent, + EdgeDims padding, + this.duration: const Duration(milliseconds: 200), + this.curve: ease + }) : super( + key: key, + scrollDirection: scrollDirection, + items: items, + itemBuilder: itemBuilder, + itemExtent: itemExtent, + padding: padding + ); + + Duration duration; + Curve curve; + + void syncFields(PageableList source) { + duration = source.duration; + curve = source.curve; + super.syncFields(source); + } + + double _snapScrollOffset(double newScrollOffset) { + double scaledScrollOffset = newScrollOffset / itemExtent; + double previousScrollOffset = scaledScrollOffset.floor() * itemExtent; + double nextScrollOffset = scaledScrollOffset.ceil() * itemExtent; + double delta = newScrollOffset - previousScrollOffset; + return (delta < itemExtent / 2.0 ? previousScrollOffset : nextScrollOffset) + .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); + } + + EventDisposition _handlePointerDown(_) { + return EventDisposition.ignored; + } + + EventDisposition _handleFlingStart(sky.GestureEvent event) { + double velocity = _eventVelocity(event); + double newScrollOffset = _snapScrollOffset(scrollOffset + velocity.sign * itemExtent) + .clamp(_snapScrollOffset(scrollOffset - itemExtent / 2.0), + _snapScrollOffset(scrollOffset + itemExtent / 2.0)); + scrollTo(newScrollOffset, duration: duration, curve: curve); + return EventDisposition.processed; + } + + void settleScrollOffset() { + scrollTo(_snapScrollOffset(scrollOffset), duration: duration, curve: curve); + } +} + /// A general scrollable list for a large number of children that might not all /// have the same height. Prefer [FixedHeightScrollable] when all the children /// have the same height because it can use that property to be more efficient. diff --git a/sky/packages/sky/lib/widgets/tabs.dart b/sky/packages/sky/lib/widgets/tabs.dart index 8aa3f8e4044..5df7f929095 100644 --- a/sky/packages/sky/lib/widgets/tabs.dart +++ b/sky/packages/sky/lib/widgets/tabs.dart @@ -408,16 +408,12 @@ class TabBar extends Scrollable { Size _tabBarSize; List _tabWidths; ValueAnimation _indicatorAnimation; - ValueAnimation _scrollAnimation; void initState() { super.initState(); _indicatorAnimation = new ValueAnimation() ..duration = _kTabBarScroll ..variable = new AnimatedRect(null, curve: ease); - _scrollAnimation = new ValueAnimation() - ..duration = _kTabBarScroll - ..variable = new AnimatedValue(0.0, curve: ease); } void syncFields(TabBar source) { @@ -473,7 +469,7 @@ class TabBar extends Scrollable { if (tabIndex != selectedIndex) { if (_tabWidths != null) { if (isScrollable) - scrollTo(_centeredTabScrollOffset(tabIndex), animation: _scrollAnimation); + scrollTo(_centeredTabScrollOffset(tabIndex), duration: _kTabBarScroll); _startIndicatorAnimation(selectedIndex, tabIndex); } if (onChanged != null)