站点图标 Linux-技术共享

Flutter组件集录,桌面导航,NavigationRail

我们都知道 BottomNavigationBar 是一个移动端非常常用的底部导航栏组件,可以用于点击处理激活菜单,并通过回调来处理界面的切换。

- -
cf89f0aa8514440caf14379c0e99a646tplv-k3u1fbpfcp-zoom-in-crop-mark1512000.awebp_

但是在桌面端,由于一般是宽大于高,所以 BottomNavigationBar 并不适用。而是侧边的导航栏较为常见,比如下面飞书的客户端界面布局。

为了满足桌面端的导航栏适用需求,官方新增了 NavigationRail 组件,而非对 BottomNavigationBar 组件进行适配。之前我也说过,对于差异较大的结构,并没有必要让一个组件通过适配来完成两端需求。分离开来也不是坏事,让一件衣服同时适配 蚂蚁 和 燕子 是很困难的,这时做两件衣服,各司其职显然是更好地方式。

BottomNavigationBar 和 NavigationRail 两个导航就是如此,从语义上来看 Bottom 就是用于底部的导航, Rail 是 扶手 、铁轨 的意思,作为侧栏导航的语义,还是很生动有趣的。两者分别处理特定的结构,这也很符合 单一职责 的原则。

该组件已录入 【FlutterUnit】 ,可以在 App 中体验。另外,本文中的代码可在对应文件夹中查看:


1. NavigationRail 组件的基本使用

下面是 NavigationRail 组件的构造方法,其中必须传入的有两个参数:

  • destinations : 表示导航栏的信息,是 NavigationRailDestination 列表。
  • selectedIndex: 表示激活索引,int 类型。


我们先来实现如下最简单的使用场景,左侧导航栏,在点击时切换右侧内容页:

如果导航栏的数据是固定的,可以提前定义如下的 destinations 常量。如下的 _buildLeftNavigation 方法负责构建左侧导航栏,NavigationRail 在构造中可以通过 onDestinationSelected 回调方法,来监听用户和导航栏的交互事件,传递用点击的索引位置。

 
dart
复制代码
final List<NavigationRailDestination> destinations = const [
  NavigationRailDestination(icon: Icon(Icons.message_outlined),label: Text("消息")),
  NavigationRailDestination(icon: Icon(Icons.video_camera_back_outlined),label: Text("视频会议")),
  NavigationRailDestination(icon: Icon(Icons.book_outlined),label: Text("通讯录")),
  NavigationRailDestination(icon: Icon(Icons.cloud_upload_outlined),label: Text("云文档")),
  NavigationRailDestination(icon: Icon(Icons.games_sharp),label: Text("工作台")),
  NavigationRailDestination(icon: Icon(Icons.calendar_month),label: Text("日历"))
];

Widget _buildLeftNavigation(int index){
  return NavigationRail(
    onDestinationSelected: _onDestinationSelected,
    destinations: destinations,
    selectedIndex: index,
  );
}

void _onDestinationSelected(int value) {
  //TODO 更新索引 + 切换界面
}

在 NavigationRail 的文档注释中说道:该组件一般在 Row 中,使用于 Scaffold.body 属性下。这也很容易理解,这是一个左右结构,在 Row 中可以通过 Expanded 可以自动延伸主体内容。如下,主体内容界面通过 PageView 进行构建,其中的 TestContent 组件在实际使用中换成你的需求界面。

 
less
复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Row(
      children: [
        _buildLeftNavigation(index),
        Expanded(child: PageView(
          children:const [
            TestContent(content: '消息',),
            TestContent(content: '视频会议',),
            TestContent(content: '通讯录',),
            TestContent(content: '云文档',),
            TestContent(content: '工作台',),
            TestContent(content: '日历',),
          ],
        ))
      ],
    ),
  );
}

最后是关键的一点:点击时,如何实现导航索引的切换和主体内容的切页。思路其实很简单,我们已经知道用户点击导航菜单的回调事件。对于 PageView 来说,可以通过 PageController 切换界面,NavigationRail 可以通过 selectedIndex 确定激活索引,所以只要用新索引重新构建 NavigationRail即可。 如下代码所示,在 _onDestinationSelected 在处理这两件重要的事。如下 tag1 处,通过 PageController 的 jumpToPage 方法进行界面跳转。

这里通过 ValueListenableBuilder 来监听 _selectIndex 实现局部更新构建,如下 tag2 处,只要更新 _selectIndex 的值,就可以通知 ValueListenableBuilder 触发 builder 方法,使用新索引,构建 NavigationRail 。这样可以避免直接触发 _MyHomePageState 的更新方法,对 Scaffold 整体进行更新。

 
less
复制代码
class _MyHomePageState extends State<MyHomePage> {

