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);
+   });
+}