AnimatedList (#9649)
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 0000000..c6bef72
--- /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<double> animation);
+
+/// Signature for the builder callback used by [AnimatedList.remove].
+typedef Widget AnimatedListRemovedItemBuilder(BuildContext context, Animation<double> 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<AnimatedListState>());
+ 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<AnimatedListState> listKey = new GlobalKey<AnimatedListState>();
+/// ...
+/// 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<AnimatedList> 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<double> 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 5c7020a..cf75d3e 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -10,6 +10,7 @@
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 0000000..1193a48
--- /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<int, Animation<double>> animations = <int, Animation<double>>{};
+
+ await tester.pumpWidget(
+ new AnimatedList(
+ initialItemCount: 2,
+ itemBuilder: (BuildContext context, int index, Animation<double> 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<AnimatedListState> listKey = new GlobalKey<AnimatedListState>();
+
+ await tester.pumpWidget(
+ new AnimatedList(
+ key: listKey,
+ itemBuilder: (BuildContext context, int index, Animation<double> animation) {
+ return new SizeTransition(
+ key: new ValueKey<int>(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<int>(index))).height;
+ double itemTop(int index) => tester.getTopLeft(find.byKey(new ValueKey<int>(index))).dy;
+ double itemBottom(int index) => tester.getBottomLeft(find.byKey(new ValueKey<int>(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<AnimatedListState> listKey = new GlobalKey<AnimatedListState>();
+ final List<int> items = <int>[0, 1, 2];
+
+ Widget buildItem(BuildContext context, int item, Animation<double> animation) {
+ return new SizeTransition(
+ key: new ValueKey<int>(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<double> animation) {
+ return buildItem(context, items[index], animation);
+ },
+ ),
+ );
+
+ double itemTop(int index) => tester.getTopLeft(find.byKey(new ValueKey<int>(index))).dy;
+ double itemBottom(int index) => tester.getBottomLeft(find.byKey(new ValueKey<int>(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<double> 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);
+ });
+}