TabBar 导航通过页面底部固定的几个 Tab 来在应用的不同功能模块之间切换,每个 Tab 内都有一个嵌套的导航器,这样每个 Tab 内的页面导航不会影响到其它 Tab。通过综合使用嵌套的 NavigatorStack 可以实现这种导航方式,但同时还需要处理一些小问题,比如 Android 返回键对内层 Navigator 不起作用,以及 Tab 切换时焦点没有随之切换等。

本文属于 JW Flutter Demo 文章系列,相关代码已在 GitHub 开源 JW Flutter Demo。如果想全面深入学习 Flutter,欢迎选购笔者制作的视频课程 Flutter 移动应用开发实战

运行效果

克隆 JWFD 仓库代码并在手机设备或模拟器里运行应用,从 Drawer 菜单里选择 Navigation > TabBar 可进入 TabBar 导航示例。

实现思路

一般在进入某个 Tab 之前,我们会先进入到一个启动页,在这个页面里会跟服务器请求一些信息,比如检查是否已登录。只有在已经登录的情况下才进入某个 Tab,否则进入登录页。Tab 页本身处于外层 Navigator 的页面堆栈中,而每个 Tab 内又有自己的页面堆栈,因此需要使用嵌套的 Navigator。每个 Tab 处于同级关系,它们需要同时保留在外层 Navigator 的页面堆栈中。可以使用一个 Stack 来容纳所有 Tab,只有当前活跃的 Tab 才可见。

采用 TabBar 导航的应用页面结构如下:

Root Navigator
├── TabBarNavigationPage
└── TabBarPage
    ├── First Tab Navigator
    │  ├── FirstTabPage
    │  └── BackPage
    ├── Second Tab Navigator
    │  ├── SecondTabPage
    │  └── BackPage
    └── Third Tab Navigator
       ├── ThirdTabPage
       └── BackPage
  1. TabBarNavigationPage 相当于是进入某个 Tab 之前的启动页。
  2. TabBarPage 是三个 Tab 的容器,三个 Tab 通过 Stack 重叠在一起,某一时刻有且只有一个 Tab 可见。
  3. BackPage 用来演示 Tab 内的导航。

编写代码

下面只对核心的 TabBar 导航代码进行讲解,完整代码请从 JWFD 仓库获取。

为了防止命名冲突,对于一些只在某个作用域(模块、类等)内部使用的标识符(变量名、类名等),最好以下划线打头来对外隐藏。

开发 TabBarPage

TabBarPage 是实现 TabBar 导航的核心,通过一个 Stack 来叠放多个 Tab,并且使用内部状态来控制哪个 Tab 可见。

class _TabBarPage extends StatefulWidget {
  static final globalKey = GlobalKey<_TabBarPageState>();

  _TabBarPage() : super(key: globalKey);

  @override
  _TabBarPageState createState() => _TabBarPageState();
}

class _TabBarPageState extends State<_TabBarPage> {
  final _navigatorKeys = _JWTabBar.tabs
      .map<GlobalKey<NavigatorState>>((v) => GlobalKey<NavigatorState>())
      .toList();
  final _focusScopeNodes =
      _JWTabBar.tabs.map<FocusScopeNode>((v) => FocusScopeNode()).toList();
  var _index = 0;

  void switchTab(int index) {
    setState(() {
      _index = index;
    });

    // FixBug: Offstage widget not auto loose focus.
    FocusScope.of(context).setFirstFocus(_focusScopeNodes[index]);
  }

