flutter 自定义下拉条件筛选菜单

4,931 阅读8分钟

链接

demo link:flutter web 展示。

spinner_box/pub:网络原因,有可能无法及时更新到市场。

spinner_box/github:保持最新。

2024/11/18 更新

  • 增加按钮拦截属性和拦截样式配置(详见demo中多选条件-可拦截筛选);
  • 完善模型数据formJson的解析,网络获取配置更方便;

1.jpg

序言

首先阐述下,为什么要自己重新写一个菜单筛选框:

  • gzx_dropdown_menu插件用的人比较多,简单看了一下使用的时候代码量比较大,更重要的是该插件是用的 stack 层叠布局(如果我没记错的话),个人感觉灵活度不太好;
  • 没有找到理想中的轮子;

自己想象中该类插件应有的模样:

  • 数据配置驱动;
  • 数据驱动(固定模板样式)后,是否能额外追加自定义的显示;
  • UI层面尽可能少配置一些版式代码;
  • 筛选完成,我只想要结果,其他什么的我都不想管;

七七八八的要求还不少嘞,所以没找到合适的 —— (✘_✘)。

开发过程

前面说的那些东西,本插件也基本实现。

整体设计就是顶部按钮和实际的弹框页面分离,再通过某个步骤关联起来,这样既完成了简单的解耦,也给完全自定义弹框视图提供了更多可能性

下面记录下插件编写过程中的一些想法:

Q1: 如何实现点击按钮后,弹出框动态跟随显示(紧挨按钮下面)?

A1: 当然首选CompositedTransform...系列组件Follower + Target,不得不说是真高效、便捷。

Q2: 点击按钮后的弹框内容,应该使用什么方式显示出来呢?

A2:1、OverlayEntry:提示框首选显示方式,但当弹框显示状态下,键盘升起会导致弹框显示异常(应该是有解决方式的,但当时并没有想到怎么来处理这个); 2、Page Router:通过新起一个页面的形式来显示筛选内容(Overlay的更复杂使用),这个就比较简单了,但是遇到的问题是,新起路由推送,在我们的弹框内容下会有一层遮罩,后续的交互效果不好(图1.0),处理方式就是修改路由的offset,有兴趣可以看一下源码中的内容。

Q3: 插件中的状态管理该使用什么方式?

A3:为了更大程度的减少入侵性,代码通过使用 InheritedWidget 完成组件树上的状态传递;通过使用 ValueNotifier<Model> 来控制页面上的各种交互后的状态修改的更新。

Q4: 如何在固定模板样式下,添加自定义的组件(图2.0)?

A4:这块涉及到自定义视图的拼接,需要确定筛选结果的获取、显示的刷新、自定义的交互,整体逻辑实际上是与弹框内容有关联的,所以这里采用了继承父类的方式,定义部分方法在子类实现,来解决相关问题。后续会详细说说这块。

Q5: 为了灵活,如何设计才能实现UI和逻辑分离;某种情况下如何手动来实现状态的修改?

A5:回到问题Q3,整个筛选逻辑及状态更新主要通过 ValueNotifier 的方式来实现。但是该工具为了减少顶部按钮集(其实header就是简单标题修改)和实际弹框页面的关联性,spinner_box 会对外暴露一个 controller 用于标题的修改,弹框的关闭,或一些其他操作,这也恰好完成用户可以手动修改状态的需求。

其他的内容也没有什么需要特别解说的,主要就是对细节逻辑处理,UI层面的处理,这里就不提了。

表序示例图表序示例图
1.02.0

图例

表序图例表序图例
1.02.0
3.04.0
2023-05-16更新(添加栅栏选中样式)2023-05-18更新(添加主题配置)

部分类型设计说明

  1. SpinnerEntity:单个筛选框面板的配置信息,比较简单看源码就行了。
  2. SpinnerItemData:筛选框中最小单元对应的数据配置信息
/// 继承 `ChangeNotifier` `ValueListenable`
/// 写入自变量 `selected`,方便监听点击选中状态
class SpinnerItemData extends ChangeNotifier implements ValueListenable<bool> {
  /// 显示名称
  final String name;

  /// 选中项的数据值 (原样输入输出)
  /// 如果数据类型与实际类型不匹配时,可将实际数据放入`result`中
  /// 以保证选中时输出实际类容的集合
  final dynamic result;

  /// 是否选互斥(选中时清空当前其他选中项,一般用于 `全部` `不限` 等合并条件项)
  final bool isMutex;

  /// 下级选项
  final List<SpinnerItemData> items;
  
  ....

SpinnerItemData 继承于 ChangeNotifier 设计为可监听变量,通过配置页面监听来修改状态。好处就是将刷新机制给到最小的UI单元(有父级关联状态显示的还是整体刷新好一些,便于处理和理解,例如栅栏样式),坏处就是整理的时候比较麻烦,没有整个单纯数据整体刷新来的简单。

