多 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 端可以采用切换 window 的 rootViewController 来实现, 甚至可以直接切换不同的 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日
许可协议