  // Make sure not pop root navigator's route if nested navigator can pop
  // when press back button on Android.
  Future<bool> _onWillPop() async {
    final maybePop = await _navigatorKeys[_index].currentState.maybePop();
    return Future.value(!maybePop);
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: _onWillPop,
      child: IndexedStack(
        index: _index,
        children: _JWTabBar.tabs
            .asMap()
            .entries
            .map<Widget>(
              (entry) => FocusScope(
                    node: _focusScopeNodes[entry.key],
                    child: Navigator(
                      key: _navigatorKeys[entry.key],
                      onGenerateRoute: (settings) {
                        WidgetBuilder builder;
                        switch (settings.name) {
                          case '/':
                            builder = entry.value['builder'];
                            break;
                          default:
                            throw Exception('Invalid route: ${settings.name}');
                        }
                        return MaterialPageRoute(
                          builder: builder,
                          settings: settings,
                        );
                      },
                    ),
                  ),
            )
            .toList(),
      ),
    );
  }
}

  1. 使用 IndexedStack 来确保同一时刻只有指定位置的 Tab 可见。
  2. 每个 Tab 都分配了一个 Navigator,该 Navigator 的首页为 Tab 页。
  3. 为了处理 Android 返回键对内层 Navigator 无效的问题,使用了 WillPopScope 组件来处理按键返回。正常情况下按返回键,只回退内层堆栈,只有内层堆栈已经回退到根页面时,才回退外层堆栈。
  4. 为了解决 Tab 切换后焦点未跟随切换的问题,给每个 Tab 分配了一个 FocusScopeNode。当切换 Tab 的时候同时切换焦点,否则切换到的 Tab 页中如果有输入框,将由于没有焦点而导致无法输入。

开发 TabBar 组件

TabBar 组件会出现在每个 Tab 页的底部,每个 Tab 页里不同的地方只有哪个 Tab 处于选中状态。

class _JWTabBar extends StatelessWidget {
  static final tabs = [
    {
      'title': Text('First'),
      'icon': Icon(Icons.home),
      'builder': (BuildContext context) => _FirstTabPage(),
    },
    {
      'title': Text('Second'),
      'icon': Icon(Icons.add),
      'builder': (BuildContext context) => _SecondTabPage(),
    },
    {
      'title': Text('Third'),
      'icon': Icon(Icons.account_circle),
      'builder': (BuildContext context) => _ThirdTabPage(),
    },
  ];

  final int currentIndex;

  _JWTabBar({
    Key key,
    this.currentIndex = 0,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: currentIndex,
      onTap: (index) {
        if (index != currentIndex) {
          _TabBarPage.globalKey.currentState.switchTab(index);
        }
      },
      items: tabs
          .map<BottomNavigationBarItem>(
            (v) => BottomNavigationBarItem(
                  icon: v['icon'],
                  title: v['title'],
                ),
          )
          .toList(),
    );
  }
}

其中使用了 Flutter 内置的 BottomNavigationBar 组件来生成导航条。使用 JWTabBar 组件的页面需要告知哪个 Tab 处于被选中状态。

当导航条里的某个 Tab 被点击时,需要通知 TabBarPage 切换 Tab。这里通过 TabBarPage 的静态属性 globalKey 来获取到其绑定的 State 对象,从而能够调用其 switchTab 方法。

开发 Tab 页

各个 Tab 页类似,下面以 FirstTabPage 为例。

class _FirstTabPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Tab'),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Spacer(flex: 3),
          RaisedButton(
            onPressed: () => Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) => _BackPage(),
                )),
            child: Text('Go'),
          ),
          Spacer(),
          Counter(),
          Spacer(flex: 3),
        ],
      ),
      bottomNavigationBar: _JWTabBar(currentIndex: 0),
    );
  }
}

通过 Scaffold 组件的 bottomNavigationBar 属性在页面底部显示了 TabBar 导航条。创建 JWTabBar 组件实例的时候需要告知哪个 Tab 处于选中状态。

Tab 页主体内容包含了一个进入到深层页面的按钮和一个计数器,分别用来演示 Tab 内导航和 Tab 页状态在 Tab 切换时仍然得以保留。

更多资料

  1. JW Flutter Demo 文章系列
  2. JW Flutter Demo 开源项目
  3. JW 课程 - Flutter 移动应用开发实战
  4. 围观 APP