在某些场景下, 希望能够实现多个导航栈共存的效果, 比如浏览器应用里面的 Tab 切换. 本文记录的就是如何在 Flutter 中通过多个 Navigator 来实现多个导航栈共存.

典型需求描述

要在 Flutter 端实现多 Tab 切换, 且各个 Tab 保留自己导航栈(因为不仅仅是网页导航, 还有 Flutter 内的页面导航.).

Tab 的定义和普通浏览器的定义无差别, 概念上说就和下面这张图上的 Tab 类似:

  1. 用户可以通过点击一个 Tab 管理页的各个 Tab 标签, 从而切换到不同的 Tab

  2. 在切换 Tab 的时候, 希望切换的是导航栈, 即从 Tab A 切换到 Tab B, 再切换回 Tab A 时, Tab A 上的状态均得到保留.

比如在 iOS 端可以采用切换 windowrootViewController 来实现, 甚至可以直接切换不同的 window.

而在 Flutter 端的话该如何实现呢? 嘿嘿, 开发如做菜, 那就拿一道菜谱出来吧!

“准备作料”

要做出这道 “多导航栈切换” 佳肴, 需要如下特殊“作料”(如果一眼看到这些东西不明就里, 不妨跳到烹饪步骤, 再根据看到的内容回来查阅):

  1. Navigator: Flutter 通过内置的 Navigator 来实现导航, 导航的时候调用它的 push, pop 等方法.

    使用 Navigator 导航时, 有两种方式 push 到“下一个界面(route)”:

    dart
    // 方式1: 通过向 `push` 方法传入 `PageRoute` 子类(比如 `MaterialPageRoute`)并提供 `builder` 来指定下一个界面.
    Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailsPage()));
    
    // 方式2: 通过预先注册到 Navigator 上的路由表进行导航, 此时只需传入页面对应的路由名称.
    // (在 `onGenerateRoute` 函数中进行路由注册)
    Navigator.of(context).pushNamed('/goods/laptop/detailsPage');

    实现时最好是为每个 Navigator 注册相同的路由表, 从而实现各个页面类之间解耦.

  2. GlobalKey: 要保持每个导航栈对应的 Navigator 的状态, 需要在创建时给它一个唯一不变的 GlobalKey 并通过外部状态持有.

  3. Stack: 总得有一个顶层容器来放这些可切换页面, 这里选择 Stack 作为容器, Stack 的特性详见文档.

  4. Offstage: 选用 Offstage 作为 Stack 中的元素, 以便在页面切换时对应改变页面显隐状态, Offstage 的特性详见文档.

“开始烹饪”

在参考过一些美食家的节目后, 我认为最好的方式就是从头到尾, 以代码骨架解法骨架的方式将“烹饪步骤”呈现出来, 才是讲解解决思路的最好方式.

  1. 先准备一个大家喜闻乐见的 main 函数:

    dart
    void main() => runApp(MaterialApp(title: 'Demo', home: AppWidget()));
  2. 准备 main 函数所需的 AppWidget 类:

    dart
    // 为了演示方便, 使用 StatefulWidget, 实践时可将状态放到外部状态管理中处理.
    class AppWidget extends StatefulWidget {
      @override
      _AppWidgetState createState() => _AppWidgetState();
    }
    
    class _AppWidgetState extends State<AppWidget> {
      // 当前选中的 index
      int _selectedTabIndex = 0;
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Stack(
            children: _buildBody(context),
          ),
        );
      }
    }
  3. 准备 _AppWidgetState_buildBody 方法:

    dart
    List<Widget> _buildBody(BuildContext context) {
      // 每个 Tab 对应一个 Offstage 容器.
      return _tabs.asMap().map(_mapToOffstage).values.toList();
    }
  4. 准备 _buildBody 中需要用到的 _tabs 数组, 这里为了演示方便, 数组定义如下:

    dart
    // 假设固定两个 Tab 来切换
    final _tabs = [
      TabPageItem(navigationKey: null),
      TabPageItem(navigationKey: null),
    ];
  5. 定义 _tabs 数组中的元素类型 TabPageItem:

    dart
    /// 表示 Tab 页的数据模型
    @immutable
    class TabPageItem {
      /// 每个 Tab 都拥有自己独立的导航栈,使用对应的 GlobalKey 来区别。
      final GlobalKey<NavigatorState> navigationKey;
    
      // ...
    
      // 在构造时, 传入 null 的话会自动赋值一个 key 上去.
      TabPageItem({
        GlobalKey<NavigatorState> navigationKey,
        // ...
      }) : this.navigationKey = navigationKey ?? GlobalKey<NavigatorState>();
    }
  6. 准备 _buildBody 方法中需要的 _mapToOffstage 方法:

    dart
    MapEntry<int, Widget> _mapToOffstage(int index, TabPageItem item) {
      return MapEntry(
          index,
          Offstage(
            offstage: index != _selectedTabIndex,   // 若为 true, 则该页 Tab 不会显示出来
            child: Navigator(
              key: item.navigationKey,
              onGenerateRoute: (RouteSettings settings) {
                // 根据实际的情况注册即可...
              },
            ),
          ));
    }

