Unverified Commit 7523ab5c authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add `SliverAnimatedGrid` and `AnimatedGrid` (#112982)

parent 78c23305
// 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.
/// Flutter code sample for [AnimatedGrid].
import 'package:flutter/material.dart';
void main() {
runApp(const AnimatedGridSample());
}
class AnimatedGridSample extends StatefulWidget {
const AnimatedGridSample({super.key});
@override
State<AnimatedGridSample> createState() => _AnimatedGridSampleState();
}
class _AnimatedGridSampleState extends State<AnimatedGridSample> {
final GlobalKey<AnimatedGridState> _gridKey = GlobalKey<AnimatedGridState>();
late ListModel<int> _list;
int? _selectedItem;
late int _nextItem; // The next item inserted when the user presses the '+' button.
@override
void initState() {
super.initState();
_list = ListModel<int>(
listKey: _gridKey,
initialItems: <int>[0, 1, 2, 3, 4, 5],
removedItemBuilder: _buildRemovedItem,
);
_nextItem = 6;
}
// Used to build list items that haven't been removed.
Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
return CardItem(
animation: animation,
item: _list[index],
selected: _selectedItem == _list[index],
onTap: () {
setState(() {
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
});
},
);
}
// 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 [AnimatedGridState.removeItem] method's
// [AnimatedGridRemovedItemBuilder] parameter.
Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
return CardItem(
animation: animation,
item: item,
removing: true,
// No gesture detector here: we don't want removed items to be interactive.
);
}
// Insert the "next item" into the list model.
void _insert() {
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
setState(() {
_list.insert(index, _nextItem++);
});
}
// Remove the selected item from the list model.
void _remove() {
if (_selectedItem != null) {
setState(() {
_list.removeAt(_list.indexOf(_selectedItem!));
_selectedItem = null;
});
} else if (_list.length > 0) {
setState(() {
_list.removeAt(_list.length - 1);
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text(
'AnimatedGrid',
style: TextStyle(fontSize: 30),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.remove_circle),
iconSize: 32,
onPressed: (_list.length > 0) ? _remove : null,
tooltip: 'remove the selected item',
),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.add_circle),
iconSize: 32,
onPressed: _insert,
tooltip: 'insert a new item',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: AnimatedGrid(
key: _gridKey,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
initialItemCount: _list.length,
itemBuilder: _buildItem,
),
),
),
);
}
}
typedef RemovedItemBuilder<T> = Widget Function(T item, BuildContext context, Animation<double> animation);
/// Keeps a Dart [List] in sync with an [AnimatedGrid].
///
/// 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 [AnimatedGridState.insertItem] and [AnimatedGrid.removeItem].
class ListModel<E> {
ListModel({
required this.listKey,
required this.removedItemBuilder,
Iterable<E>? initialItems,
}) : _items = List<E>.from(initialItems ?? <E>[]);
final GlobalKey<AnimatedGridState> listKey;
final RemovedItemBuilder<E> removedItemBuilder;
final List<E> _items;
AnimatedGridState? get _animatedGrid => listKey.currentState;
void insert(int index, E item) {
_items.insert(index, item);
_animatedGrid!.insertItem(
index,
duration: const Duration(milliseconds: 500),
);
}
E removeAt(int index) {
final E removedItem = _items.removeAt(index);
if (removedItem != null) {
_animatedGrid!.removeItem(
index,
(BuildContext context, Animation<double> 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 128 as the animation varies from 0.0 to 1.0.
class CardItem extends StatelessWidget {
const CardItem({
super.key,
this.onTap,
this.selected = false,
this.removing = false,
required this.animation,
required this.item,
}) : assert(item >= 0);
final Animation<double> animation;
final VoidCallback? onTap;
final int item;
final bool selected;
final bool removing;
@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: ScaleTransition(
scale: CurvedAnimation(parent: animation, curve: removing ? Curves.easeInOut : Curves.bounceOut),
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 + 1}', style: textStyle),
),
),
),
),
),
);
}
}
// 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.
/// Flutter code sample for [SliverAnimatedGrid].
import 'package:flutter/material.dart';
void main() => runApp(const SliverAnimatedGridSample());
class SliverAnimatedGridSample extends StatefulWidget {
const SliverAnimatedGridSample({super.key});
@override
State<SliverAnimatedGridSample> createState() => _SliverAnimatedGridSampleState();
}
class _SliverAnimatedGridSampleState extends State<SliverAnimatedGridSample> {
final GlobalKey<SliverAnimatedGridState> _listKey = GlobalKey<SliverAnimatedGridState>();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late ListModel<int> _list;
int? _selectedItem;
late int _nextItem; // The next item inserted when the user presses the '+' button.
@override
void initState() {
super.initState();
_list = ListModel<int>(
listKey: _listKey,
initialItems: <int>[0, 1, 2, 3, 4, 5],
removedItemBuilder: _buildRemovedItem,
);
_nextItem = 6;
}
// Used to build list items that haven't been removed.
Widget _buildItem(BuildContext context, int index, Animation<double> animation) {
return CardItem(
animation: animation,
item: _list[index],
selected: _selectedItem == _list[index],
onTap: () {
setState(() {
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
});
},
);
}
// 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 this ListModel is
// concerned). The widget will be used by the
// [AnimatedGridState.removeItem] method's
// [AnimatedGridRemovedItemBuilder] parameter.
Widget _buildRemovedItem(int item, BuildContext context, Animation<double> animation) {
return CardItem(
animation: animation,
removing: true,
item: item,
);
}
// Insert the "next item" into the list model.
void _insert() {
final int index = _selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
_list.insert(index, _nextItem++);
}
// Remove the selected item from the list model.
void _remove() {
if (_selectedItem != null) {
_list.removeAt(_list.indexOf(_selectedItem!));
} else {
_list.removeAt(_list.length - 1);
}
setState(() {
_selectedItem = null;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
scaffoldMessengerKey: _scaffoldMessengerKey,
debugShowCheckedModeBanner: false,
home: Scaffold(
key: _scaffoldKey,
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
title: const Text(
'SliverAnimatedGrid',
style: TextStyle(fontSize: 30),
),
expandedHeight: 60,
centerTitle: true,
backgroundColor: Colors.amber[900],
leading: IconButton(
icon: const Icon(Icons.remove_circle),
onPressed: _remove,
tooltip: 'Remove the selected item, or the last item if none selected.',
iconSize: 32,
),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: _insert,
tooltip: 'Insert a new item.',
iconSize: 32,
),
],
),
SliverAnimatedGrid(
key: _listKey,
initialItemCount: _list.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
itemBuilder: _buildItem,
),
],
),
),
);
}
}
typedef RemovedItemBuilder = Widget Function(int item, BuildContext context, Animation<double> animation);
// Keeps a Dart [List] in sync with an [AnimatedGrid].
//
// 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 [AnimatedGridState.insertItem] and [AnimatedGrid.removeItem].
class ListModel<E> {
ListModel({
required this.listKey,
required this.removedItemBuilder,
Iterable<E>? initialItems,
}) : _items = List<E>.from(initialItems ?? <E>[]);
final GlobalKey<SliverAnimatedGridState> listKey;
final RemovedItemBuilder removedItemBuilder;
final List<E> _items;
SliverAnimatedGridState get _animatedGrid => listKey.currentState!;
void insert(int index, E item) {
_items.insert(index, item);
_animatedGrid.insertItem(index);
}
E removeAt(int index) {
final E removedItem = _items.removeAt(index);
if (removedItem != null) {
_animatedGrid.removeItem(
index,
(BuildContext context, Animation<double> animation) => removedItemBuilder(index, 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 card turns gray when [selected] is true. This widget's height
// is based on the [animation] parameter. It varies as the animation value
// transitions from 0.0 to 1.0.
class CardItem extends StatelessWidget {
const CardItem({
super.key,
this.onTap,
this.selected = false,
this.removing = false,
required this.animation,
required this.item,
}) : assert(item >= 0);
final Animation<double> animation;
final VoidCallback? onTap;
final int item;
final bool selected;
final bool removing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
left: 2.0,
right: 2.0,
top: 2.0,
),
child: ScaleTransition(
scale: CurvedAnimation(parent: animation, curve: removing ? Curves.easeInOut : Curves.bounceOut),
child: GestureDetector(
onTap: onTap,
child: SizedBox(
height: 80.0,
child: Card(
color: selected ? Colors.black12 : Colors.primaries[item % Colors.primaries.length],
child: Center(
child: Text(
(item + 1).toString(),
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
),
),
),
);
}
}
// 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_grid/animated_grid.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('AnimatedGrid example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.AnimatedGridSample(),
);
expect(find.text('1'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.text('6'), findsOneWidget);
expect(find.text('7'), findsNothing);
await tester.tap(find.byIcon(Icons.add_circle));
await tester.pumpAndSettle();
expect(find.text('7'), findsOneWidget);
await tester.tap(find.byIcon(Icons.remove_circle));
await tester.pumpAndSettle();
expect(find.text('7'), findsNothing);
await tester.tap(find.text('2'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.remove_circle));
await tester.pumpAndSettle();
expect(find.text('2'), findsNothing);
expect(find.text('6'), findsOneWidget);
});
}
// 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_grid/sliver_animated_grid.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('SliverAnimatedGrid example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.SliverAnimatedGridSample(),
);
expect(find.text('1'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.text('6'), findsOneWidget);
expect(find.text('7'), findsNothing);
await tester.tap(find.byIcon(Icons.add_circle));
await tester.pumpAndSettle();
expect(find.text('7'), findsOneWidget);
await tester.tap(find.byIcon(Icons.remove_circle));
await tester.pumpAndSettle();
expect(find.text('7'), findsNothing);
await tester.tap(find.text('2'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.remove_circle));
await tester.pumpAndSettle();
expect(find.text('2'), findsNothing);
expect(find.text('6'), findsOneWidget);
});
}
......@@ -1609,9 +1609,8 @@ class ThemeData with Diagnosticable {
/// Obsolete property that was originally used as the foreground
/// color for widgets (knobs, text, overscroll edge effect, etc).
///
/// The material library no longer uses this property. In most cases
/// the theme's [colorScheme] [ColorScheme.secondary] property is now
/// used instead.
/// The material library no longer uses this property. In most cases the
/// [colorScheme]'s [ColorScheme.secondary] property is now used instead.
///
/// Apps should migrate uses of this property to the theme's [colorScheme]
/// [ColorScheme.secondary] color. In cases where a color is needed that
......
......@@ -461,7 +461,10 @@ class SliverGridDelegateWithMaxCrossAxisExtent extends SliverGridDelegate {
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
assert(_debugAssertIsValid(constraints.crossAxisExtent));
final int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
// TODO(gspencergoog): Figure out why we need this in release mode (and only
// in release mode). https://github.com/flutter/flutter/issues/113109
crossAxisCount = crossAxisCount < 1 ? 1 : crossAxisCount;
final double usableCrossAxisExtent = math.max(
0.0,
constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1),
......@@ -584,8 +587,6 @@ class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
}
final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
if (firstChild == null) {
if (!addInitialChild(index: firstIndex, layoutOffset: firstChildGridGeometry.scrollOffset)) {
......@@ -600,6 +601,8 @@ class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
}
}
final double leadingScrollOffset = firstChildGridGeometry.scrollOffset;
double trailingScrollOffset = firstChildGridGeometry.trailingScrollOffset;
RenderBox? trailingChildWithLayout;
for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
......
// 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/foundation.dart';
import 'basic.dart';
import 'framework.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scroll_view.dart';
import 'sliver.dart';
import 'ticker_provider.dart';
/// Signature for the builder callback used by widgets like [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
/// transition for the widget that is built.
///
/// - [AnimatedRemovedItemBuilder], a builder that is for removing items with
/// animations instead of adding them.
typedef AnimatedItemBuilder = Widget Function(BuildContext context, int index, Animation<double> animation);
/// Signature for the builder callback used by widgets like [AnimatedGrid] (in
/// [AnimatedGridState.removeItem]) to animated 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
/// animate an exit transition for the widget that is built.
///
/// See also:
///
/// - [AnimatedItemBuilder], a builder that is for adding items with animations
/// instead of removing them.
typedef AnimatedRemovedItemBuilder = Widget Function(BuildContext context, Animation<double> animation);
// The default insert/remove animation duration.
const Duration _kDuration = Duration(milliseconds: 300);
// Incoming and outgoing AnimatedGrid items.
class _ActiveItem implements Comparable<_ActiveItem> {
_ActiveItem.incoming(this.controller, this.itemIndex) : removedItemBuilder = null;
_ActiveItem.outgoing(this.controller, this.itemIndex, this.removedItemBuilder);
_ActiveItem.index(this.itemIndex)
: controller = null,
removedItemBuilder = null;
final AnimationController? controller;
final AnimatedRemovedItemBuilder? removedItemBuilder;
int itemIndex;
@override
int compareTo(_ActiveItem other) => itemIndex - other.itemIndex;
}
/// A scrolling container that animates items when they are inserted or removed
/// in a grid.
///
/// This widget's [AnimatedGridState] can be used to dynamically insert or
/// remove items. To refer to the [AnimatedGridState] either provide a
/// [GlobalKey] or use the static [of] method from an item's input callback.
///
/// This widget is similar to one created by [GridView.builder].
///
/// {@tool dartpad}
/// This sample application uses an [AnimatedGrid] to create an effect when
/// items are removed or added to the grid.
///
/// ** See code in examples/api/lib/widgets/animated_grid/animated_grid.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [SliverAnimatedGrid], a sliver which animates items when they are inserted
/// into or removed from a grid.
/// * [SliverAnimatedList], a sliver which animates items added and removed from
/// a list instead of a grid.
/// * [AnimatedList], which animates items added and removed from a list instead
/// of a grid.
class AnimatedGrid extends StatefulWidget {
/// Creates a scrolling container that animates items when they are inserted
/// or removed.
const AnimatedGrid({
super.key,
required this.itemBuilder,
required this.gridDelegate,
this.initialItemCount = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.primary,
this.physics,
this.padding,
this.clipBehavior = Clip.hardEdge,
}) : assert(itemBuilder != null),
assert(initialItemCount != null && initialItemCount >= 0);
/// Called, as needed, to build grid item widgets.
///
/// Grid items are only built when they're scrolled into view.
///
/// The [AnimatedItemBuilder] index parameter indicates the item's position in
/// the grid. The value of the index parameter will be between 0 and
/// [initialItemCount] plus the total number of items that have been inserted
/// with [AnimatedGridState.insertItem] and less the total number of items
/// that have been removed with [AnimatedGridState.removeItem].
///
/// Implementations of this callback should assume that
/// [AnimatedGridState.removeItem] removes an item immediately.
final AnimatedItemBuilder itemBuilder;
/// A delegate that controls the layout of the children within the
/// [AnimatedGrid].
///
/// See also:
///
/// * [SliverGridDelegateWithFixedCrossAxisCount], which creates a layout with
/// a fixed number of tiles in the cross axis.
/// * [SliverGridDelegateWithMaxCrossAxisExtent], which creates a layout with
/// tiles that have a maximum cross-axis extent.
final SliverGridDelegate gridDelegate;
/// {@template flutter.widgets.AnimatedGrid.initialItemCount}
/// The number of items the grid will start with.
///
/// The appearance of the initial items is not animated. They
/// are created, as needed, by [itemBuilder] with an animation parameter
/// of [kAlwaysCompleteAnimation].
/// {@endtemplate}
final int initialItemCount;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if the reading direction is left-to-right and
/// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from
/// left to right when [reverse] is false and from right to left when
/// [reverse] is true.
///
/// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
final bool reverse;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
final ScrollController? controller;
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// On iOS, this identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
///
/// Defaults to true when [scrollDirection] is [Axis.vertical] and
/// [controller] is null.
final bool? primary;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final ScrollPhysics? physics;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [AnimatedGrid] item widgets that insert
/// or remove items in response to user input.
///
/// If no [AnimatedGrid] surrounds the context given, then this function will
/// assert in debug mode and throw an exception in release mode.
///
/// This method can be expensive (it walks the element tree).
///
/// See also:
///
/// * [maybeOf], a similar function that will return null if no
/// [AnimatedGrid] ancestor is found.
static AnimatedGridState of(BuildContext context) {
assert(context != null);
final AnimatedGridState? result = context.findAncestorStateOfType<AnimatedGridState>();
assert(() {
if (result == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('AnimatedGrid.of() called with a context that does not contain an AnimatedGrid.'),
ErrorDescription(
'No AnimatedGrid ancestor could be found starting from the context that was passed to AnimatedGrid.of().',
),
ErrorHint(
'This can happen when the context provided is from the same StatefulWidget that '
'built the AnimatedGrid. Please see the AnimatedGrid documentation for examples '
'of how to refer to an AnimatedGridState object:\n'
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html',
),
context.describeElement('The context used was'),
]);
}
return true;
}());
return result!;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [AnimatedGrid] item widgets that insert
/// or remove items in response to user input.
///
/// If no [AnimatedGrid] surrounds the context given, then this function will
/// return null.
///
/// This method can be expensive (it walks the element tree).
///
/// See also:
///
/// * [of], a similar function that will throw if no [AnimatedGrid] ancestor
/// is found.
static AnimatedGridState? maybeOf(BuildContext context) {
assert(context != null);
return context.findAncestorStateOfType<AnimatedGridState>();
}
@override
AnimatedGridState createState() => AnimatedGridState();
}
/// The state for a scrolling 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 [AnimatedGrid.itemBuilder] whenever the item's widget
/// is needed.
///
/// When an item is removed with [removeItem] its animation is reversed.
/// The removed item's animation is passed to the [removeItem] builder
/// parameter.
///
/// An app that needs to insert or remove items in response to an event
/// can refer to the [AnimatedGrid]'s state with a global key:
///
/// ```dart
/// // (e.g. in a stateful widget)
/// GlobalKey<AnimatedGridState> gridKey = GlobalKey<AnimatedGridState>();
///
/// // ...
///
/// @override
/// Widget build(BuildContext context) {
/// return AnimatedGrid(
/// key: gridKey,
/// itemBuilder: (BuildContext context, int index, Animation<double> animation) {
/// return const Placeholder();
/// },
/// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 100.0),
/// );
/// }
///
/// // ...
///
/// void _updateGrid() {
/// // adds "123" to the AnimatedGrid
/// gridKey.currentState!.insertItem(123);
/// }
/// ```
///
/// [AnimatedGrid] item input handlers can also refer to their [AnimatedGridState]
/// with the static [AnimatedGrid.of] method.
class AnimatedGridState extends State<AnimatedGrid> with TickerProviderStateMixin<AnimatedGrid> {
final GlobalKey<SliverAnimatedGridState> _sliverAnimatedGridKey = GlobalKey();
/// Insert an item at [index] and start an animation that will be passed
/// to [AnimatedGrid.itemBuilder] when the item is visible.
///
/// This method's semantics are the same as Dart's [List.insert] method: it
/// increases the length of the list of items in the grid by one and shifts
/// all items at or after [index] towards the end of the list of items in the
/// grid.
void insertItem(int index, {Duration duration = _kDuration}) {
_sliverAnimatedGridKey.currentState!.insertItem(index, duration: duration);
}
/// Remove the item at `index` and start an animation that will be passed to
/// `builder` when the item is visible.
///
/// Items are removed immediately. After an item has been removed, its index
/// will no longer be passed to the [AnimatedGrid.itemBuilder]. However, the
/// item will still appear in the grid for `duration` and during that time
/// `builder` must construct its widget as needed.
///
/// This method's semantics are the same as Dart's [List.remove] method: it
/// decreases the length of the list of items in the grid by one and shifts
/// all items at or before `index` towards the beginning of the list of items
/// in the grid.
///
/// See also:
///
/// - [AnimatedRemovedItemBuilder], which describes the arguments to the
/// `builder` argument.
void removeItem(int index, AnimatedRemovedItemBuilder builder, {Duration duration = _kDuration}) {
_sliverAnimatedGridKey.currentState!.removeItem(index, builder, duration: duration);
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: widget.controller,
primary: widget.primary,
physics: widget.physics,
clipBehavior: widget.clipBehavior,
slivers: <Widget>[
SliverPadding(
padding: widget.padding ?? EdgeInsets.zero,
sliver: SliverAnimatedGrid(
key: _sliverAnimatedGridKey,
gridDelegate: widget.gridDelegate,
itemBuilder: widget.itemBuilder,
initialItemCount: widget.initialItemCount,
),
),
],
);
}
}
/// A sliver that animates items when they are inserted or removed in a grid.
///
/// This widget's [SliverAnimatedGridState] can be used to dynamically insert or
/// remove items. To refer to the [SliverAnimatedGridState] either provide a
/// [GlobalKey] or use the static [SliverAnimatedGrid.of] method from an item's
/// input callback.
///
/// {@tool dartpad}
/// This sample application uses a [SliverAnimatedGrid] to create an animated
/// effect when items are removed or added to the grid.
///
/// ** See code in examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [AnimatedGrid], a non-sliver scrolling container that animates items when
/// they are inserted into or removed from a grid.
/// * [SliverGrid], which does not animate items when they are inserted or
/// removed from a grid.
/// * [SliverList], which displays a non-animated list of items.
/// * [SliverAnimatedList], which animates items added and removed from a list
/// instead of a grid.
class SliverAnimatedGrid extends StatefulWidget {
/// Creates a sliver that animates items when they are inserted or removed.
const SliverAnimatedGrid({
super.key,
required this.itemBuilder,
required this.gridDelegate,
this.findChildIndexCallback,
this.initialItemCount = 0,
}) : assert(itemBuilder != null),
assert(initialItemCount != null && initialItemCount >= 0);
/// Called, as needed, to build grid item widgets.
///
/// Grid items are only built when they're scrolled into view.
///
/// The [AnimatedItemBuilder] index parameter indicates the item's position in
/// the grid. The value of the index parameter will be between 0 and
/// [initialItemCount] plus the total number of items that have been inserted
/// with [SliverAnimatedGridState.insertItem] and less the total number of
/// items that have been removed with [SliverAnimatedGridState.removeItem].
///
/// Implementations of this callback should assume that
/// [SliverAnimatedGridState.removeItem] removes an item immediately.
final AnimatedItemBuilder itemBuilder;
/// A delegate that controls the layout of the children within the
/// [SliverAnimatedGrid].
///
/// See also:
///
/// * [SliverGridDelegateWithFixedCrossAxisCount], which creates a layout with
/// a fixed number of tiles in the cross axis.
/// * [SliverGridDelegateWithMaxCrossAxisExtent], which creates a layout with
/// tiles that have a maximum cross-axis extent.
final SliverGridDelegate gridDelegate;
/// {@macro flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback}
final ChildIndexGetter? findChildIndexCallback;
/// {@macro flutter.widgets.AnimatedGrid.initialItemCount}
final int initialItemCount;
@override
SliverAnimatedGridState createState() => SliverAnimatedGridState();
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [SliverAnimatedGrid] item widgets that
/// insert or remove items in response to user input.
///
/// If no [SliverAnimatedGrid] surrounds the context given, then this function
/// will assert in debug mode and throw an exception in release mode.
///
/// This method can be expensive (it walks the element tree).
///
/// See also:
///
/// * [maybeOf], a similar function that will return null if no
/// [SliverAnimatedGrid] ancestor is found.
static SliverAnimatedGridState of(BuildContext context) {
assert(context != null);
final SliverAnimatedGridState? result = context.findAncestorStateOfType<SliverAnimatedGridState>();
assert(() {
if (result == null) {
throw FlutterError(
'SliverAnimatedGrid.of() called with a context that does not contain a SliverAnimatedGrid.\n'
'No SliverAnimatedGridState ancestor could be found starting from the '
'context that was passed to SliverAnimatedGridState.of(). This can '
'happen when the context provided is from the same StatefulWidget that '
'built the AnimatedGrid. Please see the SliverAnimatedGrid documentation '
'for examples of how to refer to an AnimatedGridState object: '
'https://api.flutter.dev/flutter/widgets/SliverAnimatedGridState-class.html\n'
'The context used was:\n'
' $context',
);
}
return true;
}());
return result!;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [SliverAnimatedGrid] item widgets that
/// insert or remove items in response to user input.
///
/// If no [SliverAnimatedGrid] surrounds the context given, then this function
/// will return null.
///
/// This method can be expensive (it walks the element tree).
///
/// See also:
///
/// * [of], a similar function that will throw if no [SliverAnimatedGrid]
/// ancestor is found.
static SliverAnimatedGridState? maybeOf(BuildContext context) {
assert(context != null);
return context.findAncestorStateOfType<SliverAnimatedGridState>();
}
}
/// The state for a sliver that animates items when they are
/// inserted or removed.
///
/// When an item is inserted with [insertItem] an animation begins running. The
/// animation is passed to [SliverAnimatedGrid.itemBuilder] whenever the item's
/// widget is needed.
///
/// When an item is removed with [removeItem] its animation is reversed.
/// The removed item's animation is passed to the [removeItem] builder
/// parameter.
///
/// An app that needs to insert or remove items in response to an event
/// can refer to the [SliverAnimatedGrid]'s state with a global key:
///
/// ```dart
/// // (e.g. in a stateful widget)
/// GlobalKey<AnimatedGridState> gridKey = GlobalKey<AnimatedGridState>();
///
/// // ...
///
/// @override
/// Widget build(BuildContext context) {
/// return AnimatedGrid(
/// key: gridKey,
/// itemBuilder: (BuildContext context, int index, Animation<double> animation) {
/// return const Placeholder();
/// },
/// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 100.0),
/// );
/// }
///
/// // ...
///
/// void _updateGrid() {
/// // adds "123" to the AnimatedGrid
/// gridKey.currentState!.insertItem(123);
/// }
/// ```
///
/// [SliverAnimatedGrid] item input handlers can also refer to their
/// [SliverAnimatedGridState] with the static [SliverAnimatedGrid.of] method.
class SliverAnimatedGridState extends State<SliverAnimatedGrid> with TickerProviderStateMixin {
final List<_ActiveItem> _incomingItems = <_ActiveItem>[];
final List<_ActiveItem> _outgoingItems = <_ActiveItem>[];
int _itemsCount = 0;
@override
void initState() {
super.initState();
_itemsCount = widget.initialItemCount;
}
@override
void dispose() {
for (final _ActiveItem item in _incomingItems.followedBy(_outgoingItems)) {
item.controller!.dispose();
}
super.dispose();
}
_ActiveItem? _removeActiveItemAt(List<_ActiveItem> items, int itemIndex) {
final int i = binarySearch(items, _ActiveItem.index(itemIndex));
return i == -1 ? null : items.removeAt(i);
}
_ActiveItem? _activeItemAt(List<_ActiveItem> items, int itemIndex) {
final int i = binarySearch(items, _ActiveItem.index(itemIndex));
return i == -1 ? null : items[i];
}
// The insertItem() and removeItem() index parameters are defined as if the
// removeItem() operation removed the corresponding grid entry immediately.
// The entry is only actually removed from the grid when the remove animation
// finishes. The entry is added to _outgoingItems when removeItem is called
// and removed from _outgoingItems when the remove animation finishes.
int _indexToItemIndex(int index) {
int itemIndex = index;
for (final _ActiveItem item in _outgoingItems) {
if (item.itemIndex <= itemIndex) {
itemIndex += 1;
} else {
break;
}
}
return itemIndex;
}
int _itemIndexToIndex(int itemIndex) {
int index = itemIndex;
for (final _ActiveItem item in _outgoingItems) {
assert(item.itemIndex != itemIndex);
if (item.itemIndex < itemIndex) {
index -= 1;
} else {
break;
}
}
return index;
}
SliverChildDelegate _createDelegate() {
return SliverChildBuilderDelegate(
_itemBuilder,
childCount: _itemsCount,
findChildIndexCallback: widget.findChildIndexCallback == null
? null
: (Key key) {
final int? index = widget.findChildIndexCallback!(key);
return index != null ? _indexToItemIndex(index) : null;
},
);
}
/// Insert an item at [index] and start an animation that will be passed to
/// [SliverAnimatedGrid.itemBuilder] when the item is visible.
///
/// This method's semantics are the same as Dart's [List.insert] method: it
/// increases the length of the list of items in the grid by one and shifts
/// all items at or after [index] towards the end of the list of items in the
/// grid.
void insertItem(int index, {Duration duration = _kDuration}) {
assert(index != null && index >= 0);
assert(duration != null);
final int itemIndex = _indexToItemIndex(index);
assert(itemIndex >= 0 && itemIndex <= _itemsCount);
// Increment the incoming and outgoing item indices to account
// for the insertion.
for (final _ActiveItem item in _incomingItems) {
if (item.itemIndex >= itemIndex) {
item.itemIndex += 1;
}
}
for (final _ActiveItem item in _outgoingItems) {
if (item.itemIndex >= itemIndex) {
item.itemIndex += 1;
}
}
final AnimationController controller = AnimationController(
duration: duration,
vsync: this,
);
final _ActiveItem incomingItem = _ActiveItem.incoming(
controller,
itemIndex,
);
setState(() {
_incomingItems
..add(incomingItem)
..sort();
_itemsCount += 1;
});
controller.forward().then<void>((_) {
_removeActiveItemAt(_incomingItems, incomingItem.itemIndex)!.controller!.dispose();
});
}
/// Remove the item at [index] and start an animation that will be passed
/// to [builder] when the item is visible.
///
/// Items are removed immediately. After an item has been removed, its index
/// will no longer be passed to the [SliverAnimatedGrid.itemBuilder]. However
/// the item will still appear in the grid for [duration] and during that time
/// [builder] must construct its widget as needed.
///
/// This method's semantics are the same as Dart's [List.remove] method: it
/// decreases the length of the list of items in the grid by one and shifts
/// all items at or before [index] towards the beginning of the list of items
/// in the grid.
void removeItem(int index, AnimatedRemovedItemBuilder builder, {Duration duration = _kDuration}) {
assert(index != null && index >= 0);
assert(builder != null);
assert(duration != null);
final int itemIndex = _indexToItemIndex(index);
assert(itemIndex >= 0 && itemIndex < _itemsCount);
assert(_activeItemAt(_outgoingItems, itemIndex) == null);
final _ActiveItem? incomingItem = _removeActiveItemAt(_incomingItems, itemIndex);
final AnimationController controller =
incomingItem?.controller ?? AnimationController(duration: duration, value: 1.0, vsync: this);
final _ActiveItem outgoingItem = _ActiveItem.outgoing(controller, itemIndex, builder);
setState(() {
_outgoingItems
..add(outgoingItem)
..sort();
});
controller.reverse().then<void>((void value) {
_removeActiveItemAt(_outgoingItems, outgoingItem.itemIndex)!.controller!.dispose();
// Decrement the incoming and outgoing item indices to account
// for the removal.
for (final _ActiveItem item in _incomingItems) {
if (item.itemIndex > outgoingItem.itemIndex) {
item.itemIndex -= 1;
}
}
for (final _ActiveItem item in _outgoingItems) {
if (item.itemIndex > outgoingItem.itemIndex) {
item.itemIndex -= 1;
}
}
setState(() => _itemsCount -= 1);
});
}
Widget _itemBuilder(BuildContext context, int itemIndex) {
final _ActiveItem? outgoingItem = _activeItemAt(_outgoingItems, itemIndex);
if (outgoingItem != null) {
return outgoingItem.removedItemBuilder!(
context,
outgoingItem.controller!.view,
);
}
final _ActiveItem? incomingItem = _activeItemAt(_incomingItems, itemIndex);
final Animation<double> animation = incomingItem?.controller?.view ?? kAlwaysCompleteAnimation;
return widget.itemBuilder(
context,
_itemIndexToIndex(itemIndex),
animation,
);
}
@override
Widget build(BuildContext context) {
return SliverGrid(
gridDelegate: widget.gridDelegate,
delegate: _createDelegate(),
);
}
}
......@@ -58,6 +58,10 @@ class _ActiveItem implements Comparable<_ActiveItem> {
///
/// * [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.
class AnimatedList extends StatefulWidget {
/// Creates a scrolling container that animates items when they are inserted
/// or removed.
......@@ -349,6 +353,10 @@ class AnimatedListState extends State<AnimatedList> with TickerProviderStateMixi
/// removed.
/// * [AnimatedList], a non-sliver scrolling container that animates items when
/// they are inserted or removed.
/// * [SliverAnimatedGrid], a sliver which animates items when they are
/// inserted into or removed from a grid.
/// * [AnimatedGrid], a non-sliver scrolling container that animates items when
/// they are inserted into or removed from a grid.
class SliverAnimatedList extends StatefulWidget {
/// Creates a sliver that animates items when they are inserted or removed.
const SliverAnimatedList({
......
......@@ -1023,7 +1023,11 @@ abstract class SliverMultiBoxAdaptorWidget extends SliverWithKeepAliveWidget {
/// * [SliverPrototypeExtentList], which is similar to [SliverFixedExtentList]
/// except that it uses a prototype list item instead of a pixel value to define
/// the main axis extent of each item.
/// * [SliverGrid], which places its children in arbitrary positions.
/// * [SliverAnimatedList], which animates items added to or removed from a
/// list.
/// * [SliverGrid], which places multiple children in a two dimensional grid.
/// * [SliverAnimatedGrid], a sliver which animates items when they are
/// inserted into or removed from a grid.
class SliverList extends SliverMultiBoxAdaptorWidget {
/// Creates a sliver that places box children in a linear array.
const SliverList({
......
......@@ -19,6 +19,7 @@ export 'foundation.dart' show UniqueKey;
export 'rendering.dart' show TextSelectionHandleType;
export 'src/widgets/actions.dart';
export 'src/widgets/animated_cross_fade.dart';
export 'src/widgets/animated_grid.dart';
export 'src/widgets/animated_list.dart';
export 'src/widgets/animated_size.dart';
export 'src/widgets/animated_switcher.dart';
......
// 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/src/foundation/diagnostics.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// Regression test for https://github.com/flutter/flutter/issues/100451
testWidgets('SliverAnimatedGrid.builder respects findChildIndexCallback', (WidgetTester tester) async {
bool finderCalled = false;
int itemCount = 7;
late StateSetter stateSetter;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
stateSetter = setState;
return CustomScrollView(
slivers: <Widget>[
SliverAnimatedGrid(
initialItemCount: itemCount,
itemBuilder: (BuildContext context, int index, Animation<double> animation) => Container(
key: Key('$index'),
height: 2000.0,
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
findChildIndexCallback: (Key key) {
finderCalled = true;
return null;
},
),
],
);
},
),
));
expect(finderCalled, false);
// Trigger update.
stateSetter(() => itemCount = 77);
await tester.pump();
expect(finderCalled, true);
});
testWidgets('AnimatedGrid', (WidgetTester tester) async {
Widget builder(BuildContext context, int index, Animation<double> animation) {
return SizedBox(
height: 100.0,
child: Center(
child: Text('item $index'),
),
);
}
final GlobalKey<AnimatedGridState> listKey = GlobalKey<AnimatedGridState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: AnimatedGrid(
key: listKey,
initialItemCount: 2,
itemBuilder: builder,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
),
),
);
expect(find.byWidgetPredicate((Widget widget) {
return widget is SliverAnimatedGrid && widget.initialItemCount == 2 && widget.itemBuilder == builder;
}), findsOneWidget);
listKey.currentState!.insertItem(0);
await tester.pump();
expect(find.text('item 2'), findsOneWidget);
listKey.currentState!.removeItem(
2,
(BuildContext context, Animation<double> animation) {
return const SizedBox(
height: 100.0,
child: Center(child: Text('removing item')),
);
},
duration: const Duration(milliseconds: 100),
);
await tester.pump();
expect(find.text('removing item'), findsOneWidget);
expect(find.text('item 2'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('removing item'), findsNothing);
});
group('SliverAnimatedGrid', () {
testWidgets('initialItemCount', (WidgetTester tester) async {
final Map<int, Animation<double>> animations = <int, Animation<double>>{};
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverAnimatedGrid(
initialItemCount: 2,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
animations[index] = animation;
return SizedBox(
height: 100.0,
child: Center(
child: Text('item $index'),
),
);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
),
],
),
),
);
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item 1'), findsOneWidget);
expect(animations.containsKey(0), true);
expect(animations.containsKey(1), true);
expect(animations[0]!.value, 1.0);
expect(animations[1]!.value, 1.0);
});
testWidgets('insert', (WidgetTester tester) async {
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverAnimatedGrid(
key: listKey,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return ScaleTransition(
key: ValueKey<int>(index),
scale: animation,
child: SizedBox(
height: 100.0,
child: Center(child: Text('item $index')),
),
);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
),
),
],
),
),
);
double itemScale(int index) =>
tester.widget<ScaleTransition>(find.byKey(ValueKey<int>(index), skipOffstage: false)).scale.value;
double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
await tester.pump();
// Newly inserted item 0's scale should animate from 0 to 1
expect(itemScale(0), 0.0);
await tester.pump(const Duration(milliseconds: 50));
expect(itemScale(0), 0.5);
await tester.pump(const Duration(milliseconds: 50));
expect(itemScale(0), 1.0);
// The list now contains one fully expanded item at the top:
expect(find.text('item 0'), findsOneWidget);
expect(itemLeft(0), 0.0);
expect(itemRight(0), 100.0);
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
listKey.currentState!.insertItem(0, duration: const Duration(milliseconds: 100));
await tester.pump();
// The scale of the newly inserted items at index 0 and 1 should animate
// from 0 to 1.
// The scale of the original item, now at index 2, should remain 1.
expect(itemScale(0), 0.0);
expect(itemScale(1), 0.0);
expect(itemScale(2), 1.0);
await tester.pump(const Duration(milliseconds: 50));
expect(itemScale(0), 0.5);
expect(itemScale(1), 0.5);
expect(itemScale(2), 1.0);
await tester.pump(const Duration(milliseconds: 50));
expect(itemScale(0), 1.0);
expect(itemScale(1), 1.0);
expect(itemScale(2), 1.0);
// The newly inserted "item 1" and "item 2" appear above "item 0"
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item 1'), findsOneWidget);
expect(find.text('item 2'), findsOneWidget);
expect(itemLeft(0), 0.0);
expect(itemRight(0), 100.0);
expect(itemLeft(1), 100.0);
expect(itemRight(1), 200.0);
expect(itemLeft(2), 200.0);
expect(itemRight(2), 300.0);
});
testWidgets('remove', (WidgetTester tester) async {
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
final List<int> items = <int>[0, 1, 2];
Widget buildItem(BuildContext context, int item, Animation<double> animation) {
return ScaleTransition(
key: ValueKey<int>(item),
scale: animation,
child: SizedBox(
height: 100.0,
child: Center(
child: Text('item $item', textDirection: TextDirection.ltr),
),
),
);
}
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverAnimatedGrid(
key: listKey,
initialItemCount: 3,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return buildItem(context, items[index], animation);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
),
),
],
),
),
);
double itemScale(int index) =>
tester.widget<ScaleTransition>(find.byKey(ValueKey<int>(index), skipOffstage: false)).scale.value;
double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey<int>(index), skipOffstage: false)).dx;
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item 1'), findsOneWidget);
expect(find.text('item 2'), findsOneWidget);
items.removeAt(0);
listKey.currentState!.removeItem(
0,
(BuildContext context, Animation<double> animation) => buildItem(context, 0, animation),
duration: const Duration(milliseconds: 100),
);
// Items 0, 1, 2 at 0, 100, 200. All heights 100.
expect(itemLeft(0), 0.0);
expect(itemRight(0), 100.0);
expect(itemLeft(1), 100.0);
expect(itemRight(1), 200.0);
expect(itemLeft(2), 200.0);
expect(itemRight(2), 300.0);
// Newly removed item 0's height should animate from 100 to 0 over 100ms
// Items 0, 1, 2 at 0, 50, 150. Item 0's height is 50.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(itemScale(0), 0.5);
expect(itemScale(1), 1.0);
expect(itemScale(2), 1.0);
// Items 1, 2 at 0, 100.
await tester.pumpAndSettle();
expect(itemLeft(1), 0.0);
expect(itemRight(1), 100.0);
expect(itemLeft(2), 100.0);
expect(itemRight(2), 200.0);
});
testWidgets('works in combination with other slivers', (WidgetTester tester) async {
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverList(
delegate: SliverChildListDelegate(<Widget>[
const SizedBox(height: 100),
const SizedBox(height: 100),
]),
),
SliverAnimatedGrid(
key: listKey,
initialItemCount: 3,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return SizedBox(
height: 100,
child: Text('item $index'),
);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
),
),
],
),
),
);
expect(tester.getTopLeft(find.text('item 0')).dx, 0);
expect(tester.getTopLeft(find.text('item 1')).dx, 100);
listKey.currentState!.insertItem(3);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('item 3')).dx, 300);
listKey.currentState!.removeItem(
0,
(BuildContext context, Animation<double> animation) {
return ScaleTransition(
scale: animation,
key: const ObjectKey('removing'),
child: const SizedBox(
height: 100,
child: Text('removing'),
),
);
},
duration: const Duration(seconds: 1),
);
await tester.pump();
expect(find.text('item 3'), findsNothing);
await tester.pump(const Duration(milliseconds: 500));
expect(
tester.widget<ScaleTransition>(find.byKey(const ObjectKey('removing'), skipOffstage: false)).scale.value,
0.5,
);
expect(tester.getTopLeft(find.text('item 0')).dx, 100);
await tester.pumpAndSettle();
expect(find.text('removing'), findsNothing);
expect(tester.getTopLeft(find.text('item 0')).dx, 0);
});
testWidgets('passes correctly derived index of findChildIndexCallback to the inner SliverChildBuilderDelegate',
(WidgetTester tester) async {
final List<int> items = <int>[0, 1, 2, 3];
final GlobalKey<SliverAnimatedGridState> listKey = GlobalKey<SliverAnimatedGridState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: CustomScrollView(
slivers: <Widget>[
SliverAnimatedGrid(
key: listKey,
initialItemCount: items.length,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return _StatefulListItem(
key: ValueKey<int>(items[index]),
index: index,
);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
findChildIndexCallback: (Key key) {
final int index = items.indexOf((key as ValueKey<int>).value);
return index == -1 ? null : index;
},
),
],
),
),
);
// get all list entries in order
final List<Text> listEntries = find.byType(Text).evaluate().map((Element e) => e.widget as Text).toList();
// check that the list is rendered in the correct order
expect(listEntries[0].data, equals('item 0'));
expect(listEntries[1].data, equals('item 1'));
expect(listEntries[2].data, equals('item 2'));
expect(listEntries[3].data, equals('item 3'));
// delete one item
listKey.currentState?.removeItem(0, (BuildContext context, Animation<double> animation) {
return Container();
});
// delete from list
items.removeAt(0);
// reorder list
items.insert(0, items.removeLast());
// render with new list order
await tester.pumpAndSettle();
// get all list entries in order
final List<Text> reorderedListEntries =
find.byType(Text).evaluate().map((Element e) => e.widget as Text).toList();
// check that the stateful items of the list are rendered in the order provided by findChildIndexCallback
expect(reorderedListEntries[0].data, equals('item 3'));
expect(reorderedListEntries[1].data, equals('item 1'));
expect(reorderedListEntries[2].data, equals('item 2'));
});
});
testWidgets(
'AnimatedGrid.of() and maybeOf called with a context that does not contain AnimatedGrid',
(WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(Container(key: key));
late FlutterError error;
expect(AnimatedGrid.maybeOf(key.currentContext!), isNull);
try {
AnimatedGrid.of(key.currentContext!);
} on FlutterError catch (e) {
error = e;
}
expect(error.diagnostics.length, 4);
expect(error.diagnostics[2].level, DiagnosticLevel.hint);
expect(
error.diagnostics[2].toStringDeep(),
equalsIgnoringHashCodes(
'This can happen when the context provided is from the same\n'
'StatefulWidget that built the AnimatedGrid. Please see the\n'
'AnimatedGrid documentation for examples of how to refer to an\n'
'AnimatedGridState object:\n'
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html\n',
),
);
expect(error.diagnostics[3], isA<DiagnosticsProperty<Element>>());
expect(
error.toStringDeep(),
equalsIgnoringHashCodes(
'FlutterError\n'
' AnimatedGrid.of() called with a context that does not contain an\n'
' AnimatedGrid.\n'
' No AnimatedGrid ancestor could be found starting from the context\n'
' that was passed to AnimatedGrid.of().\n'
' This can happen when the context provided is from the same\n'
' StatefulWidget that built the AnimatedGrid. Please see the\n'
' AnimatedGrid documentation for examples of how to refer to an\n'
' AnimatedGridState object:\n'
' https://api.flutter.dev/flutter/widgets/AnimatedGridState-class.html\n'
' The context used was:\n'
' Container-[GlobalKey#32cc6]\n',
),
);
},
);
testWidgets('AnimatedGrid.clipBehavior is forwarded to its inner CustomScrollView', (WidgetTester tester) async {
const Clip clipBehavior = Clip.none;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: AnimatedGrid(
initialItemCount: 2,
clipBehavior: clipBehavior,
itemBuilder: (BuildContext context, int index, Animation<double> _) {
return SizedBox(
height: 100.0,
child: Center(
child: Text('item $index'),
),
);
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
),
),
);
expect(tester.widget<CustomScrollView>(find.byType(CustomScrollView)).clipBehavior, clipBehavior);
});
}
class _StatefulListItem extends StatefulWidget {
const _StatefulListItem({
super.key,
required this.index,
});
final int index;
@override
_StatefulListItemState createState() => _StatefulListItemState();
}
class _StatefulListItemState extends State<_StatefulListItem> {
late final int number = widget.index;
@override
Widget build(BuildContext context) {
return Text('item $number');
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment