From 0d63d6b775bdf5a6fd6bc3412febfa474c4966c0 Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Thu, 24 Sep 2015 10:01:54 -0700 Subject: [PATCH] Port most of scrollable.dart to fn3 --- .../lib/src/fn3/homogeneous_viewport.dart | 4 +- packages/flutter/lib/src/fn3/scrollable.dart | 548 ++++++++++++++++++ packages/unit/test/fn3/coordinates_test.dart | 8 +- .../test/fn3/homogeneous_viewport_test.dart | 13 +- packages/unit/test/widget/block_test.dart | 1 - 5 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 packages/flutter/lib/src/fn3/scrollable.dart diff --git a/packages/flutter/lib/src/fn3/homogeneous_viewport.dart b/packages/flutter/lib/src/fn3/homogeneous_viewport.dart index 9c770f1ecbc..a174ed0083a 100644 --- a/packages/flutter/lib/src/fn3/homogeneous_viewport.dart +++ b/packages/flutter/lib/src/fn3/homogeneous_viewport.dart @@ -8,7 +8,7 @@ import 'package:sky/rendering.dart'; import 'package:sky/src/fn3/framework.dart'; import 'package:sky/src/fn3/basic.dart'; -typedef List ListBuilder(int startIndex, int count, BuildContext context); +typedef List ListBuilder(BuildContext context, int startIndex, int count); class HomogeneousViewport extends RenderObjectWidget { HomogeneousViewport({ @@ -137,7 +137,7 @@ class HomogeneousViewportElement extends RenderObjectElement newWidgets; if (_layoutItemCount > 0) - newWidgets = widget.builder(_layoutFirstIndex, _layoutItemCount, this); + newWidgets = widget.builder(this, _layoutFirstIndex, _layoutItemCount); else newWidgets = []; _children = updateChildren(_children, newWidgets); diff --git a/packages/flutter/lib/src/fn3/scrollable.dart b/packages/flutter/lib/src/fn3/scrollable.dart new file mode 100644 index 00000000000..32aa5ec7ca8 --- /dev/null +++ b/packages/flutter/lib/src/fn3/scrollable.dart @@ -0,0 +1,548 @@ +// 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 'dart:async'; +import 'dart:math' as math; +import 'dart:sky' as sky; + +import 'package:newton/newton.dart'; +import 'package:sky/animation.dart'; +import 'package:sky/gestures.dart'; +import 'package:sky/src/rendering/box.dart'; +import 'package:sky/src/rendering/viewport.dart'; +import 'package:sky/src/fn3/basic.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/gesture_detector.dart'; +import 'package:sky/src/fn3/homogeneous_viewport.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; + +typedef void ScrollListener(); + +/// A base class for scrollable widgets that reacts to user input and generates +/// a scrollOffset. +abstract class Scrollable extends StatefulComponent { + Scrollable({ + Key key, + this.initialScrollOffset, + this.scrollDirection: ScrollDirection.vertical + }) : super(key: key) { + assert(scrollDirection == ScrollDirection.vertical || + scrollDirection == ScrollDirection.horizontal); + } + + final double initialScrollOffset; + final ScrollDirection scrollDirection; +} + +abstract class ScrollableState extends ComponentState { + ScrollableState(T config) : super(config); + + AnimatedSimulation _toEndAnimation; // See _startToEndAnimation() + ValueAnimation _toOffsetAnimation; // Started by scrollTo() + + void initState(BuildContext context) { + if (config.initialScrollOffset is double) + _scrollOffset = config.initialScrollOffset; + _toEndAnimation = new AnimatedSimulation(_setScrollOffset); + _toOffsetAnimation = new ValueAnimation() + ..addListener(() { + AnimatedValue offset = _toOffsetAnimation.variable; + _setScrollOffset(offset.value); + }); + } + + double _scrollOffset = 0.0; + double get scrollOffset => _scrollOffset; + + Offset get scrollOffsetVector { + if (config.scrollDirection == ScrollDirection.horizontal) + return new Offset(scrollOffset, 0.0); + return new Offset(0.0, scrollOffset); + } + + ScrollBehavior _scrollBehavior; + ScrollBehavior createScrollBehavior(); + ScrollBehavior get scrollBehavior { + if (_scrollBehavior == null) + _scrollBehavior = createScrollBehavior(); + return _scrollBehavior; + } + + GestureDragUpdateCallback _getDragUpdateHandler(ScrollDirection direction) { + if (config.scrollDirection != direction || !scrollBehavior.isScrollable) + return null; + return _handleDragUpdate; + } + + GestureDragEndCallback _getDragEndHandler(ScrollDirection direction) { + if (config.scrollDirection != direction || !scrollBehavior.isScrollable) + return null; + return _handleDragEnd; + } + + Widget build(BuildContext context) { + return new GestureDetector( + onVerticalDragUpdate: _getDragUpdateHandler(ScrollDirection.vertical), + onVerticalDragEnd: _getDragEndHandler(ScrollDirection.vertical), + onHorizontalDragUpdate: _getDragUpdateHandler(ScrollDirection.horizontal), + onHorizontalDragEnd: _getDragEndHandler(ScrollDirection.horizontal), + child: new Listener( + child: buildContent(context), + onPointerDown: _handlePointerDown + ) + ); + } + + Widget buildContent(BuildContext context); + + Future _startToOffsetAnimation(double newScrollOffset, Duration duration, Curve curve) { + _stopAnimations(); + _toOffsetAnimation + ..variable = new AnimatedValue(scrollOffset, + end: newScrollOffset, + curve: curve + ) + ..progress = 0.0 + ..duration = duration; + return _toOffsetAnimation.play(); + } + + void _stopAnimations() { + if (_toOffsetAnimation.isAnimating) + _toOffsetAnimation.stop(); + if (_toEndAnimation.isAnimating) + _toEndAnimation.stop(); + } + + void _startToEndAnimation({ double velocity: 0.0 }) { + _stopAnimations(); + Simulation simulation = scrollBehavior.release(scrollOffset, velocity); + if (simulation != null) + _toEndAnimation.start(simulation); + } + + void dispose() { + _stopAnimations(); + } + + void _setScrollOffset(double newScrollOffset) { + if (_scrollOffset == newScrollOffset) + return; + setState(() { + _scrollOffset = newScrollOffset; + }); + if (_listeners.length > 0) + _notifyListeners(); + } + + Future scrollTo(double newScrollOffset, { Duration duration, Curve curve: ease }) { + if (newScrollOffset == _scrollOffset) + return new Future.value(); + + if (duration == null) { + _stopAnimations(); + _setScrollOffset(newScrollOffset); + return new Future.value(); + } + + return _startToOffsetAnimation(newScrollOffset, duration, curve); + } + + Future scrollBy(double scrollDelta, { Duration duration, Curve curve }) { + double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta); + return scrollTo(newScrollOffset, duration: duration, curve: curve); + } + + void settleScrollOffset() { + _startToEndAnimation(); + } + + double _scrollVelocity(sky.Offset velocity) { + double scrollVelocity = config.scrollDirection == ScrollDirection.horizontal + ? -velocity.dx + : -velocity.dy; + return scrollVelocity.clamp(_kMinFlingVelocity, _kMaxFlingVelocity) / _kMillisecondsPerSecond; + } + + void _handlePointerDown(_) { + _stopAnimations(); + } + + 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(-delta); + } + + void _handleDragEnd(Offset velocity) { + if (velocity != Offset.zero) { + _startToEndAnimation(velocity: _scrollVelocity(velocity)); + } else if (!_toEndAnimation.isAnimating && (_toOffsetAnimation == null || !_toOffsetAnimation.isAnimating)) { + settleScrollOffset(); + } + } + + final List _listeners = new List(); + void addListener(ScrollListener listener) { + _listeners.add(listener); + } + + void removeListener(ScrollListener listener) { + _listeners.remove(listener); + } + + void _notifyListeners() { + List localListeners = new List.from(_listeners); + for (ScrollListener listener in localListeners) + listener(); + } +} + +// TODO(abarth): findScrollableAncestor +// TODO(abarth): ensureWidgetIsVisible + +/// A simple scrollable widget that has a single child. Use this component if +/// you are not worried about offscreen widgets consuming resources. +class ScrollableViewport extends Scrollable { + ScrollableViewport({ + Key key, + this.child, + double initialScrollOffset, + ScrollDirection scrollDirection: ScrollDirection.vertical + }) : super( + key: key, + scrollDirection: scrollDirection, + initialScrollOffset: initialScrollOffset + ); + + final Widget child; + + ScrollableViewportState createState() => new ScrollableViewportState(this); +} + +class ScrollableViewportState extends ScrollableState { + ScrollableViewportState(ScrollableViewport config) : super(config); + + ScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior(); + OverscrollWhenScrollableBehavior get scrollBehavior => super.scrollBehavior; + + double _viewportSize = 0.0; + double _childSize = 0.0; + void _handleViewportSizeChanged(Size newSize) { + _viewportSize = config.scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width; + setState(() { + _updateScrollBehaviour(); + }); + } + void _handleChildSizeChanged(Size newSize) { + _childSize = config.scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width; + setState(() { + _updateScrollBehaviour(); + }); + } + void _updateScrollBehaviour() { + // if you don't call this from build() or syncConstructorArguments(), you must call it from setState(). + scrollTo(scrollBehavior.updateExtents( + contentExtent: _childSize, + containerExtent: _viewportSize, + scrollOffset: scrollOffset + )); + } + + Widget buildContent(BuildContext context) { + return new SizeObserver( + callback: _handleViewportSizeChanged, + child: new Viewport( + scrollOffset: scrollOffsetVector, + scrollDirection: config.scrollDirection, + child: new SizeObserver( + callback: _handleChildSizeChanged, + child: config.child + ) + ) + ); + } +} + +/// A mashup of [ScrollableViewport] and [BlockBody]. Useful when you have a small, +/// fixed number of children that you wish to arrange in a block layout and that +/// might exceed the height of its container (and therefore need to scroll). +class Block extends StatelessComponent { + Block(this.children, { + Key key, + this.initialScrollOffset, + this.scrollDirection: ScrollDirection.vertical + }) : super(key: key); + + final List children; + final double initialScrollOffset; + final ScrollDirection scrollDirection; + + BlockDirection get _direction { + if (scrollDirection == ScrollDirection.vertical) + return BlockDirection.vertical; + return BlockDirection.horizontal; + } + + Widget build(BuildContext context) { + return new ScrollableViewport( + initialScrollOffset: initialScrollOffset, + scrollDirection: scrollDirection, + child: new BlockBody(children, direction: _direction) + ); + } +} + +/// An optimized scrollable widget for a large number of children that are all +/// 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 ScrollableWidgetList extends Scrollable { + ScrollableWidgetList({ + Key key, + double initialScrollOffset, + ScrollDirection scrollDirection: ScrollDirection.vertical, + this.itemsWrap: false, + this.itemExtent, + this.padding + }) : super(key: key, initialScrollOffset: initialScrollOffset, scrollDirection: scrollDirection) { + assert(itemExtent != null); + } + + EdgeDims padding; + bool itemsWrap; + double itemExtent; + Size containerSize = Size.zero; +} + +abstract class ScrollableWidgetListState extends ScrollableState { + ScrollableWidgetListState(T config) : super(config); + + /// Subclasses must implement `get itemCount` to tell ScrollableWidgetList + /// how many items there are in the list. + int get itemCount; + int _previousItemCount; + + void didUpdateConfig(T oldConfig) { + super.didUpdateConfig(oldConfig); + + bool scrollBehaviorUpdateNeeded = + config.padding != oldConfig.padding || + config.itemExtent != oldConfig.itemExtent || + config.scrollDirection != oldConfig.scrollDirection; + + if (config.itemsWrap != oldConfig.itemsWrap) { + _scrollBehavior = null; + scrollBehaviorUpdateNeeded = true; + } + + if (itemCount != _previousItemCount) { + scrollBehaviorUpdateNeeded = true; + _previousItemCount = itemCount; + } + + if (scrollBehaviorUpdateNeeded) + _updateScrollBehavior(); + } + + ScrollBehavior createScrollBehavior() => new OverscrollBehavior(); + ExtentScrollBehavior get scrollBehavior => super.scrollBehavior; + + double get _containerExtent { + return config.scrollDirection == ScrollDirection.vertical + ? config.containerSize.height + : config.containerSize.width; + } + + void _handleSizeChanged(Size newSize) { + setState(() { + config.containerSize = newSize; + _updateScrollBehavior(); + }); + } + + double get _leadingPadding { + EdgeDims padding = config.padding; + if (config.scrollDirection == ScrollDirection.vertical) + return padding != null ? padding.top : 0.0; + return padding != null ? padding.left : -.0; + } + + double get _trailingPadding { + EdgeDims padding = config.padding; + if (config.scrollDirection == ScrollDirection.vertical) + return padding != null ? padding.bottom : 0.0; + return padding != null ? padding.right : 0.0; + } + + EdgeDims get _crossAxisPadding { + EdgeDims padding = config.padding; + if (padding == null) + return null; + if (config.scrollDirection == ScrollDirection.vertical) + return new EdgeDims.only(left: padding.left, right: padding.right); + return new EdgeDims.only(top: padding.top, bottom: padding.bottom); + } + + void _updateScrollBehavior() { + // if you don't call this from build() or syncConstructorArguments(), you must call it from setState(). + double contentExtent = config.itemExtent * itemCount; + if (config.padding != null) + contentExtent += _leadingPadding + _trailingPadding; + scrollTo(scrollBehavior.updateExtents( + contentExtent: contentExtent, + containerExtent: _containerExtent, + scrollOffset: scrollOffset + )); + } + + Widget buildContent(BuildContext context) { + if (itemCount != _previousItemCount) { + _previousItemCount = itemCount; + _updateScrollBehavior(); + } + + return new SizeObserver( + callback: _handleSizeChanged, + child: new Container( + padding: _crossAxisPadding, + child: new HomogeneousViewport( + builder: _buildItems, + itemsWrap: config.itemsWrap, + itemExtent: config.itemExtent, + itemCount: itemCount, + direction: config.scrollDirection, + startOffset: scrollOffset - _leadingPadding + ) + ) + ); + } + + List _buildItems(BuildContext context, int start, int count) { + List result = buildItems(context, start, count); + assert(result.every((item) => item.key != null)); + return result; + } + + List buildItems(BuildContext context, int start, int count); + +} + +typedef Widget ItemBuilder(BuildContext context, T item); + +/// A wrapper around [ScrollableWidgetList] that helps you translate a list of +/// model objects into a scrollable list of widgets. Assumes all the widgets +/// have the same height. +class ScrollableList extends ScrollableWidgetList { + ScrollableList({ + Key key, + double initialScrollOffset, + ScrollDirection scrollDirection: ScrollDirection.vertical, + this.items, + this.itemBuilder, + itemsWrap: false, + double itemExtent, + EdgeDims padding + }) : super( + key: key, + initialScrollOffset: initialScrollOffset, + scrollDirection: scrollDirection, + itemsWrap: itemsWrap, + itemExtent: itemExtent, + padding: padding); + + final List items; + final ItemBuilder itemBuilder; + + ScrollableListState> createState() => new ScrollableListState>(this); +} + +class ScrollableListState> extends ScrollableWidgetListState { + ScrollableListState(Config config) : super(config); + + ScrollBehavior createScrollBehavior() { + return config.itemsWrap ? new UnboundedBehavior() : super.createScrollBehavior(); + } + + int get itemCount => config.items.length; + + List buildItems(BuildContext context, int start, int count) { + List result = new List(); + int begin = config.itemsWrap ? start : math.max(0, start); + int end = config.itemsWrap ? begin + count : math.min(begin + count, config.items.length); + for (int i = begin; i < end; ++i) + result.add(config.itemBuilder(context, config.items[i % itemCount])); + return result; + } +} + +typedef void PageChangedCallback(int newPage); + +class PageableList extends ScrollableList { + PageableList({ + Key key, + double initialScrollOffset, + ScrollDirection scrollDirection: ScrollDirection.horizontal, + List items, + ItemBuilder itemBuilder, + bool itemsWrap: false, + double itemExtent, + PageChangedCallback this.pageChanged, + EdgeDims padding, + this.duration: const Duration(milliseconds: 200), + this.curve: ease + }) : super( + key: key, + initialScrollOffset: initialScrollOffset, + scrollDirection: scrollDirection, + items: items, + itemBuilder: itemBuilder, + itemsWrap: itemsWrap, + itemExtent: itemExtent, + padding: padding + ); + + Duration duration; + Curve curve; + PageChangedCallback pageChanged; +} + +class PageableListState extends ScrollableListState> { + PageableListState(PageableList config) : super(config); + + double _snapScrollOffset(double newScrollOffset) { + double scaledScrollOffset = newScrollOffset / config.itemExtent; + double previousScrollOffset = scaledScrollOffset.floor() * config.itemExtent; + double nextScrollOffset = scaledScrollOffset.ceil() * config.itemExtent; + double delta = newScrollOffset - previousScrollOffset; + return (delta < config.itemExtent / 2.0 ? previousScrollOffset : nextScrollOffset) + .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset); + } + + void _handleDragEnd(sky.Offset velocity) { + double scrollVelocity = _scrollVelocity(velocity); + double newScrollOffset = _snapScrollOffset(scrollOffset + scrollVelocity.sign * config.itemExtent) + .clamp(_snapScrollOffset(scrollOffset - config.itemExtent / 2.0), + _snapScrollOffset(scrollOffset + config.itemExtent / 2.0)); + scrollTo(newScrollOffset, duration: config.duration, curve: config.curve).then(_notifyPageChanged); + } + + int get currentPage => (scrollOffset / config.itemExtent).floor() % itemCount; + + void _notifyPageChanged(_) { + if (config.pageChanged != null) + config.pageChanged(currentPage); + } + + void settleScrollOffset() { + scrollTo(_snapScrollOffset(scrollOffset), duration: config.duration, curve: config.curve).then(_notifyPageChanged); + } +} + +// TODO(abarth): ScrollableMixedWidgetList diff --git a/packages/unit/test/fn3/coordinates_test.dart b/packages/unit/test/fn3/coordinates_test.dart index 536ffebc1f1..08a76493080 100644 --- a/packages/unit/test/fn3/coordinates_test.dart +++ b/packages/unit/test/fn3/coordinates_test.dart @@ -1,3 +1,4 @@ +import 'package:sky/rendering.dart'; import 'package:sky/src/fn3.dart'; import 'package:test/test.dart'; @@ -9,7 +10,6 @@ void main() { Key keyA = new GlobalKey(); Key keyB = new GlobalKey(); - Key keyC = new GlobalKey(); tester.pumpFrame( new Stack([ @@ -34,13 +34,13 @@ void main() { ]) ); - expect(tester.findElementByKey(keyA).renderObject.localToGlobal(const Point(0.0, 0.0)), + expect((tester.findElementByKey(keyA).renderObject as RenderBox).localToGlobal(const Point(0.0, 0.0)), equals(const Point(100.0, 100.0))); - expect(tester.findElementByKey(keyB).renderObject.localToGlobal(const Point(0.0, 0.0)), + expect((tester.findElementByKey(keyB).renderObject as RenderBox).localToGlobal(const Point(0.0, 0.0)), equals(const Point(100.0, 200.0))); - expect(tester.findElementByKey(keyB).renderObject.globalToLocal(const Point(110.0, 205.0)), + expect((tester.findElementByKey(keyB).renderObject as RenderBox).globalToLocal(const Point(110.0, 205.0)), equals(const Point(10.0, 5.0))); }); } diff --git a/packages/unit/test/fn3/homogeneous_viewport_test.dart b/packages/unit/test/fn3/homogeneous_viewport_test.dart index c1d16deaf8b..85488c604d1 100644 --- a/packages/unit/test/fn3/homogeneous_viewport_test.dart +++ b/packages/unit/test/fn3/homogeneous_viewport_test.dart @@ -33,7 +33,7 @@ void main() { Widget builder() { return new TestComponent(new HomogeneousViewport( - builder: (int start, int count, BuildContext context) { + builder: (BuildContext context, int start, int count) { List result = []; for (int index = start; index < start + count; index += 1) { callbackTracker.add(index); @@ -52,18 +52,19 @@ void main() { tester.pumpFrame(builder()); - TestComponentState testComponent = tester.findElement((element) => element.widget is TestComponent).state; + StatefulComponentElement testComponent = tester.findElement((element) => element.widget is TestComponent); + TestComponentState testComponentState = testComponent.state; expect(callbackTracker, equals([0, 1, 2, 3, 4, 5])); callbackTracker.clear(); - testComponent.go(false); + testComponentState.go(false); tester.pumpFrameWithoutChange(); expect(callbackTracker, equals([])); callbackTracker.clear(); - testComponent.go(true); + testComponentState.go(true); tester.pumpFrameWithoutChange(); expect(callbackTracker, equals([0, 1, 2, 3, 4, 5])); @@ -80,7 +81,7 @@ void main() { double offset = 300.0; - ListBuilder itemBuilder = (int start, int count, BuildContext context) { + ListBuilder itemBuilder = (BuildContext context, int start, int count) { List result = []; for (int index = start; index < start + count; index += 1) { callbackTracker.add(index); @@ -130,7 +131,7 @@ void main() { double offset = 300.0; - ListBuilder itemBuilder = (int start, int count, BuildContext context) { + ListBuilder itemBuilder = (BuildContext context, int start, int count) { List result = []; for (int index = start; index < start + count; index += 1) { callbackTracker.add(index); diff --git a/packages/unit/test/widget/block_test.dart b/packages/unit/test/widget/block_test.dart index 217c21282fc..e1941b8e25a 100644 --- a/packages/unit/test/widget/block_test.dart +++ b/packages/unit/test/widget/block_test.dart @@ -1,4 +1,3 @@ -import 'package:quiver/testing/async.dart'; import 'package:sky/widgets.dart'; import 'package:test/test.dart';