1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/services.dart';
6///
7/// @docImport 'page_scaffold.dart';
8/// @docImport 'route.dart';
9/// @docImport 'tab_view.dart';
10library;
11
12import 'package:flutter/widgets.dart';
13
14import 'bottom_tab_bar.dart';
15import 'colors.dart';
16import 'theme.dart';
17
18/// Coordinates tab selection between a [CupertinoTabBar] and a [CupertinoTabScaffold].
19///
20/// The [index] property is the index of the selected tab. Changing its value
21/// updates the actively displayed tab of the [CupertinoTabScaffold] the
22/// [CupertinoTabController] controls, as well as the currently selected tab item of
23/// its [CupertinoTabBar].
24///
25/// {@tool dartpad}
26/// This samples shows how [CupertinoTabController] can be used to switch tabs in
27/// [CupertinoTabScaffold].
28///
29/// ** See code in examples/api/lib/cupertino/tab_scaffold/cupertino_tab_controller.0.dart **
30/// {@end-tool}
31///
32/// See also:
33///
34/// * [CupertinoTabScaffold], a tabbed application root layout that can be
35/// controlled by a [CupertinoTabController].
36/// * [RestorableCupertinoTabController], which is a restorable version
37/// of this controller.
38class CupertinoTabController extends ChangeNotifier {
39 /// Creates a [CupertinoTabController] to control the tab index of [CupertinoTabScaffold]
40 /// and [CupertinoTabBar].
41 ///
42 /// The [initialIndex] defaults to 0. The value must be greater than or equal
43 /// to 0, and less than the total number of tabs.
44 CupertinoTabController({int initialIndex = 0}) : _index = initialIndex, assert(initialIndex >= 0);
45
46 bool _isDisposed = false;
47
48 /// The index of the currently selected tab.
49 ///
50 /// Changing the value of [index] updates the actively displayed tab of the
51 /// [CupertinoTabScaffold] controlled by this [CupertinoTabController], as well
52 /// as the currently selected tab item of its [CupertinoTabScaffold.tabBar].
53 ///
54 /// The value must be greater than or equal to 0, and less than the total
55 /// number of tabs.
56 int get index => _index;
57 int _index;
58 set index(int value) {
59 assert(value >= 0);
60 if (_index == value) {
61 return;
62 }
63 _index = value;
64 notifyListeners();
65 }
66
67 @mustCallSuper
68 @override
69 void dispose() {
70 super.dispose();
71 _isDisposed = true;
72 }
73}
74
75/// Implements a tabbed iOS application's root layout and behavior structure.
76///
77/// The scaffold lays out the tab bar at the bottom and the content between or
78/// behind the tab bar.
79///
80/// A [tabBar] and a [tabBuilder] are required. The [CupertinoTabScaffold]
81/// will automatically listen to the provided [CupertinoTabBar]'s tap callbacks
82/// to change the active tab.
83///
84/// A [controller] can be used to provide an initially selected tab index and manage
85/// subsequent tab changes. If a controller is not specified, the scaffold will
86/// create its own [CupertinoTabController] and manage it internally. Otherwise
87/// it's up to the owner of [controller] to call `dispose` on it after finish
88/// using it.
89///
90/// Tabs' contents are built with the provided [tabBuilder] at the active
91/// tab index. The [tabBuilder] must be able to build the same number of
92/// pages as there are [tabBar] items. Inactive tabs will be moved [Offstage]
93/// and their animations disabled.
94///
95/// Adding/removing tabs, or changing the order of tabs is supported but not
96/// recommended. Doing so is against the iOS human interface guidelines, and
97/// [CupertinoTabScaffold] may lose some tabs' state in the process.
98///
99/// Use [CupertinoTabView] as the root widget of each tab to support tabs with
100/// parallel navigation state and history. Since each [CupertinoTabView] contains
101/// a [Navigator], rebuilding the [CupertinoTabView] with a different
102/// [WidgetBuilder] instance in [CupertinoTabView.builder] will not recreate
103/// the [CupertinoTabView]'s navigation stack or update its UI. To update the
104/// contents of the [CupertinoTabView] after it's built, trigger a rebuild
105/// (via [State.setState], for instance) from its descendant rather than from
106/// its ancestor.
107///
108/// {@tool dartpad}
109/// A sample code implementing a typical iOS information architecture with tabs.
110///
111/// ** See code in examples/api/lib/cupertino/tab_scaffold/cupertino_tab_scaffold.0.dart **
112/// {@end-tool}
113///
114/// To push a route above all tabs instead of inside the currently selected one
115/// (such as when showing a dialog on top of this scaffold), use
116/// `Navigator.of(rootNavigator: true)` from inside the [BuildContext] of a
117/// [CupertinoTabView].
118///
119/// See also:
120///
121/// * [CupertinoTabBar], the bottom tab bar inserted in the scaffold.
122/// * [CupertinoTabController], the selection state of this widget.
123/// * [CupertinoTabView], the typical root content of each tab that holds its own
124/// [Navigator] stack.
125/// * [CupertinoPageRoute], a route hosting modal pages with iOS style transitions.
126/// * [CupertinoPageScaffold], typical contents of an iOS modal page implementing
127/// layout with a navigation bar on top.
128/// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/bars/tab-bars/).
129class CupertinoTabScaffold extends StatefulWidget {
130 /// Creates a layout for applications with a tab bar at the bottom.
131 CupertinoTabScaffold({
132 super.key,
133 required this.tabBar,
134 required this.tabBuilder,
135 this.controller,
136 this.backgroundColor,
137 this.resizeToAvoidBottomInset = true,
138 this.restorationId,
139 }) : assert(
140 controller == null || controller.index < tabBar.items.length,
141 "The CupertinoTabController's current index ${controller.index} is "
142 'out of bounds for the tab bar with ${tabBar.items.length} tabs',
143 );
144
145 /// The [tabBar] is a [CupertinoTabBar] drawn at the bottom of the screen
146 /// that lets the user switch between different tabs in the main content area
147 /// when present.
148 ///
149 /// The [CupertinoTabBar.currentIndex] is only used to initialize a
150 /// [CupertinoTabController] when no [controller] is provided. Subsequently
151 /// providing a different [CupertinoTabBar.currentIndex] does not affect the
152 /// scaffold or the tab bar's active tab index. To programmatically change
153 /// the active tab index, use a [CupertinoTabController].
154 ///
155 /// If [CupertinoTabBar.onTap] is provided, it will still be called.
156 /// [CupertinoTabScaffold] automatically also listen to the
157 /// [CupertinoTabBar]'s `onTap` to change the [controller]'s `index`
158 /// and change the actively displayed tab in [CupertinoTabScaffold]'s own
159 /// main content area.
160 ///
161 /// If translucent, the main content may slide behind it.
162 /// Otherwise, the main content's bottom margin will be offset by its height.
163 ///
164 /// By default [tabBar] disables text scaling to match the native iOS behavior.
165 /// To override this behavior, wrap each of the [tabBar]'s items inside a
166 /// [MediaQuery] with the desired [TextScaler].
167 final CupertinoTabBar tabBar;
168
169 /// Controls the currently selected tab index of the [tabBar], as well as the
170 /// active tab index of the [tabBuilder]. Providing a different [controller]
171 /// will also update the scaffold's current active index to the new controller's
172 /// index value.
173 ///
174 /// Defaults to null.
175 final CupertinoTabController? controller;
176
177 /// An [IndexedWidgetBuilder] that's called when tabs become active.
178 ///
179 /// The widgets built by [IndexedWidgetBuilder] are typically a
180 /// [CupertinoTabView] in order to achieve the parallel hierarchical
181 /// information architecture seen on iOS apps with tab bars.
182 ///
183 /// When the tab becomes inactive, its content is cached in the widget tree
184 /// [Offstage] and its animations disabled.
185 ///
186 /// Content can slide under the [tabBar] when they're translucent.
187 /// In that case, the child's [BuildContext]'s [MediaQuery] will have a
188 /// bottom padding indicating the area of obstructing overlap from the
189 /// [tabBar].
190 final IndexedWidgetBuilder tabBuilder;
191
192 /// The color of the widget that underlies the entire scaffold.
193 ///
194 /// By default uses [CupertinoTheme]'s `scaffoldBackgroundColor` when null.
195 final Color? backgroundColor;
196
197 /// Whether the body should size itself to avoid the window's bottom inset.
198 ///
199 /// For example, if there is an onscreen keyboard displayed above the
200 /// scaffold, the body can be resized to avoid overlapping the keyboard, which
201 /// prevents widgets inside the body from being obscured by the keyboard.
202 ///
203 /// Defaults to true.
204 final bool resizeToAvoidBottomInset;
205
206 /// Restoration ID to save and restore the state of the [CupertinoTabScaffold].
207 ///
208 /// This property only has an effect when no [controller] has been provided:
209 /// If it is non-null (and no [controller] has been provided), the scaffold
210 /// will persist and restore the currently selected tab index. If a
211 /// [controller] has been provided, it is the responsibility of the owner of
212 /// that controller to persist and restore it, e.g. by using a
213 /// [RestorableCupertinoTabController].
214 ///
215 /// The state of this widget is persisted in a [RestorationBucket] claimed
216 /// from the surrounding [RestorationScope] using the provided restoration ID.
217 ///
218 /// See also:
219 ///
220 /// * [RestorationManager], which explains how state restoration works in
221 /// Flutter.
222 final String? restorationId;
223
224 @override
225 State<CupertinoTabScaffold> createState() => _CupertinoTabScaffoldState();
226}
227
228class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> with RestorationMixin {
229 RestorableCupertinoTabController? _internalController;
230 CupertinoTabController get _controller => widget.controller ?? _internalController!.value;
231
232 @override
233 String? get restorationId => widget.restorationId;
234
235 @override
236 void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
237 _restoreInternalController();
238 }
239
240 void _restoreInternalController() {
241 if (_internalController != null) {
242 registerForRestoration(_internalController!, 'controller');
243 _internalController!.value.addListener(_onCurrentIndexChange);
244 }
245 }
246
247 @override
248 void initState() {
249 super.initState();
250 _updateTabController();
251 }
252
253 void _updateTabController([CupertinoTabController? oldWidgetController]) {
254 if (widget.controller == null && _internalController == null) {
255 // No widget-provided controller: create an internal controller.
256 _internalController = RestorableCupertinoTabController(
257 initialIndex: widget.tabBar.currentIndex,
258 );
259 if (!restorePending) {
260 _restoreInternalController(); // Also adds the listener to the controller.
261 }
262 }
263 if (widget.controller != null && _internalController != null) {
264 // Use the widget-provided controller.
265 unregisterFromRestoration(_internalController!);
266 _internalController!.dispose();
267 _internalController = null;
268 }
269 if (oldWidgetController != widget.controller) {
270 // The widget-provided controller has changed: move listeners.
271 if (oldWidgetController?._isDisposed == false) {
272 oldWidgetController!.removeListener(_onCurrentIndexChange);
273 }
274 widget.controller?.addListener(_onCurrentIndexChange);
275 }
276 }
277
278 void _onCurrentIndexChange() {
279 assert(
280 _controller.index >= 0 && _controller.index < widget.tabBar.items.length,
281 "The $runtimeType's current index ${_controller.index} is "
282 'out of bounds for the tab bar with ${widget.tabBar.items.length} tabs',
283 );
284
285 // The value of `_controller.index` has already been updated at this point.
286 // Calling `setState` to rebuild using `_controller.index`.
287 setState(() {});
288 }
289
290 @override
291 void didUpdateWidget(CupertinoTabScaffold oldWidget) {
292 super.didUpdateWidget(oldWidget);
293 if (widget.controller != oldWidget.controller) {
294 _updateTabController(oldWidget.controller);
295 } else if (_controller.index >= widget.tabBar.items.length) {
296 // If a new [tabBar] with less than (_controller.index + 1) items is provided,
297 // clamp the current index.
298 _controller.index = widget.tabBar.items.length - 1;
299 }
300 }
301
302 @override
303 Widget build(BuildContext context) {
304 final MediaQueryData existingMediaQuery = MediaQuery.of(context);
305 MediaQueryData newMediaQuery = MediaQuery.of(context);
306
307 Widget content = _TabSwitchingView(
308 currentTabIndex: _controller.index,
309 tabCount: widget.tabBar.items.length,
310 tabBuilder: widget.tabBuilder,
311 );
312 EdgeInsets contentPadding = EdgeInsets.zero;
313
314 if (widget.resizeToAvoidBottomInset) {
315 // Remove the view inset and add it back as a padding in the inner content.
316 newMediaQuery = newMediaQuery.removeViewInsets(removeBottom: true);
317 contentPadding = EdgeInsets.only(bottom: existingMediaQuery.viewInsets.bottom);
318 }
319
320 // Only pad the content with the height of the tab bar if the tab
321 // isn't already entirely obstructed by a keyboard or other view insets.
322 // Don't double pad.
323 if (!widget.resizeToAvoidBottomInset ||
324 widget.tabBar.preferredSize.height > existingMediaQuery.viewInsets.bottom) {
325 // TODO(xster): Use real size after partial layout instead of preferred size.
326 // https://github.com/flutter/flutter/issues/12912
327 final double bottomPadding =
328 widget.tabBar.preferredSize.height + existingMediaQuery.padding.bottom;
329
330 // If tab bar opaque, directly stop the main content higher. If
331 // translucent, let main content draw behind the tab bar but hint the
332 // obstructed area.
333 if (widget.tabBar.opaque(context)) {
334 contentPadding = EdgeInsets.only(bottom: bottomPadding);
335 newMediaQuery = newMediaQuery.removePadding(removeBottom: true);
336 } else {
337 newMediaQuery = newMediaQuery.copyWith(
338 padding: newMediaQuery.padding.copyWith(bottom: bottomPadding),
339 );
340 }
341 }
342
343 content = MediaQuery(
344 data: newMediaQuery,
345 child: Padding(padding: contentPadding, child: content),
346 );
347
348 return DecoratedBox(
349 decoration: BoxDecoration(
350 color:
351 CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ??
352 CupertinoTheme.of(context).scaffoldBackgroundColor,
353 ),
354 child: Stack(
355 children: <Widget>[
356 // The main content being at the bottom is added to the stack first.
357 content,
358 MediaQuery.withNoTextScaling(
359 child: Align(
360 alignment: Alignment.bottomCenter,
361 // Override the tab bar's currentIndex to the current tab and hook in
362 // our own listener to update the [_controller.currentIndex] on top of a possibly user
363 // provided callback.
364 child: widget.tabBar.copyWith(
365 currentIndex: _controller.index,
366 onTap: (int newIndex) {
367 _controller.index = newIndex;
368 // Chain the user's original callback.
369 widget.tabBar.onTap?.call(newIndex);
370 },
371 ),
372 ),
373 ),
374 ],
375 ),
376 );
377 }
378
379 @override
380 void dispose() {
381 if (widget.controller?._isDisposed == false) {
382 _controller.removeListener(_onCurrentIndexChange);
383 }
384 _internalController?.dispose();
385 super.dispose();
386 }
387}
388
389/// A widget laying out multiple tabs with only one active tab being built
390/// at a time and on stage. Off stage tabs' animations are stopped.
391class _TabSwitchingView extends StatefulWidget {
392 const _TabSwitchingView({
393 required this.currentTabIndex,
394 required this.tabCount,
395 required this.tabBuilder,
396 }) : assert(tabCount > 0);
397
398 final int currentTabIndex;
399 final int tabCount;
400 final IndexedWidgetBuilder tabBuilder;
401
402 @override
403 _TabSwitchingViewState createState() => _TabSwitchingViewState();
404}
405
406class _TabSwitchingViewState extends State<_TabSwitchingView> {
407 final List<bool> shouldBuildTab = <bool>[];
408 final List<FocusScopeNode> tabFocusNodes = <FocusScopeNode>[];
409
410 // When focus nodes are no longer needed, we need to dispose of them, but we
411 // can't be sure that nothing else is listening to them until this widget is
412 // disposed of, so when they are no longer needed, we move them to this list,
413 // and dispose of them when we dispose of this widget.
414 final List<FocusScopeNode> discardedNodes = <FocusScopeNode>[];
415
416 @override
417 void initState() {
418 super.initState();
419 shouldBuildTab.addAll(List<bool>.filled(widget.tabCount, false));
420 }
421
422 @override
423 void didChangeDependencies() {
424 super.didChangeDependencies();
425 _focusActiveTab();
426 }
427
428 @override
429 void didUpdateWidget(_TabSwitchingView oldWidget) {
430 super.didUpdateWidget(oldWidget);
431
432 // Only partially invalidate the tabs cache to avoid breaking the current
433 // behavior. We assume that the only possible change is either:
434 // - new tabs are appended to the tab list, or
435 // - some trailing tabs are removed.
436 // If the above assumption is not true, some tabs may lose their state.
437 final int lengthDiff = widget.tabCount - shouldBuildTab.length;
438 if (lengthDiff > 0) {
439 shouldBuildTab.addAll(List<bool>.filled(lengthDiff, false));
440 } else if (lengthDiff < 0) {
441 shouldBuildTab.removeRange(widget.tabCount, shouldBuildTab.length);
442 }
443 _focusActiveTab();
444 }
445
446 // Will focus the active tab if the FocusScope above it has focus already. If
447 // not, then it will just mark it as the preferred focus for that scope.
448 void _focusActiveTab() {
449 if (tabFocusNodes.length != widget.tabCount) {
450 if (tabFocusNodes.length > widget.tabCount) {
451 discardedNodes.addAll(tabFocusNodes.sublist(widget.tabCount));
452 tabFocusNodes.removeRange(widget.tabCount, tabFocusNodes.length);
453 } else {
454 tabFocusNodes.addAll(
455 List<FocusScopeNode>.generate(
456 widget.tabCount - tabFocusNodes.length,
457 (int index) => FocusScopeNode(
458 debugLabel: '$CupertinoTabScaffold Tab ${index + tabFocusNodes.length}',
459 ),
460 ),
461 );
462 }
463 }
464 FocusScope.of(context).setFirstFocus(tabFocusNodes[widget.currentTabIndex]);
465 }
466
467 @override
468 void dispose() {
469 for (final FocusScopeNode focusScopeNode in tabFocusNodes) {
470 focusScopeNode.dispose();
471 }
472 for (final FocusScopeNode focusScopeNode in discardedNodes) {
473 focusScopeNode.dispose();
474 }
475 super.dispose();
476 }
477
478 @override
479 Widget build(BuildContext context) {
480 return Stack(
481 fit: StackFit.expand,
482 children: List<Widget>.generate(widget.tabCount, (int index) {
483 final bool active = index == widget.currentTabIndex;
484 shouldBuildTab[index] = active || shouldBuildTab[index];
485
486 return HeroMode(
487 enabled: active,
488 child: Offstage(
489 offstage: !active,
490 child: TickerMode(
491 enabled: active,
492 child: FocusScope(
493 node: tabFocusNodes[index],
494 child: Builder(
495 builder: (BuildContext context) {
496 return shouldBuildTab[index]
497 ? widget.tabBuilder(context, index)
498 : const SizedBox.shrink();
499 },
500 ),
501 ),
502 ),
503 ),
504 );
505 }),
506 );
507 }
508}
509
510/// A [RestorableProperty] that knows how to store and restore a
511/// [CupertinoTabController].
512///
513/// The [CupertinoTabController] is accessible via the [value] getter. During
514/// state restoration, the property will restore [CupertinoTabController.index]
515/// to the value it had when the restoration data it is getting restored from
516/// was collected.
517class RestorableCupertinoTabController extends RestorableChangeNotifier<CupertinoTabController> {
518 /// Creates a [RestorableCupertinoTabController] to control the tab index of
519 /// [CupertinoTabScaffold] and [CupertinoTabBar].
520 ///
521 /// The `initialIndex` defaults to zero. The value must be greater than or
522 /// equal to zero, and less than the total number of tabs.
523 RestorableCupertinoTabController({int initialIndex = 0})
524 : assert(initialIndex >= 0),
525 _initialIndex = initialIndex;
526
527 final int _initialIndex;
528
529 @override
530 CupertinoTabController createDefaultValue() {
531 return CupertinoTabController(initialIndex: _initialIndex);
532 }
533
534 @override
535 CupertinoTabController fromPrimitives(Object? data) {
536 assert(data != null);
537 return CupertinoTabController(initialIndex: data! as int);
538 }
539
540 @override
541 Object? toPrimitives() {
542 return value.index;
543 }
544}
545

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com