Flutter应用开发:多条件搜索

背景

业务开始需求相对简单,搜索条件也比较单一,例如:根据订单号OrderNo查询对应订单。随着业务需求的升级,需要支持的查询条件也越来越多,组合查询、额外条件查询等需求升级,搜索上也要适配同步升级。

开发历程

单一搜索

原始需求是支持根据订单号查询

多条件搜索(互斥)

这些搜索条件也是在使用过程中逐渐加上来的,因为原始设计UI参与后撤离了,导致后续需求跟进缺乏UI设计支持。虽然能满足需求,界面交互设计上真的是一言难尽。

多条件搜索(互斥 + 部分组合)

这一阶段是一个临时的方案,根据进入入口的不同,区分搜索界面进入哪种查询模式。

  1. 由筛选弹框触发组合搜索模式,进入搜索页面组合展示搜索条件
  2. 其它场景进入上步的互斥搜索模式

从功能实现、界面展示、用户交互等角度看,都不是一个好的方案。于是决定参照业界优秀的案例进行改造。

改造

案例

电商产品比较通用的方案,比较适配当前业务

实现

首先将上图贴到豆包尝试搜索到相关现成插件,未发现任何相关插件。又进入gitee尝试找到类似电商开源项目剥离出实现逻辑,也未发现适配项目。不得已开始分析按钮,人工实现一下。

搜索条件Tag展示区域

分析自身需求,提取相似点,分离不同点。因为状态(单选组件)、时间范围(时间选择组件)、其他都是输入框,大体分为三类。

dart 复制代码
// 定义渲染Tag
List<Map<String, String>> _dropdownItems = [
    {'label': '订单号', 'value': 'fuzzyOrderNo'},
    {'label': '揽货人', 'value': 'accUserName'},
    {'label': '发货人', 'labels': '发货人姓名,发货人电话', 'value': 'corUserName,corUserPhone'}, 
    {'label': '收货人', 'labels': '收货人姓名,收货人电话', 'value': 'ceeUserName,ceeUserPhone'}, 
    {'label': '站点', 'value': 'toStation'},
];
  
List<Widget> queryTags = [
    QueryTag(text: '状态', isActive: _order.orderStatus != null && _order.orderStatus!.isNotEmpty),
    QueryTag(text: '时间范围', isActive: _order.createTimeStart != null),
    ..._dropdownItems.map((e) {
    final values = e['value']!.split(',');
    return QueryTag(text: e['label']!, isActive: values.any((v) => _order.getProperty(v) != null));
    }).toList(),
];

渲染部分使用Wrap组件来实现,因为搜索条件动态渲染,除过支持自动换行 外,还需要计算渲染区域的高度来动态设置。

dart 复制代码
    Widget wrapper = LayoutBuilder(
      key: _wrapperKey,
      builder: (context, constraints) {
        // 在布局完成后获取高度
        WidgetsBinding.instance.addPostFrameCallback((_) {
          final RenderBox? renderBox = _wrapperKey.currentContext?.findRenderObject() as RenderBox?;
          if (renderBox != null && mounted) {
            setState(() {
              _wrapperHeight = renderBox.size.height + rpx(8.0); // 增加内边距余量
            });
          }
        });
        return Wrap(
          direction: Axis.horizontal,
          spacing: 6.0,
          runSpacing: 4.0,
          children: queryTags
              .map(
                (e) => InkWell(
                  child: Container(width: rpx(80.0), child: e),
                  onTap: () {
                    // 计算当前标签在queryTags中的索引
                    final tagIndex = queryTags.indexOf(e);
                    _selectedIndexCache = tagIndex;
                    _showSearchBottomSheet();
                  },
                ),
              )
              .toList(),
        );
      },
    );

    return <Widget>[
      SliverPersistentHeader(
        pinned: true,
        floating: true,
        delegate: SliverAppBarDelegate(
          Container(
              height: MediaQuery.of(context).size.height,
              padding: edgeSymmetric(vertical: 4.0),
              child: Column(
                children: [Container(child: wrapper)],
              )),
          _wrapperHeight > 0 ? _wrapperHeight : rpx(90.0),
        ),
      ),
    ];

实现效果和案例上还是有些差距,不过已经美观很多

搜索弹框

搜索弹框需要注意和Tag区域的联动交互,组合查询的表现,三类组件的实现方式。左侧Tab以红点表示对应条件已经应用,顶部Tag区域的红色高亮与其对应。样式参考按钮优化。

代码比较多,整体划分左右两部分,只需要注意使用IndexedStack组件完成左侧Tab点击展示右侧对应锚点内容。右侧业务区域设置一组动态Key来做唯一标识。

js 复制代码
 final Map<int, GlobalKey> _formKeys = Map.fromEntries(List.generate(_dropdownItems.length + 2, (index) => MapEntry(index, GlobalKey())));
