Flutter筛选组件设计:数据模型驱动UI,打造可扩展Filter Widget
做移动端也快十年了Flutter 从 1.0 用到今天业务场景里最让人头大的不是复杂的动画也不是新奇的交互反而是这种“看似简单、改起来要命”的筛选组件。电商 App 里按价格、品牌、销量筛选信息流里按时间、类型筛选这类需求几乎每个项目都会碰上。Filter Widget 的核心难点在于筛选条件的组织、状态联动和 UI 形态的可扩展性一般写法要么写死写死没救要么把控制器塞得乱七八糟。这篇文章把我自己踩坑后沉淀的一套 Filter Widget 设计方案完整拆开讲从数据模型、组件拆解到核心代码以及实际开发中的问题排查给正在纠结筛选组件怎么设计的团队一个能直接落地的参考。我个人比较推荐“数据模型驱动 UI”的思路把筛选维度抽象成配置化的数据结构Widget 只是这个结构的一种可视化呈现。这样设计的好处等你哪天要新增一个筛选维度的时候就会深有体会。1. Filter Widget 的需求分析与设计思路1.1 先弄清筛选组件到底要解决什么问题Filter Widget 表面上是“让用户选择几个条件”实质上是把“用户的查询意图”翻译成“后端或者本地列表的过滤参数”。所以设计第一步不是画 UI而是先定义清楚哪些字段是可筛选的每个字段是单值还是多值值类型是枚举、区间还是布尔筛选后的结果要不要实时刷新以我做过的某跨平台商城 Demo 为例商品列表页需要支持按分类筛选、按品牌多选筛选、价格区间筛选外加一个综合排序。这四个维度看起来差不多交互上却完全不同分类是单选联动子分支品牌是多选且数量最多上百个价格区间是输入框滑块排序则是带图标的标签页。如果从一开始就按“某种统一控件”去想后面一定会被真实需求教育。所以我强烈建议动手前先把需求的“筛选维度清单”列出来每个维度明确三件事取值空间、交互形态、提交时机。取值空间决定数据模型怎么抽象交互形态决定 UI 组件怎么组装提交时机决定状态要不要实时传出。1.2 两种 UI 形态嵌入页面的 FilterBar 和弹层 FilterSheet筛选组件常见两种载体一种是固定在页面顶部的 FilterBar横排标签点击后可能原地展开也可能把标签作为入口弹出一个筛选面板另一种是页面内的嵌入式面板比如地图类应用右侧的图例筛选或者工具类软件的设置过滤区。实际情况里多数项目两者组合使用FilterBar 负责概览和入口FilterSheet 负责承载完整筛选条件。这两种形态对应到代码上本质是同一套逻辑的不同壳子。我的做法是保证“数据模型”和“筛选逻辑层”完全独立UI 层只做两套视图适配。新项目拿过去以后既可以用 FilterBarFilterSheet也可以直接内嵌 FilterPanel逻辑部分一个字符都不用改。设计上的重点是确定好筛选状态存在哪里。我踩过最深的坑就是一个条件多处分页、多份拷贝改起来互相覆盖。后来统一了原则筛选条件是页面级状态全部挂在筛选模块的控制器里筛选面板只是这个状态的“编辑器”主列表页面是“执行器”各组件之间用回调沟通。2. 数据模型与组件架构设计2.1 用配置化数据结构表达筛选维度既然要求扩展性数据模型必须能表达“不同值类型”的筛选条件。我的做法是定义一套枚举类型每个筛选项都包括标识、标题、类型和选项列表四类基础类型先覆盖 80% 的场景enum FilterType { singleSelect, multiSelect, range, toggle } class FilterOption { final String label; final dynamic value; bool selected; FilterOption({ required this.label, required this.value, this.selected false, }); } class FilterItem { final String id; // 唯一标识用于对应数据源字段 final String title; // 筛选标题FilterBar 上展示的文案 final FilterType type; // 筛选类型 final ListFilterOption options; // 选项列表 final RangeValues? rangeValue; // 区间类当前值 final bool selected; // 是否需要高亮标记 }这类数据结构有几个隐性好处。第一新增筛选维度时只需在集合里加一个 FilterItem无需新增任何业务 Widget设计模式里的“对扩展开放”在这里落到了实处。第二API 对接时遍历 FilterItem 集合即可生成请求参数后端字段名和 id 保持一致就行不写繁琐的 if-else 映射。第三重置功能是天然支持的遍历集合把 selected 和 rangeValue 全部置空/置初始值就是一次完整重置。对于单选项的联动场景比如选择“手机”分类后才出现“品牌”子筛选我的做法是在 FilterItem 上额外挂一个父项关联字段选中“手机”时父项回调里刷新子项的可选状态。这个后期可以在原来结构上加一个 parentId 字段不影响整体设计。2.2 组件层级划分容器、面板、分组和选项控件组件划分上我是严格按“单职责”来拆的。顶层是 FilterController负责整体的状态变更和对外回调之下是 FilterBar横向标签栏和 FilterSheet筛选面板再往下是 FilterGroup组容器管理每个筛选维度的布局和标题到底层就是各种选项控件。FilterBar一屏展示可扩展的横向标签每个标签显示筛选维度标题被选中时亮起提示点。FilterSheet从底部弹出的筛选面板承载多个 FilterGroup底部固定两个按钮重置、确认。FilterGroup单个维度的容器内部根据 FilterType 渲染不同选项子组件。FilterOptionTile单选/多选的具体选项行支持文字、勾选状态、图片。这里面比较核心的思路是FilterBar 上的每个标签与一个 FilterItem 绑定FilterSheet 里的每个 FilterGroup 也绑定同一 FilterItem两个视图共享一份状态。这样一来用户在 FilterBar 上看到的高亮状态和 FilterSheet 里的勾选状态永远同步不需要额外做状态通告。2.3 选 StatefulWidget 还是状态管理库筛选状态本身比较简单但和父组件之间的交互比较多这里不涉及跨页面共享数据我建议优先级顺序局部 StatefulWidget - 组件回调 - Riverpod/Provider 全局状态。我们实际项目里用的是 Provider但筛选模块内部我刻意没有依赖它而是自己维护了一套控制器。因为筛选状态的生命周期跟列表页是一致的不需要全局共享。用纯回调的方式反而让模块更内聚测试也好写。如果你把筛选状态放进全局的 Provider 里页面刷新频率高的时候反而容易引发不必要的组件重建。下面是控制器的简易骨架我只贴了核心部分完整的包括各种状态变更的 setState 批量处理逻辑class FilterController { final ListFilterItem items; ValueChangedMapString, dynamic? onFilterChanged; FilterController({required this.items}); void updateOption(String itemId, FilterOption option, bool selected) { final item items.firstWhere((e) e.id itemId); // 根据 type 决定互斥逻辑或累加逻辑 if (item.type FilterType.singleSelect) { for (final o in item.options) { o.selected (o.value option.value); } } else { option.selected selected; } _emit(); } void _emit() { final params String, dynamic{}; for (final item in items) { if (item.type FilterType.range) { params[item.id] item.rangeValue; } else { params[item.id] item.options .where((o) o.selected) .map((o) o.value) .toList(); } } onFilterChanged?.call(params); } void reset() { for (final item in items) { for (final o in item.options) { o.selected false; } item.rangeValue null; } _emit(); } }这种方式最直观的好处筛选项不管在哪个视图触发最终出口都是 onFilterChanged 回调携带一个干净参数集列表页拿到参数直接请求接口逻辑链路非常短。3. 核心代码实现细节3.1 FilterBar 实现横向滚动标签栏FilterBar 是一个横向滚动的标签栏标签按 1圆角底、2选中高亮、3当前选中展开态 三种状态来渲染。我用的基础控件是 SingleChildScrollView 横向排列而不是 ListView因为维度数量通常个位数性能完全没有压力SingleChildScrollView 更轻。class FilterBar extends StatelessWidget { final FilterController controller; final String currentExpandItemId; final void Function(String itemId) onToggle; const FilterBar({ super.key, required this.controller, required this.currentExpandItemId, required this.onToggle, }); override Widget build(BuildContext context) { return SizedBox( height: 40, child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: controller.items.map((item) { final hasActive item.options.any((o) o.selected) || (item.rangeValue ! null item.rangeValue ! const RangeValues(0, 0)); final isExpanded item.id currentExpandItemId; return _FilterBarChip( label: item.title, selected: hasActive, expanded: isExpanded, onTap: () onToggle(item.id), ); }).toList(), ), ), ); } }有几个细节说一下。第一个是“展开态”的视觉处理用户点击某个标签后该标签应该展示成高亮底色或者箭头翻转这样可以给用户一个明确的“我现在在编辑哪个条件”的空间暗示。第二个是“有选中值”的提示我在 chip 右侧做了一个小圆点一旦该维度有生效的筛选条件圆点就会出现。这个设计很小但提升体验非常明显用户不用展开面板就能知道哪些条件正在生效。还有一个细节点击同一个标签应该折叠已展开的面板而不是再次展开。我通过 currentExpandItemId 判断如果点击的 id 和当前展开的 id 相同就传空值通知父级收起。这种细节忘掉的话用户体验会有明显的割裂感。3.2 FilterSheet 的弹出与值同步FilterSheet 我用 showModalBottomSheet 封装里面布局是 Column Expanded 的列表区 底部按钮区。弹出前接收 FilterController 实例注意这里不能拷贝状态必须引用同一个实例才能真正同步。Futurevoid showFilterSheet( BuildContext context, FilterController controller, ) async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) FilterSheet(controller: controller), ); }底部弹层有一个生命周期问题用户在弹层里修改了筛选值但直接滑动关闭相当于取消时状态却在控制器里实际保留了。这会带来很严重的“视觉与结果不符”筛选面板上明明什么都没了列表却被过滤了。解决办法弹层打开时记录当前选项快照确认按钮才提交给控制器取消或滑动关闭则回滚到快照。这相当于给整个筛选面板包了一层“事务”。class FilterSheet extends StatefulWidget { final FilterController controller; const FilterSheet({super.key, required this.controller}); override StateFilterSheet createState() _FilterSheetState(); } class _FilterSheetState extends StateFilterSheet { // 快照保存进入面板时的状态取消时恢复 late ListFilterItem _snapshot; override void initState() { super.initState(); _snapshot widget.controller.items .map((item) FilterItem( id: item.id, title: item.title, type: item.type, options: item.options .map((o) FilterOption( label: o.label, value: o.value, selected: o.selected, )) .toList(), rangeValue: item.rangeValue, )) .toList(); } void _onConfirm() { widget.controller.applyChanges(); Navigator.of(context).pop(); } void _onReset() { widget.controller.reset(); setState(() { _snapshot widget.controller.items .map((item) FilterItem( id: item.id, title: item.title, type: item.type, options: item.options .map((o) FilterOption( label: o.label, value: o.value, selected: o.selected, )) .toList(), rangeValue: item.rangeValue, )) .toList(); }); } }这段代码里快照的恢复逻辑我没有完整贴出来核心思想就是弹层内直接操作 FilterController 内的状态UI 的每次刷新都从 controller 读取但控制器里区分“临时修改”和“确认提交”两阶段。实际项目中我用了一个 dirtyFlag 来处理有修改时列表页先不触发请求等确认那一刻统一 emit 一次请求。这样既保证弹层内交互的流畅性又不会让列表因为每个勾选动作都发一次网络请求而闪动。3.3 FilterGroup 与动态渲染一个容器适配四种类型FilterGroup 是渲染的灵魂部件。外面包一个标题内部根据 FilterType 分发到对应子组件。这步是关键中的关键做不好就会出现一大坨 if-else 塞在 build 里的情况。class FilterGroup extends StatelessWidget { final FilterItem item; final FilterController controller; const FilterGroup({ super.key, required this.item, required this.controller, }); override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( item.title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333), ), ), ), switch (item.type) { FilterType.singleSelect _SingleSelectView( item: item, controller: controller), FilterType.multiSelect _MultiSelectView( item: item, controller: controller), FilterType.range _RangeView(item: item, controller: controller), FilterType.toggle _ToggleView(item: item, controller: controller), }, ], ); } }这里的 switch 表达式是 Dart 3 的新语法代码可读性比旧版链式 if 好太多。如果你项目还没升级到支持 Dart 3 的 Flutter 版本照样能用这套结构只是把 switch 表达式换成普通的 switch 语句或 switch case 即可。四种子视图里单选的实现比较有代表性因为涉及“互斥选择”逻辑用户选了新选项后同一维度其他选项必须取消选中。这块逻辑之前放到 UI 层会有 bug比如快速点击两个选项可能导致两个都处于选中态。我在 FilterController 里维护互斥逻辑UI 层只负责回调。注意 toggle 类型的实现它实际上不涉及选项列表视图上是单个 Switch 控件数据模型就复用 FilterItem 的 rangeValue 来存一个布尔值或者单独加一个 value 字段来做。这里我用了 rangeValue 的扩展写法坦白说这不是最优雅的建议你加一个 dynamic currentValue 字段单独存储更清晰我这里为了不把代码贴太长简单使用了 rangeValue 承载。3.4 列表页与筛选控制器联动列表页接入 FilterController 只需要两件事创建控制器并监听回调、把控制器绑定到 FilterBar 和 FilterSheet。核心代码如下class _ProductListPageState extends StateProductListPage { late FilterController _filterController; override void initState() { super.initState(); _filterController FilterController(items: buildInitialFilters()); _filterController.onFilterChanged (params) { _loadProducts(params); }; } Futurevoid _loadProducts(MapString, dynamic params) async { // 通过 params 拼接请求参数 setState(() { _loading true; }); try { final list await fetchProducts(params); setState(() { _products list; _loading false; }); } catch (e) { setState(() { _loading false; }); // 错误提示 } } }这里有一个小坑值得强调FilterBar 点击标签时FilterSheet 弹出后用户还没点确认就滑动手势关闭了列表已经发了一次请求。这就是上面说的“事务”问题没有处理完整。确认按钮和滑动关闭是两条路径滑动关闭应该走回滚只有确认按钮才走 emit。所以 onFilterChanged 的触发不能放在 updateOption 里而应该在 applyChanges 里统一触发。我最终的做法是在控制器里维护一个 bool _hasPendingChange每次临时变更只置位只有 applyChanges 时检查这个标记后统一回调。这样列表请求次数和用户实际确认的行为完全一致不会出现弹层还在编辑列表已经在 loading 的情况。4. 常见问题与排查技巧实录4.1 状态不同步FilterBar 高亮与面板勾选不一致这个问题的根源几乎都是“状态拷贝”造成的。比如 FilterSheet 打开时把传入的 controller.items 重新 new 了一份FilterBar 引用的还是旧列表两边各改各的。排查时先确认所有视图是否引用同一个 FilterItem 实例可以用 runtimeType 和 hashCode 打印对比或者简单在控制台输出 item 是否相等。我自己的约定是任何组件如果只读不写筛选项直接接收 FilterItem 即可任何要修改选项的地方都必须走 FilterController 的方法绝不允许直接调 option.selected true。这条纪律写进团队规范以后状态不同步的问题基本绝迹了。4.2 底部弹层被键盘顶起导致布局溢出价格区间筛选经常用到 TextField 输入框FilterSheet 弹出后调出键盘时可能触发底部溢出RenderFlex overflowed报错。原因是 showModalBottomSheet 默认高度是当前可用高度的一半键盘弹出后底部约束变化内部 Column 出现溢出。解决方案是给 FilterSheet 包一层 Padding 处理键盘区域Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: FilterSheet(controller: controller), )严格来说showModalBottomSheet 默认并不自动处理 viewInsets这个 Padding 是必须的。另外建议 FilterSheet 内部使用 Expanded ListView 的结构保证内容过长时可滚动不要用 SingleChildScrollView 包 Column否则键盘弹出时一样溢出不误。4.3 清单过长时筛选面板滚动卡顿品牌维度动辄几十个选项如果直接全部渲染 CheckboxListTile卡顿几乎必然出现。FilterSheet 里如果有单个维度的选项特别多应该把那一组改为内部的 ListView.builder 来惰性渲染而不是外层一个大 ListView 包着内部 Column 一次性 build 所有选项。另外我给 FilterSheet 的列表区加了分类分隔选项较多的维度独立成一个可展开区块默认只展开前 10 个点“展开全部”再显示剩余。这个虽然是产品需求层面的取舍但对性能有质的改善。实测品牌 50 个选项时一次性 build 会有掉帧改成惰性渲染后帧率完全稳定。4.4 筛选条件重置后列表没有刷新重置按钮常见的坑重置了控制器里的状态但列表页持有的还是旧请求参数因为 onFilterChanged 没触发。这个问题的根源是 reset 方法内部如果直接改状态不发回调外部就不会知道数据变了。检查时看三处reset 方法里有没有调用 _emit 或 applyChanges 的回调重置动作发生在 FilterSheet 内部确认按钮是否有调用 reset 之后 pop列表页的 onFilterChanged 回调里是否真正触发了新的接口请求大多数情况下都是第一处遗漏。所以我的 reset 方法强制要求串行执行“状态清理 回调 emit”两步不允许只改状态不通知。5. 这套设计还能扩展到哪里Filter Widget 的实现做扎实之后可复用的范围远比预想大。搜索页的筛选条件、后台管理列表的数据过滤、甚至表单里的动态条件组都是同一套模式。搜索页面里的“高级搜索”本质上就是多个筛选维度的组合只是入口是搜索按钮不是 FilterBar 标签。后台系统的数据报表筛选维度类型会更丰富比如日期区间、多级联动选择这些都可以在现有四类 FilterType 基础上新增类型枚举或者通过 parentId 组合实现多级联动。我个人的后续计划是给这套组件补两件事第一增加筛选条件的 URL 编码支持方便把筛选状态同步到 App 分享链接用户点开链接后自动恢复筛选条件第二把 FilterController 和列表页的请求层再抽开一层做成通用的 FilterListPage 组件让表格类、瀑布流类甚至图表类页面都能直接传入数据源接口和筛选配置开箱即用。像素级的设计实现和长期可维护的架构之间真正的差距往往就在于是否有统一的抽象层。Filter Widget 看起来是个小组件但它是一个天然的架构试金石——你愿不愿意先建模再画图、愿不愿意把状态收拢到一处、愿不愿意在 UI 之间共享同一份数据源这些偏好决定了组件未来的寿命。我在这套方案里反复打磨的其实不是过滤器本身而是组件边界划分的意识什么东西属于状态、什么东西属于表现、什么东西属于交互切清楚了写代码就是按图施工而已。