mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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
516 lines
16 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|