fix: findChildIndexCallback to take seperators into account for seperated named constructor in ListView and SliverList (#174491)
FindChildIndexCallback to take seperators into account for seperated
named constructor in ListView and SliverList
fixes: #174261
## Migration guide
https://github.com/flutter/website/pull/12636
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart
index 7786e3e..d37cb17 100644
--- a/packages/flutter/lib/src/widgets/scroll_view.dart
+++ b/packages/flutter/lib/src/widgets/scroll_view.dart
@@ -1419,6 +1419,20 @@
///
/// {@macro flutter.widgets.PageView.findChildIndexCallback}
///
+ /// {@template flutter.widgets.ListView.separated.findItemIndexCallback}
+ /// The [findItemIndexCallback] returns item indices (excluding separators),
+ /// unlike the deprecated [findChildIndexCallback] which returns child indices
+ /// (including both items and separators).
+ ///
+ /// For example, in a list with 3 items and 2 separators:
+ /// * Item indices: 0, 1, 2
+ /// * Child indices: 0 (item), 1 (separator), 2 (item), 3 (separator), 4 (item)
+ ///
+ /// This callback should be implemented if the order of items may change at a
+ /// later time. If null, reordering items may result in state-loss as widgets
+ /// may not map to their existing [RenderObject]s.
+ /// {@endtemplate}
+ ///
/// {@tool snippet}
///
/// This example shows how to create [ListView] whose [ListTile] list items
@@ -1454,7 +1468,16 @@
super.shrinkWrap,
super.padding,
required NullableIndexedWidgetBuilder itemBuilder,
+ @Deprecated(
+ 'Use findItemIndexCallback instead. '
+ 'findChildIndexCallback returns child indices (which include separators), '
+ 'while findItemIndexCallback returns item indices (which do not). '
+ 'If you were multiplying results by 2 to account for separators, '
+ 'you can remove that workaround when migrating to findItemIndexCallback. '
+ 'This feature was deprecated after v3.37.0-1.0.pre.',
+ )
ChildIndexGetter? findChildIndexCallback,
+ ChildIndexGetter? findItemIndexCallback,
required IndexedWidgetBuilder separatorBuilder,
required int itemCount,
bool addAutomaticKeepAlives = true,
@@ -1467,6 +1490,11 @@
super.clipBehavior,
super.hitTestBehavior,
}) : assert(itemCount >= 0),
+ assert(
+ findItemIndexCallback == null || findChildIndexCallback == null,
+ 'Cannot provide both findItemIndexCallback and findChildIndexCallback. '
+ 'Use findItemIndexCallback as findChildIndexCallback is deprecated.',
+ ),
itemExtent = null,
itemExtentBuilder = null,
prototypeItem = null,
@@ -1478,7 +1506,12 @@
}
return separatorBuilder(context, itemIndex);
},
- findChildIndexCallback: findChildIndexCallback,
+ findChildIndexCallback: findItemIndexCallback != null
+ ? (Key key) {
+ final int? itemIndex = findItemIndexCallback(key);
+ return itemIndex == null ? null : itemIndex * 2;
+ }
+ : findChildIndexCallback,
childCount: _computeActualChildCount(itemCount),
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart
index 3fd4cd0..3412e89 100644
--- a/packages/flutter/lib/src/widgets/sliver.dart
+++ b/packages/flutter/lib/src/widgets/sliver.dart
@@ -246,6 +246,7 @@
///
/// {@macro flutter.widgets.PageView.findChildIndexCallback}
///
+ /// {@macro flutter.widgets.ListView.separated.findItemIndexCallback}
///
/// The `separatorBuilder` is similar to `itemBuilder`, except it is the widget
/// that gets placed between itemBuilder(context, index) and itemBuilder(context, index + 1).
@@ -278,13 +279,27 @@
SliverList.separated({
super.key,
required NullableIndexedWidgetBuilder itemBuilder,
+ @Deprecated(
+ 'Use findItemIndexCallback instead. '
+ 'findChildIndexCallback returns child indices (which include separators), '
+ 'while findItemIndexCallback returns item indices (which do not). '
+ 'If you were multiplying results by 2 to account for separators, '
+ 'you can remove that workaround when migrating to findItemIndexCallback. '
+ 'This feature was deprecated after v3.37.0-1.0.pre.',
+ )
ChildIndexGetter? findChildIndexCallback,
+ ChildIndexGetter? findItemIndexCallback,
required NullableIndexedWidgetBuilder separatorBuilder,
int? itemCount,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
- }) : super(
+ }) : assert(
+ findItemIndexCallback == null || findChildIndexCallback == null,
+ 'Cannot provide both findItemIndexCallback and findChildIndexCallback. '
+ 'Use findItemIndexCallback as findChildIndexCallback is deprecated.',
+ ),
+ super(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final int itemIndex = index ~/ 2;
@@ -302,7 +317,12 @@
}
return widget;
},
- findChildIndexCallback: findChildIndexCallback,
+ findChildIndexCallback: findItemIndexCallback != null
+ ? (Key key) {
+ final int? itemIndex = findItemIndexCallback(key);
+ return itemIndex == null ? null : itemIndex * 2;
+ }
+ : findChildIndexCallback,
childCount: itemCount == null ? null : math.max(0, itemCount * 2 - 1),
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
diff --git a/packages/flutter/test/widgets/scroll_view_test.dart b/packages/flutter/test/widgets/scroll_view_test.dart
index ea27384..0dfd43c3 100644
--- a/packages/flutter/test/widgets/scroll_view_test.dart
+++ b/packages/flutter/test/widgets/scroll_view_test.dart
@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:math';
+
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
@@ -9,6 +11,23 @@
import 'states.dart';
+class ItemWidget extends StatefulWidget {
+ const ItemWidget({super.key, required this.value});
+ final String value;
+
+ @override
+ State<StatefulWidget> createState() => _ItemWidgetState();
+}
+
+class _ItemWidgetState extends State<ItemWidget> {
+ int randomInt = Random().nextInt(1000);
+
+ @override
+ Widget build(BuildContext context) {
+ return Text('${widget.value}: $randomInt');
+ }
+}
+
class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
@override
bool isSupported(Locale locale) => true;
@@ -1794,4 +1813,102 @@
expect(tester.testTextInput.isVisible, isFalse);
});
+
+ testWidgets('ListView.separated findItemIndexCallback preserves state correctly', (
+ WidgetTester tester,
+ ) async {
+ final List<String> items = <String>['A', 'B', 'C'];
+
+ Widget buildFrame(List<String> itemList) {
+ return MaterialApp(
+ home: Material(
+ child: ListView.separated(
+ itemCount: itemList.length,
+ findItemIndexCallback: (Key key) {
+ final ValueKey<String> valueKey = key as ValueKey<String>;
+ return itemList.indexOf(valueKey.value);
+ },
+ itemBuilder: (BuildContext context, int index) {
+ return ItemWidget(key: ValueKey<String>(itemList[index]), value: itemList[index]);
+ },
+ separatorBuilder: (BuildContext context, int index) => const Divider(),
+ ),
+ ),
+ );
+ }
+
+ // Build initial frame
+ await tester.pumpWidget(buildFrame(items));
+
+ final Finder texts = find.byType(Text);
+ expect(texts, findsNWidgets(3));
+
+ // Store all text in list
+ final List<String?> textValues = List<String?>.generate(3, (int index) {
+ return (tester.widget(texts.at(index)) as Text).data;
+ });
+
+ await tester.pumpWidget(buildFrame(items));
+ await tester.pump();
+
+ final Finder updatedTexts = find.byType(Text);
+ expect(updatedTexts, findsNWidgets(3));
+
+ final List<String?> updatedTextValues = List<String?>.generate(3, (int index) {
+ return (tester.widget(updatedTexts.at(index)) as Text).data;
+ });
+
+ expect(textValues, updatedTextValues);
+ });
+
+ testWidgets('SliverList.separated findItemIndexCallback preserves state correctly', (
+ WidgetTester tester,
+ ) async {
+ final List<String> items = <String>['A', 'B', 'C'];
+
+ Widget buildFrame(List<String> itemList) {
+ return MaterialApp(
+ home: Material(
+ child: CustomScrollView(
+ slivers: <Widget>[
+ SliverList.separated(
+ itemCount: itemList.length,
+ findItemIndexCallback: (Key key) {
+ final ValueKey<String> valueKey = key as ValueKey<String>;
+ return itemList.indexOf(valueKey.value);
+ },
+ itemBuilder: (BuildContext context, int index) {
+ return ItemWidget(key: ValueKey<String>(itemList[index]), value: itemList[index]);
+ },
+ separatorBuilder: (BuildContext context, int index) => const Divider(),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // Build initial frame
+ await tester.pumpWidget(buildFrame(items));
+
+ final Finder texts = find.byType(Text);
+ expect(texts, findsNWidgets(3));
+
+ // Store all text in list
+ final List<String?> textValues = List<String?>.generate(3, (int index) {
+ return (tester.widget(texts.at(index)) as Text).data;
+ });
+
+ await tester.pumpWidget(buildFrame(items));
+ await tester.pump();
+
+ final Finder updatedTexts = find.byType(Text);
+ expect(updatedTexts, findsNWidgets(3));
+
+ final List<String?> updatedTextValues = List<String?>.generate(3, (int index) {
+ return (tester.widget(updatedTexts.at(index)) as Text).data;
+ });
+
+ expect(textValues, updatedTextValues);
+ });
}