做移动端也快十年了,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 层只做两套视图适配。新项目拿过去以后,既可以用 FilterBar+FilterSheet,也可以直接内嵌 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 List<FilterOption> 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 List<FilterItem> items; ValueChanged<Map<String, 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 实例,注意这里不能拷贝状态,必须引用同一个实例才能真正同步。
Future<void> 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 State<FilterSheet> createState() => _FilterSheetState(); } class _FilterSheetState extends State<FilterSheet> { // 快照:保存进入面板时的状态,取消时恢复 late List<FilterItem> _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 State<ProductListPage> { late FilterController _filterController; @override void initState() { super.initState(); _filterController = FilterController(items: buildInitialFilters()); _filterController.onFilterChanged = (params) { _loadProducts(params); }; } Future<void> _loadProducts(Map<String, 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 之间共享同一份数据源,这些偏好决定了组件未来的寿命。我在这套方案里反复打磨的其实不是过滤器本身,而是组件边界划分的意识:什么东西属于状态、什么东西属于表现、什么东西属于交互,切清楚了,写代码就是按图施工而已。