From 39472d9b6193191b8d11728bbcaf5c287bbb08bb Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Wed, 5 Jun 2024 17:20:07 +0200 Subject: [PATCH] Feature: Add AnimatedList with separators (#144899) This PR adds `AnimatedList.separated`. A widget like an AnimatedList with animated separators. `animated_list_separated.0.dart` extends `animated_list.0.dart` to work with `AnimatedList.separated` Related issue: https://github.com/flutter/flutter/issues/48226 --- .../animated_list/animated_list.0.dart | 3 +- .../animated_list_separated.0.dart | 278 +++++++++++ .../animated_list_separated.0_test.dart | 56 +++ .../lib/src/widgets/animated_scroll_view.dart | 262 +++++++++- .../test/widgets/animated_list_test.dart | 464 ++++++++++++++++++ 5 files changed, 1038 insertions(+), 25 deletions(-) create mode 100644 examples/api/lib/widgets/animated_list/animated_list_separated.0.dart create mode 100644 examples/api/test/widgets/animated_list/animated_list_separated.0_test.dart diff --git a/examples/api/lib/widgets/animated_list/animated_list.0.dart b/examples/api/lib/widgets/animated_list/animated_list.0.dart index c59a2435a54..9976ff5177f 100644 --- a/examples/api/lib/widgets/animated_list/animated_list.0.dart +++ b/examples/api/lib/widgets/animated_list/animated_list.0.dart @@ -66,7 +66,8 @@ class _AnimatedListSampleState extends State { // Insert the "next item" into the list model. void _insert() { final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!); - _list.insert(index, _nextItem++); + _list.insert(index, _nextItem); + _nextItem++; } // Remove the selected item from the list model. diff --git a/examples/api/lib/widgets/animated_list/animated_list_separated.0.dart b/examples/api/lib/widgets/animated_list/animated_list_separated.0.dart new file mode 100644 index 00000000000..c330e6f4737 --- /dev/null +++ b/examples/api/lib/widgets/animated_list/animated_list_separated.0.dart @@ -0,0 +1,278 @@ +// Copyright 2014 The Flutter 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/material.dart'; + +/// Flutter code sample for [AnimatedList.separated]. + +void main() { + runApp(const AnimatedListSeparatedSample()); +} + +class AnimatedListSeparatedSample extends StatefulWidget { + const AnimatedListSeparatedSample({super.key}); + + @override + State createState() => _AnimatedListSeparatedSampleState(); +} + +class _AnimatedListSeparatedSampleState extends State { + final GlobalKey _listKey = GlobalKey(); + late ListModel _list; + int? _selectedItem; + late int _nextItem; // The next item inserted when the user presses the '+' button. + + @override + void initState() { + super.initState(); + _list = ListModel( + listKey: _listKey, + initialItems: [0, 1, 2], + removedItemBuilder: _buildRemovedItem, + ); + _nextItem = 3; + } + + // Used to build list items that haven't been removed. + Widget _buildItem(BuildContext context, int index, Animation animation) { + return CardItem( + animation: animation, + item: _list[index], + selected: _selectedItem == _list[index], + onTap: () { + setState(() { + _selectedItem = _selectedItem == _list[index] ? null : _list[index]; + }); + }, + ); + } + + // Used to build separators for items that haven't been removed. + Widget _buildSeparator(BuildContext context, int index, Animation animation) { + return ItemSeparator( + animation: animation, + item: _list[index], + ); + } + + /// The builder function used to build items that have been removed. + /// + /// Used to build an item after it has been removed from the list. This method + /// is needed because a removed item remains visible until its animation has + /// completed (even though it's gone as far as this ListModel is concerned). + /// The widget will be used by the [AnimatedListState.removeItem] method's + /// `itemBuilder` parameter. + Widget _buildRemovedItem(int item, BuildContext context, Animation animation) { + return CardItem( + animation: animation, + item: item, + // No gesture detector here: we don't want removed items to be interactive. + ); + } + + /// The builder function used to build a separator for an item that has been removed. + /// + /// Used to build a separator after the corresponding item has been removed from the list. + /// This method is needed because the separator of a removed item remains visible until its animation has completed. + /// The widget will be passed to [AnimatedList.separated] + /// via the [AnimatedList.removedSeparatorBuilder] parameter and used + /// in the [AnimatedListState.removeItem] method. + /// + /// The item parameter is null, because the corresponding item will + /// have been removed from the list model by the time this builder is called. + Widget _buildRemovedSeparator(BuildContext context, int index, Animation animation) { + return SizeTransition( + sizeFactor: animation, + child: ItemSeparator( + animation: animation, + item: null, + ) + ); + } + + // Insert the "next item" into the list model. + void _insert() { + final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!); + _list.insert(index, _nextItem); + _nextItem++; + } + + // Remove the selected item from the list model. + void _remove() { + if (_selectedItem != null) { + _list.removeAt(_list.indexOf(_selectedItem!)); + setState(() { + _selectedItem = null; + }); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('AnimatedList.separated'), + actions: [ + IconButton( + icon: const Icon(Icons.add_circle), + onPressed: _insert, + tooltip: 'insert a new item', + ), + IconButton( + icon: const Icon(Icons.remove_circle), + onPressed: _remove, + tooltip: 'remove the selected item', + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: AnimatedList.separated( + key: _listKey, + initialItemCount: _list.length, + itemBuilder: _buildItem, + separatorBuilder: _buildSeparator, + removedSeparatorBuilder: _buildRemovedSeparator, + ), + ), + ), + ); + } +} + +typedef RemovedItemBuilder = Widget Function(T item, BuildContext context, Animation animation); + +/// Keeps a Dart [List] in sync with an [AnimatedList.separated]. +/// +/// The [insert] and [removeAt] methods apply to both the internal list and +/// the animated list that belongs to [listKey]. +/// +/// This class only exposes as much of the Dart List API as is needed by the +/// sample app. More list methods are easily added, however methods that +/// mutate the list must make the same changes to the animated list in terms +/// of [AnimatedListState.insertItem] and [AnimatedListState.removeItem]. +class ListModel { + ListModel({ + required this.listKey, + required this.removedItemBuilder, + Iterable? initialItems, + }) : _items = List.from(initialItems ?? []); + + final GlobalKey listKey; + final RemovedItemBuilder removedItemBuilder; + final List _items; + + AnimatedListState? get _animatedList => listKey.currentState; + + void insert(int index, E item) { + _items.insert(index, item); + _animatedList!.insertItem(index); + } + + E removeAt(int index) { + final E removedItem = _items.removeAt(index); + if (removedItem != null) { + _animatedList!.removeItem( + index, + (BuildContext context, Animation animation) { + return removedItemBuilder(removedItem, context, animation); + }, + ); + } + return removedItem; + } + + int get length => _items.length; + + E operator [](int index) => _items[index]; + + int indexOf(E item) => _items.indexOf(item); +} + +/// Displays its integer item as 'item N' on a Card whose color is based on +/// the item's value. +/// +/// The text is displayed in bright green if [selected] is +/// true. This widget's height is based on the [animation] parameter, it +/// varies from 0 to 80 as the animation varies from 0.0 to 1.0. +class CardItem extends StatelessWidget { + const CardItem({ + super.key, + this.onTap, + this.selected = false, + required this.animation, + required this.item, + }) : assert(item >= 0); + + final Animation animation; + final VoidCallback? onTap; + final int item; + final bool selected; + + @override + Widget build(BuildContext context) { + TextStyle textStyle = Theme.of(context).textTheme.headlineMedium!; + if (selected) { + textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]); + } + return Padding( + padding: const EdgeInsets.all(2.0), + child: SizeTransition( + sizeFactor: animation, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: 80.0, + child: Card( + color: Colors.primaries[item % Colors.primaries.length], + child: Center( + child: Text('Item $item', style: textStyle), + ), + ), + ), + ), + ), + ); + } +} + +/// Displays its integer item as 'separator N' on a Card whose color is based on +/// the corresponding item's value. +/// +/// When the item parameter is null, the separator is displayed as 'Removing separator' with a default color. +/// +/// This widget's height is based on the [animation] parameter, it +/// varies from 0 to 40 as the animation varies from 0.0 to 1.0. +class ItemSeparator extends StatelessWidget { + const ItemSeparator({ + super.key, + required this.animation, + required this.item, + }) : assert(item == null || item >= 0); + + final Animation animation; + final int? item; + + @override + Widget build(BuildContext context) { + final TextStyle textStyle = Theme.of(context).textTheme.headlineSmall!; + return Padding( + padding: const EdgeInsets.all(2.0), + child: SizeTransition( + sizeFactor: animation, + child: SizedBox( + height: 40.0, + child: Card( + color: item == null ? Colors.grey : Colors.primaries[item! % Colors.primaries.length], + child: Center( + child: Text(item == null ? 'Removing separator' : 'Separator $item', style: textStyle), + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/animated_list/animated_list_separated.0_test.dart b/examples/api/test/widgets/animated_list/animated_list_separated.0_test.dart new file mode 100644 index 00000000000..e46573af39c --- /dev/null +++ b/examples/api/test/widgets/animated_list/animated_list_separated.0_test.dart @@ -0,0 +1,56 @@ +// Copyright 2014 The Flutter 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/material.dart'; +import 'package:flutter_api_samples/widgets/animated_list/animated_list_separated.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Items can be selected, added, and removed from AnimatedList.separated', + (WidgetTester tester) async { + await tester.pumpWidget(const example.AnimatedListSeparatedSample()); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + + // Add an item at the end of the list + await tester.tap(find.byIcon(Icons.add_circle)); + await tester.pumpAndSettle(); + expect(find.text('Separator 2'), findsOneWidget); + expect(find.text('Item 3'), findsOneWidget); + + // Select Item 1. + await tester.tap(find.text('Item 1')); + await tester.pumpAndSettle(); + + // Add item at the top of the list + await tester.tap(find.byIcon(Icons.add_circle)); + await tester.pumpAndSettle(); + expect(find.text('Item 4'), findsOneWidget); + // Contrary to the behavior for insertion at other places, + // the Separator for the last item of the list will be added + // before that item instead of after it. + expect(find.text('Separator 4'), findsOneWidget); + + // Remove selected item. + await tester.tap(find.byIcon(Icons.remove_circle)); + + // Item animation is not completed. + await tester.pump(); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsNothing); + expect(find.text('Removing separator'), findsOneWidget); + + // When the animation completes, Item 1 disappears. + await tester.pumpAndSettle(); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Separator 1'), findsNothing); + expect(find.text('Removing separator'), findsNothing); + }, + ); +} diff --git a/packages/flutter/lib/src/widgets/animated_scroll_view.dart b/packages/flutter/lib/src/widgets/animated_scroll_view.dart index 7a1217feab9..53e97358325 100644 --- a/packages/flutter/lib/src/widgets/animated_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/animated_scroll_view.dart @@ -85,6 +85,92 @@ class AnimatedList extends _AnimatedScrollView { super.clipBehavior = Clip.hardEdge, }) : assert(initialItemCount >= 0); + /// A scrolling container that animates items with separators when they are inserted or removed. + /// + /// This widget's [AnimatedListState] can be used to dynamically 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.separated]. + /// + /// {@tool dartpad} + /// This sample application uses an [AnimatedList.separated] to create an effect when + /// items are removed or added to the list. + /// + /// ** See code in examples/api/lib/widgets/animated_list/animated_list_separated.0.dart ** + /// {@end-tool} + /// + /// By default, [AnimatedList.separated] will automatically pad the limits of the + /// list's scrollable to avoid partial obstructions indicated by + /// [MediaQuery]'s padding. To avoid this behavior, override with a + /// zero [padding] property. + /// + /// {@tool snippet} + /// The following example demonstrates how to override the default top and + /// bottom padding using [MediaQuery.removePadding]. + /// + /// ```dart + /// Widget myWidget(BuildContext context) { + /// return MediaQuery.removePadding( + /// context: context, + /// removeTop: true, + /// removeBottom: true, + /// child: AnimatedList.separated( + /// initialItemCount: 50, + /// itemBuilder: (BuildContext context, int index, Animation animation) { + /// return Card( + /// color: Colors.amber, + /// child: Center(child: Text('$index')), + /// ); + /// }, + /// separatorBuilder: (BuildContext context, int index, Animation animation) { + /// return const Divider(); + /// }, + /// removedSeparatorBuilder: (BuildContext context, int index, Animation animation) { + /// return const Divider(); + /// } + /// ), + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [SliverAnimatedList], a sliver that animates items when they are inserted + /// or removed from a list. + /// * [SliverAnimatedGrid], a sliver which animates items when they are + /// inserted or removed from a grid. + /// * [AnimatedGrid], a non-sliver scrolling container that animates items when + /// they are inserted or removed in a grid. + /// * [AnimatedList], which animates items added and removed from a list instead + /// of a grid. + AnimatedList.separated({ + super.key, + required AnimatedItemBuilder itemBuilder, + required AnimatedItemBuilder separatorBuilder, + required AnimatedItemBuilder super.removedSeparatorBuilder, + int initialItemCount = 0, + super.scrollDirection = Axis.vertical, + super.reverse = false, + super.controller, + super.primary, + super.physics, + super.shrinkWrap = false, + super.padding, + super.clipBehavior = Clip.hardEdge, + }) : assert(initialItemCount >= 0), + super( + initialItemCount: _computeChildCountWithSeparators(initialItemCount), + itemBuilder: (BuildContext context, int index, Animation animation) { + final int itemIndex = index ~/ 2; + if (index.isEven) { + return itemBuilder(context, itemIndex, animation); + } + return separatorBuilder(context, itemIndex, animation); + }, + ); + /// The state from the closest instance of this class that encloses the given /// context. /// @@ -148,12 +234,20 @@ class AnimatedList extends _AnimatedScrollView { return context.findAncestorStateOfType(); } + // Helper method to compute the actual child count when taking separators into account. + static int _computeChildCountWithSeparators(int itemCount) { + if (itemCount == 0) { + return 0; + } + return itemCount * 2 - 1; + } + @override AnimatedListState createState() => AnimatedListState(); } -/// The [AnimatedListState] for [AnimatedList], a scrolling list container that animates items when they are -/// inserted or removed. +/// The [AnimatedListState] for [AnimatedList], a scrolling list 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 [AnimatedList.itemBuilder] whenever the item's widget @@ -163,9 +257,13 @@ class AnimatedList extends _AnimatedScrollView { /// The animation is passed to [AnimatedList.itemBuilder] whenever the item's widget /// is needed. /// +/// If using [AnimatedList.separated], the animation is also passed to +/// `AnimatedList.separatorBuilder` whenever the separator'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. +/// parameter. If using [AnimatedList.separated], the corresponding separator's +/// animation is also passed to the [AnimatedList.removedSeparatorBuilder] 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: @@ -427,6 +525,7 @@ abstract class _AnimatedScrollView extends StatefulWidget { const _AnimatedScrollView({ super.key, required this.itemBuilder, + this.removedSeparatorBuilder, this.initialItemCount = 0, this.scrollDirection = Axis.vertical, this.reverse = false, @@ -456,6 +555,22 @@ abstract class _AnimatedScrollView extends StatefulWidget { /// {@endtemplate} final AnimatedItemBuilder itemBuilder; + /// {@template flutter.widgets.AnimatedScrollView.removedSeparatorBuilder} + /// Called, as needed, to build separator widgets. + /// + /// Separators are only built when they're scrolled into view. + /// + /// The [AnimatedItemBuilder] index parameter indicates the + /// separator's corresponding item's position in the scroll view. 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 [AnimatedListState.removeItem]. + /// + /// Implementations of this callback should assume that + /// `removeItem` removes an item immediately. + /// {@endtemplate} + final AnimatedItemBuilder? removedSeparatorBuilder; + /// {@template flutter.widgets.AnimatedScrollView.initialItemCount} /// The number of items the [AnimatedList] or [AnimatedGrid] will start with. /// @@ -546,51 +661,142 @@ abstract class _AnimatedScrollViewState extends S /// to [AnimatedGrid.itemBuilder] or [AnimatedList.itemBuilder] when the item /// is visible. /// + /// If using [AnimatedList.separated] the animation will also be passed + /// to `separatorBuilder`. + /// /// This method's semantics are the same as Dart's [List.insert] method: it /// increases the length of the list of items by one and shifts /// all items at or after [index] towards the end of the list of items. void insertItem(int index, { Duration duration = _kDuration }) { - _sliverAnimatedMultiBoxKey.currentState!.insertItem(index, duration: duration); + if (widget.removedSeparatorBuilder == null) { + _sliverAnimatedMultiBoxKey.currentState!.insertItem(index, duration: duration); + } else { + final int itemIndex = _computeItemIndex(index); + _sliverAnimatedMultiBoxKey.currentState!.insertItem(itemIndex, duration: duration); + if (_itemsCount > 1) { + // Because `insertItem` moves the items after the index, we need to insert the separator + // at the same index as the item. If there is only one item, we don't need to insert a separator. + _sliverAnimatedMultiBoxKey.currentState!.insertItem(itemIndex, duration: duration); + } + } } /// Insert multiple items at [index] and start an animation that will be passed /// to [AnimatedGrid.itemBuilder] or [AnimatedList.itemBuilder] when the items /// are visible. + /// + /// If using [AnimatedList.separated] the animation will also be passed to `separatorBuilder`. void insertAllItems(int index, int length, { Duration duration = _kDuration, bool isAsync = false }) { - _sliverAnimatedMultiBoxKey.currentState!.insertAllItems(index, length, duration: duration); + if (widget.removedSeparatorBuilder == null) { + _sliverAnimatedMultiBoxKey.currentState!.insertAllItems(index, length, duration: duration); + } else { + final int itemIndex = _computeItemIndex(index); + final int lengthWithSeparators = _itemsCount == 0 ? length * 2 - 1 : length * 2; + _sliverAnimatedMultiBoxKey.currentState!.insertAllItems(itemIndex, lengthWithSeparators, duration: duration); + } } - /// Remove the item at `index` and start an animation that will be passed to - /// `builder` when the item is visible. + /// Remove the item at [index] and start an animation that will be passed to + /// [builder] when the item is visible. + /// + /// If using [AnimatedList.separated], the animation will also be passed to the + /// corresponding separator's [AnimatedList.removedSeparatorBuilder]. /// /// 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 for `duration` and during that time - /// `builder` must construct its widget as needed. + /// will no longer be passed to the [builder]. However, the + /// item will still appear 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 items by one and shifts all items at or before - /// `index` towards the beginning of the list of items. + /// [index] towards the beginning of the list of items. /// /// See also: /// /// * [AnimatedRemovedItemBuilder], which describes the arguments to the - /// `builder` argument. + /// [builder] argument. void removeItem(int index, AnimatedRemovedItemBuilder builder, { Duration duration = _kDuration }) { - _sliverAnimatedMultiBoxKey.currentState!.removeItem(index, builder, duration: duration); + final AnimatedItemBuilder? removedSeparatorBuilder = widget.removedSeparatorBuilder; + if (removedSeparatorBuilder == null) { + // There are no separators. Remove only the item. + _sliverAnimatedMultiBoxKey.currentState!.removeItem(index, builder, duration: duration); + } else { + final int itemIndex = _computeItemIndex(index); + // Remove the item + _sliverAnimatedMultiBoxKey.currentState!.removeItem(itemIndex, builder, duration: duration); + if (_itemsCount > 1) { + if (itemIndex == _itemsCount - 1) { + // The item was removed from the end of the list, so the separator to remove is the one at `last index` - 1. + _sliverAnimatedMultiBoxKey.currentState!.removeItem(itemIndex - 1, _toRemovedItemBuilder(removedSeparatorBuilder, index - 1), duration: duration); + } else { + // The item was removed from the middle or beginning of the list, + // so the corresponding separator took its place and needs to be removed at `itemIndex`. + _sliverAnimatedMultiBoxKey.currentState!.removeItem(itemIndex, _toRemovedItemBuilder(removedSeparatorBuilder, index), duration: duration); + } + } + } } /// Remove all the items and start an animation that will be passed to - /// `builder` when the items are visible. + /// [builder] when the items are visible. + /// + /// If using [AnimatedList.separated], the animation will also be passed + /// to the corresponding separator's [AnimatedList.removedSeparatorBuilder]. /// /// Items are removed immediately. However, the - /// items will still appear for `duration`, and during that time - /// `builder` must construct its widget as needed. + /// items will still appear for [duration], and during that time + /// [builder] must construct its widget as needed. /// /// This method's semantics are the same as Dart's [List.clear] method: it /// removes all the items in the list. + /// + /// See also: + /// + /// * [AnimatedRemovedItemBuilder], which describes the arguments to the + /// [builder] argument. void removeAllItems(AnimatedRemovedItemBuilder builder, { Duration duration = _kDuration }) { - _sliverAnimatedMultiBoxKey.currentState!.removeAllItems(builder, duration: duration); + final AnimatedItemBuilder? removedSeparatorBuilder = widget.removedSeparatorBuilder; + if (removedSeparatorBuilder == null) { + // There are no separators. We can remove all items with the same builder. + _sliverAnimatedMultiBoxKey.currentState!.removeAllItems(builder, duration: duration); + return; + } + + // There are separators. We need to remove items and separators separately + // with the corresponding builders. + for (int index = _itemsCount - 1; index >= 0 ; index--) { + if (index.isEven) { + _sliverAnimatedMultiBoxKey.currentState!.removeItem(index, builder, duration: duration); + } else { + // The index of the separator's corresponding item + final int itemIndex = index ~/ 2; + _sliverAnimatedMultiBoxKey.currentState!.removeItem(index, _toRemovedItemBuilder(removedSeparatorBuilder, itemIndex), duration: duration); + } + } + } + + int get _itemsCount => _sliverAnimatedMultiBoxKey.currentState!._itemsCount; + + // Helper method to compute the index for the item to insert or remove considering the separators in between. + int _computeItemIndex(int index) { + if (index == 0) { + return index; + } + final int itemsAndSeparatorsCount = _itemsCount; + final int separatorsCount = itemsAndSeparatorsCount ~/ 2; + final int separatedItemsCount = _itemsCount - separatorsCount; + + final bool isNewLastIndex = index == separatedItemsCount; + final int indexAdjustedForSeparators = index * 2; + return isNewLastIndex ? indexAdjustedForSeparators - 1 : indexAdjustedForSeparators; + } + + // Helper method to create an [AnimatedRemovedItemBuilder] + // from an [AnimatedItemBuilder] for given [index]. + AnimatedRemovedItemBuilder _toRemovedItemBuilder(AnimatedItemBuilder builder, int index) { + return (BuildContext context, Animation animation) { + return builder(context, index, animation); + }; } Widget _wrap(Widget sliver, Axis direction) { @@ -635,14 +841,22 @@ abstract class _AnimatedScrollViewState extends S } } -/// Signature for the builder callback used by [AnimatedList] & [AnimatedGrid] to -/// build their animated children. +/// Signature for the builder callback used by [AnimatedList], [AnimatedList.separated] +/// & [AnimatedGrid] to build their animated children. /// -/// The `context` argument is the build context where the widget will be -/// created, the `index` is the index of the item to be built, and the -/// `animation` is an [Animation] that should be used to animate an entry +/// This signature is also used by [AnimatedList.separated] to build its separators and +/// to animate their exit transition after their corresponding item has been removed. +/// +/// The [context] argument is the build context where the widget will be +/// created, the [index] is the index of the item to be built, and the +/// [animation] is an [Animation] that should be used to animate an entry /// transition for the widget that is built. /// +/// For [AnimatedList.separated], the [index] is the index +/// of the corresponding item of the separator that is built or removed. +/// For [AnimatedList.separated] `removedSeparatorBuilder`, the [animation] should be used +/// to animate an exit transition for the widget that is built. +/// /// See also: /// /// * [AnimatedRemovedItemBuilder], a builder that is for removing items with @@ -653,8 +867,8 @@ typedef AnimatedItemBuilder = Widget Function(BuildContext context, int index, A /// [AnimatedGridState.removeItem] to animate their children after they have /// been removed. /// -/// The `context` argument is the build context where the widget will be -/// created, and the `animation` is an [Animation] that should be used to +/// The [context] argument is the build context where the widget will be +/// created, and the [animation] is an [Animation] that should be used to /// animate an exit transition for the widget that is built. /// /// See also: diff --git a/packages/flutter/test/widgets/animated_list_test.dart b/packages/flutter/test/widgets/animated_list_test.dart index 0c169161050..16578d4d628 100644 --- a/packages/flutter/test/widgets/animated_list_test.dart +++ b/packages/flutter/test/widgets/animated_list_test.dart @@ -688,6 +688,470 @@ void main() { // Verify that the left/right padding is not applied. expect(innerMediaQueryPadding, const EdgeInsets.symmetric(horizontal: 30.0)); }); + + testWidgets('AnimatedList.separated', (WidgetTester tester) async { + tester.view.physicalSize = const Size(600, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + Widget builder(BuildContext context, int index, Animation animation) { + return SizedBox( + height: 100.0, + child: Center( + child: Text('item $index'), + ), + ); + } + Widget separatorBuilder(BuildContext context, int index, Animation animation) { + return SizedBox( + height: 100.0, + child: Center( + child: Text('separator after item $index'), + ), + ); + } + + Widget itemRemovalBuilder(BuildContext context, int? index, Animation animation) { + final String text = index != null ? 'removing item $index' : 'removing item'; + return SizedBox( + height: 100.0, + child: Center(child: Text(text)), + ); + } + + // Helper function to wrap itemRemovalBuilder with index + // to allow testing removal of an item at the expected index. + // Null index is necessary for removeAllItems. + AnimatedRemovedItemBuilder itemRemovalBuilderWrapper({int? index}) { + return (BuildContext context, Animation animation) { + return itemRemovalBuilder(context, index, animation); + }; + } + + Widget separatorRemovalBuilder(BuildContext context, int index, Animation animation) { + return SizedBox( + height: 100.0, + child: Center(child: Text('removing separator after item $index')), + ); + } + + + List getItemsSeparatorsTexts(WidgetTester tester) { + final Finder itemsSeparators = find.descendant(of: find.byType(SliverAnimatedList), matching: find.byType(Text)); + return itemsSeparators.allCandidates.map((Element e) => e.widget).whereType().toList(); + } + + final GlobalKey listKey = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedList.separated( + key: listKey, + initialItemCount: 2, + itemBuilder: builder, + separatorBuilder: separatorBuilder, + removedSeparatorBuilder: separatorRemovalBuilder, + ), + ), + ); + + final Finder sliverAnimatedList = find.byType(SliverAnimatedList); + expect(sliverAnimatedList, findsOneWidget); + expect((sliverAnimatedList.evaluate().first.widget as SliverAnimatedList).initialItemCount, 3); // 2 items + 1 separator + + List itemsSeparatorsTexts; + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 3); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + + await tester.pumpAndSettle(); + + // Begin testing + + // insertItem - Insert at beginning of list + listKey.currentState!.insertItem(0); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 5); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + + await tester.pumpAndSettle(); + + // insertItem - Insert at end of list + listKey.currentState!.insertItem(3); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 7); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + + await tester.pumpAndSettle(); + + // insertItem - Insert in middle of list + listKey.currentState!.insertItem(2); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 9); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + expect(itemsSeparatorsTexts[7].data, 'separator after item 3'); + expect(itemsSeparatorsTexts[8].data, 'item 4'); + + await tester.pumpAndSettle(); + + // insertItem - Insert at negative index + expect(() => listKey.currentState!.insertItem(-1), throwsAssertionError); + + // insertItem - Insert at index greater than itemCount + expect(() => listKey.currentState!.insertItem(42), throwsAssertionError); + + // removeItem - Remove at beginning of list + listKey.currentState!.removeItem( + 0, + itemRemovalBuilderWrapper(index: 0), + duration: const Duration(milliseconds: 100), + ); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 9); + expect(itemsSeparatorsTexts[0].data, 'removing item 0'); + expect(itemsSeparatorsTexts[1].data, 'removing separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 0'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[4].data, 'item 1'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[6].data, 'item 2'); + expect(itemsSeparatorsTexts[7].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[8].data, 'item 3'); + + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 7); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + + // removeItem - Remove at end of list + listKey.currentState!.removeItem( + 3, + itemRemovalBuilderWrapper(index: 3), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 7); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'removing separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'removing item 3'); + + await tester.pumpAndSettle(); + + // removeItem - Remove in middle of list + listKey.currentState!.removeItem( + 1, + itemRemovalBuilderWrapper(index: 1), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 5); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'removing item 1'); + expect(itemsSeparatorsTexts[3].data, 'removing separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 1'); + + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 3); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + + // removeItem - Remove at negative index + expect( + () => listKey.currentState!.removeItem( + -1, + itemRemovalBuilderWrapper(index: -1), + duration: const Duration(milliseconds: 100), + ), + throwsAssertionError, + ); + + // removeItem - Remove at index greater than itemCount + expect( + () => listKey.currentState!.removeItem( + 42, + itemRemovalBuilderWrapper(index: 42), + duration: const Duration(milliseconds: 100), + ), + throwsAssertionError, + ); + + // insertAllItems - Insert no items + listKey.currentState!.insertAllItems(0, 0); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 3); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + + await tester.pumpAndSettle(); + + // insertAllItems - Insert negative number of items + listKey.currentState!.insertAllItems(0, -1); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 3); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + + await tester.pumpAndSettle(); + + // insertAllItems - Insert at beginning of list + listKey.currentState!.insertAllItems(0, 2); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 7); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + + await tester.pumpAndSettle(); + + // insertAllItems - Insert at end of list + listKey.currentState!.insertAllItems(4, 2); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 11); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + expect(itemsSeparatorsTexts[7].data, 'separator after item 3'); + expect(itemsSeparatorsTexts[8].data, 'item 4'); + expect(itemsSeparatorsTexts[9].data, 'separator after item 4'); + expect(itemsSeparatorsTexts[10].data, 'item 5'); + + await tester.pumpAndSettle(); + + // insertAllItems - Insert in middle of list + listKey.currentState!.insertAllItems(3, 2); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 15); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + expect(itemsSeparatorsTexts[3].data, 'separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'item 2'); + expect(itemsSeparatorsTexts[5].data, 'separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'item 3'); + expect(itemsSeparatorsTexts[7].data, 'separator after item 3'); + expect(itemsSeparatorsTexts[8].data, 'item 4'); + expect(itemsSeparatorsTexts[9].data, 'separator after item 4'); + expect(itemsSeparatorsTexts[10].data, 'item 5'); + expect(itemsSeparatorsTexts[11].data, 'separator after item 5'); + expect(itemsSeparatorsTexts[12].data, 'item 6'); + expect(itemsSeparatorsTexts[13].data, 'separator after item 6'); + expect(itemsSeparatorsTexts[14].data, 'item 7'); + + await tester.pumpAndSettle(); + + // insertAllItems - Insert at negative index + expect(() => listKey.currentState!.insertAllItems(-1, 2), throwsAssertionError); + + // insertAllItems - Insert at index greater than itemCount + expect(() => listKey.currentState!.insertAllItems(42, 2), throwsAssertionError); + + // removeAllItems - Remove all items from list with multiple items + listKey.currentState!.removeAllItems( + itemRemovalBuilderWrapper(), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 15); + expect(itemsSeparatorsTexts[0].data, 'removing item'); + expect(itemsSeparatorsTexts[1].data, 'removing separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'removing item'); + expect(itemsSeparatorsTexts[3].data, 'removing separator after item 1'); + expect(itemsSeparatorsTexts[4].data, 'removing item'); + expect(itemsSeparatorsTexts[5].data, 'removing separator after item 2'); + expect(itemsSeparatorsTexts[6].data, 'removing item'); + expect(itemsSeparatorsTexts[7].data, 'removing separator after item 3'); + expect(itemsSeparatorsTexts[8].data, 'removing item'); + expect(itemsSeparatorsTexts[9].data, 'removing separator after item 4'); + expect(itemsSeparatorsTexts[10].data, 'removing item'); + expect(itemsSeparatorsTexts[11].data, 'removing separator after item 5'); + expect(itemsSeparatorsTexts[12].data, 'removing item'); + expect(itemsSeparatorsTexts[13].data, 'removing separator after item 6'); + expect(itemsSeparatorsTexts[14].data, 'removing item'); + + await tester.pumpAndSettle(); + + // removeItem - Remove from empty list + expect( + () => listKey.currentState!.removeItem( + 0, + itemRemovalBuilderWrapper(index: 0), + duration: const Duration(milliseconds: 100), + ), + throwsAssertionError, + ); + + // removeItem - Remove item from list with single item + // Prepare + listKey.currentState!.insertItem(0); + + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 1); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + + // Test + listKey.currentState!.removeItem( + 0, + itemRemovalBuilderWrapper(index: 0), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 1); + expect(itemsSeparatorsTexts[0].data, 'removing item 0'); + + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 0); + + // removeAllItems - Remove all items from empty list + listKey.currentState!.removeAllItems( + itemRemovalBuilderWrapper(), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 0); + + await tester.pumpAndSettle(); + + // removeAllItems - Remove all items from list with single item + // Prepare + listKey.currentState!.insertItem(0); + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 1); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + + // Test + listKey.currentState!.removeAllItems( + itemRemovalBuilderWrapper(), + duration: const Duration(milliseconds: 100), + ); + + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 1); + expect(itemsSeparatorsTexts[0].data, 'removing item'); + + await tester.pumpAndSettle(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 0); + + // insertAllItems - Insert into empty list + listKey.currentState!.insertAllItems(0, 2); + await tester.pump(); + + itemsSeparatorsTexts = getItemsSeparatorsTexts(tester); + + expect(itemsSeparatorsTexts.length, 3); + expect(itemsSeparatorsTexts[0].data, 'item 0'); + expect(itemsSeparatorsTexts[1].data, 'separator after item 0'); + expect(itemsSeparatorsTexts[2].data, 'item 1'); + }); }