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 负责维护状态数据,以及数据的更新,产出状态。可以将 数据处理逻辑界面构建逻辑 分离。后续还可以进行一些更复杂的拓展,比如:

  • 支持一个菜单按钮下的子菜单。
  • 菜单支持拖拽排序等。
  • 支持工具菜单的编辑,移除或添加工具菜单。
  • 将按钮状态数据持久化,在启动时加载配置,回复状态。

这些功能后面做的话,也会通过番外篇的形式在这里跟大家简面,敬请期待 ~ 状态管理在菜单栏的应用

注册登录 后评论
    // 作者
    张风捷特烈 发布于 掘金
    • 0
    // 本帖子
    分类
    // 相关帖子
    Coming soon...
    • 0
    Flutter 状态管理 | 在菜单栏的应用张风捷特烈 发布于 掘金