[译]Flutter学习笔记:BottomNavigationBar实现多个Navigation

这个文章解决了什么问题?

最近我研究了一下Flutter,但是在使用Navigator的时候遇到了一个很头痛的问题,就是当我们去来回切换导航按钮时,Flutter会重新build,从而导致控件重新Build,从而会失去浏览历史。这个体验肯定是不好的,后来看到了这个文章,终于解决了这个问题。
原文点这里

正文

今天我们将看看Flutter的Navigation。

但不仅仅是任何无聊的Navigation。😉

不,女士们,先生们,来让我们把Navigation变得有趣。
这是一个有BottomNavigationBar的app:

1_yptwp6Ahe_-yhrLTg-NqwQ.png

我们想要的是每个选项卡都有自己的Navigation堆栈。 这样我们在切换标签时不会丢失Navigation历史记录。 如下图:


multiple-navigators-BottomNavigationBar-animation.gif

如何实现此功能?长话短说:

  • 创建一个带ScaffoldBottomNavigationBar的app。
  • 在每一个Scaffold中,为每个选项卡创建一个包含一个子项的Stack
  • 每个子布局都是一个带有子NavigatorOffstage控件。
  • 不要忘记使用WillPopScope处理Android后退导航。

想要更长更有趣的解释吗? 首先,看一下免责声明:

好了,让我们开始。

一切都关于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与我们定义的两条路径中的每一条相关联。 我们将在一秒钟内查看ColorsListPageColorDetailPage```。
  • 在第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``的所有颜色阴影的ListViewMaterialColor只不过是一个有十种不同色调的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。 它看起来像这样的:

1_u3V51SHLSoR4q0_OD45bQg.png

将这些组装起来

现在我们有了我们自己的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保持不变。 棒极了!😀
multiple-navigators-BottomNavigationBar-animation.gif

但是有一个问题。 在标签之间切换似乎不起作用,因为我们总是在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导航的知识,以及如何结合BottomNavigationBarStackOffstageNavigator控件来实现多个导航堆栈。

使用Offstage小部件可确保我们的所有导航器保留其状态,因为它们保留在控件树中。 这可能会带来一些性能损失,因此如果您选择使用它,我建议您分析您的应用。

可以在此处找到本文的完整源代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容