通过上述步骤, 就完成了整个骨架的搭建, 因为每个独立的导航栈的行为和单个导航栈时候没有任何区别.

要切换 Tab 也非常简单, 实现如下方法:

dart
// 改变当前选中的index
void updateSelectedIndex(int index) {
  setState(() {
    _selectedTabIndex = index;
  });
}

完整代码

完整代码如下所示:

dart
void main() => runApp(MaterialApp(title: 'Demo', home: AppWidget()));

final _tabs = [
  TabPageItem(navigationKey: null),
  TabPageItem(navigationKey: null),
];

class AppWidget extends StatefulWidget {
  @override
  _AppWidgetState createState() => _AppWidgetState();
}

class _AppWidgetState extends State<AppWidget> {
  // 当前选中的 index
  int _selectedTabIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: _buildBody(context),
      ),
    );
  }

  List<Widget> _buildBody(BuildContext context) {
    // 每个 Tab 对应一个 Offstage 容器.
    return _tabs.asMap().map(_mapToOffstage).values.toList();
  }

  MapEntry<int, Widget> _mapToOffstage(int index, TabPageItem item) {
    return MapEntry(
        index,
        Offstage(
          offstage: index != _selectedTabIndex,
          child: Navigator(
            key: item.navigationKey,
            onGenerateRoute: (RouteSettings settings) {
              // 根据实际的情况注册即可...
            },
          ),
        ));
  }

  // 改变当前选中的index
  void updateSelectedIndex(int index) {
    setState(() {
      _selectedTabIndex = index;
    });
  }
}

/// 表示 Tab 页的数据模型
@immutable
class TabPageItem {
  /// 每个 Tab 都拥有自己独立的导航栈,使用对应的 GlobalKey 来区别。
  final GlobalKey<NavigatorState> navigationKey;

  // ...

  TabPageItem({
    GlobalKey<NavigatorState> navigationKey,
    // ...
  }) : this.navigationKey = navigationKey ?? GlobalKey<NavigatorState>();
}

后记

  1. 整体的实现实际并不复杂, 但路由切换的时候效果会比较生硬, 这是一个需要优化的点.

  2. 在实践时, 可以使用诸如 Fluro 这样的路由库来提供 onGenerateRoute 所需的注册函数, 方便开发和维护.

  3. 本文只提供一种思路, Stack + Offstage 的套路不一定是最好, 这个需要进一步探索.

  4. 本文为演示方便在顶层使用 StatefulWidget. 实践时, StatefulWidget 尽量不要放到树(子树)根, 因为在状态改变时会让下方子树被全部重建, 造成不必要的性能浪费.(在这个例子的上下文中, 这样的情况不容易说明, 后续会专门发一篇文章来探究这个问题).