// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'bottom_tab_bar.dart'; import 'colors.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 snippet} /// /// [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 /// behind the tab bar. /// /// A [tabBar] and a [tabBuilder] are required. The [CupertinoTabScaffold] /// 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] /// and their animations disabled. /// /// Adding/removing tabs, or changing the order of tabs is supported but not /// recommended. Doing so is against the iOS human interface guidelines, and /// [CupertinoTabScaffold] may lose some tabs' state in the process. /// /// Use [CupertinoTabView] as the root widget of each tab to support tabs with /// parallel navigation state and history. Since each [CupertinoTabView] contains /// a [Navigator], rebuilding the [CupertinoTabView] with a different /// [WidgetBuilder] instance in [CupertinoTabView.builder] will not recreate /// the [CupertinoTabView]'s navigation stack or update its UI. To update the /// contents of the [CupertinoTabView] after it's built, trigger a rebuild /// (via [State.setState], for instance) from its descendant rather than from /// its ancestor. /// /// {@tool snippet} /// /// A sample code implementing a typical iOS information architecture with tabs. /// /// ```dart /// CupertinoTabScaffold( /// tabBar: CupertinoTabBar( /// items: <BottomNavigationBarItem> [ /// // ... /// ], /// ), /// tabBuilder: (BuildContext context, int index) { /// return CupertinoTabView( /// builder: (BuildContext context) { /// return CupertinoPageScaffold( /// navigationBar: CupertinoNavigationBar( /// middle: Text('Page 1 of tab $index'), /// ), /// child: Center( /// child: CupertinoButton( /// child: const Text('Next page'), /// onPressed: () { /// Navigator.of(context).push( /// CupertinoPageRoute<void>( /// builder: (BuildContext context) { /// return CupertinoPageScaffold( /// navigationBar: CupertinoNavigationBar( /// middle: Text('Page 2 of tab $index'), /// ), /// child: Center( /// child: CupertinoButton( /// child: const Text('Back'), /// onPressed: () { Navigator.of(context).pop(); }, /// ), /// ), /// ); /// }, /// ), /// ); /// }, /// ), /// ), /// ); /// }, /// ); /// }, /// ) /// ``` /// {@end-tool} /// /// To push a route above all tabs instead of inside the currently selected one /// (such as when showing a dialog on top of this scaffold), use /// `Navigator.of(rootNavigator: true)` from inside the [BuildContext] of a /// [CupertinoTabView]. /// /// 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. /// * [CupertinoPageScaffold], typical contents of an iOS modal page implementing /// layout with a navigation bar on top. /// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/bars/tab-bars/). 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. 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. /// /// 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 [controller]'s `index` /// and change the actively displayed tab in [CupertinoTabScaffold]'s own /// main content area. /// /// If translucent, the main content may slide behind it. /// Otherwise, the main content's bottom margin will be offset by its height. /// /// By default `tabBar` has its text scale factor set to 1.0 and does not /// respond to text scale factor changes from the operating system, to match /// the native iOS behavior. To override this behavior, wrap each of the `tabBar`'s /// items inside a [MediaQuery] with the desired [MediaQueryData.textScaleFactor] /// value. The text scale factor value from the operating system can be retrieved /// int many ways, such as querying [MediaQuery.textScaleFactorOf] against /// [CupertinoApp]'s [BuildContext]. /// /// 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] are typically a /// [CupertinoTabView] in order to achieve the parallel hierarchical /// information architecture seen on iOS apps with tab bars. /// /// When the tab becomes inactive, its content is cached in the widget tree /// [Offstage] and its animations disabled. /// /// Content can slide under the [tabBar] when they're translucent. /// In that case, the child's [BuildContext]'s [MediaQuery] will have a /// bottom padding indicating the area of obstructing overlap from the /// [tabBar]. /// /// Must not be null. final IndexedWidgetBuilder tabBuilder; /// The color of the widget that underlies the entire scaffold. /// /// By default uses [CupertinoTheme]'s `scaffoldBackgroundColor` when null. final Color backgroundColor; /// Whether the body should size itself to avoid the window's bottom inset. /// /// For example, if there is an onscreen keyboard displayed above the /// scaffold, the body can be resized to avoid overlapping the keyboard, which /// prevents widgets inside the body from being obscured by the keyboard. /// /// Defaults to true and cannot be null. final bool resizeToAvoidBottomInset; @override _CupertinoTabScaffoldState createState() => _CupertinoTabScaffoldState(); } class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> { CupertinoTabController _controller; @override void initState() { super.initState(); _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 (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; } } @override Widget build(BuildContext context) { final MediaQueryData existingMediaQuery = MediaQuery.of(context); MediaQueryData newMediaQuery = MediaQuery.of(context); Widget content = _TabSwitchingView( currentTabIndex: _controller.index, tabCount: widget.tabBar.items.length, tabBuilder: widget.tabBuilder, ); EdgeInsets contentPadding = EdgeInsets.zero; if (widget.resizeToAvoidBottomInset) { // Remove the view inset and add it back as a padding in the inner content. newMediaQuery = newMediaQuery.removeViewInsets(removeBottom: true); contentPadding = EdgeInsets.only(bottom: existingMediaQuery.viewInsets.bottom); } if (widget.tabBar != null && // Only pad the content with the height of the tab bar if the tab // isn't already entirely obstructed by a keyboard or other view insets. // Don't double pad. (!widget.resizeToAvoidBottomInset || widget.tabBar.preferredSize.height > existingMediaQuery.viewInsets.bottom)) { // TODO(xster): Use real size after partial layout instead of preferred size. // https://github.com/flutter/flutter/issues/12912 final double bottomPadding = widget.tabBar.preferredSize.height + existingMediaQuery.padding.bottom; // If tab bar opaque, directly stop the main content higher. If // translucent, let main content draw behind the tab bar but hint the // obstructed area. if (widget.tabBar.opaque(context)) { contentPadding = EdgeInsets.only(bottom: bottomPadding); newMediaQuery = newMediaQuery.removePadding(removeBottom: true); } else { newMediaQuery = newMediaQuery.copyWith( padding: newMediaQuery.padding.copyWith( bottom: bottomPadding, ), ); } } content = MediaQuery( data: newMediaQuery, child: Padding( padding: contentPadding, child: content, ), ); return DecoratedBox( decoration: BoxDecoration( color: CupertinoDynamicColor.resolve(widget.backgroundColor, context) ?? CupertinoTheme.of(context).scaffoldBackgroundColor, ), child: Stack( children: <Widget>[ // The main content being at the bottom is added to the stack first. content, MediaQuery( data: existingMediaQuery.copyWith(textScaleFactor: 1), child: Align( alignment: Alignment.bottomCenter, // Override the tab bar's currentIndex to the current tab and hook in // our own listener to update the [_controller.currentIndex] on top of a possibly user // provided callback. child: widget.tabBar.copyWith( currentIndex: _controller.index, onTap: (int newIndex) { _controller.index = newIndex; // Chain the user's original callback. if (widget.tabBar.onTap != null) widget.tabBar.onTap(newIndex); }, ), ), ), ], ), ); } @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 /// at a time and on stage. Off stage tabs' animations are stopped. class _TabSwitchingView extends StatefulWidget { const _TabSwitchingView({ @required this.currentTabIndex, @required this.tabCount, @required this.tabBuilder, }) : assert(currentTabIndex != null), assert(tabCount != null && tabCount > 0), assert(tabBuilder != null); final int currentTabIndex; final int tabCount; final IndexedWidgetBuilder tabBuilder; @override _TabSwitchingViewState createState() => _TabSwitchingViewState(); } class _TabSwitchingViewState extends State<_TabSwitchingView> { final List<bool> shouldBuildTab = <bool>[]; final List<FocusScopeNode> tabFocusNodes = <FocusScopeNode>[]; // When focus nodes are no longer needed, we need to dispose of them, but we // can't be sure that nothing else is listening to them until this widget is // disposed of, so when they are no longer needed, we move them to this list, // and dispose of them when we dispose of this widget. final List<FocusScopeNode> discardedNodes = <FocusScopeNode>[]; @override void initState() { super.initState(); shouldBuildTab.addAll(List<bool>.filled(widget.tabCount, false)); } @override void didChangeDependencies() { super.didChangeDependencies(); _focusActiveTab(); } @override void didUpdateWidget(_TabSwitchingView oldWidget) { super.didUpdateWidget(oldWidget); // Only partially invalidate the tabs cache to avoid breaking the current // behavior. We assume that the only possible change is either: // - new tabs are appended to the tab list, or // - some trailing tabs are removed. // If the above assumption is not true, some tabs may lose their state. final int lengthDiff = widget.tabCount - shouldBuildTab.length; if (lengthDiff > 0) { shouldBuildTab.addAll(List<bool>.filled(lengthDiff, false)); } else if (lengthDiff < 0) { shouldBuildTab.removeRange(widget.tabCount, shouldBuildTab.length); } _focusActiveTab(); } // Will focus the active tab if the FocusScope above it has focus already. If // not, then it will just mark it as the preferred focus for that scope. void _focusActiveTab() { if (tabFocusNodes.length != widget.tabCount) { if (tabFocusNodes.length > widget.tabCount) { discardedNodes.addAll(tabFocusNodes.sublist(widget.tabCount)); tabFocusNodes.removeRange(widget.tabCount, tabFocusNodes.length); } else { tabFocusNodes.addAll( List<FocusScopeNode>.generate( widget.tabCount - tabFocusNodes.length, (int index) => FocusScopeNode(debugLabel: '$CupertinoTabScaffold Tab ${index + tabFocusNodes.length}'), ), ); } } FocusScope.of(context).setFirstFocus(tabFocusNodes[widget.currentTabIndex]); } @override void dispose() { for (final FocusScopeNode focusScopeNode in tabFocusNodes) { focusScopeNode.dispose(); } for (final FocusScopeNode focusScopeNode in discardedNodes) { focusScopeNode.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: List<Widget>.generate(widget.tabCount, (int index) { final bool active = index == widget.currentTabIndex; shouldBuildTab[index] = active || shouldBuildTab[index]; return Offstage( offstage: !active, child: TickerMode( enabled: active, child: FocusScope( node: tabFocusNodes[index], child: Builder(builder: (BuildContext context) { return shouldBuildTab[index] ? widget.tabBuilder(context, index) : Container(); }), ), ), ); }), ); } }