 final PageController _controller = PageController();
 final  ValueNotifier<int> _selectIndex = ValueNotifier(0);
 
  // 略同...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          ValueListenableBuilder<int>(
            valueListenable: _selectIndex,
            builder: (_,index,__)=>_buildLeftNavigation(index),
          ),
          Expanded(child: PageView(
            controller: _controller,
           // 略同...
  }

  void _onDestinationSelected(int value) {
    _controller.jumpToPage(value); // tag1
    _selectIndex.value = value; //tag2
  }

  @override
  void dispose(){
    _controller.dispose();
    _selectIndex.dispose();
    super.dispose();
  }
}

这样就完成了 NavigationRail 最基本的使用,实现了左侧导航结构以及点击时的切换逻辑。NavigationRail 在构造方法中还有很多其他的配置参数用于样式调整,这些不是核心,但可以锦上添花,下面一起来看一下。


2.首尾组件与折叠

leading 和 trailing 属性相当于两个插槽,如下所示,表示导航菜单外的首尾组件。

 
php
复制代码
Widget _buildLeftNavigation(int index){
  return NavigationRail(
    leading: const Icon(Icons.menu_open,color: Colors.grey,),
    trailing: FlutterLogo(),
    onDestinationSelected: _onDestinationSelected,
    destinations: destinations,
    selectedIndex: index,
  );
}

这里有个小细节,trailing 紧随最后一个菜单,如何让它像飞书的导航那样,在最尾部呢?偷瞄一些源码可以看出 trailing 是和导航菜单一起被放入 Column 中的。

所以我们可以通过 Expanded 来延伸剩余空间形成紧约束,通过 Align 使 FlutterLogo 排在下方:

 
less
复制代码
Widget _buildLeftNavigation(int index){
  return NavigationRail(
    leading: const Icon(Icons.menu_open,color: Colors.grey,),
    extended: false,
    trailing: const Expanded(
      child: Align(
        alignment: Alignment.bottomCenter,
        child: Padding(
          padding: EdgeInsets.only(bottom: 20.0),
          child: FlutterLogo(),
        ),
      ),
    ),
    onDestinationSelected: _onDestinationSelected,
    destinations: destinations,
    selectedIndex: index,
  );
}

另外,NavigationRail 中有个 extended 的 bool 参数,用于控制是否展开侧边栏,当该属性变化时,会进行动画展开和收起。如下所示,点击头部时,更新 NavigationRail 的 extended 入参即可:


3.影深 与 标签类型

elevation 表示阴影的深度,这是非常常见的一个属性,如下红框所示,设置 elevation 之后右侧会有阴影,该值越大,阴影越明显。


labelType 参数表示标签类型,对应的属性是 NavigationRailLabelType 枚举。用于表示什么时候显示文字标签,默认是 none ,也就是只显示图标,没有文字。

 
dart
复制代码
enum NavigationRailLabelType {
  none,
  selected,
  all,
}

设置为 all 时,效果如下:导航菜单会同时显示 图标 和 文字标签。


设置为 selected 时,效果如下:只有激活的导航菜单会同时显示 图标 和 文字标签 。

另外,有一点需要注意: 当 extended 属性为 true 时, labelType 必须为 NavigationRailLabelType.none 不然会报错。

 
dart
复制代码
---->[NavigationRail构造断言]----
assert(!extended || (labelType == null || labelType == NavigationRailLabelType.none)),

4.背景、文字、图标样式
  • unselectedLabelTextStyle : 未选中签文字样式
  • selectedLabelTextStyle : 选中标签文字样式
  • unselectedIconTheme : 未选中图标样式
  • selectedIconTheme : 选中图标样式

