diff --git a/packages/flutter/lib/src/cupertino/tab_scaffold.dart b/packages/flutter/lib/src/cupertino/tab_scaffold.dart index aed410c635..d6fa60d0df 100644 --- a/packages/flutter/lib/src/cupertino/tab_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/tab_scaffold.dart @@ -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 { +/// final CupertinoTabController _controller = CupertinoTabController(); +/// +/// @override +/// Widget build(BuildContext context) { +/// return CupertinoTabScaffold( +/// tabBar: CupertinoTabBar( +/// items: [ +/// // ... +/// ], +/// ), +/// 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 { - 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 { 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 { 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 { ), ); } + + @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 diff --git a/packages/flutter/test/cupertino/tab_scaffold_test.dart b/packages/flutter/test/cupertino/tab_scaffold_test.dart index 4f2fb96c90..156dffd625 100644 --- a/packages/flutter/test/cupertino/tab_scaffold_test.dart +++ b/packages/flutter/test/cupertino/tab_scaffold_test.dart @@ -10,11 +10,43 @@ import '../rendering/rendering_tester.dart'; List 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 = []; }); + 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 tabsPainted = []; @@ -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 tabsPainted = []; + + 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, [1]); + + controller.index = 0; + await tester.pump(); + + expect(tabsPainted, [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, [1, 0, 1]); + expect(selectedTabs, [1]); + }); + + testWidgets('Programmatic tab switching by passing in a new controller', (WidgetTester tester) async { final List tabsPainted = []; 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 tabsBuilt = []; - 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, [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 tabsPainted = []; + final CupertinoTabController oldController = CupertinoTabController(initialIndex: 0); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.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, [0]); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.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, [0, 0]); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + // Tapping the tabs should still work. + expect(tabsPainted, [0, 0, 1]); + + oldController.index = 10; + await tester.pump(); + + // Changing [index] of the oldController should not work. + expect(tabsPainted, [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.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.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.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.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 tabsPainted0 = []; + final List tabsPainted1 = []; + MockCupertinoTabController controller = MockCupertinoTabController(initialIndex: 2); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: [ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () => tabsPainted0.add(index) + ) + ); + } + ), + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () => tabsPainted1.add(index) + ) + ); + } + ), + ] + ) + ) + ) + ); + expect(tabsPainted0, const [2]); + expect(tabsPainted1, const [2]); + expect(controller.numOfListeners, 2); + + controller.index = 0; + await tester.pump(); + expect(tabsPainted0, const [2, 0]); + expect(tabsPainted1, const [2, 0]); + + controller.index = 1; + // Removing one of the tabs works. + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: [ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () => tabsPainted0.add(index) + ) + ); + } + ), + ] + ) + ) + ) + ); + + expect(tabsPainted0, const [2, 0, 1]); + expect(tabsPainted1, const [2, 0]); + expect(controller.numOfListeners, 1); + + // Replacing controller works. + controller = MockCupertinoTabController(initialIndex: 2); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: [ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () => tabsPainted0.add(index) + ) + ); + } + ), + ] + ) + ) + ) + ); + expect(tabsPainted0, const [2, 0, 1, 2]); + expect(tabsPainted1, const [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.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.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.