多 Navigator 实现浏览器 Tab 切换效果
本文最后更新于 2021年4月4日 晚上
在某些场景下, 希望能够实现多个导航栈共存的效果, 比如浏览器应用里面的 Tab 切换. 本文记录的就是如何在 Flutter 中通过多个 Navigator
来实现多个导航栈共存.
典型需求描述
要在 Flutter 端实现多 Tab 切换, 且各个 Tab 保留自己导航栈(因为不仅仅是网页导航, 还有 Flutter 内的页面导航.).
而 Tab 的定义和普通浏览器的定义无差别, 概念上说就和下面这张图上的 Tab 类似:
用户可以通过点击一个 Tab 管理页的各个 Tab 标签, 从而切换到不同的 Tab
在切换 Tab 的时候, 希望切换的是导航栈, 即从 Tab A 切换到 Tab B, 再切换回 Tab A 时, Tab A 上的状态均得到保留.
比如在 iOS 端可以采用切换 window
的 rootViewController
来实现, 甚至可以直接切换不同的 window
.
而在 Flutter 端的话该如何实现呢? 嘿嘿, 开发如做菜, 那就拿一道菜谱出来吧!
“准备作料”
要做出这道 “多导航栈切换” 佳肴, 需要如下特殊”作料”(如果一眼看到这些东西不明就里, 不妨跳到烹饪步骤, 再根据看到的内容回来查阅):
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
注册相同的路由表, 从而实现各个页面类之间解耦.GlobalKey
: 要保持每个导航栈对应的Navigator
的状态, 需要在创建时给它一个唯一不变的GlobalKey
并通过外部状态持有.Stack
: 总得有一个顶层容器来放这些可切换页面, 这里选择Stack
作为容器,Stack
的特性详见文档.Offstage
: 选用Offstage
作为Stack
中的元素, 以便在页面切换时对应改变页面显隐状态,Offstage
的特性详见文档.
“开始烹饪”
在参考过一些美食家的节目后, 我认为最好的方式就是从头到尾, 以代码骨架或解法骨架的方式将”烹饪步骤”呈现出来, 才是讲解解决思路的最好方式.
先准备一个大家喜闻乐见的
main
函数:1
void main() => runApp(MaterialApp(title: 'Demo', home: AppWidget()));
准备
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),
),
);
}
}准备
_AppWidgetState
的_buildBody
方法:1
2
3
4List<Widget> _buildBody(BuildContext context) {
// 每个 Tab 对应一个 Offstage 容器.
return _tabs.asMap().map(_mapToOffstage).values.toList();
}准备
_buildBody
中需要用到的_tabs
数组, 这里为了演示方便, 数组定义如下:1
2
3
4
5// 假设固定两个 Tab 来切换
final _tabs = [
TabPageItem(navigationKey: null),
TabPageItem(navigationKey: null),
];定义
_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>();
}准备
_buildBody
方法中需要的_mapToOffstage
方法:1
2
3
4
5
6
7
8
9
10
11
12
13MapEntry<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 |
|
完整代码
完整代码如下所示:
1 |
|
后记
整体的实现实际并不复杂, 但路由切换的时候效果会比较生硬, 这是一个需要优化的点.
在实践时, 可以使用诸如
Fluro
这样的路由库来提供onGenerateRoute
所需的注册函数, 方便开发和维护.本文只提供一种思路,
Stack
+Offstage
的套路不一定是最好, 这个需要进一步探索.本文为演示方便在顶层使用 StatefulWidget. 实践时, StatefulWidget 尽量不要放到树(子树)根, 因为在状态改变时会让下方子树被全部重建, 造成不必要的性能浪费.(在这个例子的上下文中, 这样的情况不容易说明, 后续会专门发一篇文章来探究这个问题).