flutter_flutter/packages/flutter/test/widgets/scrollable_restoration_test.dart
Loïc Sharma 384331e171
Migrate to list and builder Sliver convenience constructors (#173011)
In 2022, we introduced new convenience constructors like
`SliverList.builder` and `SliverList.list`. Unfortunately, LLMs like
Gemini seem to prefer the delegate pattern even when these convenience
constructors are usable. This updates Flutter's docs, code, and tests to
use these convenience constructors where possible. Hopefully this will
nudge LLMs to consider using the new APIs :)

I migrated 80% of the code by hand, and 20% using Gemini CLI. See
[go/loic-ai-log](http://goto.google.com/loic-ai-log) (Google internal)
for details.

There's a few locations that I wasn't able to migrate to the convenience
constructors due to missing APIs. I filed the following issues:

1. https://github.com/flutter/flutter/issues/173018
2. https://github.com/flutter/flutter/issues/173019

## 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].
- [ ] 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.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-08-04 22:32:22 +00:00

516 lines
16 KiB
Dart

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('CustomScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: CustomScrollView(
restorationId: 'list',
cacheExtent: 0,
slivers: <Widget>[
SliverList.builder(
itemCount: 50,
itemBuilder: (BuildContext context, int index) {
return SizedBox(height: 50, child: Text('Tile $index'));
},
),
],
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView(
restorationId: 'list',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.builder(
restorationId: 'list',
cacheExtent: 0,
itemBuilder: (BuildContext context, int index) =>
SizedBox(height: 50, child: Text('Tile $index')),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.separated restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.separated(
restorationId: 'list',
cacheExtent: 0,
itemCount: 50,
separatorBuilder: (BuildContext context, int index) => const SizedBox.shrink(),
itemBuilder: (BuildContext context, int index) =>
SizedBox(height: 50, child: Text('Tile $index')),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('ListView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListView.custom(
restorationId: 'list',
cacheExtent: 0,
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.builder(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
itemBuilder: (BuildContext context, int index) =>
SizedBox(height: 50, child: Text('Tile $index')),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.custom(
restorationId: 'grid',
cacheExtent: 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 1),
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.count restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.count(
restorationId: 'grid',
cacheExtent: 0,
crossAxisCount: 1,
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('GridView.extent restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: GridView.extent(
restorationId: 'grid',
cacheExtent: 0,
maxCrossAxisExtent: 50,
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
);
await restoreScrollAndVerify(tester);
});
testWidgets('SingleChildScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: SingleChildScrollView(
restorationId: 'single',
child: Column(
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
);
expect(tester.getTopLeft(find.text('Tile 0')), Offset.zero);
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, 50));
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(525);
await tester.pump();
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 525);
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0);
await tester.pump();
expect(tester.getTopLeft(find.text('Tile 0')), Offset.zero);
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, 50));
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 525);
expect(tester.getTopLeft(find.text('Tile 0')), const Offset(0, -525));
expect(tester.getTopLeft(find.text('Tile 1')), const Offset(0, -475));
});
testWidgets('PageView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView(
restorationId: 'pager',
children: List<Widget>.generate(50, (int index) => Text('Tile $index')),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('PageView.builder restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView.builder(
restorationId: 'pager',
itemBuilder: (BuildContext context, int index) =>
SizedBox(height: 50, child: Text('Tile $index')),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('PageView.custom restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: PageView.custom(
restorationId: 'pager',
childrenDelegate: SliverChildListDelegate(
List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
);
await pageViewScrollAndRestore(tester);
});
testWidgets('ListWheelScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListWheelScrollView(
restorationId: 'wheel',
itemExtent: 50,
children: List<Widget>.generate(50, (int index) => Text('Tile $index')),
),
),
);
await restoreScrollAndVerify(tester, secondOffset: 542);
});
testWidgets('ListWheelScrollView.useDelegate restoration', (WidgetTester tester) async {
await tester.pumpWidget(
TestHarness(
child: ListWheelScrollView.useDelegate(
restorationId: 'wheel',
itemExtent: 50,
childDelegate: ListWheelChildListDelegate(
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
);
await restoreScrollAndVerify(tester, secondOffset: 542);
});
testWidgets('NestedScrollView restoration', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: TestHarness(
height: 200,
child: NestedScrollView(
restorationId: 'outer',
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: const Text('Books'),
pinned: true,
expandedHeight: 150.0,
forceElevated: innerBoxIsScrolled,
),
),
];
},
body: ListView(
restorationId: 'inner',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
),
),
);
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry!.paintExtent, 150);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.drag(find.byType(NestedScrollView), const Offset(0, -500));
await tester.pump();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry!.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry!.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
await tester.drag(find.byType(NestedScrollView), const Offset(0, 600));
await tester.pump();
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry!.paintExtent, 150);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.restoreFrom(data);
expect(tester.renderObject<RenderSliver>(find.byType(SliverAppBar)).geometry!.paintExtent, 56);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
});
testWidgets('RestorationData is flushed even if no frame is scheduled', (
WidgetTester tester,
) async {
await tester.pumpWidget(
TestHarness(
child: ListView(
restorationId: 'list',
cacheExtent: 0,
children: List<Widget>.generate(
50,
(int index) => SizedBox(height: 50, child: Text('Tile $index')),
),
),
),
);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
final TestRestorationData initialData = await tester.getRestorationData();
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)));
await gesture.moveBy(const Offset(0, -525));
await tester.pump();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
// Restoration data hasn't changed.
expect(await tester.getRestorationData(), initialData);
// Restoration data changes with up event.
await gesture.up();
await tester.pump();
expect(await tester.getRestorationData(), isNot(initialData));
});
}
Future<void> pageViewScrollAndRestore(WidgetTester tester) async {
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(50.0 * 10);
await tester.pumpAndSettle();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 50.0 * 10);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0);
await tester.pump();
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 50.0 * 10);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
}
Future<void> restoreScrollAndVerify(WidgetTester tester, {double secondOffset = 525}) async {
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is Scrollable);
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
tester.state<ScrollableState>(findScrollable).position.jumpTo(secondOffset);
await tester.pump();
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
await tester.restartAndRestore();
expect(tester.state<ScrollableState>(findScrollable).position.pixels, secondOffset);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
final TestRestorationData data = await tester.getRestorationData();
tester.state<ScrollableState>(findScrollable).position.jumpTo(0);
await tester.pump();
expect(find.text('Tile 0'), findsOneWidget);
expect(find.text('Tile 1'), findsOneWidget);
expect(find.text('Tile 10'), findsNothing);
expect(find.text('Tile 11'), findsNothing);
expect(find.text('Tile 12'), findsNothing);
await tester.restoreFrom(data);
expect(tester.state<ScrollableState>(findScrollable).position.pixels, secondOffset);
expect(find.text('Tile 0'), findsNothing);
expect(find.text('Tile 1'), findsNothing);
expect(find.text('Tile 10'), findsOneWidget);
expect(find.text('Tile 11'), findsOneWidget);
expect(find.text('Tile 12'), findsOneWidget);
}
class TestHarness extends StatelessWidget {
const TestHarness({super.key, required this.child, this.height = 100});
final Widget child;
final double height;
@override
Widget build(BuildContext context) {
return RootRestorationScope(
restorationId: 'root',
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(height: height, width: 50, child: child),
),
),
);
}
}