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