Unverified Commit 8fa470f3 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Add CupertinoTabController (#31227)

Add CupertinoTabController that allows a CupertinoTabScaffold's current page to be controlled from an ancestor widget.
parent a0ed52ca
......@@ -6,6 +6,100 @@ import 'package:flutter/widgets.dart';
import 'bottom_tab_bar.dart';
import 'theme.dart';
/// Coordinates tab selection between a [CupertinoTabBar] and a [CupertinoTabScaffold].
///
/// The [index] property is the index of the selected tab. Changing its value
/// updates the actively displayed tab of the [CupertinoTabScaffold] the
/// [CupertinoTabController] controls, as well as the currently selected tab item of
/// its [CupertinoTabBar].
///
/// {@tool sample}
///
/// [CupertinoTabController] can be used to switch tabs:
///
/// ```dart
/// class MyCupertinoTabScaffoldPage extends StatefulWidget {
/// @override
/// _CupertinoTabScaffoldPageState createState() => _CupertinoTabScaffoldPageState();
/// }
///
/// class _CupertinoTabScaffoldPageState extends State<MyCupertinoTabScaffoldPage> {
/// final CupertinoTabController _controller = CupertinoTabController();
///
/// @override
/// Widget build(BuildContext context) {
/// return CupertinoTabScaffold(
/// tabBar: CupertinoTabBar(
/// items: <BottomNavigationBarItem> [
/// // ...
/// ],
/// ),
/// controller: _controller,
/// tabBuilder: (BuildContext context, int index) {
/// return Center(
/// child: CupertinoButton(
/// child: const Text('Go to first tab'),
/// onPressed: () => _controller.index = 0,
/// )
/// );
/// }
/// );
/// }
///
/// @override
/// void dispose() {
/// _controller.dispose();
/// super.dispose();
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoTabScaffold], a tabbed application root layout that can be
/// controlled by a [CupertinoTabController].
class CupertinoTabController extends ChangeNotifier {
/// Creates a [CupertinoTabController] 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.
CupertinoTabController({ int initialIndex = 0 })
: _index = initialIndex,
assert(initialIndex != null),
assert(initialIndex >= 0);
bool _isDisposed = false;
/// The index of the currently selected tab.
///
/// Changing the value of [index] updates the actively displayed tab of the
/// [CupertinoTabScaffold] controlled by this [CupertinoTabController], as well
/// as the currently selected tab item of its [CupertinoTabScaffold.tabBar].
///
/// The value must be greater than or equal to 0, and less than the total
/// number of tabs.
int get index => _index;
int _index;
set index(int value) {
assert(value != null);
assert(value >= 0);
if (_index == value) {
return;
}
_index = value;
notifyListeners();
}
@mustCallSuper
@override
void dispose() {
super.dispose();
_isDisposed = true;
}
}
/// Implements a tabbed iOS application's root layout and behavior structure.
///
/// The scaffold lays out the tab bar at the bottom and the content between or
......@@ -15,6 +109,12 @@ import 'theme.dart';
/// will automatically listen to the provided [CupertinoTabBar]'s tap callbacks
/// to change the active tab.
///
/// A [controller] can be used to provide an initially selected tab index and manage
/// subsequent tab changes. If a controller is not specified, the scaffold will
/// create its own [CupertinoTabController] and manage it internally. Otherwise
/// it's up to the owner of [controller] to call `dispose` on it after finish
/// using it.
///
/// Tabs' contents are built with the provided [tabBuilder] at the active
/// tab index. The [tabBuilder] must be able to build the same number of
/// pages as there are [tabBar.items]. Inactive tabs will be moved [Offstage]
......@@ -87,6 +187,7 @@ import 'theme.dart';
/// See also:
///
/// * [CupertinoTabBar], the bottom tab bar inserted in the scaffold.
/// * [CupertinoTabController], the selection state of this widget
/// * [CupertinoTabView], the typical root content of each tab that holds its own
/// [Navigator] stack.
/// * [CupertinoPageRoute], a route hosting modal pages with iOS style transitions.
......@@ -96,27 +197,35 @@ class CupertinoTabScaffold extends StatefulWidget {
/// Creates a layout for applications with a tab bar at the bottom.
///
/// The [tabBar] and [tabBuilder] arguments must not be null.
const CupertinoTabScaffold({
CupertinoTabScaffold({
Key key,
@required this.tabBar,
@required this.tabBuilder,
this.controller,
this.backgroundColor,
this.resizeToAvoidBottomInset = true,
}) : assert(tabBar != null),
assert(tabBuilder != null),
assert(
controller == null || controller.index < tabBar.items.length,
"The CupertinoTabController's current index ${controller.index} is "
'out of bounds for the tab bar with ${tabBar.items.length} tabs'
),
super(key: key);
/// The [tabBar] is a [CupertinoTabBar] drawn at the bottom of the screen
/// that lets the user switch between different tabs in the main content area
/// when present.
///
/// Setting and changing [CupertinoTabBar.currentIndex] programmatically will
/// change the currently selected tab item in the [tabBar] as well as change
/// the currently focused tab from the [tabBuilder].
/// The [CupertinoTabBar.currentIndex] is only used to initialize a
/// [CupertinoTabController] when no [controller] is provided. Subsequently
/// providing a different [CupertinoTabBar.currentIndex] does not affect the
/// scaffold or the tab bar's active tab index. To programmatically change
/// the active tab index, use a [CupertinoTabController].
///
/// If [CupertinoTabBar.onTap] is provided, it will still be called.
/// [CupertinoTabScaffold] automatically also listen to the
/// [CupertinoTabBar]'s `onTap` to change the [CupertinoTabBar]'s `currentIndex`
/// [CupertinoTabBar]'s `onTap` to change the [controller]'s `index`
/// and change the actively displayed tab in [CupertinoTabScaffold]'s own
/// main content area.
///
......@@ -126,6 +235,14 @@ class CupertinoTabScaffold extends StatefulWidget {
/// Must not be null.
final CupertinoTabBar tabBar;
/// Controls the currently selected tab index of the [tabBar], as well as the
/// active tab index of the [tabBuilder]. Providing a different [controller]
/// will also update the scaffold's current active index to the new controller's
/// index value.
///
/// Defaults to null.
final CupertinoTabController controller;
/// An [IndexedWidgetBuilder] that's called when tabs become active.
///
/// The widgets built by [IndexedWidgetBuilder] is typically a [CupertinoTabView]
......@@ -162,29 +279,55 @@ class CupertinoTabScaffold extends StatefulWidget {
}
class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
int _currentPage;
CupertinoTabController _controller;
@override
void initState() {
super.initState();
_currentPage = widget.tabBar.currentIndex;
_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;
}
if (shouldDisposeOldController) {
_controller?.dispose();
} else if (_controller?._isDisposed == false) {
_controller.removeListener(_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 "
'out of bounds for the tab bar with ${widget.tabBar.items.length} tabs'
);
// The value of `_controller.index` has already been updated at this point.
// Calling `setState` to rebuild using `_controller.index`.
setState(() {});
}
@override
void didUpdateWidget(CupertinoTabScaffold oldWidget) {
super.didUpdateWidget(oldWidget);
if (_currentPage >= widget.tabBar.items.length) {
// Clip down to an acceptable range.
_currentPage = widget.tabBar.items.length - 1;
// Sanity check, since CupertinoTabBar.items's minimum length is 2.
assert(
_currentPage >= 0,
'CupertinoTabBar is expected to keep at least 2 tabs after updating',
);
}
// The user can still specify an exact desired index.
if (widget.tabBar.currentIndex != oldWidget.tabBar.currentIndex) {
_currentPage = widget.tabBar.currentIndex;
if (widget.controller != oldWidget.controller) {
_updateTabController(shouldDisposeOldController: oldWidget.controller == null);
} 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;
}
}
......@@ -196,7 +339,7 @@ class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
MediaQueryData newMediaQuery = MediaQuery.of(context);
Widget content = _TabSwitchingView(
currentTabIndex: _currentPage,
currentTabIndex: _controller.index,
tabNumber: widget.tabBar.items.length,
tabBuilder: widget.tabBuilder,
);
......@@ -248,14 +391,12 @@ class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
stacked.add(Align(
alignment: Alignment.bottomCenter,
// Override the tab bar's currentIndex to the current tab and hook in
// our own listener to update the _currentPage on top of a possibly user
// our own listener to update the [_controller.currentIndex] on top of a possibly user
// provided callback.
child: widget.tabBar.copyWith(
currentIndex: _currentPage,
currentIndex: _controller.index,
onTap: (int newIndex) {
setState(() {
_currentPage = newIndex;
});
_controller.index = newIndex;
// Chain the user's original callback.
if (widget.tabBar.onTap != null)
widget.tabBar.onTap(newIndex);
......@@ -273,6 +414,18 @@ 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);
}
super.dispose();
}
}
/// A widget laying out multiple tabs with only one active tab being built
......
......@@ -10,11 +10,43 @@ import '../rendering/rendering_tester.dart';
List<int> selectedTabs;
class MockCupertinoTabController extends CupertinoTabController {
MockCupertinoTabController({ int initialIndex }): super(initialIndex: initialIndex);
bool isDisposed = false;
int numOfListeners = 0;
@override
void addListener(VoidCallback listener) {
numOfListeners++;
super.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
numOfListeners--;
super.removeListener(listener);
}
@override
void dispose() {
isDisposed = true;
super.dispose();
}
}
void main() {
setUp(() {
selectedTabs = <int>[];
});
BottomNavigationBarItem tabGenerator(int index) {
return BottomNavigationBarItem(
icon: const ImageIcon(TestImageProvider(24, 24)),
title: Text('Tab ${index + 1}'),
);
}
testWidgets('Tab switching', (WidgetTester tester) async {
final List<int> tabsPainted = <int>[];
......@@ -203,7 +235,45 @@ void main() {
);
});
testWidgets('Programmatic tab switching', (WidgetTester tester) async {
testWidgets('Programmatic tab switching by changing the index of an existing controller', (WidgetTester tester) async {
final CupertinoTabController controller = CupertinoTabController(initialIndex: 1);
final List<int> tabsPainted = <int>[];
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: _buildTabBar(),
controller: controller,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
child: Text('Page ${index + 1}'),
painter: TestCallbackPainter(
onPaint: () { tabsPainted.add(index); }
),
);
},
),
),
);
expect(tabsPainted, <int>[1]);
controller.index = 0;
await tester.pump();
expect(tabsPainted, <int>[1, 0]);
// onTap is not called when changing tabs programmatically.
expect(selectedTabs, isEmpty);
// Can still tap out of the programmatically selected tab.
await tester.tap(find.text('Tab 2'));
await tester.pump();
expect(tabsPainted, <int>[1, 0, 1]);
expect(selectedTabs, <int>[1]);
});
testWidgets('Programmatic tab switching by passing in a new controller', (WidgetTester tester) async {
final List<int> tabsPainted = <int>[];
await tester.pumpWidget(
......@@ -227,7 +297,8 @@ void main() {
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: _buildTabBar(selectedTab: 1), // Programmatically change the tab now.
tabBar: _buildTabBar(),
controller: CupertinoTabController(initialIndex: 1), // Programmatically change the tab now.
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
child: Text('Page ${index + 1}'),
......@@ -393,16 +464,9 @@ void main() {
expect(MediaQuery.of(innerContext).padding.bottom, 0);
});
testWidgets('Deleting tabs after selecting them works', (WidgetTester tester) async {
testWidgets('Deleting tabs after selecting them should switch to the last available tab', (WidgetTester tester) async {
final List<int> tabsBuilt = <int>[];
BottomNavigationBarItem tabGenerator(int index) {
return BottomNavigationBarItem(
icon: const ImageIcon(TestImageProvider(24, 24)),
title: Text('Tab ${index + 1}'),
);
}
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
......@@ -434,7 +498,7 @@ void main() {
expect(find.text('Page 4'), findsOneWidget);
tabsBuilt.clear();
// Delete 2 tabs.
// Delete 2 tabs while Page 4 is still selected.
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
......@@ -448,7 +512,7 @@ void main() {
return Text('Different page ${index + 1}');
},
),
),
)
);
expect(tabsBuilt, <int>[0, 1]);
......@@ -469,6 +533,314 @@ void main() {
expect(find.text('Page 4', skipOffstage: false), findsNothing);
});
testWidgets('If a controller is initially provided then the parent stops doing so for rebuilds, '
'a new instance of CupertinoTabController should be created and used by the widget, '
"while preserving the previous controller's tab index",
(WidgetTester tester) async {
final List<int> tabsPainted = <int>[];
final CupertinoTabController oldController = CupertinoTabController(initialIndex: 0);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(10, tabGenerator),
),
controller: oldController,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
child: Text('Page ${index + 1}'),
painter: TestCallbackPainter(
onPaint: () { tabsPainted.add(index); }
),
);
}
),
)
);
expect(tabsPainted, <int> [0]);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(10, tabGenerator),
),
controller: null,
tabBuilder:
(BuildContext context, int index) {
return CustomPaint(
child: Text('Page ${index + 1}'),
painter: TestCallbackPainter(
onPaint: () { tabsPainted.add(index); }
),
);
}
),
)
);
expect(tabsPainted, <int> [0, 0]);
await tester.tap(find.text('Tab 2'));
await tester.pump();
// Tapping the tabs should still work.
expect(tabsPainted, <int>[0, 0, 1]);
oldController.index = 10;
await tester.pump();
// Changing [index] of the oldController should not work.
expect(tabsPainted, <int> [0, 0, 1]);
});
testWidgets('Do not call dispose on a controller that we do not own'
'but do remove from its listeners when done listening to it',
(WidgetTester tester) async {
final MockCupertinoTabController mockController = MockCupertinoTabController(initialIndex: 0);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
),
controller: mockController,
tabBuilder: (BuildContext context, int index) => const Placeholder(),
),
)
);
expect(mockController.numOfListeners, 1);
expect(mockController.isDisposed, isFalse);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
),
controller: null,
tabBuilder: (BuildContext context, int index) => const Placeholder(),
),
)
);
expect(mockController.numOfListeners, 0);
expect(mockController.isDisposed, isFalse);
});
testWidgets('The owner can dispose the old controller', (WidgetTester tester) async {
CupertinoTabController controller = CupertinoTabController(initialIndex: 2);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) => const Placeholder()
),
)
);
expect(find.text('Tab 1'), findsOneWidget);
expect(find.text('Tab 2'), findsOneWidget);
expect(find.text('Tab 3'), findsOneWidget);
controller.dispose();
controller = CupertinoTabController(initialIndex: 0);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) => const Placeholder()
),
)
);
// Should not crash here.
expect(find.text('Tab 1'), findsOneWidget);
expect(find.text('Tab 2'), findsOneWidget);
expect(find.text('Tab 3'), findsNothing);
});
testWidgets('A controller can control more than one CupertinoTabScaffold,'
'removal of listeners does not break the controller',
(WidgetTester tester) async {
final List<int> tabsPainted0 = <int>[];
final List<int> tabsPainted1 = <int>[];
MockCupertinoTabController controller = MockCupertinoTabController(initialIndex: 2);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
child: Stack(
children: <Widget>[
CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
painter: TestCallbackPainter(
onPaint: () => tabsPainted0.add(index)
)
);
}
),
CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
painter: TestCallbackPainter(
onPaint: () => tabsPainted1.add(index)
)
);
}
),
]
)
)
)
);
expect(tabsPainted0, const <int>[2]);
expect(tabsPainted1, const <int>[2]);
expect(controller.numOfListeners, 2);
controller.index = 0;
await tester.pump();
expect(tabsPainted0, const <int>[2, 0]);
expect(tabsPainted1, const <int>[2, 0]);
controller.index = 1;
// Removing one of the tabs works.
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
child: Stack(
children: <Widget>[
CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
painter: TestCallbackPainter(
onPaint: () => tabsPainted0.add(index)
)
);
}
),
]
)
)
)
);
expect(tabsPainted0, const <int>[2, 0, 1]);
expect(tabsPainted1, const <int>[2, 0]);
expect(controller.numOfListeners, 1);
// Replacing controller works.
controller = MockCupertinoTabController(initialIndex: 2);
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
child: Stack(
children: <Widget>[
CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) {
return CustomPaint(
painter: TestCallbackPainter(
onPaint: () => tabsPainted0.add(index)
)
);
}
),
]
)
)
)
);
expect(tabsPainted0, const <int>[2, 0, 1, 2]);
expect(tabsPainted1, const <int>[2, 0]);
expect(controller.numOfListeners, 1);
});
testWidgets('Assert when current tab index >= number of tabs', (WidgetTester tester) async {
final CupertinoTabController controller = CupertinoTabController(initialIndex: 2);
try {
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'),
),
)
);
} on AssertionError catch (e) {
expect(e.toString(), contains('controller.index < tabBar.items.length'));
}
await tester.pumpWidget(
CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
),
controller: controller,
tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'),
),
)
);
expect(tester.takeException(), null);
controller.index = 10;
await tester.pump();
final String message = tester.takeException().toString();
expect(message, contains('current index ${controller.index}'));
expect(message, contains('with 3 tabs'));
});
testWidgets('Current tab index cannot go below zero or be null', (WidgetTester tester) async {
void expectAssertionError(VoidCallback callback, String errorMessage) {
try {
callback();
} on AssertionError catch (e) {
expect(e.toString(), contains(errorMessage));
}
}
expectAssertionError(() => CupertinoTabController(initialIndex: -1), '>= 0');
expectAssertionError(() => CupertinoTabController(initialIndex: null), '!= null');
final CupertinoTabController controller = CupertinoTabController();
expectAssertionError(() => controller.index = -1, '>= 0');
expectAssertionError(() => controller.index = null, '!= null');
});
testWidgets('Does not lose state when focusing on text input', (WidgetTester tester) async {
// Regression testing for https://github.com/flutter/flutter/issues/28457.
......
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