// 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'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; void main() { testWidgets('CustomScrollView restoration', (WidgetTester tester) async { await tester.pumpWidget( TestHarness( child: CustomScrollView( restorationId: 'list', cacheExtent: 0, slivers: <Widget>[ SliverList( delegate: SliverChildListDelegate( List<Widget>.generate( 50, (int index) => 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', // TODO(polina-c): clean up leaks, https://github.com/flutter/flutter/issues/134787 experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), (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', // TODO(polina-c): Remove when PageView is fixed, https://github.com/flutter/flutter/issues/141119 experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), (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', // TODO(polina-c): Remove when PageView is fixed, https://github.com/flutter/flutter/issues/141119 experimentalLeakTesting: LeakTesting.settings.withIgnoredAll(), (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, ), ), ), ); } }