diff --git a/packages/flutter/lib/src/widgets/animated_list.dart b/packages/flutter/lib/src/widgets/animated_list.dart index 867bb4b1aec..c8e91e12be1 100644 --- a/packages/flutter/lib/src/widgets/animated_list.dart +++ b/packages/flutter/lib/src/widgets/animated_list.dart @@ -339,6 +339,7 @@ class SliverAnimatedList extends StatefulWidget { const SliverAnimatedList({ Key? key, required this.itemBuilder, + this.findChildIndexCallback, this.initialItemCount = 0, }) : assert(itemBuilder != null), assert(initialItemCount != null && initialItemCount >= 0), @@ -359,6 +360,9 @@ class SliverAnimatedList extends StatefulWidget { /// [SliverAnimatedListState.removeItem] removes an item immediately. final AnimatedListItemBuilder itemBuilder; + /// {@macro flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback} + final ChildIndexGetter? findChildIndexCallback; + /// {@macro flutter.widgets.animatedList.initialItemCount} final int initialItemCount; @@ -507,7 +511,11 @@ class SliverAnimatedListState extends State with TickerProvi } SliverChildDelegate _createDelegate() { - return SliverChildBuilderDelegate(_itemBuilder, childCount: _itemsCount); + return SliverChildBuilderDelegate( + _itemBuilder, + childCount: _itemsCount, + findChildIndexCallback: widget.findChildIndexCallback, + ); } /// Insert an item at [index] and start an animation that will be passed to diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart index 358453fb427..f095b4d636c 100644 --- a/packages/flutter/lib/src/widgets/page_view.dart +++ b/packages/flutter/lib/src/widgets/page_view.dart @@ -665,9 +665,14 @@ class PageView extends StatefulWidget { /// [itemBuilder] will be called only with indices greater than or equal to /// zero and less than [itemCount]. /// - /// [PageView.builder] by default does not support child reordering. If - /// you are planning to change child order at a later time, consider using - /// [PageView] or [PageView.custom]. + /// {@template flutter.widgets.PageView.findChildIndexCallback} + /// The [findChildIndexCallback] corresponds to the + /// [SliverChildBuilderDelegate.findChildIndexCallback] property. If null, + /// a child widget may not map to its existing [RenderObject] when the order + /// of children returned from the children builder changes. + /// This may result in state-loss. This callback needs to be implemented if + /// the order of the children may change at a later time. + /// {@endtemplate} /// /// {@macro flutter.widgets.PageView.allowImplicitScrolling} PageView.builder({ @@ -679,6 +684,7 @@ class PageView extends StatefulWidget { this.pageSnapping = true, this.onPageChanged, required IndexedWidgetBuilder itemBuilder, + ChildIndexGetter? findChildIndexCallback, int? itemCount, this.dragStartBehavior = DragStartBehavior.start, this.allowImplicitScrolling = false, @@ -689,7 +695,11 @@ class PageView extends StatefulWidget { }) : assert(allowImplicitScrolling != null), assert(clipBehavior != null), controller = controller ?? _defaultPageController, - childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount), + childrenDelegate = SliverChildBuilderDelegate( + itemBuilder, + findChildIndexCallback: findChildIndexCallback, + childCount: itemCount, + ), super(key: key); /// Creates a scrollable list that works page by page with a custom child diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 0eb6b2cda05..9af083cacd8 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -430,6 +430,7 @@ class SliverReorderableList extends StatefulWidget { const SliverReorderableList({ Key? key, required this.itemBuilder, + this.findChildIndexCallback, required this.itemCount, required this.onReorder, this.onReorderStart, @@ -447,6 +448,9 @@ class SliverReorderableList extends StatefulWidget { /// {@macro flutter.widgets.reorderable_list.itemBuilder} final IndexedWidgetBuilder itemBuilder; + /// {@macro flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback} + final ChildIndexGetter? findChildIndexCallback; + /// {@macro flutter.widgets.reorderable_list.itemCount} final int itemCount; @@ -908,6 +912,7 @@ class SliverReorderableListState extends State with Ticke // by a zero height SizedBox, so that the gap can move around. To make the // list extent stable we add a dummy entry to the end. childCount: widget.itemCount + (_dragInfo != null ? 1 : 0), + findChildIndexCallback: widget.findChildIndexCallback, ); if (widget.itemExtent != null) { return SliverFixedExtentList( diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index efc66a233d8..887c2cc7da3 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart @@ -1126,6 +1126,8 @@ class ListView extends BoxScrollView { /// efficient, however, is to create the instances on demand using this /// constructor's `itemBuilder` callback. /// + /// {@macro flutter.widgets.PageView.findChildIndexCallback} + /// /// The `addAutomaticKeepAlives` argument corresponds to the /// [SliverChildBuilderDelegate.addAutomaticKeepAlives] property. The /// `addRepaintBoundaries` argument corresponds to the @@ -1133,10 +1135,6 @@ class ListView extends BoxScrollView { /// `addSemanticIndexes` argument corresponds to the /// [SliverChildBuilderDelegate.addSemanticIndexes] property. None may be /// null. - /// - /// [ListView.builder] by default does not support child reordering. If - /// you are planning to change child order at a later time, consider using - /// [ListView] or [ListView.custom]. ListView.builder({ Key? key, Axis scrollDirection = Axis.vertical, @@ -1149,6 +1147,7 @@ class ListView extends BoxScrollView { this.itemExtent, this.prototypeItem, required IndexedWidgetBuilder itemBuilder, + ChildIndexGetter? findChildIndexCallback, int? itemCount, bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, @@ -1167,6 +1166,7 @@ class ListView extends BoxScrollView { ), childrenDelegate = SliverChildBuilderDelegate( itemBuilder, + findChildIndexCallback: findChildIndexCallback, childCount: itemCount, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, @@ -1211,6 +1211,8 @@ class ListView extends BoxScrollView { /// view's children are created in advance, or all at once when the [ListView] /// itself is created, it is more efficient to use the [ListView] constructor. /// + /// {@macro flutter.widgets.PageView.findChildIndexCallback} + /// /// {@tool snippet} /// /// This example shows how to create [ListView] whose [ListTile] list items @@ -1246,6 +1248,7 @@ class ListView extends BoxScrollView { bool shrinkWrap = false, EdgeInsetsGeometry? padding, required IndexedWidgetBuilder itemBuilder, + ChildIndexGetter? findChildIndexCallback, required IndexedWidgetBuilder separatorBuilder, required int itemCount, bool addAutomaticKeepAlives = true, @@ -1278,6 +1281,7 @@ class ListView extends BoxScrollView { } return widget; }, + findChildIndexCallback: findChildIndexCallback, childCount: _computeActualChildCount(itemCount), addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, @@ -1797,6 +1801,8 @@ class GridView extends BoxScrollView { /// `itemBuilder` will be called only with indices greater than or equal to /// zero and less than `itemCount`. /// + /// {@macro flutter.widgets.PageView.findChildIndexCallback} + /// /// The [gridDelegate] argument must not be null. /// /// The `addAutomaticKeepAlives` argument corresponds to the @@ -1815,6 +1821,7 @@ class GridView extends BoxScrollView { EdgeInsetsGeometry? padding, required this.gridDelegate, required IndexedWidgetBuilder itemBuilder, + ChildIndexGetter? findChildIndexCallback, int? itemCount, bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, @@ -1828,6 +1835,7 @@ class GridView extends BoxScrollView { }) : assert(gridDelegate != null), childrenDelegate = SliverChildBuilderDelegate( itemBuilder, + findChildIndexCallback: findChildIndexCallback, childCount: itemCount, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index a8de6dba3b0..ffc6c9b1cab 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -194,6 +194,10 @@ abstract class SliverChildDelegate { /// This will be called during `performRebuild` in [SliverMultiBoxAdaptorElement] /// to check if a child has moved to a different position. It should return the /// index of the child element with associated key, null if not found. + /// + /// If not provided, a child widget may not map to its existing [RenderObject] + /// when the order of children returned from the children builder changes. + /// This may result in state-loss. int? findIndexByKey(Key key) => null; @override @@ -429,14 +433,16 @@ class SliverChildBuilderDelegate extends SliverChildDelegate { /// Defaults to providing an index for each widget. final SemanticIndexCallback semanticIndexCallback; + /// {@template flutter.widgets.SliverChildBuilderDelegate.findChildIndexCallback} /// Called to find the new index of a child based on its key in case of reordering. /// /// If not provided, a child widget may not map to its existing [RenderObject] - /// when the order in which children are returned from [builder] changes. + /// when the order of children returned from the children builder changes. /// This may result in state-loss. /// /// This callback should take an input [Key], and it should return the /// index of the child element with that associated key, or null if not found. + /// {@endtemplate} final ChildIndexGetter? findChildIndexCallback; @override diff --git a/packages/flutter/test/widgets/animated_list_test.dart b/packages/flutter/test/widgets/animated_list_test.dart index 579377ba257..c5dd7519d00 100644 --- a/packages/flutter/test/widgets/animated_list_test.dart +++ b/packages/flutter/test/widgets/animated_list_test.dart @@ -7,6 +7,46 @@ 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('SliverAnimatedList.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: [ + SliverAnimatedList( + initialItemCount: itemCount, + itemBuilder: (BuildContext context, int index, Animation animation) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + ), + ], + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + testWidgets('AnimatedList', (WidgetTester tester) async { Widget builder(BuildContext context, int index, Animation animation) { return SizedBox( diff --git a/packages/flutter/test/widgets/grid_view_test.dart b/packages/flutter/test/widgets/grid_view_test.dart index c7326d3ca3c..434cd1862c6 100644 --- a/packages/flutter/test/widgets/grid_view_test.dart +++ b/packages/flutter/test/widgets/grid_view_test.dart @@ -12,6 +12,45 @@ import '../rendering/rendering_tester.dart' show TestClipPaintingContext; import 'states.dart'; void main() { + // Regression test for https://github.com/flutter/flutter/issues/100451 + testWidgets('GridView.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 GridView.builder( + itemCount: itemCount, + itemBuilder: (BuildContext _, int index) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + ), + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + testWidgets('Empty GridView', (WidgetTester tester) async { await tester.pumpWidget( Directionality( diff --git a/packages/flutter/test/widgets/list_view_test.dart b/packages/flutter/test/widgets/list_view_test.dart index 0e2bf9db901..70ce3031192 100644 --- a/packages/flutter/test/widgets/list_view_test.dart +++ b/packages/flutter/test/widgets/list_view_test.dart @@ -76,6 +76,79 @@ class _StatefulListViewState extends State<_StatefulListView> { } void main() { + // Regression test for https://github.com/flutter/flutter/issues/100451 + testWidgets('ListView.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 ListView.builder( + itemCount: itemCount, + itemBuilder: (BuildContext _, int index) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/100451 + testWidgets('ListView.separator 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 ListView.separated( + itemCount: itemCount, + itemBuilder: (BuildContext _, int index) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + separatorBuilder: (BuildContext _, int __) => const Divider(), + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + testWidgets('ListView default control', (WidgetTester tester) async { await tester.pumpWidget( Directionality( diff --git a/packages/flutter/test/widgets/page_view_test.dart b/packages/flutter/test/widgets/page_view_test.dart index d4549b0e204..a0b82be017f 100644 --- a/packages/flutter/test/widgets/page_view_test.dart +++ b/packages/flutter/test/widgets/page_view_test.dart @@ -12,6 +12,42 @@ import 'semantics_tester.dart'; import 'states.dart'; void main() { + // Regression test for https://github.com/flutter/flutter/issues/100451 + testWidgets('PageView.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 PageView.builder( + itemCount: itemCount, + itemBuilder: (BuildContext _, int index) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + testWidgets('PageView resize from zero-size viewport should not lose state', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/88956 final PageController controller = PageController( diff --git a/packages/flutter/test/widgets/reorderable_list_test.dart b/packages/flutter/test/widgets/reorderable_list_test.dart index 38c7b92976e..e6a07f136fa 100644 --- a/packages/flutter/test/widgets/reorderable_list_test.dart +++ b/packages/flutter/test/widgets/reorderable_list_test.dart @@ -7,6 +7,46 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + // Regression test for https://github.com/flutter/flutter/issues/100451 + testWidgets('SliverReorderableList.builder respects findChildIndexCallback', (WidgetTester tester) async { + bool finderCalled = false; + int itemCount = 7; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return CustomScrollView( + slivers: [ + SliverReorderableList( + itemCount: itemCount, + itemBuilder: (BuildContext _, int index) => Container( + key: Key('$index'), + height: 2000.0, + ), + findChildIndexCallback: (Key key) { + finderCalled = true; + return null; + }, + onReorder: (int oldIndex, int newIndex) { }, + ), + ], + ); + }, + ), + ) + ); + expect(finderCalled, false); + + // Trigger update. + stateSetter(() => itemCount = 77); + await tester.pump(); + + expect(finderCalled, true); + }); + // Regression test for https://github.com/flutter/flutter/issues/88191 testWidgets('Do not crash when dragging with two fingers simultaneously', (WidgetTester tester) async { final List items = List.generate(3, (int index) => index);