[译]Flutter学习笔记:BottomNavigationB
这个文章解决了什么问题?
最近我研究了一下Flutter,但是在使用Navigator的时候遇到了一个很头痛的问题,就是当我们去来回切换导航按钮时,Flutter会重新build,从而导致控件重新Build,从而会失去浏览历史。这个体验肯定是不好的,后来看到了这个文章,终于解决了这个问题。
原文点这里
正文
今天我们将看看Flutter的Navigation。
但不仅仅是任何无聊的Navigation。😉
不,女士们,先生们,来让我们把Navigation变得有趣。
这是一个有BottomNavigationBar的app:
我们想要的是每个选项卡都有自己的Navigation堆栈。 这样我们在切换标签时不会丢失Navigation历史记录。 如下图:
multiple-navigators-BottomNavigationBar-animation.gif
如何实现此功能?长话短说:
- 创建一个带
Scaffold
和BottomNavigationBar
的app。 - 在每一个
Scaffold
中,为每个选项卡创建一个包含一个子项的Stack
。 - 每个子布局都是一个带有子
Navigator
的Offstage
控件。 - 不要忘记使用WillPopScope处理Android后退导航。
想要更长更有趣的解释吗? 首先,看一下免责声明:
- 本文假设您熟悉Flutter中的导航。 更多知识,请参阅Navigation基础知识教程,以及Navigator,MaterialPageRoute和MaterialApp。
- 其中一些代码是实验性的。 如果您知道更好的方法,请告诉我。
好了,让我们开始。
一切都关于Navigator
所有Flutter应用程序都被定义为MaterialApp
。 通常来说,MaterialApp
位于控件树的根结点:
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.red,
),
home: App(),
);
}
}
然后我们就可以以如下的方式定义我们的App
类:
enum TabItem { red, green, blue }
class App extends StatefulWidget {
@override
State<StatefulWidget> createState() => AppState();
}
class AppState extends State<App> {
TabItem currentTab = TabItem.red;
void _selectTab(TabItem tabItem) {
setState(() {
currentTab = tabItem;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _buildBody(),
bottomNavigationBar: BottomNavigation(
currentTab: currentTab,
onSelectTab: _selectTab,
),
);
}
Widget _buildBody() {
// return a widget representing a page
}
}
这里,BottomNavigation
是一个自定义控件,使用BottomNavigationBar
绘制具有正确颜色的三个选项卡。 它将currentTab
作为输入并调用_selectTab
方法以根据需要更新状态。
有趣的部分是_buildBody()
方法。 为简单起见,我们可以首先添加一个带回调的FlatButton来推送新页面:
Widget _buildBody() {
return Container(
color: TabHelper.color(TabItem.red),
alignment: Alignment.center,
child: FlatButton(
child: Text(
'PUSH',
style: TextStyle(fontSize: 32.0, color: Colors.white),
),
onPressed: _push,
)
);
}
void _push() {
Navigator.of(context).push(MaterialPageRoute(
// we'll look at ColorDetailPage later
builder: (context) => ColorDetailPage(
color: TabHelper.color(TabItem.red),
title: TabHelper.description(TabItem.red),
),
));
}
_push()
方法是如何工作的?
-
MaterialPageRoute
负责创建要推送的新路由。 -
Navigator.of(context)
在窗口控件树中找到Navigator
,并使用它来推送新route。
你可能好奇 Navigator
是从哪来的。
我们自己没有创建一个,我们的App类的父级是位于控件树根部的MaterialApp
。
事实证明,MaterialApp
在内部创建了自己的Navigator
。
但是,如果我们只使用Navigator.of(context)
来推送新路由,就会发生意想不到的情况。
当新页面出现时,整个``BottomNavigationBar```及其内容会滑动。 不酷。🤨
1_k5yMOPCem_z5JZVpa6RJCQ.gif我们真正想要的是将详细页面推到主页面上,但要将BottomNavigationBar保持在底部。
这不起作用,因为Navigator.of(context)找到BottomNavigatorBar本身的祖先。 事实上,控件树看起来像这样:
▼ MyApp
▼ MaterialApp
▼ <some other widgets>
▼ Navigator
▼ <some other widgets>
▼ App
▼ Scaffold
▼ body: <some other widgets>
▼ BottomNavigationBar
如果我们打开Flutter inspector:
1_zSeQkAGwARf2KtSkZqgRSg.png
如果我们可以使用不是我们BottomNavigationBar
的祖先的Navigator
,那么它就会按预期工作。
好的 ,Navigator
,看看我们能做什么
解决方案是使用新的Navigator````包裹我们的
Scaffold```对象的主体。
但在我们这样做之前,让我们介绍一下我们将用来展示最终UI的新类。
第一个类叫做TabNavigator
:
class TabNavigatorRoutes {
static const String root = '/';
static const String detail = '/detail';
}
class TabNavigator extends StatelessWidget {
TabNavigator({this.navigatorKey, this.tabItem});
final GlobalKey<NavigatorState> navigatorKey;
final TabItem tabItem;
void _push(BuildContext context, {int materialIndex: 500}) {
var routeBuilders = _routeBuilders(context, materialIndex: materialIndex);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
routeBuilders[TabNavigatorRoutes.detail](context)));
}
Map<String, WidgetBuilder> _routeBuilders(BuildContext context,
{int materialIndex: 500}) {
return {
TabNavigatorRoutes.root: (context) => ColorsListPage(
color: TabHelper.color(tabItem),
title: TabHelper.description(tabItem),
onPush: (materialIndex) =>
_push(context, materialIndex: materialIndex),
),
TabNavigatorRoutes.detail: (context) => ColorDetailPage(
color: TabHelper.color(tabItem),
title: TabHelper.description(tabItem),
materialIndex: materialIndex,
),
};
}
@override
Widget build(BuildContext context) {
var routeBuilders = _routeBuilders(context);
return Navigator(
key: navigatorKey,
initialRoute: TabNavigatorRoutes.root,
onGenerateRoute: (routeSettings) {
return MaterialPageRoute(
builder: (context) => routeBuilders[routeSettings.name](context));
});
}
}
这个怎么起作用的?
- 在第1-4行,我们定义了两个路由名称:
/
和/ detail
在第7行,我们定义了TabNavigator
的构造函数。 这需要一个navigatorKey
和一个tabItem
。 - 请注意,
navigatorKey
的类型为GlobalKey <NavigatorState>
。 我们需要这个来唯一地标识整个应用程序中的navigator(在此处阅读有关GlobalKey的更多信息)。 - 在第22行,我们定义了一个
_routeBuilders
方法,它将``WidgetBuilder与我们定义的两条路径中的每一条相关联。 我们将在一秒钟内查看
ColorsListPage和
ColorDetailPage```。 - 在第38行,我们实现了
build(
方法,该方法返回一个新的Navigator对象。 - 这需要一个
key
和一个initialRoute
参数。 - 它还有一个
onGenerateRoute
方法,每次需要生成路由时都会调用该方法。 这使用了我们上面定义的_routeBuilders()
方法。 - 在第11-19行,我们定义了一个
_push()
方法,该方法用于使用ColorDetailPage推送细节路径。
这是ColorsListPage类:
class ColorsListPage extends StatelessWidget {
ColorsListPage({this.color, this.title, this.onPush});
final MaterialColor color;
final String title;
final ValueChanged<int> onPush;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
title,
),
backgroundColor: color,
),
body: Container(
color: Colors.white,
child: _buildList(),
));
}
final List<int> materialIndices = [900, 800, 700, 600, 500, 400, 300, 200, 100, 50];
Widget _buildList() {
return ListView.builder(
itemCount: materialIndices.length,
itemBuilder: (BuildContext content, int index) {
int materialIndex = materialIndices[index];
return Container(
color: color[materialIndex],
child: ListTile(
title: Text('$materialIndex', style: TextStyle(fontSize: 24.0)),
trailing: Icon(Icons.chevron_right),
onTap: () => onPush(materialIndex),
),
);
});
}
}
这个类的目的是显示可以用来输入的MaterialColor``的所有颜色阴影的
ListView。
MaterialColor只不过是一个有十种不同色调的
ColorSwatch```。
为了完整性,这里是ColorDetailPage
:
class ColorDetailPage extends StatelessWidget {
ColorDetailPage({this.color, this.title, this.materialIndex: 500});
final MaterialColor color;
final String title;
final int materialIndex;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: color,
title: Text(
'$title[$materialIndex]',
),
),
body: Container(
color: color[materialIndex],
),
);
}
}
这个很简单:它只显示一个带有AppBar
的页面并显示之前选择的MaterialColor
。 它看起来像这样的:
将这些组装起来
现在我们有了我们自己的TabNavigator
,让我们回到我们的App并使用它:
final navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return Scaffold(
body: TabNavigator(
navigatorKey: navigatorKey,
tabItem: currentTab,
),
bottomNavigationBar: BottomNavigation(
currentTab: currentTab,
onSelectTab: _selectTab,
),
);
}
- 首先,我们定义一个
navigatorKey
。 - 然后在我们的
build()
方法中,我们用它创建一个TabNavigator
,并传入currentTab
。
如果我们现在运行应用程序,我们可以看到推送在选择列表项时正常工作,并且BottomNavigationBar
保持不变。 棒极了!😀
但是有一个问题。 在标签之间切换似乎不起作用,因为我们总是在Scaffold
主体内显示红色页面。
多个Navigator
这是因为我们已经定义了一个新的导航器,但这是在所有三个选项卡中共享的。
记住:我们想要的是每个标签的独立导航堆栈!
我们解决这个问题:
class App extends StatefulWidget {
@override
State<StatefulWidget> createState() => AppState();
}
class AppState extends State<App> {
TabItem currentTab = TabItem.red;
Map<TabItem, GlobalKey<NavigatorState>> navigatorKeys = {
TabItem.red: GlobalKey<NavigatorState>(),
TabItem.green: GlobalKey<NavigatorState>(),
TabItem.blue: GlobalKey<NavigatorState>(),
};
void _selectTab(TabItem tabItem) {
setState(() {
currentTab = tabItem;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
_buildOffstageNavigator(TabItem.red),
_buildOffstageNavigator(TabItem.green),
_buildOffstageNavigator(TabItem.blue),
]),
bottomNavigationBar: BottomNavigation(
currentTab: currentTab,
onSelectTab: _selectTab,
),
);
}
Widget _buildOffstageNavigator(TabItem tabItem) {
return Offstage(
offstage: currentTab != tabItem,
child: TabNavigator(
navigatorKey: navigatorKeys[tabItem],
tabItem: tabItem,
),
);
}
}
几点说明:
- 在第9-13行,我们定义了一个全局导航键的地图。 这是我们确保使用多个导航器所需的。
- 我们的脚手架的身体现在是一个有三个孩子的堆栈。
- 每个子项都在
_buildOffstageNavigator()
方法中构建。 - 这将Offstage控件与子TabNavigator一起使用。 如果正在呈现的选项卡与当前选项卡不匹配,则offstage属性为true。
- 我们将
navigatorKey [tabItem]
传递给TabNavigator
,以确保每个选项卡都有一个单独的导航键。 - 如果我们编译并运行应用程序,现在一切都按照预期的方式工作。 我们可以独立地推送/弹出每个导航器,并且后台导航员保持他们的状态。🚀
One more thing
如果我们在Android上运行应用程序,当我们按下后退按钮时,我们会发现一个有趣的现象:
1_4_rjL1Hh_zKHJHjO4MNOIg.gif
app消失了,我们回到了主屏幕!
这是因为我们没有指定应该如何处理后退按钮。
我们来解决这个问题:
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async =>
!await navigatorKeys[currentTab].currentState.maybePop(),
child: Scaffold(
body: Stack(children: <Widget>[
_buildOffstageNavigator(TabItem.red),
_buildOffstageNavigator(TabItem.green),
_buildOffstageNavigator(TabItem.blue),
]),
bottomNavigationBar: BottomNavigation(
currentTab: currentTab,
onSelectTab: _selectTab,
),
),
);
}
这是通过WillPopScope
完成的,该控件控制如何解除路由。 看一下WillPopScope
的文档:
注册用户否决尝试的回调以解除封闭的/// [ModalRoute]
在第4行,我们定义一个onWillPop()回调,如果当前导航器可以弹出则返回false,否则返回true。
如果我们再次运行应用程序,我们可以看到按下后退按钮会解除所有推送路线,只有当我们再次按下它时我们才会离开应用程序。
1_qQW2iGXiWL2F1tu6cLQfwg.gif
需要注意的一点是,当我们在Android上推送新路线时,会从底部滑入。 相反,惯例是在iOS上从右侧滑入。
此外,由于某些原因,Android上的过渡有点紧张。 我不确定这是否是一个模拟器问题,它在真实设备上看起来不错。
Credits
积分转到]Brian Egan](https://github.com/brianegan)找到一种让Navigator
工作的方法。 他的想法是使用Stack with Offstage来保持导航器的状态。
回顾
今天我们学习了很多关于Flutter导航的知识,以及如何结合BottomNavigationBar
,Stack
,Offstage
和Navigator
控件来实现多个导航堆栈。
使用Offstage小部件可确保我们的所有导航器保留其状态,因为它们保留在控件树中。 这可能会带来一些性能损失,因此如果您选择使用它,我建议您分析您的应用。