diff --git a/examples/api/lib/widgets/animated_grid/animated_grid.0.dart b/examples/api/lib/widgets/animated_grid/animated_grid.0.dart new file mode 100644 index 00000000000000..76dc3e41be827e --- /dev/null +++ b/examples/api/lib/widgets/animated_grid/animated_grid.0.dart @@ -0,0 +1,231 @@ +// 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 createState() => _AnimatedGridSampleState(); +} + +class _AnimatedGridSampleState extends State { + final GlobalKey _gridKey = 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: _gridKey, + initialItems: [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 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 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: [ + 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 = Widget Function(T item, BuildContext context, Animation 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 { + ListModel({ + required this.listKey, + required this.removedItemBuilder, + Iterable? initialItems, + }) : _items = List.from(initialItems ?? []); + + final GlobalKey listKey; + final RemovedItemBuilder removedItemBuilder; + final List _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 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 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), + ), + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart b/examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart new file mode 100644 index 00000000000000..157043fc6eb40f --- /dev/null +++ b/examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart @@ -0,0 +1,229 @@ +// 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 createState() => _SliverAnimatedGridSampleState(); +} + +class _SliverAnimatedGridSampleState extends State { + final GlobalKey _listKey = GlobalKey(); + final GlobalKey _scaffoldKey = GlobalKey(); + final GlobalKey _scaffoldMessengerKey = 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, 3, 4, 5], + removedItemBuilder: _buildRemovedItem, + ); + _nextItem = 6; + } + + // 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 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 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: [ + 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: [ + 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 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 { + ListModel({ + required this.listKey, + required this.removedItemBuilder, + Iterable? initialItems, + }) : _items = List.from(initialItems ?? []); + + final GlobalKey listKey; + final RemovedItemBuilder removedItemBuilder; + final List _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 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 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, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/animated_grid/animated_grid.0_test.dart b/examples/api/test/widgets/animated_grid/animated_grid.0_test.dart new file mode 100644 index 00000000000000..896e05d14a76b3 --- /dev/null +++ b/examples/api/test/widgets/animated_grid/animated_grid.0_test.dart @@ -0,0 +1,43 @@ +// 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); + }); +} diff --git a/examples/api/test/widgets/animated_grid/sliver_animated_grid.0_test.dart b/examples/api/test/widgets/animated_grid/sliver_animated_grid.0_test.dart new file mode 100644 index 00000000000000..d6565af65090a1 --- /dev/null +++ b/examples/api/test/widgets/animated_grid/sliver_animated_grid.0_test.dart @@ -0,0 +1,43 @@ +// 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); + }); +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 3b1e90cc00c328..4ae69b3da27fc9 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -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 diff --git a/packages/flutter/lib/src/rendering/sliver_grid.dart b/packages/flutter/lib/src/rendering/sliver_grid.dart index 26847e24b55909..e33d4f81f14258 100644 --- a/packages/flutter/lib/src/rendering/sliver_grid.dart +++ b/packages/flutter/lib/src/rendering/sliver_grid.dart @@ -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) { diff --git a/packages/flutter/lib/src/widgets/animated_grid.dart b/packages/flutter/lib/src/widgets/animated_grid.dart new file mode 100644 index 00000000000000..db6c7e373d9b91 --- /dev/null +++ b/packages/flutter/lib/src/widgets/animated_grid.dart @@ -0,0 +1,721 @@ +// 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 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 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(); + assert(() { + if (result == null) { + throw FlutterError.fromParts([ + 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(); + } + + @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 gridKey = GlobalKey(); +/// +/// // ... +/// +/// @override +/// Widget build(BuildContext context) { +/// return AnimatedGrid( +/// key: gridKey, +/// itemBuilder: (BuildContext context, int index, Animation 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 with TickerProviderStateMixin { + final GlobalKey _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: [ + 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(); + 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(); + } +} + +/// 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 gridKey = GlobalKey(); +/// +/// // ... +/// +/// @override +/// Widget build(BuildContext context) { +/// return AnimatedGrid( +/// key: gridKey, +/// itemBuilder: (BuildContext context, int index, Animation 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 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((_) { + _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 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 animation = incomingItem?.controller?.view ?? kAlwaysCompleteAnimation; + return widget.itemBuilder( + context, + _itemIndexToIndex(itemIndex), + animation, + ); + } + + @override + Widget build(BuildContext context) { + return SliverGrid( + gridDelegate: widget.gridDelegate, + delegate: _createDelegate(), + ); + } +} diff --git a/packages/flutter/lib/src/widgets/animated_list.dart b/packages/flutter/lib/src/widgets/animated_list.dart index d45bf2e465604f..3543df5dae457e 100644 --- a/packages/flutter/lib/src/widgets/animated_list.dart +++ b/packages/flutter/lib/src/widgets/animated_list.dart @@ -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 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({ diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index b3b154c0d6d34d..aac5de39243f6d 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -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({ diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index e36197735b9588..cbf8e9bdd1947d 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widgets/animated_grid_test.dart b/packages/flutter/test/widgets/animated_grid_test.dart new file mode 100644 index 00000000000000..6c3746b209cbb2 --- /dev/null +++ b/packages/flutter/test/widgets/animated_grid_test.dart @@ -0,0 +1,536 @@ +// 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: [ + SliverAnimatedGrid( + initialItemCount: itemCount, + itemBuilder: (BuildContext context, int index, Animation 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 animation) { + return SizedBox( + height: 100.0, + child: Center( + child: Text('item $index'), + ), + ); + } + + final GlobalKey listKey = GlobalKey(); + + 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 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> animations = >{}; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + slivers: [ + SliverAnimatedGrid( + initialItemCount: 2, + itemBuilder: (BuildContext context, int index, Animation 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 listKey = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + slivers: [ + SliverAnimatedGrid( + key: listKey, + itemBuilder: (BuildContext context, int index, Animation animation) { + return ScaleTransition( + key: ValueKey(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(find.byKey(ValueKey(index), skipOffstage: false)).scale.value; + double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey(index), skipOffstage: false)).dx; + double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey(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 listKey = GlobalKey(); + final List items = [0, 1, 2]; + + Widget buildItem(BuildContext context, int item, Animation animation) { + return ScaleTransition( + key: ValueKey(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: [ + SliverAnimatedGrid( + key: listKey, + initialItemCount: 3, + itemBuilder: (BuildContext context, int index, Animation animation) { + return buildItem(context, items[index], animation); + }, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 100.0, + ), + ), + ], + ), + ), + ); + + double itemScale(int index) => + tester.widget(find.byKey(ValueKey(index), skipOffstage: false)).scale.value; + double itemLeft(int index) => tester.getTopLeft(find.byKey(ValueKey(index), skipOffstage: false)).dx; + double itemRight(int index) => tester.getTopRight(find.byKey(ValueKey(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 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 listKey = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 100), + const SizedBox(height: 100), + ]), + ), + SliverAnimatedGrid( + key: listKey, + initialItemCount: 3, + itemBuilder: (BuildContext context, int index, Animation 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 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(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 items = [0, 1, 2, 3]; + final GlobalKey listKey = GlobalKey(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + slivers: [ + SliverAnimatedGrid( + key: listKey, + initialItemCount: items.length, + itemBuilder: (BuildContext context, int index, Animation animation) { + return _StatefulListItem( + key: ValueKey(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).value); + return index == -1 ? null : index; + }, + ), + ], + ), + ), + ); + + // get all list entries in order + final List 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 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 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>()); + 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 _) { + 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(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'); + } +}