Skip to content

Commit 39472d9

Browse files
authored
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: flutter/flutter#48226
1 parent cd8e84f commit 39472d9

File tree

5 files changed

+1038
-25
lines changed

5 files changed

+1038
-25
lines changed

examples/api/lib/widgets/animated_list/animated_list.0.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ class _AnimatedListSampleState extends State<AnimatedListSample> {
6666
// Insert the "next item" into the list model.
6767
void _insert() {
6868
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
69-
_list.insert(index, _nextItem++);
69+
_list.insert(index, _nextItem);
70+
_nextItem++;
7071
}
7172

7273
// Remove the selected item from the list model.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [AnimatedList.separated].
8+
9+
void main() {
10+
runApp(const AnimatedListSeparatedSample());
11+
}
12+
13+
class AnimatedListSeparatedSample extends StatefulWidget {
14+
const AnimatedListSeparatedSample({super.key});
15+
16+
@override
17+
State<AnimatedListSeparatedSample> createState() => _AnimatedListSeparatedSampleState();
18+
}
19+
20+
class _AnimatedListSeparatedSampleState extends State<AnimatedListSeparatedSample> {
21+
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
22+
late ListModel<int> _list;
23+
int? _selectedItem;
24+
late int _nextItem; // The next item inserted when the user presses the '+' button.
25+
26+
@override
27+
void initState() {
28+
super.initState();
29+
_list = ListModel<int>(
30+
listKey: _listKey,
31+
initialItems: <int>[0, 1, 2],
32+
removedItemBuilder: _buildRemovedItem,
33+
);
34+
_nextItem = 3;
35+
}
36+
37+
// Used to build list items that haven't been removed.
38+
Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
39+
return CardItem(
40+
animation: animation,
41+
item: _list[index],
42+
selected: _selectedItem == _list[index],
43+
onTap: () {
44+
setState(() {
45+
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
46+
});
47+
},
48+
);
49+
}
50+
51+
// Used to build separators for items that haven't been removed.
52+
Widget _buildSeparator(BuildContext context, int index, Animation<double> animation) {
53+
return ItemSeparator(
54+
animation: animation,
55+
item: _list[index],
56+
);
57+
}
58+
59+
/// The builder function used to build items that have been removed.
60+
///
61+
/// Used to build an item after it has been removed from the list. This method
62+
/// is needed because a removed item remains visible until its animation has
63+
/// completed (even though it's gone as far as this ListModel is concerned).
64+
/// The widget will be used by the [AnimatedListState.removeItem] method's
65+
/// `itemBuilder` parameter.
66+
Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
67+
return CardItem(
68+
animation: animation,
69+
item: item,
70+
// No gesture detector here: we don't want removed items to be interactive.
71+
);
72+
}
73+
74+
/// The builder function used to build a separator for an item that has been removed.
75+
///
76+
/// Used to build a separator after the corresponding item has been removed from the list.
77+
/// This method is needed because the separator of a removed item remains visible until its animation has completed.
78+
/// The widget will be passed to [AnimatedList.separated]
79+
/// via the [AnimatedList.removedSeparatorBuilder] parameter and used
80+
/// in the [AnimatedListState.removeItem] method.
81+
///
82+
/// The item parameter is null, because the corresponding item will
83+
/// have been removed from the list model by the time this builder is called.
84+
Widget _buildRemovedSeparator(BuildContext context, int index, Animation<double> animation) {
85+
return SizeTransition(
86+
sizeFactor: animation,
87+
child: ItemSeparator(
88+
animation: animation,
89+
item: null,
90+
)
91+
);
92+
}
93+
94+
// Insert the "next item" into the list model.
95+
void _insert() {
96+
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
97+
_list.insert(index, _nextItem);
98+
_nextItem++;
99+
}
100+
101+
// Remove the selected item from the list model.
102+
void _remove() {
103+
if (_selectedItem != null) {
104+
_list.removeAt(_list.indexOf(_selectedItem!));
105+
setState(() {
106+
_selectedItem = null;
107+
});
108+
}
109+
}
110+
111+
@override
112+
Widget build(BuildContext context) {
113+
return MaterialApp(
114+
home: Scaffold(
115+
appBar: AppBar(
116+
title: const Text('AnimatedList.separated'),
117+
actions: <Widget>[
118+
IconButton(
119+
icon: const Icon(Icons.add_circle),
120+
onPressed: _insert,
121+
tooltip: 'insert a new item',
122+
),
123+
IconButton(
124+
icon: const Icon(Icons.remove_circle),
125+
onPressed: _remove,
126+
tooltip: 'remove the selected item',
127+
),
128+
],
129+
),
130+
body: Padding(
131+
padding: const EdgeInsets.all(16.0),
132+
child: AnimatedList.separated(
133+
key: _listKey,
134+
initialItemCount: _list.length,
135+
itemBuilder: _buildItem,
136+
separatorBuilder: _buildSeparator,
137+
removedSeparatorBuilder: _buildRemovedSeparator,
138+
),
139+
),
140+
),
141+
);
142+
}
143+
}
144+
145+
typedef RemovedItemBuilder<T> = Widget Function(T item, BuildContext context, Animation<double> animation);
146+
147+
/// Keeps a Dart [List] in sync with an [AnimatedList.separated].
148+
///
149+
/// The [insert] and [removeAt] methods apply to both the internal list and
150+
/// the animated list that belongs to [listKey].
151+
///
152+
/// This class only exposes as much of the Dart List API as is needed by the
153+
/// sample app. More list methods are easily added, however methods that
154+
/// mutate the list must make the same changes to the animated list in terms
155+
/// of [AnimatedListState.insertItem] and [AnimatedListState.removeItem].
156+
class ListModel<E> {
157+
ListModel({
158+
required this.listKey,
159+
required this.removedItemBuilder,
160+
Iterable<E>? initialItems,
161+
}) : _items = List<E>.from(initialItems ?? <E>[]);
162+
163+
final GlobalKey<AnimatedListState> listKey;
164+
final RemovedItemBuilder<E> removedItemBuilder;
165+
final List<E> _items;
166+
167+
AnimatedListState? get _animatedList => listKey.currentState;
168+
169+
void insert(int index, E item) {
170+
_items.insert(index, item);
171+
_animatedList!.insertItem(index);
172+
}
173+
174+
E removeAt(int index) {
175+
final E removedItem = _items.removeAt(index);
176+
if (removedItem != null) {
177+
_animatedList!.removeItem(
178+
index,
179+
(BuildContext context, Animation<double> animation) {
180+
return removedItemBuilder(removedItem, context, animation);
181+
},
182+
);
183+
}
184+
return removedItem;
185+
}
186+
187+
int get length => _items.length;
188+
189+
E operator [](int index) => _items[index];
190+
191+
int indexOf(E item) => _items.indexOf(item);
192+
}
193+
194+
/// Displays its integer item as 'item N' on a Card whose color is based on
195+
/// the item's value.
196+
///
197+
/// The text is displayed in bright green if [selected] is
198+
/// true. This widget's height is based on the [animation] parameter, it
199+
/// varies from 0 to 80 as the animation varies from 0.0 to 1.0.
200+
class CardItem extends StatelessWidget {
201+
const CardItem({
202+
super.key,
203+
this.onTap,
204+
this.selected = false,
205+
required this.animation,
206+
required this.item,
207+
}) : assert(item >= 0);
208+
209+
final Animation<double> animation;
210+
final VoidCallback? onTap;
211+
final int item;
212+
final bool selected;
213+
214+
@override
215+
Widget build(BuildContext context) {
216+
TextStyle textStyle = Theme.of(context).textTheme.headlineMedium!;
217+
if (selected) {
218+
textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
219+
}
220+
return Padding(
221+
padding: const EdgeInsets.all(2.0),
222+
child: SizeTransition(
223+
sizeFactor: animation,
224+
child: GestureDetector(
225+
behavior: HitTestBehavior.opaque,
226+
onTap: onTap,
227+
child: SizedBox(
228+
height: 80.0,
229+
child: Card(
230+
color: Colors.primaries[item % Colors.primaries.length],
231+
child: Center(
232+
child: Text('Item $item', style: textStyle),
233+
),
234+
),
235+
),
236+
),
237+
),
238+
);
239+
}
240+
}
241+
242+
/// Displays its integer item as 'separator N' on a Card whose color is based on
243+
/// the corresponding item's value.
244+
///
245+
/// When the item parameter is null, the separator is displayed as 'Removing separator' with a default color.
246+
///
247+
/// This widget's height is based on the [animation] parameter, it
248+
/// varies from 0 to 40 as the animation varies from 0.0 to 1.0.
249+
class ItemSeparator extends StatelessWidget {
250+
const ItemSeparator({
251+
super.key,
252+
required this.animation,
253+
required this.item,
254+
}) : assert(item == null || item >= 0);
255+
256+
final Animation<double> animation;
257+
final int? item;
258+
259+
@override
260+
Widget build(BuildContext context) {
261+
final TextStyle textStyle = Theme.of(context).textTheme.headlineSmall!;
262+
return Padding(
263+
padding: const EdgeInsets.all(2.0),
264+
child: SizeTransition(
265+
sizeFactor: animation,
266+
child: SizedBox(
267+
height: 40.0,
268+
child: Card(
269+
color: item == null ? Colors.grey : Colors.primaries[item! % Colors.primaries.length],
270+
child: Center(
271+
child: Text(item == null ? 'Removing separator' : 'Separator $item', style: textStyle),
272+
),
273+
),
274+
),
275+
),
276+
);
277+
}
278+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/animated_list/animated_list_separated.0.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets(
11+
'Items can be selected, added, and removed from AnimatedList.separated',
12+
(WidgetTester tester) async {
13+
await tester.pumpWidget(const example.AnimatedListSeparatedSample());
14+
15+
expect(find.text('Item 0'), findsOneWidget);
16+
expect(find.text('Separator 0'), findsOneWidget);
17+
expect(find.text('Item 1'), findsOneWidget);
18+
expect(find.text('Separator 1'), findsOneWidget);
19+
expect(find.text('Item 2'), findsOneWidget);
20+
21+
// Add an item at the end of the list
22+
await tester.tap(find.byIcon(Icons.add_circle));
23+
await tester.pumpAndSettle();
24+
expect(find.text('Separator 2'), findsOneWidget);
25+
expect(find.text('Item 3'), findsOneWidget);
26+
27+
// Select Item 1.
28+
await tester.tap(find.text('Item 1'));
29+
await tester.pumpAndSettle();
30+
31+
// Add item at the top of the list
32+
await tester.tap(find.byIcon(Icons.add_circle));
33+
await tester.pumpAndSettle();
34+
expect(find.text('Item 4'), findsOneWidget);
35+
// Contrary to the behavior for insertion at other places,
36+
// the Separator for the last item of the list will be added
37+
// before that item instead of after it.
38+
expect(find.text('Separator 4'), findsOneWidget);
39+
40+
// Remove selected item.
41+
await tester.tap(find.byIcon(Icons.remove_circle));
42+
43+
// Item animation is not completed.
44+
await tester.pump();
45+
expect(find.text('Item 1'), findsOneWidget);
46+
expect(find.text('Separator 1'), findsNothing);
47+
expect(find.text('Removing separator'), findsOneWidget);
48+
49+
// When the animation completes, Item 1 disappears.
50+
await tester.pumpAndSettle();
51+
expect(find.text('Item 1'), findsNothing);
52+
expect(find.text('Separator 1'), findsNothing);
53+
expect(find.text('Removing separator'), findsNothing);
54+
},
55+
);
56+
}

0 commit comments

Comments
 (0)