theme: cyanosis
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
《Flutter TolyUI 框架》系列前言:
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
一、侧栏菜单设计思考
侧栏菜单可以说是 App 的第一门面,我们可以在很多桌面端应用产品中看到。它一般用于处理一些全局性的交互事件,比如导航、切换暗亮模式、弹出用户介绍面板等。比如下面自左到右依次是 企业微信、 有道翻译、哔哩哔哩、飞书:
1. 封装、抽象与取舍
就像没有包治百病的药,也没有包实现所有功能的组件。封装在 取得 使用简洁性的同时,也必然要 舍去 一定的灵活性。框架主要目的就是将共性或复杂的功能封装在内部,以简化开发者使用,其关键在于如何平衡取舍。 侧栏菜单条目具有非常强的灵活性,靠一个组件来封装所有的可能性是不现实的。
功能需求的多变性和视图表现的多样性,让我思考:
对于 侧栏菜单 的交互过程中,什么是共性的、什么是个性的、什么是复杂的、什么是可封装的。
在视图结构中,侧栏菜单有着类似的结构,可以分为上中下三个部分,上分一般放置用户头像,或者应用 logo。中间放置菜单项,下方放置一些图标按钮触发事件:
对于不同的开发者来说,菜单项展示的具体视图是个性化的,每个 App 的 UI 设计或者功能需求都不同。但在交互过程在,菜单项的某些视觉表现也存在共性,比如 悬浮事件、动画效果、宽度拖拽 等功能。所以对于条目来说,如何在封装共性时,提供给开发者个性化的构建方式,是一个挑战。庆幸的是 TolyUI 实现了这一点。
2. 侧栏菜单设计动机
Flutter 虽然提供了 NavigationRail 组件展示侧栏菜单,但是可定制性很差。很多样式无法自主控制,所以 TolyUI 希望提供 TolyRailMenuBar 组件,使得侧栏的表现样式可以更自由地构建。
这就是侧栏菜单设计动机,它在交互语义上承担的职能是:
[1]. 承载若干个 事件元件 ,参与交互。
[2]. 展示菜单列表,一般用于切换导航中的路由界面。
[3]. 展示头像、logo、图标按钮等附加视图元件。
如下所示,是 TolyUI 提供的侧栏菜单效果。将 悬浮事件、动画效果、宽度拖拽 封装在内部,对于条目来说,使用者可以通过回调来自定义构建内容,其中是否悬浮、动画数据、宽度信息等内部数据,将会通过回调参数让使用者感知到,而不必在意内部具体的复杂逻辑实现。这就是框架需要承担的复杂性与灵活性的平衡:
3. 导航视图模块: tolyui_navigation
为了让 TolyUI 的功能模块可以细粒度地服务于开发者,采取模块化的分包模式。导航视图相关的组件,将通过 【tolyui_navigation】 包独立维护。
tolyui 的模块化将呈现一个树形结构,父节点的模块可以享用子模块的所有功能。同时子模块又可以单独存在,服务于开发者。
二、TolyRailMenuBar 的使用方式
TolyRailMenuBar 的使用案例介绍可以网站访问 TolyUI 的 web 版 Flutter 应用。或者下载各平台的桌面端程序查阅体验。
> 组件/反馈组件/popover: toly1994.com/ui/#//widgets/navigation/railmenubar
1. TolyRailMenuBar 的基本样式
左侧是支持拖拽拉伸,点击选中时条目背景色、字号、指示器动画变化。
中间是禁止拖拽拉伸的设置案例。
右间是自定义动画参数的配置案例。
TolyRailMenuBar 只需要简单地配置属性,就可以达到展示的效果。
- 菜单项属于来源于
MenuMate
元数据列表。元数据中可以指定图标、标签文字和对应的路径。 - 它需要指定一个激活 id 表示当前的激活项,一般取用路径。
- 菜单项的点击事件通过 onSelected 感知,可以在其中除了跳转路由或更新激活 id 的工作。
- enableWidthChange 可以启用拖拽改变宽度,maxWidth 设置最大宽度值。
```dart String activeId = '/guide/start';
List navMenus = const [ MenuMate(icon: Icons.real estateagent rounded, label: "开始使用", router: '/guide/start'), MenuMate(icon: Icons.accounttree, label: "模块树", router: '/guide/modules'), MenuMate(icon: Icons.privacy tip, label: "设计原则", router: '/guide/principle'), MenuMate(icon: Icons.notealt, label: "更新日志", router: '/guide/update_log'), ];
TolyRailMenuBar( width: 72, maxWidth: 200, menus: navMenus, activeId: activeId, enableWidthChange: true, backgroundColor: backgroundColor, onSelected: onSelected, leading: (type) => DebugLeadingAvatar(type: type), tail: (type) => DebugTail(type: type), ); ```
和 Flutter 内置组件激活和事件设计类似,激活 id 变化后需要重新构建 TolyRailMenuBar 组件。实际使用中,一般会点击时跳转路由。然后监听路由的变化,改变激活 id。可参考 TolyUI 官网实现的相关源码。
dart void onSelected(String path) { setState(() { activeId = path; }); }
2. 菜单宽度类型
另外注意一点,leading 和 tail 是首尾的区域构建回调,使用者可以自由展示组件。其中会回调宽度类型辅助构建,这样便于实现宽窄模式下不同的视图表现,如下所示:
目前有两种类型,small
和 large
:
dart enum MenuWidthType { small, large }
框架中 默认情况 下宽度大于 140 视为 large;但也提供了 widthTypeParser
解析策略,你可以设置它来自定义何时是 large。这就是在封装功能的基础上,给使用者自定义操作的空间。保证简易性的同时,增加灵活性。而这就是回调函数所带来的效力。
dart TolyRailMenuBar( width: 72, maxWidth: 240, widthTypeParser: (width) => width > 150 ? MenuWidthType.large : MenuWidthType.small, /// 略同...
3. 动画参数的配置
TolyRailMenuBar 可以通过 AnimationConfig 东西配置 动画时长、 动画曲线、 动画触发方式 三个数据:
比如右侧的案例会在鼠标悬浮时触发动画,是因为 type 设置为 AnimTickType.hove,你也可以设置为 null 来禁用动画。默认是激活状态变化时触发动画。
dart TolyRailMenuBar( animationConfig: const AnimationConfig( duration: Duration(milliseconds: 500), curve: Curves.fastEaseInToSlowEaseOut, type: AnimTickType.hove, ), );
默认样式对暗色主题也有适配处理,当然配色方面你也可以自定义设置:
三、TolyRailMenuBar 自定义菜单项
业务需求千差万别,你可能需要在菜单项上加千奇百怪的装饰。对一个框架来说,是不可能,也没有必要面面俱到的。TolyRailMenuBar 提供了 MenuCellBuilder 构造器,让开发者拥有极大的发挥空间,来自定义菜单项内容。用变化来处理变化,才是永恒不变的真理。
1. 如何自定义菜单项
如果 TolyUI 默认的条目展示样式不符合需求,可以通过 cellBuilder 参数自定义菜单项,其中 menu
和 display
分别承载菜单和展示信息的元数据,展示信息包括动画值、宽度类型、是否选中、是否激活等。这些封装在框架内部的功能,通过回调的方式暴露核心数据,让开发者可以感知到,并依赖于它们自由构建视图。
比如下面的 QiWeiMenuCell 是自定义的组件,模仿企业微信的侧栏菜单。该案例会禁用过渡动画,整个体看起来简洁清爽:
自定义的逻辑也不过 40 行代码,将 MenuMate
和 DisplayMate
作为自定义函数。构建过程中,依赖 DisplayMate
提供选中、悬浮数据,依赖 MenuMate
提供图标、标签信息。构建一个容器包裹文字和图标即可:
```dart class QiWeiMenuCell extends StatelessWidget{ final MenuMate menu; final DisplayMate display;
const QiWeiMenuCell({ super.key, required this.menu, required this.display, });
Color? get foregroundColor { return display.selected ? Colors.white : const Color(0xffafc8e8); }
Color? get backgroundColor { if(display.hovered&&display.selected) return const Color(0xff578acf); if(display.hovered) return const Color(0xff427cc9); if(display.selected) return const Color(0xff4c83cc); return Colors.transparent; }
@override Widget build(BuildContext context) { bool largeWidth = display.widthType==MenuWidthType.large; TextStyle style = TextStyle(color: foregroundColor, fontSize: largeWidth?14:11); BorderRadius br = const BorderRadius.all( Radius.circular(6)); return Container( alignment: largeWidth?Alignment.centerLeft:Alignment.center, padding: largeWidth?const EdgeInsets.symmetric(horizontal: 12):null, decoration: BoxDecoration(color: backgroundColor, borderRadius: br), height: largeWidth?42:56, child: Wrap( spacing: 6, direction: largeWidth ? Axis.horizontal : Axis.vertical, crossAxisAlignment: WrapCrossAlignment.center, children: [ Icon(menu.icon, color: foregroundColor, size: 18), Text(menu.label, style: style), ], ), ); } } ```
2. 如何自定义菜单项:仿哔哩哔哩
如下所示,哔哩哔哩桌面端应用侧栏导航没有圆角着色,动画触发的事件在悬浮时,文字颜色由黑渐变到粉色,取消激活时从紫色渐变到黑色。同样自定义一个 BilibliMenuCell
作为 cellBuilder 返回值即可:
这个效果中使用了动画,通过 DisplayMate
对象可以感知到当前动画值这样借由 Tween 皆可以轻松实现数值上的过渡。代码如下所示:
```dart class BilibliMenuCell extends StatelessWidget { final MenuMate menu; final DisplayMate display;
const BilibliMenuCell({ super.key, required this.menu, required this.display, });
ColorTween get foregroundTween => ColorTween( begin: const Color(0xff61666d), end: const Color(0xffff6699), );
ColorTween get textTween => ColorTween( begin: const Color(0xff9499a0), end: const Color(0xffff6699) );
Color? get foregroundColor => foregroundTween.transform(display.rate); Color? get textColor => textTween.transform(display.rate);
@override Widget build(BuildContext context) { bool largeWidth = display.widthType==MenuWidthType.large; TextStyle style = TextStyle(color: textColor, fontSize: 12); return Container( alignment: largeWidth?Alignment.centerLeft:Alignment.center, height: 64, child: Wrap( spacing: 6, direction: largeWidth ? Axis.horizontal : Axis.vertical, crossAxisAlignment: WrapCrossAlignment.center, children: [ Icon(menu.icon, color: foregroundColor, size: 24), Text(menu.label, style: style), ], ), ); } } ```
3. 自定义 TolyUI 默认样式
除了 cellBuilder 自定义菜单项展示之外,为了简化使用 TolyUI 默认样式也提供了样式数据,通过 MenuCellStyle
对象来配置,如下是一个黑色风格的侧栏导航。
配置方式如下所示,如果这些样式无法满足你的需求,可以将源码中的 TolyUiMenuCell
组件改吧改吧拿来用。
dart TolyRailMenuBar( width: 64, gap: 10, maxWidth: 200, cellStyle: const MenuCellStyle( showIndicator: false, // 隐藏指示器 hideActiveText: false, // 激活时不隐藏文字 height: 56, // 高度 heightLarge:46, // 宽模式高度 hoverColor: Color(0xff4b5569), activeColor: Colors.white, foregroundColor: Color(0xffc2c5cc), iconSize: 20, ),
我思考过是否需要为 TolyRailMenuBar 提供主题配置。就目前而言 TolyRailMenuBar 有足够灵活的自定义方式进行展示,它只是封装了宽度拖拽、布局结构、动画处理等共性功能。
另外,应该 App 中可能有 500 个链接组件,1000 个按钮组件。但侧栏导航并不会出现非常多次,通过主题来统一样式配置的意义也不大。所以希望把时间和精力花在刀刃上,暂时不提供 TolyRailMenuBar 的主题配置。后面有时间再酌情考量。
四、 TolyRailMenuBar 实践: FlutterUnit 侧栏导航
下面以一个具体的案例,来介绍一下 TolyRailMenuBar 的使用。关注我的应该的知道 【FlutterUnit】 是我的一个知名开源项目,介绍 Flutter 内置组件的使用,以及一些有趣的知识集锦。其中的侧栏菜单是之前花了挺大心力手搓的,现在看一下如何通过 TolyRailMenuBar 来轻松实现它,旧版代码可在 这里 查看。
1. 迁移样式
FlutterUni 的侧栏菜单之前效果如下,包括菜单项激活状态变化的动画效果:
tolyui 全家桶目前还没有正式发布,而是分模块逐步推进。所以这里只使用导航模块 【tolyui_navigation】,将其加入到 pubspec.yaml
中 :
yaml dependencies: ... ## tolyui 导航模块 tolyui_navigation: ^0.0.2
侧栏菜单分为上中下三个区域,顶部 MenuBarLeading、底部 MenuBarTail 组件和之前界面类似,这里不做细数,已经抽离为两个组件,详见源码。
2. 自定义菜单项
菜单项是一个右圆角矩形,在激活变化时,宽度、颜色、字号会动画渐变。这里通过三个 Tween
对动画数值进行计算。通过 MenuMate
和 DisplayMate
中的数据,构建具体的菜单条目组件内容:
```dart final Tween _widthTween = Tween(begin: 0.82, end: 0.95); final Tween _sizeTween = Tween(begin: 18.0, end: 22.0); final Tween _fontSizeTween = Tween(begin: 14.0, end: 15);
class FlutterUnitMenuCell extends StatelessWidget { final MenuMate menu; final DisplayMate display;
const FlutterUnitMenuCell.create(this.menu, this.display, {super.key});
Color? get foregroundColor => display.selected ? Colors.white : Colors.white70;
@override Widget build(BuildContext context) { double height = 42; double anim = display.rate; Color? color = ColorTween( begin: Colors.white.withAlpha(33), end: Theme.of(context).primaryColor).transform(anim); double iconSize = _sizeTween.transform(anim); double fontSize = _fontSizeTween.transform(anim); TextStyle style = TextStyle(color: foregroundColor, fontSize: fontSize); Radius radius = Radius.circular(height / 2); BorderRadius br = BorderRadius.only(topRight: radius, bottomRight: radius);
return Align(
alignment: Alignment.centerLeft,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(color: color, borderRadius: br),
width: _widthTween.transform(anim) * 130,
height: height,
child: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(menu.icon, color: foregroundColor, size: iconSize),
Text(menu.label, style: style),
],
),
),
);
} } ```
3. 使用 TolyRailMenuBar 组件
首先菜单的数据需要国际化,在 didChangeDependencies 中初始化 List<MenuMeta>
菜单元数据:
```dart late List deskNavBarMenus;
@override void didChangeDependencies() { super.didChangeDependencies(); String widget = context.l10n.widgetCollection; String canvas = context.l10n.paintCollection; String knowledge = context.l10n.knowledgeCollection; String treasure = context.l10n.treasureTools; String account = context.l10n.homeAccount; deskNavBarMenus = [ MenuMeta(label: widget, icon: TolyIcon.iconlayout, router: '/widget'), MenuMeta(label: canvas, icon: Icons.palette, router: '/painter'), MenuMeta(label: knowledge, icon: TolyIcon.iconartifact, router: '/knowledge'), MenuMeta(label: treasure, icon: TolyIcon.icon_fast, router: '/tools'), MenuMeta(label: account, icon: Icons.person, router: '/account'), ]; } ```
然后 build 方法中构建 TolyRailMenuBar
组件,cellBuilder
指定为 FlutterUnitMenuCell
的 create
构造。create 是一个命名构造方法,其本质上也是一个函数,而 cellBuilder
需要的也是一个返回 Widget 的函数,所以可以直接赋值。简化书写形式。
另外在 onSelected
回调事件触发 context.go
跳转路由,这也是赋值函数对象:
dart @override Widget build(BuildContext context) { return TolyRailMenuBar( cellBuilder: FlutterUnitMenuCell.create, width: 130, gap: 8, padding: EdgeInsets.zero, backgroundColor: const Color(0xff2C3036), menus: deskNavBarMenus, activeId: activePath, enableWidthChange: false, onSelected: context.go, tail: (_) => const MenuBarTail(), leading: (_) => const MenuBarLeading(), ); }
最后激活 id 通过当前路由来确定,由下方的 activePath
承担该工作,它会依赖 GoRouterState
得到当前路由,取第一段作为激活路径:
```dart final RegExp _segReg = RegExp(r'/\w+');
String? get activePath { final String path = GoRouterState.of(context).uri.toString(); RegExpMatch? match = _segReg.firstMatch(path); if (match == null) return null; String? target = match.group(0); return target; } ```
4. 尾声
到这里 TolyRailMenuBar 就介绍完了。对于树形的导航菜单将单独通过另一个组件 TolyRailMenuTree 实现。目前为止,TolyUI 已经完成了响应式布局和反馈模块的核心功能。下一步是对导航模块的设计开发:
感谢你关注 tolyui 的成长,如果喜欢,也希望你能在 github 中点赞支持\~
github 开源地址: https://github.com/TolyFx/toly_ui\ TolyUI 官方案例演示网站:http://toly1994.com/ui