theme: cyanosis
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
《Flutter TolyUI 框架》系列前言:
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
一、下拉菜单设计思考
下拉菜单 是我曾经开发桌面端 Flutter 应用的一根骨刺,虽然 Flutter 内置了 MenuAnchor 组件支持多级菜单。但是 Material 风格的样式很难自定义,在 TolyUI 实现 Popover 之后,让我看到了多级菜单自定义的曙光。
下拉菜单是一个非常非常重要的视图元件,它会将很多交互事件 收敛 到一块浮层区域。通过某些手势交互,比如点击、移入、右键等展开菜单浮层,参与交互。甚至可以通过树形结构来组织交互元件,从而大大拓展了可交互区域,另外其点击外部即可关闭的特点,也使得浮层交互非常轻量级。下面是几款应用中下拉菜的表现:
| 飞书 | 有道云笔记 | 企业微信 | | --- | --- |--- | | |
|
1. 导航之目的
导航之目的在于:对 布局空间 的拓展,以较小的区域来驱动更大的操作空间。比如侧栏导航的一个菜单项,可以驱动右侧大区域的内容变化。这就是一种以小区域,调度大区域的手段:
广义上来说,所有可以通过小区域调度其他区域的手段都可以称之为 导航。对于 UI 界面的交互来说,提示信息 Tooltip、 弹出浮层 Popover、对话框 Dialog 、侧栏导航 RailMenu 、Tabs 页签、新界面跳转,都是导航的一种体现。
2. 导航与弹出层
弹出层是一个非常经典的以小区域博得额外大区域的交互手段,它可以在目标元件的基础上,展开额外的视觉元件,进行呈现或参与交互。可能会有人觉得,Tooltip、Popover、DropMenu 看起来都差不多,为什么不只用一个组件来完成呢?
| Tooltip | Popover | DropMenu | | --- | --- | --- | | |
|
视觉元件在界面中有其固有的语义,就像医生负责治疗、警察负责治安、教师进行教育。再细化分,医生有不同的门类,警察有不同的警种,教师有不同的科目。各司其职是一个社会稳定的保障。如果将整个应用程序交互,看作一个由视觉元件参与运转的社会,每种视觉元件应具有其固有的职能,这就是视觉元件的语义。
本质上来说,导航就是浮层面板的添加和移除。Flutter 中通过 Navigator push 推入的界面,最终也是以浮层节点的方式被加入路由栈,进行展示。根据浮层区域的大小和交互性,可以大致分为三个类别:
- 局部浮层: 以 Popover、Tooltip、DropMenu 为代表,它们额外弹出浮层面板,且 不屏蔽 浮层下方的视觉元件,一般会在点击外部区域时被关闭,是一种 轻量级 的导航交互。
- 模态浮层:以 Dialog 和 BottomSheet、Drawer 为代表,它们会弹出浮层面板,且通过模态背景(半透明灰色) 屏蔽 浮层下方的视觉元件。一般点击模态背景关闭,或主动关闭。是一种 中量级 的导航交互。
- 路由浮层:以 Navigator、Router 为代表,会在某个区域推入新的界面浮层,完全替换或者遮挡下方的视觉元件,需要主动关闭来退出。是一种 重量级 的导航交互。
| 模态浮层 | 路由浮层 | | --- | --- | | |
|
3. DropMenu 的交互语义
Popover 的职能是展示浮层面板,其功能比较宽泛,你可以在浮层面板中展示任何组件,所以其目的性比较弱。而 DropMenu 是基于 Popover 实现的一种 仅用于展示浮层菜单 的浮层面板。
打个比方,Popover 相当于一个警察的笼统概念,而 DropMenu 相当于刑警,专门负责侦查破案、抓捕罪犯工作。DropMenu 的职能是将若干个菜单交互动作,集中起来,通过浮层面板进行呈现和交互。
Tooltip 也相当于 Popover 的一种具体的功能实现者,它 仅用于展示提示文字,所以界面交互要比 DropMenu 更轻量。在 TolyUI 模块化中,Popover 隶属于交互反馈模块 tolyuifeedback 。而 DropMenu 希望视为导航模块的一员,所以 tolyuinavigation 将依赖于 tolyui_feedback 模块,调用相关功能。
二、下拉菜单的基本使用
TolyDropMenu 的使用案例介绍可以网站访问 TolyUI 的 web 版 Flutter 应用。或者下载各平台的桌面端程序查阅体验。
组件/导航/railmenutree: http://toly1994.com/ui/#/widgets/navigation/drop_menu
1. 悬浮与点击的触发模式
如下效果是 TolyDropMenu 的基本使用方式:
- 左侧案例通过悬浮展开下拉菜单,鼠标移出时会关闭菜单,但移入到浮层中时会取消关闭。
- 右侧案例通过点击事件展开下拉菜单,点击外部区域可关闭菜单。
通过 TolyDropMenu
组件,以 child
为目标展开下拉菜单,其中通过 hoverConfig
可以配置悬浮时打开菜单。具有点击行为的菜单项,称之为 ActionMenu
,它持有 MenuMeta 表示菜单的元数据。MenuMeta 的 router 是一个菜单项的唯一标识,在 onSelect
回调中可以响应点击每个菜单项的事件:
```dart TolyDropMenu( hoverConfig: const HoverConfig(enterPop: true, exitClose: true), onSelect: onSelect, menuItems: [ ActionMenu(const MenuMeta(router: '01', label: '1st menu item')), ActionMenu(const MenuMeta(router: '02', label: '2nd menu item')), ActionMenu(const MenuMeta(router: '03', label: '3rd menu item')), ActionMenu(const MenuMeta(router: '04', label: '4ur menu item')), ], child: DebugDisplayButton( info: 'Hover Pop', onPressed: () {}, ), );
void onSelect(MenuMeta menu) { $message.success(message: '点击了 [${menu.label}] 个菜单'); }。 ```
由于 TolyDropMenu 在底层基于 TolyPopover 实现,所以 TolyPopover 的很多基本属性对 TolyDropMenu 同样适用。比如:
- 通过
decorationConfig
参数可以配置外框装饰效果; - 通过
placement
参数配置浮层与目标组件的定位关系; - 通过
childBuilder
回调构建目标组件,从而自主控制菜单的展示时间; - 通过
offsetCalculator
偏移计数器,对浮层进行偏移。
| 气泡框,bottom | 非气泡框,bottomStart | | --- | --- | | |
|
dart TolyDropMenu( placement: Placement.bottomStart, offsetCalculator: boxOffsetCalculator, decorationConfig: DecorationConfig(isBubble: false, backgroundColor: bgColor), onSelect: onSelect, menuItems: [ ActionMenu(const MenuMeta(router: '01', label: '1st menu item')), ActionMenu(const MenuMeta(router: '02', label: '2nd menu item')), ActionMenu(const MenuMeta(router: '03', label: '3rd menu item')), ActionMenu(const MenuMeta(router: '04', label: '4ur menu item')), ], childBuilder: (_, ctrl, __) { return DebugDisplayButton( info: 'Click Pop', onPressed: ctrl.open, ); });
##### 2. 分割线与禁用
上图所示,MenuMeta 可以设置 icon 展示图标,通过 DividerMenu
展示菜单项中的分割线:
将 ActionMenu 中的 enable
设置为 false,可以禁用菜单项,鼠标移入时会变为禁止样式:
dart ActionMenu(const MenuMeta(router: '03', label: '3rd menu item'), enable: false),
3.子菜单的支持与定位
TolyDropMenu 支持子菜单的悬浮展开,并且子菜单超出边界时,也会自动适应对齐方式。比如下面的右侧案例,第三级菜单出现时超出右边界,会自动适应展示在左侧,这本质上是 TolyPopover 的特性。另外,通过 TolyDropMenu#subMenuGap
可以配置子菜单的水平偏移间距。
通过 SubMenu
来承载菜单项及子菜单数据,可以在 menus
参数中设置若干个菜单项。这里 menus 是 MenuDisplay 类型,目前有三种类型的菜单项,未来如有需要,还可以继续拓展:
dart menuItems: [ ActionMenu(const MenuMeta(router: '01', label: '1st menu item')), ActionMenu(const MenuMeta(router: '02', label: '2nd menu item')), SubMenu(const MenuMeta(router: 'export', label: 'export image'), menus: [ ActionMenu(const MenuMeta(router: 'png', label: 'sub out .png')), ActionMenu(const MenuMeta(router: 'jpeg', label: 'sub out .jpeg')), ActionMenu(const MenuMeta(router: 'svg', label: 'sub out .svg')), SubMenu(const MenuMeta(router: 'sub sub', label: 'sub sub menu'), menus: [ ActionMenu(const MenuMeta(router: 's1', label: 'sub menu1')), ActionMenu(const MenuMeta(router: 's2', label: 'sub menu2')), ActionMenu(const MenuMeta(router: 's3', label: 'sub menu3')), ]), ]), const DividerMenu(), ActionMenu(const MenuMeta(router: '03', label: '3rd menu item'), enable: false), ActionMenu(const MenuMeta(router: '04', label: '4ur menu item')), ],
TolyPopover 的 12 种弹出方式,对 TolyDropMenu 依旧适用。可以通过 placement
参数进行设置,效果如下:
三、自定义菜单样式
TolyUI 的宗旨是为开发者提供灵活的视图元件构建方式,所以会尽可能地提供样式和回调,让开发者可以自主定义展示效果。TolyUI 框架内部会封装动画、结构、解析等逻辑,兼具易用性和灵活性。
1. 修改条目样式
如下所示,如果希望鼠标移入时背景色呈浅灰的圆角矩形。可以通过 DropMenuCellStyle
来配置样式,作为 TolyDropMenu#style 入参:
如下所示,DropMenuCellStyle 可以设置边距、圆角半径、前景色、背景色、禁用色、悬浮的前景和背景色。
```dart DropMenuCellStyle lightStyle = const DropMenuCellStyle( padding: EdgeInsets.symmetric(horizontal: 8), borderRadius: BorderRadius.all(Radius.circular(6)), foregroundColor: Color(0xff1f1f1f), backgroundColor: Colors.transparent, disableColor: Color(0xffbfbfbf), hoverBackgroundColor: Color(0xfff5f5f5), hoverForegroundColor: Color(0xff1f1f1f), );
TolyDropMenu( style: style, ... ```
2. 自定义 Meta 拓展和首尾组件
默认情况下,菜单项首尾组件很难自定义。如何让左侧展示图片资源,或者任意组件呢。还记得上一篇 《树形菜单设计》 中 MenuMeta 可以通过拓展来丰富菜单项的展示内容吗?这里也是类似:
如下所示,自定义 MenuDisplayExt
,其中拓展了图片资源、尾部文字、以及左侧组件三个数据。
```dart class MenuDisplayExt extends MenuMateExt { final ImageProvider? image; final String? action; final Widget? leading;
const MenuDisplayExt({this.image, this.action,this.leading}); } ```
在对应的 MenuMeta
中设置对应的 ext
拓展对象,比如前面两个提供图片资源,后面两个提供 leading 组件:
```dart ActionMenu(const MenuMeta( router: '01', label: '1st menu item', ext: MenuDisplayExt(image: AssetImage('assets/images/icon_head.webp'), action: 'Ctrl+J'))), ActionMenu( const MenuMeta(router: '02', label: '2nd menu item', ext: MenuDisplayExt(image: AssetImage('assets/images/logo.png'), action: 'Ctrl+P')), ),
ActionMenu(const MenuMeta(router: '03', label: '3rd menu item', ext: MenuDisplayExt(leading: SizedBox(width: 20))), enable: false), ActionMenu(const MenuMeta(router: '04', label: '4ur menu item', ext: MenuDisplayExt(leading: FlutterLogo(size: 20)))), ```
拓展属性之所以能在界面上展示出来,自然少不了基于这些属性构建 Widget 的逻辑。 TolyDropMenu
基于 tailBuilder
和 leadingBuilder
两个回调来自定义构建组件。同样回调中可以感知菜单的元数据 MenuMeta 以及内部的一些参数。
拿尾巴来说,可以通过 menu.ext?.me
方法查看拓展是否为指定类型,并得到该类型对象。如果 MenuDisplayExt 中的 action 文字非空,返回 Text 文字展示。也就是下面的尾部组件:
dart Widget? _tailBuilder(_, MenuMeta menu, DropMenuDisplayMeta display) { MenuDisplayExt? ext = menu.ext?.me<MenuDisplayExt>(); if (ext?.action != null) { const TextStyle style = TextStyle(color: Colors.grey, fontSize: 12); return Padding( padding: const EdgeInsets.only(left: 12.0), child: Text(ext!.action!, style: style), ); } return null; }
同理左侧的头部,也是通过 MenuDisplayExt
中的数据,构建出对应的组件进行呈现:
dart Widget? _leadingBuilder(_, MenuMeta menu, DropMenuDisplayMeta display) { MenuDisplayExt? ext = menu.ext?.me<MenuDisplayExt>(); Widget? child; if (ext?.image != null) { child = Image(image: ext!.image!, width: 20); } if (ext?.leading != null) { child = ext?.leading; } if (menu.icon != null) { child = Icon(menu.icon!, size: 20); } if(child!=null){ return Padding(padding: const EdgeInsets.only(right: 8.0), child: child); } return null; }
3. 右键菜单的支持
右键展开浮层菜单,对于桌面端来说是非常常见的功能,TolyDropMenu 也提供了支持。最后一个案例中就是这个功能,效果如下:
TolyDropMenu 的 childBuilder
回调可以感知 PopoverController 控制器。 GestureDetector
组件的 onSecondaryTapDown
用于监听鼠标右键按下事件。在其中通过 open
打开,并传入落点的相对坐标即可:
```dart class DropMenuDemo7 extends StatelessWidget{ const DropMenuDemo7({super.key});
@override Widget build(BuildContext context) { return TolyDropMenu( /// 略... childBuilder: _childBuilder, ); }
Widget childBuilder(, PopoverController ctrl, _) { return GestureDetector( onTapDown: () => ctrl.close(), onSecondaryTapDown: (detail) => _onSecondaryTapDown(detail, ctrl), child: Container( color: const Color(0xfff7f7f7), alignment: Alignment.center, height: 180, child: const Text('Right Click on here'), ), ); }
void _onSecondaryTapDown(TapDownDetails details, PopoverController ctrl) async { if (ctrl.isOpen) { ctrl.close(); await Future.delayed(const Duration(milliseconds: 280)); } ctrl.open(position: details.localPosition); } ```
四、小结
到这里 TolyUI 就完成了一个可以灵活定制的下拉菜单 TolyDropMenu。目前为止,TolyUI 已经完成了响应式布局和反馈模块的核心功能。导航模块也完成了三个非常重要的组件,下一步会继续对导航模块进行开发,目标是下拉菜单 Tabs 和 Breadcrumb,敬请期待 ~
感谢你关注 tolyui 的成长,如果喜欢,也希望你能在 github 中点赞支持\~
github 开源地址: https://github.com/TolyFx/toly_ui\ TolyUI 官方案例演示网站:http://toly1994.com/ui