diff --git a/packages/flutter/lib/src/widgets/animated_list.dart b/packages/flutter/lib/src/widgets/animated_list.dart index e15366ea62..73a251bca7 100644 --- a/packages/flutter/lib/src/widgets/animated_list.dart +++ b/packages/flutter/lib/src/widgets/animated_list.dart @@ -10,7 +10,6 @@ import 'framework.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scroll_view.dart'; -import 'sliver.dart'; import 'ticker_provider.dart'; /// Signature for the builder callback used by [AnimatedList]. @@ -76,13 +75,11 @@ class AnimatedList extends StatefulWidget { /// [AnimatedListState.removeItem] removes an item immediately. final AnimatedListItemBuilder itemBuilder; - /// {@template flutter.widgets.animatedList.initialItemCount} /// The number of items the list will start with. /// /// The appearance of the initial items is not animated. They /// are created, as needed, by [itemBuilder] with an animation parameter /// of [kAlwaysCompleteAnimation]. - /// {@endtemplate} final int initialItemCount; /// The axis along which the scroll view scrolls. @@ -210,150 +207,6 @@ class AnimatedList extends StatefulWidget { /// [AnimatedList] item input handlers can also refer to their [AnimatedListState] /// with the static [AnimatedList.of] method. class AnimatedListState extends State with TickerProviderStateMixin { - final GlobalKey _sliverAnimatedListKey = GlobalKey(); - - /// Insert an item at [index] and start an animation that will be passed - /// to [AnimatedList.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 }) { - _sliverAnimatedListKey.currentState.insertItem(index, duration: duration); - } - - /// 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 [AnimatedList.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 }) { - _sliverAnimatedListKey.currentState.removeItem(index, builder, duration: duration); - } - - @override - Widget build(BuildContext context) { - return CustomScrollView( - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - controller: widget.controller, - primary: widget.primary, - physics: widget.physics, - shrinkWrap: widget.shrinkWrap, - slivers: [ - SliverPadding( - padding: widget.padding ?? const EdgeInsets.all(0), - sliver: SliverAnimatedList( - key: _sliverAnimatedListKey, - itemBuilder: widget.itemBuilder, - initialItemCount: widget.initialItemCount, - ), - ), - ], - ); - } -} - -/// A sliver that animates items when they are inserted or removed. -/// -/// This widget's [SliverAnimatedListState] can be used to dynamically insert or -/// remove items. To refer to the [SliverAnimatedListState] either provide a -/// [GlobalKey] or use the static [SliverAnimatedList.of] method from an item's -/// input callback. -/// -/// See also: -/// -/// * [SliverList], which does not animate items when they are inserted or removed. -class SliverAnimatedList extends StatefulWidget { - /// Creates a sliver that animates items when they are inserted or removed. - const SliverAnimatedList({ - Key key, - @required this.itemBuilder, - this.initialItemCount = 0, - }) : assert(itemBuilder != null), - assert(initialItemCount != null && initialItemCount >= 0), - super(key: key); - - /// 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 - /// position 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 [SliverAnimatedListState.insertItem] and less the total - /// number of items that have been removed with - /// [SliverAnimatedListState.removeItem]. - /// - /// Implementations of this callback should assume that - /// [SliverAnimatedListState.removeItem] removes an item immediately. - final AnimatedListItemBuilder itemBuilder; - - /// {@macro flutter.widgets.animatedList.initialItemCount} - final int initialItemCount; - - @override - SliverAnimatedListState createState() => SliverAnimatedListState(); - - /// The state from the closest instance of this class that encloses the given context. - /// - /// This method is typically used by [SliverAnimatedList] item widgets that - /// insert or remove items in response to user input. - /// - /// ```dart - /// SliverAnimatedListState animatedList = SliverAnimatedList.of(context); - /// ``` - static SliverAnimatedListState of(BuildContext context, {bool nullOk = false}) { - assert(context != null); - assert(nullOk != null); - final SliverAnimatedListState result = context.ancestorStateOfType(const TypeMatcher()); - if (nullOk || result != null) - return result; - throw FlutterError( - 'SliverAnimatedList.of() called with a context that does not contain a SliverAnimatedList.\n' - 'No SliverAnimatedListState ancestor could be found starting from the ' - 'context that was passed to SliverAnimatedListState.of(). ' - 'This can happen when the context provided is from the same StatefulWidget that ' - 'built the AnimatedList. Please see the SliverAnimatedList documentation ' - 'for examples of how to refer to an AnimatedListState object: ' - ' https://docs.flutter.io/flutter/widgets/SliverAnimatedListState-class.html \n' - 'The context used was:\n' - ' $context'); - } -} - -/// The state for a sliver 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 [SliverAnimatedList.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 [SliverAnimatedList]'s state with a global key: -/// -/// ```dart -/// GlobalKey listKey = GlobalKey(); -/// ... -/// SliverAnimatedList(key: listKey, ...); -/// ... -/// listKey.currentState.insert(123); -/// ``` -/// -/// [SliverAnimatedList] item input handlers can also refer to their -/// [SliverAnimatedListState] with the static [SliverAnimatedList.of] method. -class SliverAnimatedListState extends State with TickerProviderStateMixin { - final List<_ActiveItem> _incomingItems = <_ActiveItem>[]; final List<_ActiveItem> _outgoingItems = <_ActiveItem>[]; int _itemsCount = 0; @@ -366,9 +219,10 @@ class SliverAnimatedListState extends State with TickerProvi @override void dispose() { - for (_ActiveItem item in _incomingItems.followedBy(_outgoingItems)) { + for (_ActiveItem item in _incomingItems) + item.controller.dispose(); + for (_ActiveItem item in _outgoingItems) item.controller.dispose(); - } super.dispose(); } @@ -411,12 +265,8 @@ class SliverAnimatedListState extends State with TickerProvi return index; } - SliverChildDelegate _createDelegate() { - return SliverChildBuilderDelegate(_itemBuilder, childCount: _itemsCount); - } - - /// Insert an item at [index] and start an animation that will be passed to - /// [SliverAnimatedList.itemBuilder] when the item is visible. + /// Insert an item at [index] and start an animation that will be passed + /// to [AnimatedList.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 @@ -457,8 +307,8 @@ class SliverAnimatedListState extends State with TickerProvi /// 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 [SliverAnimatedList.itemBuilder]. However - /// the item will still appear in the list for [duration] and during that time + /// will no longer be passed to the [AnimatedList.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: @@ -515,8 +365,16 @@ class SliverAnimatedListState extends State with TickerProvi @override Widget build(BuildContext context) { - return SliverList( - delegate: _createDelegate(), + return 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/test/widgets/animated_list_test.dart b/packages/flutter/test/widgets/animated_list_test.dart index 3b8161072b..47ab0322de 100644 --- a/packages/flutter/test/widgets/animated_list_test.dart +++ b/packages/flutter/test/widgets/animated_list_test.dart @@ -6,15 +6,36 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; void main() { - testWidgets('AnimatedList', (WidgetTester tester) async { - final AnimatedListItemBuilder builder = (BuildContext context, int index, Animation animation) { - return SizedBox( - height: 100.0, - child: Center( - child: Text('item $index'), + testWidgets('AnimatedList initialItemCount', (WidgetTester tester) async { + final Map> animations = >{}; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedList( + initialItemCount: 2, + itemBuilder: (BuildContext context, int index, Animation animation) { + animations[index] = animation; + return SizedBox( + height: 100.0, + child: Center( + child: 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 = GlobalKey(); await tester.pumpWidget( @@ -22,286 +43,141 @@ void main() { textDirection: TextDirection.ltr, child: AnimatedList( key: listKey, - initialItemCount: 2, - itemBuilder: builder, + itemBuilder: (BuildContext context, int index, Animation animation) { + return SizeTransition( + key: ValueKey(index), + axis: Axis.vertical, + sizeFactor: animation, + child: SizedBox( + height: 100.0, + child: Center( + child: Text('item $index'), + ), + ), + ); + }, ), ), ); - expect(find.byWidgetPredicate((Widget widget) { - return widget is SliverAnimatedList - && widget.initialItemCount == 2 - && widget.itemBuilder == builder; - }), findsOneWidget); + double itemHeight(int index) => tester.getSize(find.byKey(ValueKey(index), skipOffstage: false)).height; + double itemTop(int index) => tester.getTopLeft(find.byKey(ValueKey(index), skipOffstage: false)).dy; + double itemBottom(int index) => tester.getBottomLeft(find.byKey(ValueKey(index), skipOffstage: false)).dy; - listKey.currentState.insertItem(0); + 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 = GlobalKey(); + final List items = [0, 1, 2]; + + Widget buildItem(BuildContext context, int item, Animation animation) { + return SizeTransition( + key: ValueKey(item), + axis: Axis.vertical, + sizeFactor: animation, + child: SizedBox( + height: 100.0, + child: Center( + child: Text('item $item', textDirection: TextDirection.ltr), + ), + ), + ); + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: 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(ValueKey(index))).dy; + double itemBottom(int index) => tester.getBottomLeft(find.byKey(ValueKey(index))).dy; + + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); expect(find.text('item 2'), findsOneWidget); - listKey.currentState.removeItem(2, (BuildContext context, Animation animation) { - return const SizedBox( - height: 100.0, - child: Center( - child: Text('removing item'), - ), - ); - }, duration: const Duration(milliseconds: 100)); + items.removeAt(0); + listKey.currentState.removeItem(0, + (BuildContext context, Animation animation) => buildItem(context, 0, animation), + duration: const Duration(milliseconds: 100), + ); + + // Items 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 + + // Items 0, 1, 2 at 0, 50, 150. Item 0's height is 50. await tester.pump(); - expect(find.text('removing item'), findsOneWidget); - expect(find.text('item 2'), findsNothing); + 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); - await tester.pumpAndSettle(const Duration(milliseconds: 100)); - expect(find.text('removing item'), findsNothing); - }); - - group('SliverAnimatedList', () { - testWidgets('initialItemCount', (WidgetTester tester) async { - final Map> animations = >{}; - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: CustomScrollView( - slivers: [ - SliverAnimatedList( - initialItemCount: 2, - itemBuilder: (BuildContext context, int index, Animation animation) { - animations[index] = animation; - return SizedBox( - height: 100.0, - child: Center( - child: 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('insert', (WidgetTester tester) async { - final GlobalKey listKey = GlobalKey(); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: CustomScrollView( - slivers: [ - SliverAnimatedList( - key: listKey, - itemBuilder: (BuildContext context, int index, Animation animation) { - return SizeTransition( - key: ValueKey(index), - axis: Axis.vertical, - sizeFactor: animation, - child: SizedBox( - height: 100.0, - child: Center( - child: Text('item $index'), - ), - ), - ); - }, - ) - ], - ), - ), - ); - - double itemHeight(int index) => tester.getSize(find.byKey(ValueKey(index), skipOffstage: false)).height; - double itemTop(int index) => tester.getTopLeft(find.byKey(ValueKey(index), skipOffstage: false)).dy; - double itemBottom(int index) => tester.getBottomLeft(find.byKey(ValueKey(index), skipOffstage: false)).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('remove', (WidgetTester tester) async { - final GlobalKey listKey = GlobalKey(); - final List items = [0, 1, 2]; - - Widget buildItem(BuildContext context, int item, Animation animation) { - return SizeTransition( - key: ValueKey(item), - axis: Axis.vertical, - sizeFactor: animation, - child: SizedBox( - height: 100.0, - child: Center( - child: Text('item $item', textDirection: TextDirection.ltr), - ), - ), - ); - } - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: CustomScrollView( - slivers: [ - SliverAnimatedList( - 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(ValueKey(index))).dy; - double itemBottom(int index) => tester.getBottomLeft(find.byKey(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), - ); - - // Items 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 - - // Items 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); - - // Items 1, 2 at 0, 100. - await tester.pumpAndSettle(); - expect(itemTop(1), 0.0); - expect(itemBottom(1), 100.0); - expect(itemTop(2), 100.0); - expect(itemBottom(2), 200.0); - }); - - testWidgets('works in combination with other slivers', (WidgetTester tester) async { - final GlobalKey listKey = GlobalKey(); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: CustomScrollView( - slivers: [ - const SliverList( - delegate: SliverChildListDelegate([ - SizedBox(height: 100), - SizedBox(height: 100), - ]), - ), - SliverAnimatedList( - key: listKey, - initialItemCount: 3, - itemBuilder: (BuildContext context, int index, Animation animation) { - return SizedBox( - height: 100, - child: Text('item $index'), - ); - }, - ), - ], - ), - ), - ); - - expect(tester.getTopLeft(find.text('item 0')).dy, 200); - expect(tester.getTopLeft(find.text('item 1')).dy, 300); - - listKey.currentState.insertItem(3); - await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.text('item 3')).dy, 500); - - listKey.currentState.removeItem(0, - (BuildContext context, Animation animation) { - return SizeTransition( - sizeFactor: animation, - key: const ObjectKey('removing'), - child: const SizedBox( - height: 100, - child: Text('removing'), - ), - ); - }, - duration: const Duration(seconds: 1), - ); - - await tester.pump(); - expect(find.text('item 3'), findsNothing); - - await tester.pump(const Duration(milliseconds: 500)); - expect(tester.getSize(find.byKey(const ObjectKey('removing'))).height, 50); - expect(tester.getTopLeft(find.text('item 0')).dy, 250); - - await tester.pumpAndSettle(); - expect(find.text('removing'), findsNothing); - expect(tester.getTopLeft(find.text('item 0')).dy, 200); - }); + // Items 1, 2 at 0, 100. + await tester.pumpAndSettle(); + expect(itemTop(1), 0.0); + expect(itemBottom(1), 100.0); + expect(itemTop(2), 100.0); + expect(itemBottom(2), 200.0); }); }