Make CupertinoTabScaffold restorable (#67770)
This commit is contained in:
parent
daa6b2cc29
commit
053ebf2c08
@ -61,6 +61,8 @@ import 'theme.dart';
|
|||||||
///
|
///
|
||||||
/// * [CupertinoTabScaffold], a tabbed application root layout that can be
|
/// * [CupertinoTabScaffold], a tabbed application root layout that can be
|
||||||
/// controlled by a [CupertinoTabController].
|
/// controlled by a [CupertinoTabController].
|
||||||
|
/// * [RestorableCupertinoTabController], which is a restorable version
|
||||||
|
/// of this controller.
|
||||||
class CupertinoTabController extends ChangeNotifier {
|
class CupertinoTabController extends ChangeNotifier {
|
||||||
/// Creates a [CupertinoTabController] to control the tab index of [CupertinoTabScaffold]
|
/// Creates a [CupertinoTabController] to control the tab index of [CupertinoTabScaffold]
|
||||||
/// and [CupertinoTabBar].
|
/// and [CupertinoTabBar].
|
||||||
@ -211,6 +213,7 @@ class CupertinoTabScaffold extends StatefulWidget {
|
|||||||
this.controller,
|
this.controller,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.resizeToAvoidBottomInset = true,
|
this.resizeToAvoidBottomInset = true,
|
||||||
|
this.restorationId,
|
||||||
}) : assert(tabBar != null),
|
}) : assert(tabBar != null),
|
||||||
assert(tabBuilder != null),
|
assert(tabBuilder != null),
|
||||||
assert(
|
assert(
|
||||||
@ -289,12 +292,46 @@ class CupertinoTabScaffold extends StatefulWidget {
|
|||||||
/// Defaults to true and cannot be null.
|
/// Defaults to true and cannot be null.
|
||||||
final bool resizeToAvoidBottomInset;
|
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
|
@override
|
||||||
_CupertinoTabScaffoldState createState() => _CupertinoTabScaffoldState();
|
_CupertinoTabScaffoldState createState() => _CupertinoTabScaffoldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
|
class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> with RestorationMixin {
|
||||||
CupertinoTabController? _controller;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -302,30 +339,33 @@ class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
|
|||||||
_updateTabController();
|
_updateTabController();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateTabController({ bool shouldDisposeOldController = false }) {
|
void _updateTabController([CupertinoTabController? oldWidgetController]) {
|
||||||
final CupertinoTabController newController =
|
if (widget.controller == null && _internalController == null) {
|
||||||
// User provided a new controller, update `_controller` with it.
|
// No widget-provided controller: create an internal controller.
|
||||||
widget.controller
|
_internalController = RestorableCupertinoTabController(initialIndex: widget.tabBar.currentIndex);
|
||||||
?? CupertinoTabController(initialIndex: widget.tabBar.currentIndex);
|
if (!restorePending) {
|
||||||
|
_restoreInternalController(); // Also adds the listener to the controller.
|
||||||
if (newController == _controller) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDisposeOldController) {
|
|
||||||
_controller?.dispose();
|
|
||||||
} else if (_controller?._isDisposed == false) {
|
|
||||||
_controller!.removeListener(_onCurrentIndexChange);
|
|
||||||
}
|
}
|
||||||
|
if (widget.controller != null && _internalController != null) {
|
||||||
newController.addListener(_onCurrentIndexChange);
|
// Use the widget-provided controller.
|
||||||
_controller = newController;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCurrentIndexChange() {
|
void _onCurrentIndexChange() {
|
||||||
assert(
|
assert(
|
||||||
_controller!.index >= 0 && _controller!.index < widget.tabBar.items.length,
|
_controller.index >= 0 && _controller.index < widget.tabBar.items.length,
|
||||||
"The $runtimeType's current index ${_controller!.index} is "
|
"The $runtimeType's current index ${_controller.index} is "
|
||||||
'out of bounds for the tab bar with ${widget.tabBar.items.length} tabs'
|
'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) {
|
void didUpdateWidget(CupertinoTabScaffold oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.controller != oldWidget.controller) {
|
if (widget.controller != oldWidget.controller) {
|
||||||
_updateTabController(shouldDisposeOldController: oldWidget.controller == null);
|
_updateTabController(oldWidget.controller);
|
||||||
} else if (_controller!.index >= widget.tabBar.items.length) {
|
} else if (_controller.index >= widget.tabBar.items.length) {
|
||||||
// If a new [tabBar] with less than (_controller.index + 1) items is provided,
|
// If a new [tabBar] with less than (_controller.index + 1) items is provided,
|
||||||
// clamp the current index.
|
// 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)!;
|
MediaQueryData newMediaQuery = MediaQuery.of(context)!;
|
||||||
|
|
||||||
Widget content = _TabSwitchingView(
|
Widget content = _TabSwitchingView(
|
||||||
currentTabIndex: _controller!.index,
|
currentTabIndex: _controller.index,
|
||||||
tabCount: widget.tabBar.items.length,
|
tabCount: widget.tabBar.items.length,
|
||||||
tabBuilder: widget.tabBuilder,
|
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
|
// our own listener to update the [_controller.currentIndex] on top of a possibly user
|
||||||
// provided callback.
|
// provided callback.
|
||||||
child: widget.tabBar.copyWith(
|
child: widget.tabBar.copyWith(
|
||||||
currentIndex: _controller!.index,
|
currentIndex: _controller.index,
|
||||||
onTap: (int newIndex) {
|
onTap: (int newIndex) {
|
||||||
_controller!.index = newIndex;
|
_controller.index = newIndex;
|
||||||
// Chain the user's original callback.
|
// Chain the user's original callback.
|
||||||
widget.tabBar.onTap?.call(newIndex);
|
widget.tabBar.onTap?.call(newIndex);
|
||||||
},
|
},
|
||||||
@ -431,13 +471,10 @@ class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Only dispose `_controller` when the state instance owns it.
|
if (widget.controller?._isDisposed == false) {
|
||||||
if (widget.controller == null) {
|
_controller.removeListener(_onCurrentIndexChange);
|
||||||
_controller?.dispose();
|
|
||||||
} else if (_controller?._isDisposed == false) {
|
|
||||||
_controller!.removeListener(_onCurrentIndexChange);
|
|
||||||
}
|
}
|
||||||
|
_internalController?.dispose();
|
||||||
super.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
|
/// A [RestorableProperty] that knows how to store and restore a
|
||||||
/// [TextEditingController].
|
/// [TextEditingController].
|
||||||
///
|
///
|
||||||
@ -299,7 +336,7 @@ abstract class RestorableListenable<T extends Listenable> extends RestorableProp
|
|||||||
/// state restoration, the property will restore [TextEditingController.text] to
|
/// state restoration, the property will restore [TextEditingController.text] to
|
||||||
/// the value it had when the restoration data it is getting restored from was
|
/// the value it had when the restoration data it is getting restored from was
|
||||||
/// collected.
|
/// collected.
|
||||||
class RestorableTextEditingController extends RestorableListenable<TextEditingController> {
|
class RestorableTextEditingController extends RestorableChangeNotifier<TextEditingController> {
|
||||||
/// Creates a [RestorableTextEditingController].
|
/// Creates a [RestorableTextEditingController].
|
||||||
///
|
///
|
||||||
/// This constructor treats a null `text` argument as if it were the empty
|
/// This constructor treats a null `text` argument as if it were the empty
|
||||||
@ -331,27 +368,4 @@ class RestorableTextEditingController extends RestorableListenable<TextEditingCo
|
|||||||
Object toPrimitives() {
|
Object toPrimitives() {
|
||||||
return value.text;
|
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.length, greaterThan(0));
|
||||||
expect(contents.any((RichText t) => t.textScaleFactor != 99), isFalse);
|
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 }) {
|
CupertinoTabBar _buildTabBar({ int selectedTab = 0 }) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user