这四个样式基本上是顾名思义,下面通过一个深色背景版本来使用一下:

 
php
复制代码
@override
Widget build(BuildContext context) {
  const Color textColor =  Color(0xffcfd1d7);
  const  Color activeColor =  Colors.blue;
  const TextStyle labelStyle =  TextStyle(color: textColor,fontSize: 11);
  
  return NavigationRail(
    backgroundColor: const Color(0xff324465),
    unselectedIconTheme: const IconThemeData(color: textColor) ,
    selectedIconTheme: const IconThemeData(color: activeColor) ,
    unselectedLabelTextStyle: labelStyle,
    selectedLabelTextStyle: labelStyle,
    // 略同...
}

5.指示器与最小宽度
  • useIndicator : 是否添加指示器
  • indicatorColor : 指示器颜色

这两个属性用于控制图标后面的背景指示器,如下是在 NavigationRailLabelType.all 类型下指示器的样式,通过圆角矩形包裹图标:


在 NavigationRailLabelType.none 类型下,指示器通过圆形包裹图标:


  • minWidth : 默认 72 ,未展开时导航栏宽度
采集失败,请手动处理

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5774b17b94b146dc98ce3ceb203eabe9~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp

 

  • indicatorColor :默认 256 ,展开时导航栏宽度

NavigationRail 组件的属性介绍就到这里,总的来看,悬浮和点击时,导航栏还是一股 Material 的味。个人觉得这并不适合桌面端,导航栏的菜单可定制性也一般般,只能满足基本的需求。对于稍微特别点的样式,无法支持,比如飞书客户端的导航样式。另外像 拖动更换菜单位置 这样的交互,我们也只通过自定义组件来实现。


6.剖析 NavigationRail 组件,借鉴思路

就像世界上并没有什么包治百病的 药 ,我们也并不能苛求一个组件能满足所有的布局需求。对于一个原生组件满足不了的需求,发挥创造能力去解决问题,这应是我们的本职工作。借鉴官方对于组件实现的思路是非常重要的,它可以为你提供一个主方向。

我们可以发现 NavigationRail 和 Switch 、BottomNavigationBar 等组件一样,虽然自身是 StatefulWidget, 但对于激活状态的数据并不是在内部状态中维护,而是让 使用者主动提供,比如这里在构造 NavigationRail 时必须传入 selectedIndex 。 该组件只提供回调事件来通知使用者,这样的用意是让使用者更容易 控制 该状态,而不是完全封装在状态类内部。

另外,从 selectedIndex 属性在状态类中的使用中可以看出,每个菜单的条目组件通过 _RailDestination 进行构建。从这里可以看出,_RailDestination 会通过 selected 属性来区分是否激活,而且会通过 onTap 回调点击事件。在此触发 widget.onDestinationSelected ,将当前索引 i 传递给用户。

这里 _RailDestination 是 StatelessWidget, 只说明并不需要维护内部状态的变化,组需要根据构造中的配置信息构建需要的组件即可。这就尽可能地简化了 _RailDestination 的构建逻辑,让其相对独立,专注地去做一件事。这就是组件分离的好处之一:既可以简化构建结构,增加可读性,又可以将相对独立的构建逻辑内聚在一起。我们完全可以在日常开发中对这样的分离进行借鉴和发挥。


另外这里比较值得借鉴的还有动画的处理,我看了一下目前桌面的一些应用,比如 微信 、飞书 、有道词典、百度网盘、AndroidStudio 、有道云笔记 ,这些导航栏在切换时都是没有动画的。如下所示,NavigationRail 对应的状态类中维护了两种动画控制器,这也是 NavigationRail 为什么需要是 StatefulWidget 的原因。

其中 _destinationControllers 用于处理,菜单背景指示器在点击时激活/非激活的透明度渐变动画。可以追踪一下动画器的去向: 在 NavigationIndicator 中通过 FadeTransition使用动画器完成透明度渐变动画。

 
rust
复制代码
_RailDestination -->  _AddIndicator --> NavigationIndicator


最后看一下 _extendedController 动画控制器,它对应的动画器也被传入 _RailDestination 中来完成动画功能。这个动画控制器在 extended 属性变化时,展开折叠导航栏的动画。如下源码所示,可以看出关于这个动画更多的细节。 动画过程中文字标签有个透明度渐变的动画,宽度约束通过对 ConstrainedBox 进行限制,并通过 Align 的 widthFactor 控制文字标签区域的尺寸。

这里的 ClipRect 组件套的很迷,我试了一下去除后并不影响动画效果,一开始不知道为什么要加。之后将动画时长拉长,进行了一些测试发现端倪,如果不进行裁剪,就会出现如下的不和谐情况。默认动画 200 ms 看不出太大差异。从这里我又学到了一个小技巧:如何动画展开一个区域。

所以说源码是最好的老师,通过分析源码的实现去思考和学习,是成长的一条很好的途径。而不是什么东西都靠别人给你灌输,遇到不会的或犹豫不决时就到处问。Flutter 组件的源码相对独立,套路也比较简单,很适合去研究学习。《Flutter 组件集录》 专栏专门用于收录我对 Flutter 常用组件的使用介绍,其中一般也会有相关源码实现的一些分析。对一些能力稍弱的朋友,也可以根据这些介绍去尝试研究。那本文就到这里,谢谢观看 ~

作者:张风捷特烈
链接:https://juejin.cn/post/7124097837389840397
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
退出移动版