diff --git a/packages/flutter/lib/src/widgets/animated_list.dart b/packages/flutter/lib/src/widgets/animated_list.dart new file mode 100644 index 0000000000..c6bef72885 --- /dev/null +++ b/packages/flutter/lib/src/widgets/animated_list.dart @@ -0,0 +1,373 @@ +// Copyright 2017 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:collection/collection.dart' show binarySearch; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'scroll_controller.dart'; +import 'scroll_physics.dart'; +import 'scroll_view.dart'; +import 'ticker_provider.dart'; + +/// Signature for the builder callback used by [AnimatedList]. +typedef Widget AnimatedListItemBuilder(BuildContext context, int index, Animation animation); + +/// Signature for the builder callback used by [AnimatedList.remove]. +typedef Widget AnimatedListRemovedItemBuilder(BuildContext context, Animation animation); + +// The default insert/remove animation duration. +const Duration _kDuration = const Duration(milliseconds: 300); + +// Incoming and outgoing AnimatedList items. +class _ActiveItem implements Comparable<_ActiveItem> { + _ActiveItem.incoming(this.controller, this.itemIndex) : removedItemBuilder = null; + _ActiveItem.outgoing(this.controller, this.itemIndex, this.removedItemBuilder); + _ActiveItem.index(this.itemIndex) : controller = null, removedItemBuilder = null; + + final AnimationController controller; + final AnimatedListRemovedItemBuilder removedItemBuilder; + int itemIndex; + + @override + int compareTo(_ActiveItem other) => itemIndex - other.itemIndex; +} + +/// A scrolling container that animates items when they are inserted or removed. +/// +/// This widget's [AnimatedListState] can be used to dynmically insert or remove +/// items. To refer to the [AnimatedListState] either provide a [GlobalKey] or +/// use the static [of] method from an item's input callback. +/// +/// This widget is similar to one created by [ListView.builder]. +class AnimatedList extends StatefulWidget { + /// Creates a scrolling container that animates items when they are inserted or removed. + AnimatedList({ + Key key, + @required this.itemBuilder, + this.initialItemCount: 0, + this.scrollDirection: Axis.vertical, + this.reverse: false, + this.controller, + this.primary, + this.physics, + this.shrinkWrap: false, + this.padding, + }) : super(key: key) { + assert(itemBuilder != null); + assert(initialItemCount != null && initialItemCount >= 0); + } + + /// Called, as needed, to build list item widgets. + /// + /// List items are only built when they're scrolled into view. + /// + /// The [AnimatedListItemBuilder] index parameter indicates the item's + /// posiition in the list. The value of the index parameter will be between 0 and + /// [initialItemCount] plus the total number of items that have been inserted + /// with [AnimatedListState.insertItem] and less the total number of items + /// that have been removed with [AnimatedList.removeItem]. + /// + /// Implementations of this callback should assume that [AnimatedList.removeItem] + /// removes an item immediately. + final AnimatedListItemBuilder itemBuilder; + + /// The number of items the list will start with. + /// + /// The appareance of the initial items is not animated. They are + /// are created, as needed, by [itemBuilder] with an animation paramter + /// of [kAlwaysCompleteAnimation]. + final int initialItemCount; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + final bool reverse; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + final ScrollController controller; + + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// On iOS, this identifies the scroll view that will scroll to top in + /// response to a tap in the status bar. + /// + /// Defaults to true when [scrollDirection] is [Axis.vertical] and + /// [controller] is null. + final bool primary; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics physics; + + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// If the scroll view does not shrink wrap, then the scroll view will expand + /// to the maximum allowed size in the [scrollDirection]. If the scroll view + /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must + /// be true. + /// + /// Shrink wrapping the content of the scroll view is significantly more + /// expensive than expanding to the maximum allowed size because the content + /// can expand and contract during scrolling, which means the size of the + /// scroll view needs to be recomputed whenever the scroll position changes. + /// + /// Defaults to false. + final bool shrinkWrap; + + /// The amount of space by which to inset the children. + final EdgeInsets padding; + + /// The state from the closest instance of this class that encloses the given context. + /// + /// This method is typically used by [AnimatedList] item widgets that insert or + /// remove items in response to user input. + /// + /// ```dart + /// AnimatedListState animatedList = AnimatedList.of(context); + /// ``` + static AnimatedListState of(BuildContext context, { bool nullOk: false }) { + assert(nullOk != null); + assert(context != null); + final AnimatedListState result = context.ancestorStateOfType(const TypeMatcher()); + if (nullOk || result != null) + return result; + throw new FlutterError( + 'AnimatedList.of() called with a context that does not contain a AnimatedList.\n' + 'No AnimatedList ancestor could be found starting from the context that was passed to AnimatedList.of(). ' + 'This can happen when the context provided is from the same StatefulWidget that ' + 'built the AnimatedList. Please see the AnimatedList documentation for examples ' + 'of how to refer to an AnimatedListState object: ' + ' https://docs.flutter.io/flutter/widgets/AnimatedState-class.html\n' + 'The context used was:\n' + ' $context' + ); + } + + @override + AnimatedListState createState() => new AnimatedListState(); +} + +/// The state for a scrolling container that animates items when they are +/// inserted or removed. +/// +/// When an item is inserted with [insertItem] an animation begins running. +/// The animation is passed to [itemBuilder] whenever the item's widget +/// is needed. +/// +/// When an item is removed with [removeItem] its animation is reversed. +/// The removed item's animation is passed to the [removeItem] builder +/// parameter. +/// +/// An app that needs to insert or remove items in response to an event +/// can refer to the [AnimatedList]'s state with a global key: +/// +/// ```dart +/// GlobalKey listKey = new GlobalKey(); +/// ... +/// new AnimatedList(key: listKey, ...); +/// ... +/// listKey.currentState.insert(123); +/// ``` +/// +/// AnimatedList item input handlers can also refer to their [AnimatedListState] +/// with the static [of] method. +class AnimatedListState extends State with TickerProviderStateMixin { + final List<_ActiveItem> _incomingItems = <_ActiveItem>[]; + final List<_ActiveItem> _outgoingItems = <_ActiveItem>[]; + int _itemsCount = 0; + + @override + void initState() { + super.initState(); + _itemsCount = widget.initialItemCount; + } + + @override + void dispose() { + for (_ActiveItem item in _incomingItems) + item.controller.dispose(); + for (_ActiveItem item in _outgoingItems) + item.controller.dispose(); + super.dispose(); + } + + _ActiveItem _removeActiveItemAt(List<_ActiveItem> items, int itemIndex) { + final int i = binarySearch(items, new _ActiveItem.index(itemIndex)); + return i == -1 ? null : items.removeAt(i); + } + + _ActiveItem _activeItemAt(List<_ActiveItem> items, int itemIndex) { + final int i = binarySearch(items, new _ActiveItem.index(itemIndex)); + return i == -1 ? null : items[i]; + } + + // The insertItem() and removeItem() index parameters are defined as if the + // removeItem() operation removed the corresponding list entry immediately. + // The entry is only actually removed from the ListView when the remove animation + // finishes. The entry is added to _outgoingItems when removeItem is called + // and removed from _outgoingItems when the remove animation finishes. + + int _indexToItemIndex(int index) { + int itemIndex = index; + for (_ActiveItem item in _outgoingItems) { + if (item.itemIndex <= itemIndex) + itemIndex += 1; + else + break; + } + return itemIndex; + } + + int _itemIndexToIndex(int itemIndex) { + int index = itemIndex; + for (_ActiveItem item in _outgoingItems) { + assert(item.itemIndex != itemIndex); + if (item.itemIndex < itemIndex) + index -= 1; + else + break; + } + return index; + } + + /// Insert an item at [index] and start an animation that will be passed + /// to [itemBuilder] when the item is visible. + /// + /// This method's semantics are the same as Dart's [List.insert] method: + /// it increases the length of the list by one and shifts all items at or + /// after [index] towards the end of the list. + void insertItem(int index, { Duration duration: _kDuration }) { + assert(index != null && index >= 0); + assert(duration != null); + + final int itemIndex = _indexToItemIndex(index); + assert(itemIndex >= 0 && itemIndex <= _itemsCount); + + // Increment the incoming and outgoing item indices to account + // for the insertion. + for (_ActiveItem item in _incomingItems) { + if (item.itemIndex >= itemIndex) + item.itemIndex += 1; + } + for (_ActiveItem item in _outgoingItems) { + if (item.itemIndex >= itemIndex) + item.itemIndex += 1; + } + + final AnimationController controller = new AnimationController(duration: duration, vsync: this); + final _ActiveItem incomingItem = new _ActiveItem.incoming(controller, itemIndex); + setState(() { + _incomingItems + ..add(incomingItem) + ..sort(); + _itemsCount += 1; + }); + + controller.forward().then((Null value) { + _removeActiveItemAt(_incomingItems, incomingItem.itemIndex).controller.dispose(); + }); + } + + /// Remove the item at [index] and start an animation that will be passed + /// to [builder] when the item is visible. + /// + /// Items are removed immediately. After an item has been removed, its index + /// will no longer be passed to the [itemBuilder]. However the item will still + /// appear in the list for [duration] and during that time [builder] must + /// construct its widget as needed. + /// + /// This method's semantics are the same as Dart's [List.remove] method: + /// it decreases the length of the list by one and shifts all items at or + /// before [index] towards the beginning of the list. + void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration: _kDuration }) { + assert(index != null && index >= 0); + assert(builder != null); + assert(duration != null); + + final int itemIndex = _indexToItemIndex(index); + assert(itemIndex >= 0 && itemIndex < _itemsCount); + assert(_activeItemAt(_outgoingItems, itemIndex) == null); + + final _ActiveItem incomingItem = _removeActiveItemAt(_incomingItems, itemIndex); + final AnimationController controller = incomingItem?.controller + ?? new AnimationController(duration: duration, value: 1.0, vsync: this); + final _ActiveItem outgoingItem = new _ActiveItem.outgoing(controller, itemIndex, builder); + setState(() { + _outgoingItems + ..add(outgoingItem) + ..sort(); + }); + + controller.reverse().then((Null value) { + _removeActiveItemAt(_outgoingItems, outgoingItem.itemIndex).controller.dispose(); + + // Decrement the incoming and outgoing item indices to account + // for the removal. + for (_ActiveItem item in _incomingItems) { + if (item.itemIndex > outgoingItem.itemIndex) + item.itemIndex -= 1; + } + for (_ActiveItem item in _outgoingItems) { + if (item.itemIndex > outgoingItem.itemIndex) + item.itemIndex -= 1; + } + + setState(() { + _itemsCount -= 1; + }); + }); + } + + Widget _itemBuilder(BuildContext context, int itemIndex) { + final _ActiveItem outgoingItem = _activeItemAt(_outgoingItems, itemIndex); + if (outgoingItem != null) + return outgoingItem.removedItemBuilder(context, outgoingItem.controller.view); + + final _ActiveItem incomingItem = _activeItemAt(_incomingItems, itemIndex); + final Animation animation = incomingItem?.controller?.view ?? kAlwaysCompleteAnimation; + return widget.itemBuilder(context, _itemIndexToIndex(itemIndex), animation); + } + + @override + Widget build(BuildContext context) { + return new ListView.builder( + itemBuilder: _itemBuilder, + itemCount: _itemsCount, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + controller: widget.controller, + primary: widget.primary, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + padding: widget.padding, + ); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 5c7020ad7d..cf75d3e3d9 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -10,6 +10,7 @@ library widgets; export 'package:vector_math/vector_math_64.dart' show Matrix4; export 'src/widgets/animated_cross_fade.dart'; +export 'src/widgets/animated_list.dart'; export 'src/widgets/animated_size.dart'; export 'src/widgets/app.dart'; export 'src/widgets/async.dart'; diff --git a/packages/flutter/test/widgets/animated_list_test.dart b/packages/flutter/test/widgets/animated_list_test.dart new file mode 100644 index 0000000000..1193a48b1f --- /dev/null +++ b/packages/flutter/test/widgets/animated_list_test.dart @@ -0,0 +1,176 @@ +// Copyright 2017 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:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + testWidgets('AnimatedList initialItemCount', (WidgetTester tester) async { + final Map> animations = >{}; + + await tester.pumpWidget( + new AnimatedList( + initialItemCount: 2, + itemBuilder: (BuildContext context, int index, Animation animation) { + animations[index] = animation; + return new SizedBox( + height: 100.0, + child: new Center( + child: new Text('item $index'), + ), + ); + }, + ), + ); + + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(animations.containsKey(0), true); + expect(animations.containsKey(1), true); + expect(animations[0].value, 1.0); + expect(animations[1].value, 1.0); + }); + + testWidgets('AnimatedList insert', (WidgetTester tester) async { + final GlobalKey listKey = new GlobalKey(); + + await tester.pumpWidget( + new AnimatedList( + key: listKey, + itemBuilder: (BuildContext context, int index, Animation animation) { + return new SizeTransition( + key: new ValueKey(index), + axis: Axis.vertical, + sizeFactor: animation, + child: new SizedBox( + height: 100.0, + child: new Center( + child: new Text('item $index'), + ), + ), + ); + }, + ), + ); + + double itemHeight(int index) => tester.getSize(find.byKey(new ValueKey(index))).height; + double itemTop(int index) => tester.getTopLeft(find.byKey(new ValueKey(index))).dy; + double itemBottom(int index) => tester.getBottomLeft(find.byKey(new ValueKey(index))).dy; + + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + await tester.pump(); + + // Newly inserted item 0's height should animate from 0 to 100 + expect(itemHeight(0), 0.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(itemHeight(0), 50.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(itemHeight(0), 100.0); + + // The list now contains one fully expanded item at the top: + expect(find.text('item 0'), findsOneWidget); + expect(itemTop(0), 0.0); + expect(itemBottom(0), 100.0); + + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + await tester.pump(); + + // The height of the newly inserted items at index 0 and 1 should animate from 0 to 100. + // The height of the original item, now at index 2, should remain 100. + expect(itemHeight(0), 0.0); + expect(itemHeight(1), 0.0); + expect(itemHeight(2), 100.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(itemHeight(0), 50.0); + expect(itemHeight(1), 50.0); + expect(itemHeight(2), 100.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(itemHeight(0), 100.0); + expect(itemHeight(1), 100.0); + expect(itemHeight(2), 100.0); + + // The newly inserted "item 1" and "item 2" appear above "item 0" + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + expect(itemTop(0), 0.0); + expect(itemBottom(0), 100.0); + expect(itemTop(1), 100.0); + expect(itemBottom(1), 200.0); + expect(itemTop(2), 200.0); + expect(itemBottom(2), 300.0); + }); + + testWidgets('AnimatedList remove', (WidgetTester tester) async { + final GlobalKey listKey = new GlobalKey(); + final List items = [0, 1, 2]; + + Widget buildItem(BuildContext context, int item, Animation animation) { + return new SizeTransition( + key: new ValueKey(item), + axis: Axis.vertical, + sizeFactor: animation, + child: new SizedBox( + height: 100.0, + child: new Center( + child: new Text('item $item'), + ), + ), + ); + } + + await tester.pumpWidget( + new AnimatedList( + key: listKey, + initialItemCount: 3, + itemBuilder: (BuildContext context, int index, Animation animation) { + return buildItem(context, items[index], animation); + }, + ), + ); + + double itemTop(int index) => tester.getTopLeft(find.byKey(new ValueKey(index))).dy; + double itemBottom(int index) => tester.getBottomLeft(find.byKey(new ValueKey(index))).dy; + + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + + items.removeAt(0); + listKey.currentState.removeItem(0, + (BuildContext context, Animation animation) => buildItem(context, 0, animation), + duration: const Duration(milliseconds: 100), + ); + + // Item's 0, 1, 2 at 0, 100, 200. All heights 100. + expect(itemTop(0), 0.0); + expect(itemBottom(0), 100.0); + expect(itemTop(1), 100.0); + expect(itemBottom(1), 200.0); + expect(itemTop(2), 200.0); + expect(itemBottom(2), 300.0); + + // Newly removed item 0's height should animate from 100 to 0 over 100ms + + // Item's 0, 1, 2 at 0, 50, 150. Item 0's height is 50. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(itemTop(0), 0.0); + expect(itemBottom(0), 50.0); + expect(itemTop(1), 50.0); + expect(itemBottom(1), 150.0); + expect(itemTop(2), 150.0); + expect(itemBottom(2), 250.0); + + // Item's 0, 1, 2 at 0, 0, 0. Item 0's height is 0. + await tester.pumpAndSettle(); + expect(itemTop(0), 0.0); + expect(itemBottom(0), 0.0); + expect(itemTop(1), 0.0); + expect(itemBottom(1), 100.0); + expect(itemTop(2), 100.0); + expect(itemBottom(2), 200.0); + }); +}