From 053ebf2c080c7b8efbf4020683a5ba27d9daa3b8 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Mon, 12 Oct 2020 14:17:02 -0700 Subject: [PATCH] Make CupertinoTabScaffold restorable (#67770) --- .../lib/src/cupertino/tab_scaffold.dart | 137 ++++++++++++++---- .../src/widgets/restoration_properties.dart | 62 +++++--- .../test/cupertino/tab_scaffold_test.dart | 104 +++++++++++++ 3 files changed, 247 insertions(+), 56 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/tab_scaffold.dart b/packages/flutter/lib/src/cupertino/tab_scaffold.dart index 1d85bfc9f7..2c65a092a9 100644 --- a/packages/flutter/lib/src/cupertino/tab_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/tab_scaffold.dart @@ -61,6 +61,8 @@ import 'theme.dart'; /// /// * [CupertinoTabScaffold], a tabbed application root layout that can be /// controlled by a [CupertinoTabController]. +/// * [RestorableCupertinoTabController], which is a restorable version +/// of this controller. class CupertinoTabController extends ChangeNotifier { /// Creates a [CupertinoTabController] to control the tab index of [CupertinoTabScaffold] /// and [CupertinoTabBar]. @@ -211,6 +213,7 @@ class CupertinoTabScaffold extends StatefulWidget { this.controller, this.backgroundColor, this.resizeToAvoidBottomInset = true, + this.restorationId, }) : assert(tabBar != null), assert(tabBuilder != null), assert( @@ -289,12 +292,46 @@ class CupertinoTabScaffold extends StatefulWidget { /// Defaults to true and cannot be null. final bool resizeToAvoidBottomInset; + /// Restoration ID to save and restore the state of the [CupertinoTabScaffold]. + /// + /// This property only has an effect when no [controller] has been provided: + /// If it is non-null (and no [controller] has been provided), the scaffold + /// will persist and restore the currently selected tab index. If a + /// [controller] has been provided, it is the responsibility of the owner of + /// that controller to persist and restore it, e.g. by using a + /// [RestorableCupertinoTabController]. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + @override _CupertinoTabScaffoldState createState() => _CupertinoTabScaffoldState(); } -class _CupertinoTabScaffoldState extends State { - CupertinoTabController? _controller; +class _CupertinoTabScaffoldState extends State with RestorationMixin { + RestorableCupertinoTabController? _internalController; + CupertinoTabController get _controller => widget.controller ?? _internalController!.value; + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + _restoreInternalController(); + } + + void _restoreInternalController() { + if (_internalController != null) { + registerForRestoration(_internalController!, 'controller'); + _internalController!.value.addListener(_onCurrentIndexChange); + } + } @override void initState() { @@ -302,30 +339,33 @@ class _CupertinoTabScaffoldState extends State { _updateTabController(); } - void _updateTabController({ bool shouldDisposeOldController = false }) { - final CupertinoTabController newController = - // User provided a new controller, update `_controller` with it. - widget.controller - ?? CupertinoTabController(initialIndex: widget.tabBar.currentIndex); - - if (newController == _controller) { - return; + void _updateTabController([CupertinoTabController? oldWidgetController]) { + if (widget.controller == null && _internalController == null) { + // No widget-provided controller: create an internal controller. + _internalController = RestorableCupertinoTabController(initialIndex: widget.tabBar.currentIndex); + if (!restorePending) { + _restoreInternalController(); // Also adds the listener to the controller. + } } - - if (shouldDisposeOldController) { - _controller?.dispose(); - } else if (_controller?._isDisposed == false) { - _controller!.removeListener(_onCurrentIndexChange); + if (widget.controller != null && _internalController != null) { + // Use the widget-provided controller. + unregisterFromRestoration(_internalController!); + _internalController!.dispose(); + _internalController = null; + } + if (oldWidgetController != widget.controller) { + // The widget-provided controller has changed: move listeners. + if (oldWidgetController?._isDisposed == false) { + oldWidgetController!.removeListener(_onCurrentIndexChange); + } + widget.controller?.addListener(_onCurrentIndexChange); } - - newController.addListener(_onCurrentIndexChange); - _controller = newController; } void _onCurrentIndexChange() { assert( - _controller!.index >= 0 && _controller!.index < widget.tabBar.items.length, - "The $runtimeType's current index ${_controller!.index} is " + _controller.index >= 0 && _controller.index < widget.tabBar.items.length, + "The $runtimeType's current index ${_controller.index} is " 'out of bounds for the tab bar with ${widget.tabBar.items.length} tabs' ); @@ -338,11 +378,11 @@ class _CupertinoTabScaffoldState extends State { void didUpdateWidget(CupertinoTabScaffold oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { - _updateTabController(shouldDisposeOldController: oldWidget.controller == null); - } else if (_controller!.index >= widget.tabBar.items.length) { + _updateTabController(oldWidget.controller); + } else if (_controller.index >= widget.tabBar.items.length) { // If a new [tabBar] with less than (_controller.index + 1) items is provided, // clamp the current index. - _controller!.index = widget.tabBar.items.length - 1; + _controller.index = widget.tabBar.items.length - 1; } } @@ -352,7 +392,7 @@ class _CupertinoTabScaffoldState extends State { MediaQueryData newMediaQuery = MediaQuery.of(context)!; Widget content = _TabSwitchingView( - currentTabIndex: _controller!.index, + currentTabIndex: _controller.index, tabCount: widget.tabBar.items.length, tabBuilder: widget.tabBuilder, ); @@ -415,9 +455,9 @@ class _CupertinoTabScaffoldState extends State { // our own listener to update the [_controller.currentIndex] on top of a possibly user // provided callback. child: widget.tabBar.copyWith( - currentIndex: _controller!.index, + currentIndex: _controller.index, onTap: (int newIndex) { - _controller!.index = newIndex; + _controller.index = newIndex; // Chain the user's original callback. widget.tabBar.onTap?.call(newIndex); }, @@ -431,13 +471,10 @@ class _CupertinoTabScaffoldState extends State { @override void dispose() { - // Only dispose `_controller` when the state instance owns it. - if (widget.controller == null) { - _controller?.dispose(); - } else if (_controller?._isDisposed == false) { - _controller!.removeListener(_onCurrentIndexChange); + if (widget.controller?._isDisposed == false) { + _controller.removeListener(_onCurrentIndexChange); } - + _internalController?.dispose(); super.dispose(); } } @@ -555,3 +592,39 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> { ); } } + +/// A [RestorableProperty] that knows how to store and restore a +/// [CupertinoTabController]. +/// +/// The [CupertinoTabController] is accessible via the [value] getter. During +/// state restoration, the property will restore [CupertinoTabController.index] +/// to the value it had when the restoration data it is getting restored from +/// was collected. +class RestorableCupertinoTabController extends RestorableChangeNotifier { + /// Creates a [RestorableCupertinoTabController] to control the tab index of + /// [CupertinoTabScaffold] and [CupertinoTabBar]. + /// + /// The `initialIndex` must not be null and defaults to 0. The value must be + /// greater than or equal to 0, and less than the total number of tabs. + RestorableCupertinoTabController({ int initialIndex = 0 }) + : assert(initialIndex != null), + assert(initialIndex >= 0), + _initialIndex = initialIndex; + + final int _initialIndex; + + @override + CupertinoTabController createDefaultValue() { + return CupertinoTabController(initialIndex: _initialIndex); + } + + @override + CupertinoTabController fromPrimitives(Object data) { + return CupertinoTabController(initialIndex: data as int); + } + + @override + Object? toPrimitives() { + return value.index; + } +} diff --git a/packages/flutter/lib/src/widgets/restoration_properties.dart b/packages/flutter/lib/src/widgets/restoration_properties.dart index 8073ed738e..430e6cb387 100644 --- a/packages/flutter/lib/src/widgets/restoration_properties.dart +++ b/packages/flutter/lib/src/widgets/restoration_properties.dart @@ -292,6 +292,43 @@ abstract class RestorableListenable extends RestorableProp } } +/// A base class for creating a [RestorableProperty] that stores and restores a +/// [ChangeNotifier]. +/// +/// This class may be used to implement a [RestorableProperty] for a +/// [ChangeNotifier], whose information it needs to store in the restoration +/// data change whenever the [ChangeNotifier] notifies its listeners. +/// +/// The [RestorationMixin] this property is registered with will call +/// [toPrimitives] whenever the wrapped [ChangeNotifier] notifies its listeners +/// to update the information that this property has stored in the restoration +/// data. +/// +/// Furthermore, the property will dispose the wrapped [ChangeNotifier] when +/// either the property itself is disposed or its value is replaced with another +/// [ChangeNotifier] instance. +abstract class RestorableChangeNotifier extends RestorableListenable { + @override + void initWithValue(T value) { + _diposeOldValue(); + super.initWithValue(value); + } + + @override + void dispose() { + _diposeOldValue(); + super.dispose(); + } + + void _diposeOldValue() { + if (_value != null) { + // Scheduling a microtask for dispose to give other entities a chance + // to remove their listeners first. + scheduleMicrotask(_value!.dispose); + } + } +} + /// A [RestorableProperty] that knows how to store and restore a /// [TextEditingController]. /// @@ -299,7 +336,7 @@ abstract class RestorableListenable extends RestorableProp /// state restoration, the property will restore [TextEditingController.text] to /// the value it had when the restoration data it is getting restored from was /// collected. -class RestorableTextEditingController extends RestorableListenable { +class RestorableTextEditingController extends RestorableChangeNotifier { /// Creates a [RestorableTextEditingController]. /// /// This constructor treats a null `text` argument as if it were the empty @@ -331,27 +368,4 @@ class RestorableTextEditingController extends RestorableListenable t.textScaleFactor != 99), isFalse); }); + + testWidgets('state restoration', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + restorationScopeId: 'app', + home: CupertinoTabScaffold( + restorationId: 'scaffold', + tabBar: CupertinoTabBar( + items: List.generate( + 4, + (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'), + ), + ), + tabBuilder: (BuildContext context, int i) => Text('Content $i'), + ), + ), + ); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + await tester.restartAndRestore(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.tap(find.text('Tab 1')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsOneWidget); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.restoreFrom(data); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + }); + + testWidgets('switch from internal to external controller with state restoration', (WidgetTester tester) async { + Widget buildWidget({CupertinoTabController? controller}) { + return CupertinoApp( + restorationScopeId: 'app', + home: CupertinoTabScaffold( + controller: controller, + restorationId: 'scaffold', + tabBar: CupertinoTabBar( + items: List.generate( + 4, + (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'), + ), + ), + tabBuilder: (BuildContext context, int i) => Text('Content $i'), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + final CupertinoTabController controller = CupertinoTabController(initialIndex: 3); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsOneWidget); + + await tester.pumpWidget(buildWidget()); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + }); } CupertinoTabBar _buildTabBar({ int selectedTab = 0 }) {