多 Navigator 实现浏览器 Tab 切换效果

本文最后更新于 2021年4月4日 晚上

在某些场景下, 希望能够实现多个导航栈共存的效果, 比如浏览器应用里面的 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)”:

    1
    2
    3
    4
    5
    6
    // 方式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 函数:

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 为了演示方便, 使用 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 方法:

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /// 表示 Tab 页的数据模型
    @immutable
    class TabPageItem {
    /// 每个 Tab 都拥有自己独立的导航栈,使用对应的 GlobalKey 来区别。
    final GlobalKey<NavigatorState> navigationKey;

    // ...

    // 在构造时, 传入 null 的话会自动赋值一个 key 上去.
    TabPageItem({
    GlobalKey<NavigatorState> navigationKey,
    // ...
    }) : this.navigationKey = navigationKey ?? GlobalKey<NavigatorState>();
    }
  6. 准备 _buildBody 方法中需要的 _mapToOffstage 方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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 也非常简单, 实现如下方法:

1
2
3
4
5
6
// 改变当前选中的index
void updateSelectedIndex(int index) {
setState(() {
_selectedTabIndex = index;
});
}

完整代码

完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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 尽量不要放到树(子树)根, 因为在状态改变时会让下方子树被全部重建, 造成不必要的性能浪费.(在这个例子的上下文中, 这样的情况不容易说明, 后续会专门发一篇文章来探究这个问题).


多 Navigator 实现浏览器 Tab 切换效果
https://blog.rayy.top/2019/11/23/2019-2019-11-23-multi-navigator/
作者
貘鸣
发布于
2019年11月23日
许可协议