本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
开源地址: github.com/TolyFx/toly…
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
树形是一种非常自然而常见结构,它可以展示大量具有自相似的信息。比如文件夹中包含文件夹、文件;XMind 中一个节点可以分出若干个枝节点,这些都树形结构数据在界面上展示信息的需求。
在布局空间中,树形结构具有 折叠特性
,可以延和收起子区域。子区域的偏移也能更好的展示树形的层次结构。 本文将探讨 TolyUI 在树形导航菜单中的设计。
树形菜单是 Flutter 本身不支持的,但在桌面端或 Web 端中是非常常见。所以设计一个树形菜单组件是非常必要,它属于一种基础设施。有了之前的 TolyRailMenuBar 的实现经验,抽象与封装就相对简单。其中条目提供了 TolyUI 的默认样式,并且也提供了菜单项的自定义构建途径。
TolyUI 模块化设计中,树形菜单对应的组件是 TolyRailMenuTree。隶属于 【tolyui_navigation】 导航模块:
树形菜单在交互语义上承担的职能是:
1. 承载若干个 视图元件 ,并参与交互。 2. 视图元件 间呈树形组织结构。 3. 允许交互时,动画折叠/收起子节点。
下面是 PLCKI 项目导航的树形结构效果,采用了 TolyUI 的默认风格:
树形结构在使用时,最复杂的地方莫过于节点对象的创建。如何更好的提供树形数据组织形式和解析方式,也是 TolyRailMenuTree 需要考量的地方。
Toly对于树形菜单,定义了两个类型 MenuNode
和 MenuMeta
:
其中 MenuMeta 是菜单的元数据,包含菜单项需要的所有基本信息。包括路由、标签、图标、是否可用四个核心字段。另外这里定义了一个 Identify
的接口,标识唯一的身份标识,对于 MenuMeta
来说,路由信息 router 是一个 MenuMeta 的唯一标识:
class MenuMeta implements Identify<String>{
final String router;
final String label;
final bool enable;
final IconData? icon;
// ...
@override
String get id => router;
}
abstract interface class Identify<T> {
T get id;
}
MenuNode
会持有 MenuMeta 数据以及 MenuNode 列表,以此实现树形的组织结构结构:
class MenuNode implements Identify<String> {
final List<MenuNode> children;
final int depth;
final MenuMeta data;
TolyRailMenuTree 的使用案例介绍可以网站访问 TolyUI 的 web 版 Flutter 应用。或者下载各平台的桌面端程序查阅体验。
组件/导航/rail_menu_tree: toly1994.com/ui/#/widget…
如果仅靠手动书写菜单节点树,会写出非常复杂的代码。比如下面的伪代码,这不仅不便于阅读和维护,也不便于数据传输。比如菜单树的节点信息树如果是网络请求返回的 Json 数据,这种方式需要额外的解析:
---->[伪代码]----
MenuNode(
data:MenuMeta...,
children: [
MenuNode(
data:MenuMeta...,
children: [
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
]
),
MenuNode(
data:MenuMeta...,
children: [
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
MenuNode(data:MenuMeta...),
]
)
]
)
为了更便于开发者的使用,TolyUI 内部提供了映射关系 Map 到 MenuNode 的转换逻辑。你只需要定义类似于 Json 样式的 Map 对象,传入解析器即可得到 MenuNode 节点。映射数据是菜单数据的源泉,一份映射数据对应着唯一的菜单树,比如下面是 PLCKI 项目的映射数据:
注: 树形结构的嵌套层级不可避免,数据全部信息可以参阅 plcki_menu_tree_data.dart
你只需要通过 MenuNode.fromMap
构造,既可以将 plckiMenuData
的映射数据解析转换为 MenuNode
树形节点:
MenuNode root = MenuNode.fromMap(plckiMenuData);
对于树形菜单来说,交互过程中决定它展示样式的有三个核心数据。这里通过 MenuTreeMeta
进行维护:
class MenuTreeMeta {
final List<String> expandMenus;
final MenuNode? activeMenu;
final MenuNode root;
MenuTreeMeta 将作为树形菜单展示的核心数据,作为 TolyRailMenuTree
的入参。如下案例中,由于交互过程中 MenuTreeMeta
数据需要改变,使用 StatefulWidget 组件通过状态类维护状态变化,当然你也可以使用任何形式的状态管理 方式。
class RailMenuTreeDemo1 extends StatefulWidget {
const RailMenuTreeDemo1({super.key});
@override
State<RailMenuTreeDemo1> createState() => _RailMenuTreeDemo1State();
}
class _RailMenuTreeDemo1State extends State<RailMenuTreeDemo1> {
在状态类的 initState
回调中通过 _initTreeMeta
方法,初始化 _treeMeta
数据。默认展开 dashboard
、激活 /dashboard/home
菜单项。 MenuNode 中提供了 find
方法可以查找指定路径的节点:
late MenuTreeMeta _treeMeta;
@override
void initState() {
super.initState();
_initTreeMeta();
}
void _initTreeMeta() {
MenuNode root = MenuNode.fromMap(plckiMenuData);
_treeMeta = MenuTreeMeta(
expandMenus: ['/dashboard'],
activeMenu: root.find('/dashboard/home'),
root: root,
);
}
组件构建过程中,使用 TolyRailMenuTree
组件,meta 参数为上面初始化的 MenuTreeMeta 数据。另外可以通过 onSelect
回调,感知用户点击条目的事件。MenuTreeMeta
中提供了 select
方法,便于开发者基于当前状态处理选中时的数据变化:
@override
Widget build(BuildContext context) {
return SizedBox(
height: 460,
child: TolyRailMenuTree(
enableWidthChange: true,
meta: _treeMeta,
onSelect: _onSelect,
),
);
}
void _onSelect(MenuNode menu) {
_menuMeta = _menuMeta.select(menu);
setState(() {});
}
}
最后说明一点,如果希望 Debug 模式下开发过程 中,每次修改 plckiMenuData
映射数据时,界面可以 热重载 。可以复写 reassemble
方法重新加载数据,它仅对 debug 模式生效,对 release 模式不会产生任何影响。
@override
void reassemble() {
_initTreeMeta();
super.reassemble();
}
这就是 TolyRailMenuTree 最基本的使用,树形结构的视图构建逻辑被封装在框架内部,使用者只需简单地配置数据即可。另外,通过自定义映射关系和解析函数,可以极大方便开发者对树形结构数据的维护。树形结构的映射关系,也可以通过网络请求 json 数据解码获得,这样就可以动态化配置菜单树。
有时我们希望可以在展开子菜单面板时,关闭其他已展开面板。如下所示:
菜单选择时状态变化,是通过 MenuTreeMeta#select
方法完成的。其中封装了选中和折叠的逻辑,并且提供了 singleExpand
参数,默认为 false。将其置为 true 时,可以实现上面的仅展开一个面板的功能:
void _onSelect(MenuNode menu) {
_menuMeta = _menuMeta.select(menu,singleExpand: true);
setState(() {});
}
树形菜单和侧栏菜单类似,可以配置上方和下方区域的组件,以及右侧边线区域,可拉伸面板。
属性名 | 类型 | 介绍 |
---|---|---|
enableWidthChange | bool | 是否支持宽度拉伸 |
width | double | 默认宽度 |
maxWidth | double | 可拉伸最大宽度 |
leading | Widget | 头部组件 |
tail | Widget | 尾部组件 |
TolyRailMenuTree(
leading: const DebugLeadingAvatar(),
tail: const VersionTail(),
...
配色方面,可以设置背景色、展开背景色、菜单项样式。如下所示,是暗色模式下对树形菜单的样式表现。
属性名 | 类型 | 介绍 |
---|---|---|
backgroundColor | Color? | 背景色 |
expandBackgroundColor | Color? | 展开背景色 |
cellStyle | MenuTreeCellStyle | 菜单项样式 |
如下所示,对于暗色模式的适配,可以通过上下文感知是否是暗色模式。为 TolyRailMenuTree 配置不同的颜色:
注:context.isDark 是 TolyUI 的拓展方法,本质是 Theme.of(this).brightness == Brightness.dark
@override
Widget build(BuildContext context) {
Color expandBackgroundColor = context.isDark?Colors.black:Colors.transparent;
Color backgroundColor = context.isDark?Color(0xff001529):Colors.white;
TolyRailMenuTree(
backgroundColor: backgroundColor,
expandBackgroundColor: expandBackgroundColor,
...
),
}
不同的应用程序,由于功能需求的差异,菜单的元数据可能会有不同。比如下面的菜单项可以展示 副标题 和 标签 两个额外的信息。那该怎么办呢?
其实框架内部可以在 MenuMeta
提供两个字段,但这并不是最优解。因为还有可能有其他额外数据,总不能每遇到一个就添加一个。这样违背了开放封闭原则,也不利于开发者灵活地自定义,毕竟这个行为属于框架层的源码修改。于是我设计了一种策略,将变化交由外界处理,框架只在意变化的结果:
如下所示,MenuMeta
元数据中增加了一个 MenuMateExt
的抽象对象,表示拓展元数据。开发者可以继承 MenuMateExt 来拓展项目中的个性化菜单元数据。
比如 PLCKI 项目中,树形菜单需要副标题和标签两个拓展元数据。定义如下的 PlckiMenuMetaExt
持有数据,并提供 fromMap
构造基于映射对象创建 PlckiMenuMetaExt 对象:
class PlckiMenuMetaExt extends MenuMateExt {
final String? subtitle;
final String? tag;
const PlckiMenuMetaExt({
required this.subtitle,
required this.tag,
});
factory PlckiMenuMetaExt.fromMap(Map<String, dynamic> map) {
return PlckiMenuMetaExt(
subtitle: map['subtitle'],
tag: map['tag'],
);
}
}
前面说过,树形结构是由 映射数据 决定的,所以拓展数据也需要加入到映射数据中。如下所示,在菜单项的映射数据中,可以放入对应的拓展项:完整数据可见 plcki_menu_tree_data_plus.dart
有了数据之后,接下来的问题就是:如何将映射数据中的拓展字段,解析到 MenuMeta 对象的拓展数据中。如下所示,在 MenuNode.fromMap
中,有一个 extParser
的解析函数,将其置为 PlckiMenuMetaExt.fromMap
构造函数即可。
void _initTreeMeta() {
MenuNode root = MenuNode.fromMap(
plckiMenuDataPlus,
extParser: PlckiMenuMetaExt.fromMap,
);
}
通过调试可以看出拓展的元数据已经被解析放入了 MenuNode
节点中了。可以看出,开发者可以很简单地拓展这些数据,其中复杂的解析逻辑,树形结构处理都由 TolyUI 框架内部处理。
和 TolyRailMenuBar 一样,TolyRailMenuTree 也支持自定义菜单项条目。其中会回调 MenuNode
和 DisplayMeta
数据,作为菜单项构建过程中的数据支持:
typedef MenuTreeCellBuilder = Widget Function(
MenuNode node,
DisplayMeta display,
);
我们上面已经将拓展数据解析放入了 MenuMeta
的 ext
字段中,而 MenuNode
持有MenuMeta
。也就是说,我们可以在构建逻辑中访问拓展数据,将其呈现在界面上。
PlckiTreeMenuCell
在构建过程中 ext
拓展数据通过 menuNode.data.ext
得到。下面是基于 PlckiMenuMetaExt
数据构建标题(_buildTitle
)和标签( _buildTag
) 的逻辑。其他的构建逻辑和 TolyUI 中的一致,具体可以参考案例的实现代码 rail_menu_tree_demo4.dart
PlckiMenuMetaExt? get ext {
if (menuNode.data.ext is PlckiMenuMetaExt) {
return (menuNode.data.ext) as PlckiMenuMetaExt;
}
return null;
}
Widget _buildTitle(Color? fgColor) {
TextStyle subStyle = const TextStyle(fontSize: 12, color: Colors.grey);
TextStyle titleStyle = TextStyle(color: fgColor);
Widget child = Text(menuNode.data.label,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: titleStyle);
if (ext?.subtitle != null) {
child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
Text(ext!.subtitle!, style: subStyle)
],
);
}
return child;
}
Widget _buildTag(PlckiMenuMetaExt? ext) {
TextStyle tagStyle = const TextStyle(color: Colors.white, height: 1, fontSize: 12);
Widget child = Text('${ext?.tag}', overflow: TextOverflow.ellipsis, maxLines: 1, style: tagStyle);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8),
borderRadius: BorderRadius.circular(4)),
child: child),
);
}
到这里 TolyUI 就完成了一个可以灵活定制的侧栏树形菜单 TolyRailMenuTree。目前为止,TolyUI 已经完成了响应式布局和反馈模块的核心功能。导航模块也完成了两个非常重要的组件,下一步会继续对导航模块进行开发,目标是下拉菜单 DropMenu,敬请期待 ~
感谢你关注 tolyui 的成长,如果喜欢,也希望你能在 github 中点赞支持~
github 开源地址: github.com/TolyFx/toly… TolyUI 官方案例演示网站:toly1994.com/ui