在某些场景下, 希望能够实现多个导航栈共存的效果, 比如浏览器应用里面的 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)”: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注册相同的路由表, 从而实现各个页面类之间解耦. -
GlobalKey: 要保持每个导航栈对应的Navigator的状态, 需要在创建时给它一个唯一不变的GlobalKey并通过外部状态持有. -
Stack: 总得有一个顶层容器来放这些可切换页面, 这里选择Stack作为容器,Stack的特性详见文档. -
Offstage: 选用Offstage作为Stack中的元素, 以便在页面切换时对应改变页面显隐状态,Offstage的特性详见文档.
“开始烹饪”
在参考过一些美食家的节目后, 我认为最好的方式就是从头到尾, 以代码骨架或解法骨架的方式将“烹饪步骤”呈现出来, 才是讲解解决思路的最好方式.
-
先准备一个大家喜闻乐见的
main函数:dartvoid main() => runApp(MaterialApp(title: 'Demo', home: AppWidget())); -
准备
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), ), ); } } -
准备
_AppWidgetState的_buildBody方法:dartList<Widget> _buildBody(BuildContext context) { // 每个 Tab 对应一个 Offstage 容器. return _tabs.asMap().map(_mapToOffstage).values.toList(); } -
准备
_buildBody中需要用到的_tabs数组, 这里为了演示方便, 数组定义如下:dart// 假设固定两个 Tab 来切换 final _tabs = [ TabPageItem(navigationKey: null), TabPageItem(navigationKey: null), ]; -
定义
_tabs数组中的元素类型TabPageItem:dart/// 表示 Tab 页的数据模型 @immutable class TabPageItem { /// 每个 Tab 都拥有自己独立的导航栈,使用对应的 GlobalKey 来区别。 final GlobalKey<NavigatorState> navigationKey; // ... // 在构造时, 传入 null 的话会自动赋值一个 key 上去. TabPageItem({ GlobalKey<NavigatorState> navigationKey, // ... }) : this.navigationKey = navigationKey ?? GlobalKey<NavigatorState>(); } -
准备
_buildBody方法中需要的_mapToOffstage方法:dartMapEntry<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 也非常简单, 实现如下方法:
// 改变当前选中的index void updateSelectedIndex(int index) { setState(() { _selectedTabIndex = index; }); }
完整代码
完整代码如下所示:
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>(); }
后记
-
整体的实现实际并不复杂, 但路由切换的时候效果会比较生硬, 这是一个需要优化的点.
-
在实践时, 可以使用诸如
Fluro这样的路由库来提供onGenerateRoute所需的注册函数, 方便开发和维护. -
本文只提供一种思路,
Stack+Offstage的套路不一定是最好, 这个需要进一步探索. -
本文为演示方便在顶层使用 StatefulWidget. 实践时, StatefulWidget 尽量不要放到树(子树)根, 因为在状态改变时会让下方子树被全部重建, 造成不必要的性能浪费.(在这个例子的上下文中, 这样的情况不容易说明, 后续会专门发一篇文章来探究这个问题).