tab_scaffold.dart 11.7 KB
Newer Older
1 2 3 4 5 6
// Copyright 2017 The Chromium 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/widgets.dart';
import 'bottom_tab_bar.dart';
xster's avatar
xster committed
7
import 'theme.dart';
8 9 10 11 12 13 14 15 16 17 18

/// 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.
///
/// Tabs' contents are built with the provided [tabBuilder] at the active
19 20 21
/// 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.
22 23 24 25
///
/// Use [CupertinoTabView] as the content of each tab to support tabs with parallel
/// navigation state and history.
///
26
/// {@tool sample}
27 28 29 30
///
/// A sample code implementing a typical iOS information architecture with tabs.
///
/// ```dart
31 32
/// CupertinoTabScaffold(
///   tabBar: CupertinoTabBar(
33 34 35 36 37
///     items: <BottomNavigationBarItem> [
///       // ...
///     ],
///   ),
///   tabBuilder: (BuildContext context, int index) {
38
///     return CupertinoTabView(
39
///       builder: (BuildContext context) {
40 41 42
///         return CupertinoPageScaffold(
///           navigationBar: CupertinoNavigationBar(
///             middle: Text('Page 1 of tab $index'),
43
///           ),
44 45
///           child: Center(
///             child: CupertinoButton(
46 47 48
///               child: const Text('Next page'),
///               onPressed: () {
///                 Navigator.of(context).push(
49
///                   CupertinoPageRoute<void>(
50
///                     builder: (BuildContext context) {
51 52 53
///                       return CupertinoPageScaffold(
///                         navigationBar: CupertinoNavigationBar(
///                           middle: Text('Page 2 of tab $index'),
54
///                         ),
55 56
///                         child: Center(
///                           child: CupertinoButton(
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
///                             child: const Text('Back'),
///                             onPressed: () { Navigator.of(context).pop(); },
///                           ),
///                         ),
///                       );
///                     },
///                   ),
///                 );
///               },
///             ),
///           ),
///         );
///       },
///     );
///   },
/// )
/// ```
74
/// {@end-tool}
75
///
76 77 78 79 80
/// 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].
///
81 82
/// See also:
///
83 84
///  * [CupertinoTabBar], the bottom tab bar inserted in the scaffold.
///  * [CupertinoTabView], the typical root content of each tab that holds its own
85
///    [Navigator] stack.
86 87
///  * [CupertinoPageRoute], a route hosting modal pages with iOS style transitions.
///  * [CupertinoPageScaffold], typical contents of an iOS modal page implementing
88
///    layout with a navigation bar on top.
89
class CupertinoTabScaffold extends StatefulWidget {
90 91
  /// Creates a layout for applications with a tab bar at the bottom.
  ///
92
  /// The [tabBar] and [tabBuilder] arguments must not be null.
93 94 95 96
  const CupertinoTabScaffold({
    Key key,
    @required this.tabBar,
    @required this.tabBuilder,
97 98
    this.backgroundColor,
    this.resizeToAvoidBottomInset = true,
99 100 101 102 103 104 105 106
  }) : assert(tabBar != null),
       assert(tabBuilder != null),
       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.
  ///
107 108 109 110 111 112
  /// 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].

  /// If [CupertinoTabBar.onTap] is provided, it will still be called.
  /// [CupertinoTabScaffold] automatically also listen to the
113 114 115 116 117 118
  /// [CupertinoTabBar]'s `onTap` to change the [CupertinoTabBar]'s `currentIndex`
  /// 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.
119 120
  ///
  /// Must not be null.
121 122 123 124
  final CupertinoTabBar tabBar;

  /// An [IndexedWidgetBuilder] that's called when tabs become active.
  ///
125 126 127 128
  /// The widgets built by [IndexedWidgetBuilder] is typically a [CupertinoTabView]
  /// in order to achieve the parallel hierarchies information architecture seen
  /// on iOS apps with tab bars.
  ///
129 130 131
  /// When the tab becomes inactive, its content is still cached in the widget
  /// tree [Offstage] and its animations disabled.
  ///
132 133 134 135
  /// 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].
136 137
  ///
  /// Must not be null.
138 139
  final IndexedWidgetBuilder tabBuilder;

140 141 142 143 144 145 146 147 148 149 150 151 152 153
  /// The color of the widget that underlies the entire scaffold.
  ///
  /// By default uses [CupertinoTheme]'s `scaffoldBackgroundColor` when null.
  final Color backgroundColor;

  /// Whether the [child] 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;

154
  @override
155
  _CupertinoTabScaffoldState createState() => _CupertinoTabScaffoldState();
156 157 158
}

class _CupertinoTabScaffoldState extends State<CupertinoTabScaffold> {
159 160 161 162 163 164 165 166 167 168 169
  int _currentPage;

  @override
  void initState() {
    super.initState();
    _currentPage = widget.tabBar.currentIndex;
  }

  @override
  void didUpdateWidget(CupertinoTabScaffold oldWidget) {
    super.didUpdateWidget(oldWidget);
170 171 172 173 174 175 176 177 178 179
    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.
180 181 182 183
    if (widget.tabBar.currentIndex != oldWidget.tabBar.currentIndex) {
      _currentPage = widget.tabBar.currentIndex;
    }
  }
184 185 186 187 188

