Flutter 状态管理 | 在菜单栏的应用
本文隶属于 《Flutter 状态管理: 源码探索与实战》 小册的番外篇。最近在折腾我的 迷你版 PS, 其中左侧菜单栏的实现,感觉是一个比较好的状态管理应用场景。所以单独出一篇文章分析其实现的细节:
1. pix 项目背景
我曾经一度为文章、视频等封面图所困扰。我的封面一般比较简单,某一系列使用一类封面图,只是改改文字就行了。虽然用 PhotoShop 可以做,但是太重了,而且不支持移动端、网页版。Flutter 作为一个全平台的应用开发框架,可以快速构建多端的软件产品。
所以我为自己设立一个小目标,先从 绘制封面图 为起点,来迭代出一个小巧版 PS ,并支持 Windows、Macos、Android、iOS、Linux 和 Web 六大主流平台,项目代号 pix
。
本文的焦点是左侧的菜单工具条,探讨一下如何管理和维护其中的数据:
2. 状态数据分析
对于状态管理来说,最重要的是知道管理的目标。可能会有人说,这不就是一个图标列表嘛,有什么好管理的? 如果仔细分析交互的需求,就可以发现按钮分为三类 :
[1]
. 普通按钮,只响应点击事件,比如返回、保存按钮。[2]
. 可选中按钮,点击时可以切换激活与不激活状态。比如是否绘制网格、是否展示右侧面版。[3]
. 可选中按钮组,按钮组中只有一个可被选中,选中一个,组中其他的按键需要取消激活。
基于这三点分析,设计一个 MenuAction
密封类负责承载菜单的视图数据。
- id 表示菜单按钮的唯一身份标识。
- icon 表示菜单的图标数据。
- checked 表示是否被激活。
sealed class MenuAction {
final String id;
final IconData icon;
MenuAction({required this.id, required this.icon});
bool get checked;
}
SelectableMenu
继承自 MenuAction
,指可以被选中的按钮,其中的 group 字段用于表示菜单组:
class SelectableMenu extends MenuAction {
@override
final bool checked;
final String? group;
SelectableMenu(
this.checked, {
required super.id,
required super.icon,
this.group,
});
}
所以目前来说,左侧的菜单栏状态数据是 List<MenuAction>
列表,其中记录了构建界面所需的所有数据。
3. 状态管理
这里通过 flutter_bloc 实现状态管理,这种简单的状态数据可以交给 Cubit
, 如下所示,创建 MenusBloc 类继承自 Cubit<List<MenuAction>>
, 在入参中传入 List<MenuAction>
数据作为菜单的初始数据:
class MenusBloc extends Cubit<List<MenuAction>> {
final List<MenuAction> menus;
MenusBloc({required this.menus}) : super(menus);
void changeSelect(SelectableMenu menu) {
// TODO 处理选择的菜单项
}
}
然后再视图的上级通过 MultiBlocProvider
向下层组件树提供 MenusBloc
,这里整体的界面是 ProjectView
:
MultiBlocProvider(providers: [
// 其他的 bloc
BlocProvider(create: (BuildContext context) {
return MenusBloc(menus: menus);
}),
], child: const ProjectView()
List<MenuAction> get menus => [
ClickMenu(id: ActionType.back.name, icon: Icons.arrow_back),
SelectableMenu(false, id: ActionType.grid.name, icon: Icons.grid_3x3_outlined),
SelectableMenu(true, group: 'pointer', id: ActionType.move.name, icon: CupertinoIcons.arrow_up_left),
SelectableMenu(false, group: 'pointer', id: ActionType.painter.name, icon: CupertinoIcons.pen),
SelectableMenu(false, group: 'pointer', id: ActionType.eraser.name, icon: Icons.e_mobiledata),
SelectableMenu(false, id: ActionType.detail.name, icon: Icons.padding),
ClickMenu(id: ActionType.reset.name, icon: Icons.my_location),
ClickMenu(id: ActionType.save.name, icon: Icons.save_alt),
];
4. 视图层构建
下层的组件可以通过上下文访问 MenusBloc 的状态数据,这里左侧菜单栏数据通过 MenuActionBar
类构建,通过 onTap
回调,通知外界按钮的点击事件,处理相关逻辑。
context.select 可以选择状态类中的某一字段数据。仅当该字段数据变化时,才会通知组件重新构建,可以更细粒度地掌控视图的更新时机。
class MenuActionBar extends StatelessWidget {
final ValueChanged<MenuAction> onTap;
const MenuActionBar({super.key, required this.onTap});
@override
Widget build(BuildContext context) {
List<MenuAction> menus = context.select((MenusBloc bloc) => bloc.state);
return SizedBox(
width: 36,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
spacing: 6,
children: menus.map((e) => TolyAction(
selected: e.checked,
child: Icon(e.icon, size: 16),
onTap: () => _handleAction(context, e),
)).toList(),
),
),
);
}
按钮的点击事件触发 _handleAction
,首先触发 onTap 回调通知外界菜单事件,然后如果按钮是 SelectableMenu
,触发 MenusBloc#changeSelect
方法,修改按钮的选中状态:
void _handleAction(BuildContext context, MenuAction menu) {
onTap(menu);
if (menu is SelectableMenu) {
context.read<MenusBloc>().changeSelect(menu);
}
}
5. 状态数据变化的逻辑
拿切换网格辅助线来说:当非激活状态时,点击按钮。需要修改 MenusBloc
中对应数据的激活状态:
可以寻找到状态列表中对应的菜单项,将其移除,并加入相反激活状态的菜单项。通过定义 copyWith 方法,可以快速基于当前对象,创建另一个属性稍有不同的对象:
void changeSelect(SelectableMenu menu) {
List<MenuAction> menus = state.toList();
int index = menus.indexWhere((e) => e.id == menu.id);
if (index != -1) {
menus.removeAt(index);
menus.insert(index, menu.copyWith(checked: !menu.checked));
if (menu.group != null) {
handleActionGroup(menus, menu);
}
emit(menus);
}
}
当遇到点击的菜单具有 group
时,表示是按钮组。此时需要取消激活其他。如下所示,第三、四、五个菜单用于激活鼠标的某种交互操作,彼此是互斥的。一个激活,其他两个都需要取消激活。
在逻辑上,可以从 menus 列表中寻找到当前组中的其他元素,将他们移除,并在对应位置添加非激活状态的菜单即可。
void handleActionGroup(List<MenuAction> menus, SelectableMenu menu) {
Iterable<MenuAction> groupMenu = menus.where((e) =>
(e is SelectableMenu) && e.id != menu.id && e.group == menu.group);
for (MenuAction action in groupMenu) {
int index = menus.indexWhere((e) => e.id == action.id);
if (index != -1) {
MenuAction action = menus.removeAt(index);
if (action is SelectableMenu) {
menus.insert(index, action.copyWith(checked: false));
}
}
}
}
在 MenusBloc 中处理完毕后,产出新的状态,flutter_bloc 框架会自动通知,对应 select 访问的上下文,触发从新构建,达到视图的更新。
有了状态数据的处理逻辑,你完全可以使用任何状态管理手段来达成相同的效果,用你喜欢的状态管理库试试吧 ~
尾声
这样 MenusBloc 负责维护状态数据,以及数据的更新,产出状态。可以将 数据处理逻辑
和 界面构建逻辑
分离。后续还可以进行一些更复杂的拓展,比如:
- 支持一个菜单按钮下的子菜单。
- 菜单支持拖拽排序等。
- 支持工具菜单的编辑,移除或添加工具菜单。
- 将按钮状态数据持久化,在启动时加载配置,回复状态。
这些功能后面做的话,也会通过番外篇的形式在这里跟大家简面,敬请期待 ~ 状态管理在菜单栏的应用