js 复制代码
 IndexedStack(index: _selectedIndex, children: [
                // 状态选择(使用_index=0)
                SpecificaButton(
                    key: _formKeys[0],
                    title: '选择状态',
                    defaultValue: _order.orderStatus,
                    vModels: _searchStatusList,
                    canCancel: true,
                    onChanged: (value) {
                      Iterable<vModel> temp = _searchStatusList.where((element) => element.id == value);
                      setState(() {
                        _order.orderStatus = value != null ? temp.first.id : null;
                      });
                      _setFilterAdditional();
                    }),

                Padding(
                    key: _formKeys[1],
                    padding: EdgeInsets.all(rpx(12)),
                    child: Column(children: [
                      Row(
                        mainAxisAlignment: MainAxisAlignment.start,
                        children: [Text('起始时间', style: TextStyle(fontSize: rpx(11.0)))],
                      ),
                      Gaps.vGap5,
                      ElFormItem(
                        '',
                        type: 'select',
                        required: false,
                        hintText: '请选择起始时间',
                        initValue: _order.createTimeStartStr,
                        labelSize: rpx(12.0),
                        onOpenSheet: () async {
                          final result = await showBoardDateTimePicker(
                            context: context,
                            pickerType: DateTimePickerType.datetime,
                            options: BoardDateTimeOptions(
                              languages: const BoardPickerLanguages.zh(),
                              startDayOfWeek: DateTime.sunday,
                              useResetButton: true,
                              pickerFormat: PickerFormat.ymd,
                              withSecond: true,
                            ),
                          );
                          if (result != null) {
                            setState(() {
                              _order.createTimeStartStr = DateFormat('yyyy-MM-dd HH:mm:ss').format(result);
                              _order.createTimeStart = result.millisecondsSinceEpoch;
                            });
                          }
                        },
                        suffixIcon: IconButton(
                            onPressed: () {
                              setState(() {
                                _order.createTimeStartStr = null;
                                _order.createTimeStart = null;
                              });
                            }, // ← 这里添加缺失的逗号
                            padding: edgeAll(0),
                            icon: Icon(Icons.close, size: rpx(14.0))),
                      ),
                      Gaps.vGap10,
                      Row(
                        mainAxisAlignment: MainAxisAlignment.start,
                        children: [Text('截止时间', style: TextStyle(fontSize: rpx(11.0)))],
                      ),
                      Gaps.vGap5,
                      ElFormItem(
                        '',
                        type: 'select',
                        required: false,
                        hintText: '请选择截止时间',
                        initValue: _order.createTimeEndStr,
                        labelSize: rpx(12.0),
                        onOpenSheet: () async {
                          final result = await showBoardDateTimePicker(
                            context: context,
                            pickerType: DateTimePickerType.datetime,
                            options: BoardDateTimeOptions(
                              languages: const BoardPickerLanguages.zh(),
                              startDayOfWeek: DateTime.sunday,
                              useResetButton: true,
                              pickerFormat: PickerFormat.ymd,
                              withSecond: true,
                            ),
                          );
                          if (result != null) {
                            setState(() {
                              _order.createTimeEndStr = DateFormat('yyyy-MM-dd HH:mm:ss').format(result);
                              _order.createTimeEnd = result.millisecondsSinceEpoch;
                            });
                          }
                        },
                        suffixIcon: IconButton(
                            onPressed: () {
                              setState(() {
                                _order.createTimeEndStr = null;
                                _order.createTimeEnd = null;
                              });
                            },
                            padding: edgeAll(0),
                            icon: Icon(Icons.close, size: rpx(14.0))),
                      ),
                    ])),

                // 动态生成字段输入(使用_index=1到N)
                ..._dropdownItems.asMap().entries.map((entry) {
                  final values = entry.value['value']!.split(',');
                  final labels = entry.value['labels'] != null ? entry.value['labels']!.split(',') : [entry.value['label']!];
                  return Column(
                      key: _formKeys[2 + entry.key], // 从1开始分配key
                      children: [
                        ...values.map((fValue) => Padding(
                            padding: EdgeInsets.all(rpx(12)),
                            child: Column(
                              children: [
                                Row(
                                  mainAxisAlignment: MainAxisAlignment.start,
                                  children: [Text(labels[values.indexOf(fValue)], style: TextStyle(fontSize: rpx(11.0)))],
                                ),
                                Gaps.vGap5,
                                ElFormItem(
                                  '',
                                  required: false,
                                  hintText: '请输入',
                                  initValue: _order.getProperty(fValue),
                                  labelSize: rpx(12.0),
                                  suffixIcon: IconButton(
                                    onPressed: () {
                                      setState((() {
                                        _order.setProperty(fValue, null);
                                      }));
                                    }, // ← 这里添加缺失的逗号
                                    color: Colours.status_gray,
                                    icon: Icon(
                                      Icons.close,
                                      size: rpx(14.0),
                                    ),
                                  ),
                                  onChanged: (value) {
                                    _order.setProperty(fValue, value);
                                  },
                                ),
                              ],
                            )))
                      ]);
                }),
              ])

后记

在开发思路上在寻找现成插件上浪费太多时间,迟迟没有落地实现。真正没有办法去实现时,借助AI辅助编程发现效率也很高,效果也比较好。之前一直遵循的开发思路在AI编程兴起后,有些落伍了。程序老兵们转变一下思路,或许还能苟几年。

相关推荐
钛态4 小时前
Flutter for OpenHarmony:mockito 单元测试的替身演员,轻松模拟复杂依赖(测试驱动开发必备) 深度解析与鸿蒙适配指南
服务器·驱动开发·安全·flutter·华为·单元测试·harmonyos
念格7 小时前
Flutter 弹窗 UI 不刷新?用 StatefulBuilder 解决
flutter
程序员老刘8 小时前
2026春招Flutter岗位为何变少?我看到的3个招聘逻辑变化
flutter·ai编程·客户端
念格9 小时前
Flutter 实现点击任意位置收起键盘的最佳实践
flutter
念格9 小时前
Flutter ListView Physics 滚动物理效果详解
flutter
国医中兴9 小时前
ClickHouse的数据模型设计:从理论到实践
flutter·harmonyos·鸿蒙·openharmony
国医中兴12 小时前
ClickHouse数据导入导出最佳实践:从性能到可靠性
flutter·harmonyos·鸿蒙·openharmony
国医中兴13 小时前
大数据处理的性能优化技巧:从理论到实践
flutter·harmonyos·鸿蒙·openharmony
●VON14 小时前
Flutter 入门指南:从基础组件到状态管理核心机制
前端·学习·flutter·von
西西学代码14 小时前
Flutter---SingleChildScrollView
前端·javascript·flutter