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'; |
10 | library; |
11 | |
12 | import 'package:flutter/widgets.dart'; |
13 | |
14 | import 'bottom_tab_bar.dart'; |
15 | import 'colors.dart'; |
16 | import '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. |
38 | class 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/). |
129 | class 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 | |
228 | class _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. |
391 | class _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 | |
406 | class _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. |
517 | class 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 |
Definitions
- CupertinoTabController
- CupertinoTabController
- index
- index
- dispose
- CupertinoTabScaffold
- CupertinoTabScaffold
- createState
- _CupertinoTabScaffoldState
- _controller
- restorationId
- restoreState
- _restoreInternalController
- initState
- _updateTabController
- _onCurrentIndexChange
- didUpdateWidget
- build
- dispose
- _TabSwitchingView
- _TabSwitchingView
- createState
- _TabSwitchingViewState
- initState
- didChangeDependencies
- didUpdateWidget
- _focusActiveTab
- dispose
- build
- RestorableCupertinoTabController
- RestorableCupertinoTabController
- createDefaultValue
- fromPrimitives
Learn more about Flutter for embedded and desktop on industrialflutter.com