1. 需求与交互设计拆解
1.1 右侧多条件筛选是什么
做 Flutter 开发的朋友应该都接过类似需求:列表页右上角有个“筛选”按钮,点一下从屏幕右侧滑出一个面板,里面是一排排条件选项,选完之后点确定,列表刷新成符合条件的数据。这个交互在主流电商 App 里太常见了,以至于用户已经养成了肌肉记忆——筛选必须要从右侧出来,底部弹出、全屏跳转都差点意思。
先说清楚我们这次要复刻的到底是什么。它不只是“一个抽屉”那么简单,核心在于“多条件”三个字:价格区间、品牌勾选、分类筛选、库存状态、排序方式,可能还有自定义的标签属性。这些条件混在一个面板里,同类目之间可能是单选,也可能是多选,选完之后还要在底部展示“已选几个条件”。真正把它拆开,你会发现这其实是一套“筛选态”的数据模型,而不是一个简单的视觉组件。
所以我在动手前先梳理了交互形态。主流电商产品里右侧筛选大概分三种:全屏侧滑、半屏侧滑(背景仍能看到列表)、以及带底部操作栏的混合式。淘宝系产品的典型做法是半屏侧滑,顶部保留标题栏和关闭按钮,底部固定一条“重置/确定”操作栏,中间部分可以滚动,条件分组之间用分割线隔开。这次我选择的就是这种结构,因为它信息密度高,又不会让用户觉得脱离了主页面。
1.2 组件拆分与数据模型设计
老规矩,写任何功能前先把数据模型定好,组件结构才能稳。我定义了三层结构:
- 筛选分组(FilterGroup):对应面板里的一个大类,比如“品牌”“价格”“颜色”,包含一组筛选项;
- 筛选条件(FilterItem):分组内的具体条件条目,比如“品牌”分组下的“某品牌A”“某品牌B”;
- 选中状态(SelectedState):记录当前面板中所有已被用户选中的条件,它独立于面板UI,是提交筛选时真正要用的数据。
这样做的好处是:无论 UI 怎么变(价格区间用输入框、品牌用复选、分类用单选),底层都是一套 key-value 结构,后续加条件只需要扩展分组,不需要改面板框架。
具体到 Flutter 代码层面,我用不可变对象配合 copyWith 来处理状态:
enum FilterType { single, multi, range, text } class FilterGroup { final String groupId; final String name; final FilterType type; final List<FilterItem> items; FilterGroup({ required this.groupId, required this.name, required this.type, required this.items, }); } class FilterItem { final String itemId; final String label; const FilterItem({required this.itemId, required this.label}); }这里有个值得注意的细节:单选和多选不要用同一套 UI 组件硬塞,因为它们的交互反馈逻辑差很多。单选逻辑是“选A则取消B”,对应电商里的排序、价格区间;多选是“可叠加大批量条件”,对应品牌勾选。我一开始偷懒做了统一组件,结果单选条件出现“同时两个价格区间亮起”的怪异状态,后来彻底分开了。
数据模型设计好后,筛选面板的组件树自然而然就出来了:
- FilterPanel:最外层容器,负责遮罩层、侧滑动画、底部按钮;
- FilterSectionList:中间可滚动区域,管理多个 FilterGroup;
- FilterSingleSelector / FilterMultiSelector:单项选择器和多项选择器,分别处理单选/多选交互;
- FilterBottomBar:底部重置和确定按钮。
组件的边界一旦明确,写代码就是填肉的工作了。
2. 技术选型与状态管理
2.1 为什么选择这种实现方案
这个需求最大的技术难点不是动画,也不是 UI 绘制,而是“筛选面板和列表页之间的状态同步”。选错了状态管理方案,后面会踩非常多的坑。
我见过有人用纯 StatefulWidget 做:列表页持有一个 Map,打开面板时传进去,面板内部自己维护选中项,点确定时把 Map 通过回调传回来。这种方案在筛选条件少时确实够用,但一旦分组超过 5 个、条件总量超过 30 条,回调参数会变成灾难,而且很难做“重置”和“上次选中的回显”。
所以我在这个项目里选择了 Provider + ChangeNotifier 的组合,原因有三点:
- 状态单一来源:筛选面板和列表页共享同一个筛选状态模型,任何一处修改都会自动通知另一个处刷新;
- 便于回显:用户再次打开面板时,需要看到上次选了什么,方案天然支持;
- 可测试性:筛选逻辑从 UI 中抽离成纯 Dart 类,单元测试好写得多。
Picker 问题则简单一些,没有额外引入动画库,直接用 Flutter 自带的 AnimationController + SlideTransition 就搞定了。电商筛选面板的动画诉求其实很低:滑出、滑入、遮罩淡入淡出,这三个就够了。过度设计的弹性动画反而显得不适合。
技术栈清单如下:
- Flutter 3.x,Dart 2.18+
- Provider:筛选全局状态管理
- 无第三方 UI 库,面板完全自绘,方便定制样式
- 列表页使用 CustomScrollView 保持滚动状态
2.2 筛选面板的组件树与层级关系
组件树设计直接影响布局代码的可维护性。我最终的层级结构是这样的:
Scaffold └── Stack ├── 主页内容 │ └── CustomScrollView(列表 + 顶部筛选入口) └── FilterPanel ├── ModalBarrier(半透明遮罩) └── SlideTransition(侧滑面板) ├── PanelHeader(标题 + 关闭按钮) ├── Expanded(FilterSectionList) │ └── FilterGroupWidget * n └── FilterBottomBar(重置 / 确定)Stack 层叠布局是侧滑面板的标配做法。面板未打开时不渲染 FilterPanel 节点,避免无效的布局计算。打开时用 AnimatedBuilder 驱动 SlideTransition 从屏幕右侧滑入,遮罩层的透明度用同一个 Animation 派生。
这里有一个非常容易被忽视的点:面板内部的可滚动区域不要用 ListView,用 Column + Expanded + ListView。换句话说,只有中间条件区域滚动,顶部标题栏和底部操作栏固定在面板的上下方。如果整个面板做成一张大 ListView,滚动时标题栏会一起滚出去,不符合电商筛选的固定操作栏习惯。
另外,屏幕宽度适配要处理好。我定义了面板宽度为屏幕宽度的 0.85,并且限制了最小 300、最大 400 的逻辑。这个数值来自实测:太窄了条件文字显示不完整,太宽了列表页完全没有遮挡感,交互上不舒服。
3. 核心代码实现过程
3.1 侧滑打开与关闭的动画实现
面板显隐是整个功能的第一印象,也是性能最敏感的地方。我建议用 AnimatedBuilder + SlideTransition 来做,而不是用 AnimatedContainer 左改右改。因为 SlideTransition 只操作 transform,不会触发布局重算,动画全程保持 60 帧几乎没问题。
核心代码如下:
class FilterPanelWrapper extends StatefulWidget { final Widget child; final Widget panel; final double panelWidth; final bool isOpen; const FilterPanelWrapper({ Key? key, required this.child, required this.panel, this.panelWidth = 360, required this.isOpen, }) : super(key: key); @override State<FilterPanelWrapper> createState() => _FilterPanelWrapperState(); } class _FilterPanelWrapperState extends State<FilterPanelWrapper> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation<Offset> _slideAnimation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 280), ); // 面板从右侧滑入,Offset(1.0, 0) -> Offset.zero _slideAnimation = Tween<Offset>(begin: const Offset(1.0, 0), end: Offset.zero) .animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, )); if (widget.isOpen) { _controller.value = 1.0; } } @override void didUpdateWidget(covariant FilterPanelWrapper oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isOpen && !_controller.isAnimating && _controller.value < 1.0) { _controller.forward(); } else if (!widget.isOpen && _controller.value > 0.0) { _controller.reverse(); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( children: [ Positioned.fill(child: widget.child), if (_controller.value > 0 || widget.isOpen) Positioned.fill( child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Stack( children: [ // 遮罩层:淡化透明度 Positioned.fill( child: GestureDetector( onTap: () => _controller.reverse(), child: Container( color: Colors.black.withOpacity(0.4 * _controller.value), ), ), ), // 右侧面板 Align( alignment: Alignment.centerRight, child: SizedBox( width: widget.panelWidth, child: SlideTransition( position: _slideAnimation, child: widget.panel, ), ), ), ], ); }, ), ), ], ); } }我特别处理了遮罩点击关闭的交互:点击遮罩区域直接逆行动画收起面板,但不重置用户已选条件。这是电商产品的细节。用户滑出筛选觉得不想改了,点遮罩关掉,下次打开应该还保留之前选的条件;而如果点了“重置”,才清空所有选择。
3.2 单选与多选筛选器的实现
单选筛选器对应排序、价格区间这种“一组只能选一个”的场景。我实现时用了一个 _selectedSingleIndex 字段,点击新项时直接覆盖旧的选中状态。
class FilterSingleSelector extends StatelessWidget { final FilterGroup group; final String? selectedItemId; final ValueChanged<String?> onSelected; const FilterSingleSelector({ Key? key, required this.group, required this.selectedItemId, required this.onSelected, }) : super(key: key); @override Widget build(BuildContext context) { return Wrap( spacing: 12, runSpacing: 10, children: group.items.map((item) { final isSelected = item.itemId == selectedItemId; return GestureDetector( onTap: () => onSelected(isSelected ? null : item.itemId), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? const Color(0xFFE8F0FF) : Colors.grey[50], borderRadius: BorderRadius.circular(16), border: Border.all( color: isSelected ? const Color(0xFF3478F6) : Colors.grey[300]!, width: 1, ), ), child: Text( item.label, style: TextStyle( fontSize: 14, color: isSelected ? const Color(0xFF3478F6) : Colors.black87, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), ), ); }).toList(), ); } }注意我在 onTap 里做了“再点一次取消选中”的处理。这个交互加得很值,很多产品反而省略了它,用户选错了只能凭直觉再找第二个选项,体验上是扣分的。多选器的逻辑类似,区别是它维护的是一个 Set,点击时把 itemId 加入或移除集合。
多选器还有一种特殊形态叫“以图选码”,就是品牌列表带 logo 缩略图、颜色列表带色块。这种场景其实还是在 FilterItem 里扩展一个 imageUrl 或 colorValue 字段,UI 上做条件判断即可,数据模型不需要动。
3.3 共用筛选状态与重置/确定逻辑
状态管理这部分我把筛选状态单独抽成了一个 ChangeNotifier,挂在全局上,列表页和面板共用:
class FilterState extends ChangeNotifier { final List<FilterGroup> groups; final Map<String, Set<String>> _selectedMulti = {}; final Map<String, String?> _selectedSingle = {}; final Map<String, _RangeValue> _rangeValue = {}; final Map<String, String> _textValue = {}; bool get isEmpty => _selectedMulti.values.every((s) => s.isEmpty) && _selectedSingle.values.every((v) => v == null) && _rangeValue.values.every((v) => v.start == null && v.end == null) && _textValue.values.every((v) => v.isEmpty); void toggleMulti(String groupId, String itemId) { final set = _selectedMulti.putIfAbsent(groupId, () => {}); if (!set.add(itemId)) { set.remove(itemId); } notifyListeners(); } void selectSingle(String groupId, String? itemId) { _selectedSingle[groupId] = itemId; notifyListeners(); } void reset() { _selectedMulti.clear(); _selectedSingle.clear(); _rangeValue.clear(); _textValue.clear(); notifyListeners(); } Map<String, dynamic> toFilterMap() { // 转换成服务端接口需要的参数格式 return {}; } }重置和确定在 UI 层就变成了两个非常薄的按钮逻辑:重置调用 FilterState.reset(),确定则把 toFilterMap() 的结果通过回调传回列表页,然后收起面板。
有个关键点:确定之后,是否把面板整个关闭?我查过主流产品行为,几乎都是关闭并回到列表,同时列表顶部出现一个“筛选条件”的面包屑,显示如“品牌: 某品牌A ×”“价格: 100-300”的标签,支持点击删除单条筛选。这是筛选能力完整性的重要一环,后面可以再接上。
3.4 筛选结果处理与列表联动
筛选结果的过滤我分两层做:本地过滤和服务端过滤。
本地过滤适合那种数据量不大、已经把全量数据拉下来的场景——比如收藏夹、订单列表、通讯录。服务端过滤适合商品列表这种数据量大的场景,前端只提交筛选参数,服务端返回结果集。我这次实现的是服务端过滤模式,因为商品列表的分页和筛选天然要配合。
FilterState.toFilterMap() 返回的 MAP 直接作为查询参数:
- 品牌多选:brand=A,B,C,服务端按逗号拆
- 价格区间:price=100-300,无边界时用 100- 或 -300
- 排序:sort=price_asc / sort=price_desc / sort=default
列表页的刷新逻辑是监听筛选状态变化:
context.watch<FilterState>().toFilterMap(); // 触发请求,替换列表数据这里建议防抖 300 毫秒,避免用户快速点多个品牌时频繁请求。我当时测试时快速点击,结果在 2 秒内发出了 6 个请求,客户端和服务端都吃不消。加一个 Timer 防抖后就舒服了。
4. 踩坑记录与常见问题排查
4.1 面板内部的滚动边界与嵌套滚动冲突
第一个大坑就是滚动冲突。项目里列表页本身就是 CustomScrollView。筛选面板打开时,Stack 里出现了两个可滚动区域:底下的列表和上层的面板。虽然面板用 Positioned 盖在上面,但 Android 平台上手势事件有“事件穿透”的风险。
具体症状是:在面板上快速上下滑动时,偶尔会带动底下的列表也跟着滚。这是因为 Flutter 的手势竞技场(GestureArena)需要时间决定胜者,如果面板的滚动视图没有及时宣告手势胜利,底层滚动视图可能会被“误伤”。
解决方案有三个层次:
- 在面板外层套一个 AbsorbPointer,阻断手势向底层传递。这是个粗暴但有效的办法;
- 给面板的根容器设置 a NeverScrollableScrollPhysics 的 ScrollConfiguration;
- 更彻底的方案:在面板打开期间,把列表页的滚动禁用。
我最终用了方案 3:面板打开时,列表页的 CustomScrollView 设置 physics: const NeverScrollableScrollPhysics()。虽然 Stack 层级上底层列表本来不该被触摸到,但多一层保险总不是坏事,滚动体验也更符合直觉。
4.2 筛选结果回显时的数据错位
第二个坑比第一个隐蔽得多。解决回显时,“上次选中的条件”在打开面板后总是对不上,特别是多选条件。
根源是我把 FilterState 设计成了全局共享,但列表页初次加载时会修改筛选项的数据结构。比如服务端返回的品牌列表和我初始化面板时用的品牌列表顺序不同,直接导致 itemId 匹配不上。
解决办法是:面板的数据源与列表页的筛选结果生成采用同一份基础配置。创建 FilterGroup 列表的代码只写一遍,列表页和面板都从同一个配置函数获取。
具体写法:
List<FilterGroup> buildFilterGroups() { return [ FilterGroup( groupId: 'sort', name: '排序', type: FilterType.single, items: const [ FilterItem(itemId: 'default', label: '综合'), FilterItem(itemId: 'sales', label: '销量'), FilterItem(itemId: 'price_asc', label: '价格从低到高'), FilterItem(itemId: 'price_desc', label: '价格从高到低'), ], ), FilterGroup( groupId: 'brand', name: '品牌', type: FilterType.multi, items: serviceBrandList, // 运行时赋值 ), ]; }一次初始化,两处引用,再没出现过回显错乱。
4.3 底部按钮遮挡内容的陷阱
第三个问题是布局上的经典陷阱:面板的可滚动区域把最后几个筛选条件挡住了。原因是面板是 Stack 结构,底部操作栏是 Positioned 定位的,而中间的可滚动区域高度计算时没有把底部操作栏的高度扣除。
怎么发现的问题?我在真机上测试时,滚动到面板底部,“电池”这个条件的“确定”按钮死活露不出来。查看 widget 树才发现,可滚动区域的 bottom 距底部是 0,没有给操作栏让位。
修复方法:
Padding( padding: EdgeInsets.only(bottom: bottomBarHeight), child: ListView(...), )我固定了底部操作栏高度为 56,加上安全区域底部的 padding,加起来刚好不会遮挡。这里也引出一个建议:如果后续想支持底部按钮在滚动过程中不固定,可以直接把操作栏放进 ListView 的底部,但那样用户选到一半想看底部栏时就必须滚到底部,体验反而不如固定。
4.4 筛选状态残留导致的列表空态
还有一种情况很容易在测试时漏掉:筛选条件组合之后确实没有数据,列表应该显示空态而不是白屏。
我接到的线上反馈就是“选了两个条件,列表变成空白,没有任何提示,用户以为 App 坏了”。这个必须处理,在列表页增加空态视图:
- 一个居中的图标
- 文案:暂时没有符合条件的商品
- 一个“清除筛选条件”按钮
空态视图同样监听 FilterState,只有筛选条件非空且数据为空时才展示。
4.5 性能优化:构建过程中的重复布局
筛选面板虽然 UI 不复杂,但条件可能几十个,重新布局会卡顿。我做了两件事:
第一,把每个 FilterGroupWidget 用 RepaintBoundary 包起来。这样单个条件选中变化时,只重绘那个分组,不会影响整个面板。
第二,GroupWidget 里的条件列表用 ListView 而不是 Column 包所有 children。比如同一个分组下 40 个品牌,Column 会一次性构建所有 Widget,ListView 配合懒加载则只会构建可见区域。
这两招做完之后,面板从点击打开到完全渲染的时间体感缩短了大概一半。虽然没有精确测量,但明显感觉滑动和点击反馈更跟手了。
5. 细节打磨与后续扩展
5.1 面板打开时主列表的加载状态
筛选条件确定后,列表需要请求新数据。这个过程要处理好“旧数据 + 新数据”的切换,别让用户看到一闪而过的旧数据残留。
我采用的做法是:确定按钮点击后,立即把列表页的 loading 状态置为 true,列表顶部出现一个细条的 LinearProgressIndicator。而不是先关面板再等数据加载,这样会让用户感觉“点了确定没有反应”。数据返回后再关闭 loading 状态并收起面板。
顺带一提,面板关闭动画和数据加载可以并行,不必等数据返回再收面板。用户体验上,“确认后立刻关闭面板,同时列表加载”是最顺的。
5.2 从功能模块到通用组件
如果不止一个页面需要这个筛选功能,建议把 FilterPanel 封装成独立组件,只暴露三个接口:
- 配置:List
- 数据绑定:外部的 FilterState 实例
- 回调:onConfirm(Map<String, dynamic> filterMap)
接口越少,复用难度越低。我在项目里就把它用在了两个不同页面,一个是商品列表,一个是优惠券列表,数据结构不同但交互完全一致,只改了传入配置就接好了。
5.3 后续可以做的增强动作
这套右侧筛选机制还有两个很实用的扩展方向:
第一个是“条件联动”。比如选了“品牌 A”,分类分组里自动把不包含品牌 A 商品的品类置灰。实现上需要在 FilterState 里增加一个 enabledGroups 的派生逻辑,每个分组设置依赖条件。这种功能适合垂直品类多、属性层级深的电商场景。
第二个是“筛选条件持久化”。把用户最后一次的筛选组合保存到本地存储,下次进入列表页默认应用。这个尤其适合“常买回购”的场景,但注意别过度使用——用户可能在查一个临时品类,把上次的筛选强加上去反而碍事。我给方案加了个 24 小时的保存时效,超过时间就用默认无筛选。
第三个是配合搜索关键词的筛选。列表页搜索框输入关键词后,筛选面板里应该显示对应的搜索结果分支,比如搜索“连衣裙”时,分类条件自动定位到“女装—连衣裙”。这块逻辑可以放在 FilterState 中单独处理,与面板 UI 解耦。
6. 写在最后的实操感受
这套多条件筛选做下来,我最深的体会是:它表面是一个 UI 组件,实际上是数据模型、动画、滚动、状态管理、网络请求多个环节的综合工程。很多实现上的坑不是 Flutter 框架本身的问题,而是对交互细节理解不透导致的设计缺陷。
如果回头看一遍,我会在动手写代码之前更早地把数据模型定稿,而不是先搭 UI 再补状态。数据模型一旦清晰,UI、回显、重置、提交全都顺理成章;数据模型模糊,后面任何一步都会返工。
另外,建议各位在调试时多用真机,尤其是低端 Android 真机。侧滑动画在模拟器上很流畅,但低端机上如果掉帧,就要检查是不是没有给面板加 RepaintBoundary。性能优化这种东西,真的不需要等出了问题再做,提前加上不会损失什么。
最后分享一个极小但我挺在意的细节:面板关闭后,要把焦点还给列表页。如果有人用键盘操作,焦点还留在面板的隐藏节点上,下次打开面板会发现布局多了一个焦点矩形。这故障不算严重,但在 Flutter 里加一句 FocusScope.of(context).unfocus() 就能解决,建议顺手做掉。