  1. SpinnerFilterNotifier:筛选页面的控制器,继承于ValueNotifier<SpinnerFilterState> ,目的是将逻辑集中到某个单独的类型,也为了方便状态刷新,当然这个类型在外部能需要使用。
  2. SpinnerBoxTheme: 主题配置,主要是各种颜色、大小、形状的设置,这个参照系统主题设计方式来处理的。
  3. SpinnerFence: 栅栏样式的弹框面板,区分 SpinnerFilter(wrap/column),因为数据处理方式不同,所以单独起一个文件来处理,避免同一文件内容太多,不方便阅读。

使用方式

1、筛选条件的创建

插件提供的专有数据对象,进行新建:

SpinnerEntity text({
  String key = 'text',
  bool isRadio = true,
  int count = 8,
  MoreContentType type = MoreContentType.column,
}) {
  return SpinnerEntity(
      key: key,
      title: '条件标题',
      isRadio: isRadio,
      type: type,
      desc: type == MoreContentType.column
          ? ''
          : '描述文本描述文本描述文本描述文本描述文本描述文本描述文本描述文本',
      items: [
        SpinnerItemData(name: '全部', result: '', selected: true, isMutex: true),
        ...List.generate(count, (index) {
          const text = '庆历四年春滕子京谪守巴陵郡越明年政通人和百废具兴乃重修岳阳楼增其旧制刻唐贤今人诗赋于其上属予作文以记之';
          final space = Random().nextInt(3) + 2;
          final posi = max(Random().nextInt(text.length) - space, 0);
          final name = text.substring(posi, posi + space);
          return SpinnerItemData.fromJson({'name': name, 'result': name});
        }),
      ]);
}

简要说明下部分属性: 1、type: 筛选项的排列方式分为wrap(按钮组)和column(列表);2、items:由SpinnerItemData集合构成,具有是否选中的属性,设置默认选中也是通过这里处理。

2、Widget的创建

在你需要的地方添加类似代码:

...
  var _condition3 = [
    years(key: 'year3', isRadio: true, count: 3),
    text(key: 'text1', type: MoreContentType.wrap, count: 15),
    text(key: 'text2', type: MoreContentType.wrap, isRadio: false, count: 8),
  ];
...
...
  SpinnerBox.rebuilder(
            titles: const ['多选条件', '多选条件', '混合条件+拦截'],
            builder: (notifier) {
              return [
                SpinnerFilter(
                  ...
                ).heightPart,
                SpinnerFilter(
                  ...
                ).heightPart,
                SpinnerFilter(
                  data: _condition3,
                   attachment: [   < ----- 通过自定义组件传递
                    _InputAttach(data: _condition3),
                    _PickerAttach(data: _condition3)
                  ],
                  onItemIntercept: (p0, p1) { < ----- 点击按钮拦截
                    if (p0.key == 'text2' && p1 == 2) {
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(content: Text('欸~ 就是选不了~')),
                      );
                      return true;
                    }
                    return false;
                  },
                  onCompleted: (result, name, data, onlyClosed) {
                    notifier.updateName(name);
                    setState(() {
                      _condition3 = data;
                      _result = result;
                    });
                  },
                ).heightPart,
              ];
            },
          ),
...

组件的创建有两种方式:rebuilderbuilder和默认构建方法,区别就是rebuilder每次显示弹框视图都会重新创建一次,所以修改了数据源后,每次打开都是有选中记录。而后两者则需要自己去动态刷新对应的弹框视图 SpinnerFilter。前一种使用简单,适用于绝大多数情况都适用,不用额外去刷新对应弹窗视图。

3、筛选完成后更新选中项的标题/设置高亮
  notifier.updateName(name);
or
  notifier.setHighlight(true)
4、设置弹框的宽度
  SpinnerBox.builder(
              titles: const ['Builder', 'width-full'],
              builder: (notifier) {
                return [
                  SpinnerPopScope(
                    width: 150,  < ------- 固定宽度
                    offsetX: 30,  < ------- 偏移量
                    child: ValueListenableBuilder(
                        valueListenable: _condition2,
                        builder: (context, value, child) {
                             ...
                        }),
                  ),
                  SpinnerPopScope(
                    width: double.infinity,  < ------- 屏幕宽度
                    child: ValueListenableBuilder(
                        valueListenable: _condition2,
                        builder: (context, value, child) {
                          ...
                        }),
                  )
                ];
              },
            )
5、自定义的拼接选项

class _InputAttach extends AttachmentView {
  _InputAttach({required super.data});
  final textEditing = TextEditingController();
  
  @override
  String get groupKey => 'text1'; < --- 关联选项分组
  @override
  String get extraName => '输入标题';
  
  @override
  Widget build(BuildContext context) {
    textEditing.text = extraData ?? '';
    return Container(
      height: 38,
      color: Colors.black12,
      child: CupertinoTextField(
        controller: textEditing,
        placeholder: '输入框',
        onChanged: (value) {
          updateExtra(value);
        },
      ),
    );
  }

  @override
  void reset() {
    super.reset();
    textEditing.clear();
  }
}

AttachmentView的设计本着插件使用绝对简洁的想法,额外拼接的操作视图(表1-图2.0)应该具有以下特点:

1、无入侵,不能对我原有的筛选有冲突,并且该关联的地方你还得给我关联上(例如单选情况选项互斥); 2、新建的追加视图逻辑操作独立,但获取的结果你得回传给当前筛选项组里; 3、涉及到关联的地方的简单;

说下思路: 将 AttachmentView 设计成抽象类型,用追加视图来继承它,并在实现的过程中关联到对应位置,并完成筛选操作时更新筛选结果到对应的数据里;

在控制器SpinnerFilterNotifier里要有获取到拼接视图结果的操作,在点击 重置 按钮的时候需要通过监听来清空选项;

尽量将需要的操作在抽象类型中完成,子类继承只需要完成固定的操作;

代码可以在源码中看一下。

结语

下拉菜单筛选框在国内的设计中还是比较常见的,所以写个扩展性比较强的轮子还是有意义的(当然的好用才行 (╥_╥))。

说回插件本身,初始化的时候带 titles 是一个比较脑残的设计,这个东西其实是可以跟着 SpinnerFilter或者SpinnerFence的属性走,暂时不想改了,毕竟一改又有这里那里的问题。 还有就是引入SpinnerFilter或者SpinnerFence两类弹框视图时,有点傻,没有别人的看起来高级。等等还有一系列不满足的地方。。。

贴出来就是希望大家斧正,共同进步。

最后完结撒花。