  @override
  Widget build(BuildContext context) {
    final List<Widget> stacked = <Widget>[];

189 190 191
    final MediaQueryData existingMediaQuery = MediaQuery.of(context);
    MediaQueryData newMediaQuery = MediaQuery.of(context);

192
    Widget content = _TabSwitchingView(
193 194 195
      currentTabIndex: _currentPage,
      tabNumber: widget.tabBar.items.length,
      tabBuilder: widget.tabBuilder,
196
    );
197
    EdgeInsets contentPadding = EdgeInsets.zero;
198

199 200 201
    if (widget.resizeToAvoidBottomInset) {
      // Remove the view inset and add it back as a padding in the inner content.
      newMediaQuery = newMediaQuery.removeViewInsets(removeBottom: true);
202
      contentPadding = EdgeInsets.only(bottom: existingMediaQuery.viewInsets.bottom);
203
    }
204

205 206 207 208 209 210
    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)) {
211 212
      // TODO(xster): Use real size after partial layout instead of preferred size.
      // https://github.com/flutter/flutter/issues/12912
xster's avatar
xster committed
213 214
      final double bottomPadding =
          widget.tabBar.preferredSize.height + existingMediaQuery.padding.bottom;
215 216 217 218

      // 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.
xster's avatar
xster committed
219
      if (widget.tabBar.opaque(context)) {
220
        contentPadding = EdgeInsets.only(bottom: bottomPadding);
221
      } else {
222 223 224
        newMediaQuery = newMediaQuery.copyWith(
          padding: newMediaQuery.padding.copyWith(
            bottom: bottomPadding,
225 226 227 228 229
          ),
        );
      }
    }

230 231
    content = MediaQuery(
      data: newMediaQuery,
232 233 234 235
      child: Padding(
        padding: contentPadding,
        child: content,
      ),
236 237
    );

238 239 240 241
    // The main content being at the bottom is added to the stack first.
    stacked.add(content);

    if (widget.tabBar != null) {
242
      stacked.add(Align(
243
        alignment: Alignment.bottomCenter,
244 245 246 247 248 249 250 251 252 253 254 255
        // 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
        // provided callback.
        child: widget.tabBar.copyWith(
          currentIndex: _currentPage,
          onTap: (int newIndex) {
            setState(() {
              _currentPage = newIndex;
            });
            // Chain the user's original callback.
            if (widget.tabBar.onTap != null)
              widget.tabBar.onTap(newIndex);
256
          },
257 258 259 260
        ),
      ));
    }

xster's avatar
xster committed
261 262
    return DecoratedBox(
      decoration: BoxDecoration(
263
        color: widget.backgroundColor ?? CupertinoTheme.of(context).scaffoldBackgroundColor,
xster's avatar
xster committed
264 265 266 267
      ),
      child: Stack(
        children: stacked,
      ),
268 269 270 271
    );
  }
}

272
/// A widget laying out multiple tabs with only one active tab being built
273
/// at a time and on stage. Off stage tabs' animations are stopped.
274 275
class _TabSwitchingView extends StatefulWidget {
  const _TabSwitchingView({
276 277 278 279 280 281 282 283 284 285 286 287
    @required this.currentTabIndex,
    @required this.tabNumber,
    @required this.tabBuilder,
  }) : assert(currentTabIndex != null),
       assert(tabNumber != null && tabNumber > 0),
       assert(tabBuilder != null);

  final int currentTabIndex;
  final int tabNumber;
  final IndexedWidgetBuilder tabBuilder;

  @override
288
  _TabSwitchingViewState createState() => _TabSwitchingViewState();
289 290
}

291
class _TabSwitchingViewState extends State<_TabSwitchingView> {
292
  List<Widget> tabs;
293
  List<FocusScopeNode> tabFocusNodes;
294 295 296 297

  @override
  void initState() {
    super.initState();
298 299
    tabs = List<Widget>(widget.tabNumber);
    tabFocusNodes = List<FocusScopeNode>.generate(
300
      widget.tabNumber,
301
      (int index) => FocusScopeNode(),
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326
    );
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _focusActiveTab();
  }

  @override
  void didUpdateWidget(_TabSwitchingView oldWidget) {
    super.didUpdateWidget(oldWidget);
    _focusActiveTab();
  }

  void _focusActiveTab() {
    FocusScope.of(context).setFirstFocus(tabFocusNodes[widget.currentTabIndex]);
  }

  @override
  void dispose() {
    for (FocusScopeNode focusScopeNode in tabFocusNodes) {
      focusScopeNode.detach();
    }
    super.dispose();
327 328 329 330
  }

  @override
  Widget build(BuildContext context) {
331
    return Stack(
332
      fit: StackFit.expand,
333
      children: List<Widget>.generate(widget.tabNumber, (int index) {
334 335
        final bool active = index == widget.currentTabIndex;

336
        if (active || tabs[index] != null) {
337
          tabs[index] = widget.tabBuilder(context, index);
338
        }
339

340
        return Offstage(
341
          offstage: !active,
342
          child: TickerMode(
343
            enabled: active,
344
            child: FocusScope(
345
              node: tabFocusNodes[index],
346
              child: tabs[index] ?? Container(),
347
            ),
348 349 350 351 352 353
          ),
        );
      }),
    );
  }
}