diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index cc72bbf072..f7bb89a18d 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart @@ -21,6 +21,7 @@ import 'scroll_notification.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; import 'sliver.dart'; +import 'sliver_prototype_extent_list.dart'; import 'viewport.dart'; // Examples can assume: @@ -761,11 +762,19 @@ abstract class BoxScrollView extends ScrollView { /// children are required to fill the [ListView]. /// /// If non-null, the [itemExtent] forces the children to have the given extent -/// in the scroll direction. Specifying an [itemExtent] is more efficient than +/// in the scroll direction. +/// +/// If non-null, the [prototypeItem] forces the children to have the same extent +/// as the given widget in the scroll direction. +/// +/// Specifying an [itemExtent] or an [prototypeItem] is more efficient than /// letting the children determine their own extent because the scrolling /// machinery can make use of the foreknowledge of the children's extent to save /// work, for example when the scroll position changes drastically. /// +/// You can't specify both [itemExtent] and [prototypeItem], only one or none of +/// them. +/// /// There are four options for constructing a [ListView]: /// /// 1. The default constructor takes an explicit [List] of children. This @@ -951,9 +960,10 @@ abstract class BoxScrollView extends ScrollView { /// and [shrinkWrap] properties on [ListView] map directly to the identically /// named properties on [CustomScrollView]. /// -/// The [CustomScrollView.slivers] property should be a list containing either a -/// [SliverList] or a [SliverFixedExtentList]; the former if [itemExtent] on the -/// [ListView] was null, and the latter if [itemExtent] was not null. +/// The [CustomScrollView.slivers] property should be a list containing either: +/// * a [SliverList] if both [itemExtent] and [prototypeItem] were null; +/// * a [SliverFixedExtentList] if [itemExtent] was not null; or +/// * a [SliverPrototypeExtentList] if [prototypeItem] was not null. /// /// The [childrenDelegate] property on [ListView] corresponds to the /// [SliverList.delegate] (or [SliverFixedExtentList.delegate]) property. The @@ -1104,6 +1114,7 @@ class ListView extends BoxScrollView { bool shrinkWrap = false, EdgeInsetsGeometry? padding, this.itemExtent, + this.prototypeItem, bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, bool addSemanticIndexes = true, @@ -1114,7 +1125,11 @@ class ListView extends BoxScrollView { ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, String? restorationId, Clip clipBehavior = Clip.hardEdge, - }) : childrenDelegate = SliverChildListDelegate( + }) : assert( + itemExtent == null || prototypeItem == null, + 'You can only pass itemExtent or prototypeItem, not both.', + ), + childrenDelegate = SliverChildListDelegate( children, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, @@ -1178,6 +1193,7 @@ class ListView extends BoxScrollView { bool shrinkWrap = false, EdgeInsetsGeometry? padding, this.itemExtent, + this.prototypeItem, required IndexedWidgetBuilder itemBuilder, int? itemCount, bool addAutomaticKeepAlives = true, @@ -1191,6 +1207,10 @@ class ListView extends BoxScrollView { Clip clipBehavior = Clip.hardEdge, }) : assert(itemCount == null || itemCount >= 0), assert(semanticChildCount == null || semanticChildCount <= itemCount!), + assert( + itemExtent == null || prototypeItem == null, + 'You can only pass itemExtent or prototypeItem, not both.', + ), childrenDelegate = SliverChildBuilderDelegate( itemBuilder, childCount: itemCount, @@ -1286,6 +1306,7 @@ class ListView extends BoxScrollView { assert(separatorBuilder != null), assert(itemCount != null && itemCount >= 0), itemExtent = null, + prototypeItem = null, childrenDelegate = SliverChildBuilderDelegate( (BuildContext context, int index) { final int itemIndex = index ~/ 2; @@ -1425,6 +1446,7 @@ class ListView extends BoxScrollView { bool shrinkWrap = false, EdgeInsetsGeometry? padding, this.itemExtent, + this.prototypeItem, required this.childrenDelegate, double? cacheExtent, int? semanticChildCount, @@ -1433,6 +1455,10 @@ class ListView extends BoxScrollView { String? restorationId, Clip clipBehavior = Clip.hardEdge, }) : assert(childrenDelegate != null), + assert( + itemExtent == null || prototypeItem == null, + 'You can only pass itemExtent or prototypeItem, not both', + ), super( key: key, scrollDirection: scrollDirection, @@ -1457,8 +1483,33 @@ class ListView extends BoxScrollView { /// determine their own extent because the scrolling machinery can make use of /// the foreknowledge of the children's extent to save work, for example when /// the scroll position changes drastically. + /// + /// See also: + /// + /// * [SliverFixedExtentList], the sliver used internally when this property + /// is provided. It constrains its box children to have a specific given + /// extent along the main axis. + /// * The [prototypeItem] property, which allows forcing the children's + /// extent to be the same as the given widget. final double? itemExtent; + /// If non-null, forces the children to have the same extent as the given + /// widget in the scroll direction. + /// + /// Specifying an [prototypeItem] is more efficient than letting the children + /// determine their own extent because the scrolling machinery can make use of + /// the foreknowledge of the children's extent to save work, for example when + /// the scroll position changes drastically. + /// + /// See also: + /// + /// * [SliverPrototypeExtentList], the sliver used internally when this + /// property is provided. It constrains its box children to have the same + /// extent as a prototype item along the main axis. + /// * The [itemExtent] property, which allows forcing the children's extent + /// to a given value. + final Widget? prototypeItem; + /// A delegate that provides the children for the [ListView]. /// /// The [ListView.custom] constructor lets you specify this delegate @@ -1474,6 +1525,11 @@ class ListView extends BoxScrollView { delegate: childrenDelegate, itemExtent: itemExtent!, ); + } else if (prototypeItem != null) { + return SliverPrototypeExtentList( + delegate: childrenDelegate, + prototypeItem: prototypeItem!, + ); } return SliverList(delegate: childrenDelegate); } diff --git a/packages/flutter/test/widgets/list_view_builder_test.dart b/packages/flutter/test/widgets/list_view_builder_test.dart index 4cd39ce17b..b06c0f23e4 100644 --- a/packages/flutter/test/widgets/list_view_builder_test.dart +++ b/packages/flutter/test/widgets/list_view_builder_test.dart @@ -261,6 +261,54 @@ void main() { callbackTracker.clear(); }); + testWidgets('ListView.builder 30 items with big jump, using prototypeItem', (WidgetTester tester) async { + final List callbackTracker = []; + + // The root view is 800x600 in the test environment and our list + // items are 300 tall. Scrolling should cause two or three items + // to be built. + + Widget itemBuilder(BuildContext context, int index) { + callbackTracker.add(index); + return Text('$index', key: ValueKey(index), textDirection: TextDirection.ltr); + } + + final Widget testWidget = Directionality( + textDirection: TextDirection.ltr, + child: ListView.builder( + itemBuilder: itemBuilder, + prototypeItem: const SizedBox( + width: 800, + height: 300, + ), + itemCount: 30, + ), + ); + + void jumpTo(double newScrollOffset) { + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(newScrollOffset); + } + + await tester.pumpWidget(testWidget); + + // 2 is in the cache area, but not visible. + expect(callbackTracker, equals([0, 1, 2])); + final List initialExpectedHidden = List.generate(28, (int i) => i + 2); + check(visible: [0, 1], hidden: initialExpectedHidden); + callbackTracker.clear(); + + // Jump to the end of the ListView. + jumpTo(8400); + await tester.pump(); + + // 27 is in the cache area, but not visible. + expect(callbackTracker, equals([27, 28, 29])); + final List finalExpectedHidden = List.generate(28, (int i) => i); + check(visible: [28, 29], hidden: finalExpectedHidden); + callbackTracker.clear(); + }); + testWidgets('ListView.separated', (WidgetTester tester) async { Widget buildFrame({ required int itemCount }) { return Directionality( diff --git a/packages/flutter/test/widgets/scroll_view_test.dart b/packages/flutter/test/widgets/scroll_view_test.dart index b7eebb7aa9..c5dae2e86a 100644 --- a/packages/flutter/test/widgets/scroll_view_test.dart +++ b/packages/flutter/test/widgets/scroll_view_test.dart @@ -1231,6 +1231,13 @@ void main() { expect(finder, findsOneWidget); }); + testWidgets('ListView asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { + expect(() => ListView( + itemExtent: 100, + prototypeItem: const SizedBox(), + ), throwsAssertionError); + }); + testWidgets('ListView.builder asserts on negative childCount', (WidgetTester tester) async { expect(() => ListView.builder( itemBuilder: (BuildContext context, int index) { @@ -1260,6 +1267,28 @@ void main() { ), throwsAssertionError); }); + testWidgets('ListView.builder asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { + expect(() => ListView.builder( + itemBuilder: (BuildContext context, int index) { + return const SizedBox(); + }, + itemExtent: 100, + prototypeItem: const SizedBox(), + ), throwsAssertionError); + }); + + testWidgets('ListView.custom asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { + expect(() => ListView.custom( + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return const SizedBox(); + }, + ), + itemExtent: 100, + prototypeItem: const SizedBox(), + ), throwsAssertionError); + }); + testWidgets('PrimaryScrollController provides fallback ScrollActions', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp(