Unverified Commit 053ebf2c authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Make CupertinoTabScaffold restorable (#67770)

parent daa6b2cc
......@@ -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<CupertinoTabScaffold> {
CupertinoTabController? _controller;
class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> 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<CupertinoTabScaffold> {
_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<CupertinoTabScaffold> {
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<CupertinoTabScaffold> {
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<CupertinoTabScaffold> {
// 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<CupertinoTabScaffold> {
@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<CupertinoTabController> {
/// 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;
}
}
......@@ -292,6 +292,43 @@ abstract class RestorableListenable<T extends Listenable> 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<T extends ChangeNotifier> extends RestorableListenable<T> {
@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<T extends Listenable> 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<TextEditingController> {
class RestorableTextEditingController extends RestorableChangeNotifier<TextEditingController> {
/// Creates a [RestorableTextEditingController].
///
/// This constructor treats a null `text` argument as if it were the empty
......@@ -331,27 +368,4 @@ class RestorableTextEditingController extends RestorableListenable<TextEditingCo
Object toPrimitives() {
return value.text;
}
TextEditingController? _controller;
@override
void initWithValue(TextEditingController value) {
_disposeControllerIfNecessary();
_controller = value;
super.initWithValue(value);
}
@override
void dispose() {
super.dispose();
_disposeControllerIfNecessary();
}
void _disposeControllerIfNecessary() {
if (_controller != null) {
// Scheduling a microtask for dispose to give other entities a chance
// to remove their listeners first.
scheduleMicrotask(_controller!.dispose);
}
}
}
......@@ -1110,6 +1110,110 @@ void main() {
expect(contents.length, greaterThan(0));
expect(contents.any((RichText t) => t.textScaleFactor != 99), isFalse);
});
testWidgets('state restoration', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
restorationScopeId: 'app',
home: CupertinoTabScaffold(
restorationId: 'scaffold',
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.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<BottomNavigationBarItem>.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 }) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment