blob: 306a6ecb7aae14891c39410f090545ab73eac109 [file] [log] [blame]
// 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].
/// * [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].
///
/// 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,
this.restorationId,
}) : 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;
/// 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> 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() {
super.initState();
_updateTabController();
}
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 (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);
}
}
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(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;
}
}
@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.
widget.tabBar.onTap?.call(newIndex);
},
),
),
),
],
),
);
}
@override
void dispose() {
if (widget.controller?._isDisposed == false) {
_controller.removeListener(_onCurrentIndexChange);
}
_internalController?.dispose();
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();
}),
),
),
);
}),
);
}
}
/// 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) {
assert(data != null);
return CupertinoTabController(initialIndex: data! as int);
}
@override
Object? toPrimitives() {
return value.index;
}
}