From d2de911d50ea792d6743b2cdc74e8daf26112b91 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 6 May 2019 11:26:38 +0200 Subject: [PATCH] Sliver animated list (#28834) --- .../lib/src/widgets/animated_list.dart | 176 +++++++- .../test/widgets/animated_list_test.dart | 410 ++++++++++++------ 2 files changed, 426 insertions(+), 160 deletions(-) diff --git a/packages/flutter/lib/src/widgets/animated_list.dart b/packages/flutter/lib/src/widgets/animated_list.dart index 73a251bca76..e15366ea62f 100644 --- a/packages/flutter/lib/src/widgets/animated_list.dart +++ b/packages/flutter/lib/src/widgets/animated_list.dart @@ -10,6 +10,7 @@ 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]. @@ -75,11 +76,13 @@ 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. @@ -207,6 +210,150 @@ 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; @@ -219,10 +366,9 @@ class AnimatedListState extends State with TickerProviderStateMixi @override void dispose() { - for (_ActiveItem item in _incomingItems) - item.controller.dispose(); - for (_ActiveItem item in _outgoingItems) + for (_ActiveItem item in _incomingItems.followedBy(_outgoingItems)) { item.controller.dispose(); + } super.dispose(); } @@ -265,8 +411,12 @@ class AnimatedListState extends State with TickerProviderStateMixi return index; } - /// Insert an item at [index] and start an animation that will be passed - /// to [AnimatedList.itemBuilder] when the item is visible. + 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. /// /// 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 @@ -307,8 +457,8 @@ class AnimatedListState extends State with TickerProviderStateMixi /// 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 + /// will no longer be passed to the [SliverAnimatedList.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: @@ -365,16 +515,8 @@ class AnimatedListState extends State with TickerProviderStateMixi @override Widget build(BuildContext context) { - 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, + return SliverList( + delegate: _createDelegate(), ); } } diff --git a/packages/flutter/test/widgets/animated_list_test.dart b/packages/flutter/test/widgets/animated_list_test.dart index 47ab0322de8..3b8161072ba 100644 --- a/packages/flutter/test/widgets/animated_list_test.dart +++ b/packages/flutter/test/widgets/animated_list_test.dart @@ -6,36 +6,15 @@ 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( - 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'), - ), - ); - }, + 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'), ), - ), - ); - - 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( @@ -43,141 +22,286 @@ void main() { textDirection: TextDirection.ltr, child: AnimatedList( 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'), - ), - ), - ); - }, + initialItemCount: 2, + itemBuilder: builder, ), ), ); - 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; + expect(find.byWidgetPredicate((Widget widget) { + return widget is SliverAnimatedList + && widget.initialItemCount == 2 + && widget.itemBuilder == builder; + }), findsOneWidget); - listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + listKey.currentState.insertItem(0); 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); + + 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)); + await tester.pump(); + expect(find.text('removing item'), findsOneWidget); + expect(find.text('item 2'), findsNothing); + + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + expect(find.text('removing item'), findsNothing); }); - testWidgets('AnimatedList remove', (WidgetTester tester) async { - final GlobalKey listKey = GlobalKey(); - final List items = [0, 1, 2]; + group('SliverAnimatedList', () { + testWidgets('initialItemCount', (WidgetTester tester) async { + final Map> animations = >{}; - 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( + initialItemCount: 2, + itemBuilder: (BuildContext context, int index, Animation animation) { + animations[index] = animation; + return SizedBox( + height: 100.0, + child: Center( + child: Text('item $index'), + ), + ); + }, + ) + ], ), ), ); - } - 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); - }, + 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 itemTop(int index) => tester.getTopLeft(find.byKey(ValueKey(index))).dy; - double itemBottom(int index) => tester.getBottomLeft(find.byKey(ValueKey(index))).dy; + 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; - expect(find.text('item 0'), findsOneWidget); - expect(find.text('item 1'), findsOneWidget); - expect(find.text('item 2'), findsOneWidget); + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + await tester.pump(); - items.removeAt(0); - listKey.currentState.removeItem(0, - (BuildContext context, Animation animation) => buildItem(context, 0, animation), - duration: const Duration(milliseconds: 100), - ); + // 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); - // 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); + // 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); - // Newly removed item 0's height should animate from 100 to 0 over 100ms + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + listKey.currentState.insertItem(0, duration: const Duration(milliseconds: 100)); + await tester.pump(); - // 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); + // 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); - // 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); + // 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); + }); }); }