做 Flutter 列表交互这么久,“侧滑删除 + 长按批量操作”一直是我绕不开的两个硬需求。这次在 OpenHarmony 上做仿原生系统风格列表,我以为直接把 Flutter 那套 Dismissible、ReorderableListView 拼一拼就能交差,结果真机一跑就露馅:手势判定跟 ArkUI 原生的侧滑差距太大,批量左滑菜单也容易误触,更别提动画节奏、阻尼、状态复位这些细节。于是花了几天时间把列表这一整套交互重新做了一遍深度定制,最终效果接近原生质感。这篇文章就把我的踩坑过程、状态设计思路和关键代码拆开来讲,希望对要在 OpenHarmony 上用 Flutter 做类似功能的同行有帮助。
1. 项目背景与定制目标
先说清楚,我这里说的“OpenHarmony 上的 Flutter”,指的是通过官方或社区维护的 Flutter-OHOS 适配分支,把 Flutter 工程跑在 OpenHarmony 真机上。由于 OpenHarmony 已经支持了比较完整的 Flutter 运行时,大部分 UI 逻辑可以跨端复用,但手势、滚动、列表项的交互细节跟 Android/iOS 生态仍然有差异,尤其在侧滑删除、批量操作这种强交互场景里,需要单独做深度定制。
1.1 为什么直接“拿来主义”不行
Flutter 官方组件里,跟侧滑删除最接近的是 Dismissible,跟拖拽排序相关的是 ReorderableListView。但它们解决的是“整行滑动后消失”和“长按拖动改变顺序”,跟原生系统列表的侧滑菜单完全是两回事。
原生风格列表通常需要满足几个体验点:
- 行内容左滑,露出后方的删除按钮,按钮可以是一个也可以是多个;
- 手指松手后,如果滑动距离不够,列表行自动回弹,而不是直接触发删除;
- 滑动过程中有一个最大滑动距离,超过后手指继续左移也不会把整行甩飞;
- 删除操作在确认后,行需要以合适的动画移除,同时保持列表滚动位置稳定;
- 长按列表项进入多选模式,此时侧滑手势失效,顶部或底部的批量操作栏出现。
这些需求用 Dismissible 实现非常别扭,因为 Dismissible 的核心逻辑是“滑过阈值就消失”,它不善于维护一个“菜单露出”状态;而批量操作则需要一个独立于单行滑动之外的全局选中状态。把这两套逻辑强行塞进一个通用组件里,代码会越来越难维护。
1.2 理想目标定成什么样
我给自己定的目标不是“代码能跑”,而是“还原原生交互的肌肉记忆”:
- 侧滑删除从触摸开始到菜单露出,动画时长、曲线、阻尼要跟系统原生接近;
- 同一时间只有一行可以处于侧滑展开状态,点击其他行或下滑其他行时当前展开行自动收起;
- 批量模式下,列表项头部出现选中圈,点击任意行切换选中态,顶部显眼位置显示已选数量或操作入口;
- 批量操作进行时,滑动手势不跟点击选择打架;
- 列表渲染性能在大量数据下依旧稳定,滑动过程不掉帧。
基于这些目标,我放弃组件拼装,选择自己实现核心的状态管理再组合基础组件,最终代码量虽然比用现成组件大,但每个行为都可控,后面的问题排查也变得容易。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计思路:从状态机到组件分工
这种交互定制,最容易翻车的地方在于“不知道当前处于什么状态”。用户的手指在屏幕上动一下,可能触发滑动、点击、滚动、长按好几个手势的竞争。如果一开始不把状态定义清楚,后面所有代码都会在 case 堆里打转。
2.1 列表行的状态怎么抽象
我做了个枚举,把所有可能的行状态列出来:
normal:普通状态,行内容完整展示;swiping:手指正在滑动,行内容跟着偏移;menuOpen:已经左滑露出操作按钮,手指离开但菜单保持打开;menuClosing:正在收起菜单;editMode:长按进入批量操作模式,此时行左侧出现选中圈;selected:在批量模式下,这一行被选中。
这几种状态之间并不是任意切换的。比如从 menuOpen 可以直接切换到 editMode,但必须先收起菜单;从 swiping 不能直接跳到 selected,因为批量模式的入口是长按,而长按需要手指先停留一段时间,此时行不应该处于滑动状态。
我把状态迁移画成了一张显式的关系表,然后严格按照迁移表写逻辑,能避免很多隐性 bug。
2.2 组件之间的分工方式
整个列表我拆成了三层:
ListScreen:负责整个页面,承载状态管理和批量工具栏;SlidableWidget:负责单行的手势识别、位移计算、菜单展示;RowContent:负责行的普通内容展示和批量模式下的选中圈、选中态颜色。
状态我统一放在 ChangeNotifier 里,不引入重量级状态管理框架。原因很简单:批量模式下需要跨行共享选中集合,侧滑展开状态又需要全局唯一,依赖太多会拖慢 OpenHarmony 上的启动性能,用原生状态管理足够清晰。
ListScreen 持有两个核心对象:一个是 SelectionModel,记录当前选中的主键集合;另一个是 SlidableController,它保存当前处于展开状态的行 ID,并给 SlidableWidget 提供关闭指令。
SlidableWidget 接收到“关闭指令”后,内部动画控制器执行反向动画,把行内容挪回原位。避免每个行组件之间直接通信,不然一旦行数变多,组件间的隐式调用会非常混乱。
2.3 批量操作和滑动操作的全局协调
这里有个关键点:批量模式开启后,行内容左侧需要留出空间放选中圈,而不是把原有内容直接覆盖。我选择用 AnimatedContainer 在普通模式和批量模式之间切换边距,而不是推倒重建复杂行内容。
同时在全局状态里加一个 isEditing 标志。SlidableWidget 的手势处理器会先检查 isEditing,为 true 时直接放行点击事件,关闭滑动识别。由于 isEditing 是页面级状态,所有行都会同时生效,不会出现某些行还在滑、另一些行已经开始选中的错乱。
3. 侧滑删除实现:关键代码和计算细节
这块是整个定制的核心。先说结论:不要试图调用某个现成的包一劳永逸,真正要控制到像素级,最好自己封装一个手势容器,然后用 AnimatedBuilder 去驱动行位移和菜单宽度。
3.1 不直接用 Dismissible 的三个理由
第一,Dismissible 会把滑动过程绑定到 confirmDismiss 返回值上,如果返回 false,行会回弹,但回弹动画和控制器细节我无法完全控制;第二,它的滑动方向虽然可以设置,但我需要的是“露出按钮”而非“让行跑出去”,语义不符;第三,OpenHarmony 适配分支的 Dismissible 在某些版本里对 direction 参数和背景布局支持不全,真机会出现背景闪动。
所以我用了最朴素也最稳的做法:GestureDetector 监听 onHorizontalDragUpdate 和 onHorizontalDragEnd,实时修改偏移量。
3.2 手势计算里最容易算错的参数
行内容偏移量不是简单等于拖动距离,需要经过限制和阻尼处理。
假设菜单宽度是 72 逻辑像素,那么最大可拖动距离就是 72。用户手指左滑时,dx 为负,偏移量应该从 0 走向 -72。但用户实际手势可能远大于 72,如果我直接给偏移量赋值,行内容会跟着手指无限左移,体验非常差。
我采用经验公式:
- 当
dragDistance = -dx,表示手指实际向左滑动的距离; - 实际偏移量 =
-min(dragDistance * 0.5, maxOffset); - 超过菜单宽度的手感会变重,也就是阻尼系数从 1 降到 0.5。
这样用户快速用力左滑时,行内容会停住并显示按钮的完整宽度,而不是跟着手指“飞”出屏幕。
松手时的判断也很关键:
- 如果当前实际偏移量超过菜单宽度的一半,让菜单展开到完整宽度;
- 否则执行回弹动画。
这个“过半”阈值参考了原生 ListItem 的吸附逻辑。用户即使手指提前松开,也能感觉到菜单自己完成了剩余动作,舒适度很高。
3.3 菜单按钮点击和整行点击的事件关系
行内容左移后,露出来的部分叫 actionMenu,我用 Positioned 或 Row 的尾部元素实现。整行的外层我用 GestureDetector 处理点击,但点击事件的命中区域默认是 Transform.translate 之前的内容区域,还是整个组件区域,这一块需要特别测试。
直接给 GestureDetector 外层包 behavior: HitTestBehavior.opaque 并不够。因为当行内容左移时,露出的按钮在行内容的右侧,也就是视觉上位于行的尾部,点击按钮会优先命中按钮自身的 GestureDetector。而行内容的点击事件如果检测到 offset != 0,应该主动吞掉这次点击,用来收起菜单,而不是触发进入详情页。
实现思路:
dart复制onTap: () {
if (offset <= -menuWidth / 2) {
// 菜单展开中,点击行内容先收起
controller.close();
} else {
// 正常点击,进入详情
onItemTap(item);
}
}
按钮点击则直接执行删除,但因为后续可能还要支持“标记已读”之类的操作,我封装了一个 MultiActionMenu,接收一组 action。
3.4 单行滑动跟列表滚动的冲突
在 GestureDetector 加滑动手势之后,还需要保证垂直滚动不受影响。如果我把 onHorizontalDragUpdate 和 onVerticalDragUpdate 都加了,会互相干扰。正确做法是只监听水平方向,让包裹在 ListView 里的垂直滚动由 Scrollable 自己处理。
水平拖动和垂直滚动在斜向滑动时存在竞争。Flutter 手势竞技场里,水平手势和垂直手势会同时收到指针事件,直到系统判断哪个方向占优。为了在斜向滑动时减少误判,我设置了一个挺实用的开关:当行已经处于展开状态时,即使手指有小幅水平移动意图关闭菜单,也优先响应水平;当行处于关闭状态时,才把机会交给垂直滚动。
3.5 同一时间只能展开一个菜单
这是还原原生体感非常重要的一点。我维护了 SlidableController,它内部有一个 ValueNotifier<String?>,表示当前打开菜单的行主键。
行组件在初始化时会把自己注册进控制器,并在 dispose 时移除。每次手势开始时,先检查当前是否有其他行展开,如果有,就触发那一行关闭;新行的手势等旧菜单关闭动画完成后再真正开始。
不加这一步就会出现一个很诡异的现象:上一行菜单还开着,下一行又能左滑,两个菜单同时露出来,视觉上很乱。原生列表通常不允许这种状态。
4. 长按批量操作模式怎么做
侧滑删除适合单条或少量的场景。当用户需要清理一屏数据,一条条滑下去删除会变得很烦,所以批量操作入口必不可少。
4.1 批量模式的触发与退出
我用的是最常见的长按触发方式。行组件外壳有一个 onLongPress 回调,到 ListScreen 层先做两件事:
- 关闭当前可能打开的侧滑菜单;
- 调用
selectionModel.enterEditMode(itemId),把被长按那一行默认选中。
进入批量模式后,页面底部或顶部弹出一个工具栏,我选择顶部固定栏,因为底部按钮容易被常用手势遮挡。
退出批量模式有三种情况:
- 点击工具栏上的“完成”或“取消”;
- 选中所有待操作项并执行完成后自动退出;
- 按系统的返回键强制退出。
实现返回键退出时要注意:批量模式下返回键不应直接退出页面,而应先退出批量模式。这个需要在外层用 PopScope 或 WillPopScope 拦截。
4.2 选中状态存储与高效刷新
批量状态的核心是“一个主键集合”。我用 Set<String> 存储选中的主键,每次点击直接对集合做增删。不要在 Map<id, bool> 里反复 setState,数据量大时会导致整行频繁重建。
数据更新后,怎么通知列表刷新也很讲究。监听 Set 的增删用最简单方式:
dart复制class SelectionModel extends ChangeNotifier {
final Set<String> _selectedIds = {};
Set<String> get selectedIds => UnmodifiableSetView(_selectedIds);
bool isSelected(String id) => _selectedIds.contains(id);
void toggle(String id) {
if (!_selectedIds.add(id)) {
_selectedIds.remove(id);
}
notifyListeners();
}
void clear() {
_selectedIds.clear();
notifyListeners();
}
}
行组件内部用 Selector 或通过 ListenableBuilder 只监听自己的 isSelected 状态,可以控制只有受影响的行重建。
4.3 批量选择时的点击跟滑动冲突
进入批量模式后,需要彻底停用侧滑手势。最干净的办法不是在各行组件里写判断,而是让 SlidableController 置空当前展开菜单,然后统一设置一个 enabled = false 开关。
行组件的手势识别入口是:
dart复制GsTapDetector?,
RawGestureDetector: GestureDetector(
onHorizontalDragStart: selectionModel.isEditing
? null
: (details) => slidableController.onDragStart(item.id),
onHorizontalDragUpdate: selectionModel.isEditing
? null
: _handleDragUpdate,
onHorizontalDragEnd: selectionModel.isEditing
? null
: _handleDragEnd,
)
把手势回调置空比在内部 return 全局标志更高效,因为 GestureDetector 没有手势回调时,不会参与手势竞技,省掉了手势竞争开销。
4.4 批量模式下的全选与红点数字
批量操作通常还要有全选逻辑。全选按钮的状态有三种:
- 未全选;
- 部分选中;
- 全部选中。
所以我不能只用一个 bool,要在 UI 里动态判断 selectedIds.length == 0、selectedIds.length < totalCount、selectedIds.length == totalCount。
当全选点击时,如果当前不是全选状态,就把当前可操作数据全部加入集合;如果是全选状态,则清空集合。我测试时遇到一个典型问题:页面带筛选条件时,上次已选中的某项如果在筛选结果里没有出现,全选后它的选中状态不会从集合里移除,导致最终删除数量超过预期。后来我在全选逻辑里加了一步“先移除所有不在当前列表中的 id,再执行本轮全选”,彻底解决了脏数据问题。
5. 侧滑菜单与批量模式的状态联动细节
单行菜单和批量操作不是毫无关联的两个功能,它们之间有几条容易被忽视的联动规则。
5.1 进入批量模式时先收起菜单
我在进入批量模式前的顺序上踩过小坑。如果不先收起菜单,行内容还处于左移状态,此时左侧又插入了选中圈边距,会导致内容跳动。正确顺序是:
- 找到当前展开的菜单行,执行关闭动画;
- 关闭动画结束后,再进入编辑模式,让选中圈平滑出现。
这里需要一个 await 机制,不能让第二步和第一步同时执行。在原型阶段我图省事,把两步都放在一个方法里,结果显示行在关闭菜单和展开选中圈的过程中闪了一下。
5.2 批量模式下选中圈和原行内容的空间分配
原生列表进入多选模式时,左侧选中圈会出现,标题区会轻微右移。为了达到这种效果,我给普通行内容加了一个 Padding,动态变化:
dart复制Container(
padding: EdgeInsets.only(left: isEditing ? 24 : 16),
child: rowContent,
)
当 isEditing 从 false 变成 true 时,AnimatedContainer 会带着这个 8 像素的差值平滑过渡,视觉上不突兀。
选中圈我直接用 AnimatedContainer 画了一个圆形按钮:
dart复制AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? theme.primaryColor : Colors.transparent,
border: Border.all(
color: isSelected ? theme.primaryColor : Colors.grey,
width: 2,
),
),
child: isSelected
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
)
全套选中、取消选中都是用一个圆和勾的状态变换完成,动效可控,逻辑很简洁。
5.3 批量删除完成后的列表收缩动画
普通逐条删除时,Flutter 用 ListView 配合数据源移除,会出现一条一条占位闪烁的问题。我处理删除动画不是直接改 dataSource,而是把行高度动画化:让行内容先经历透明度降为 0 和高度收缩到 0 的过程,动画结束后再真正移除数据项。
在批量删除时,如果一次删除几十条,逐条播放动画会很慢,用户会觉得操作很奇怪。我做了分批动画,每次最多同时收缩 5 行,按批次串行执行。实测下来,删除 50 条数据大约 1.2 秒完成,视觉上可接受,不会卡死主线程。
批量删除过程中还要防止用户在动画未完成时就进行新的操作,我加了 _isDeleting 标志位,动画期间顶部按钮全部置灰,防止重复点击导致数据越界。
6. 高频问题排查与避坑记录
整个定制过程中,我在 OpenHarmony 真机上遇到过很多诡异问题,有几个特别有代表性,整理成表供参考。
| 现象 | 根因分析 | 解决方式 |
|---|---|---|
| 左滑删除后 ListView 空白闪烁 | 用 setState 直接在列表中删除数据,没有给 ListView 稳定 key |
每个 item 绑定唯一 ValueKey(id),删除动画完成后再更新数据源 |
| 菜单刚滑出瞬间,行内容抖动 | 在动画期间重复设置了位移值 | 手势状态和动画控制器状态隔离,只在手势结束后启动控制器动画 |
| 批量模式下点击行为误触详情 | 长按结束后系统仍派发了点击事件 | 用状态位记录是否已经触发过长按,触发了就吞掉后续点击 |
| 快速滑动多行时多个菜单同时展开 | 每行独立控制自己的偏移量 | 引入全局 SlidableController,同一时间只允许一个展开菜单 |
| 真机上侧滑菜单按钮点击区域偏移 | 行内容左移后按钮区域被外层 Transform 影响 |
不使用 Transform.translate 做菜单露出,改为 Row 中根据偏移量动态设置 Padding 或 Container 的 margin |
| 删除动画期间列表跳动 | removeAt 发生在 ListView 构建前 |
通过 AnimatedList 或手动控制的 AnimationController 在行移除前先执行尺寸动画 |
这里再补充几个值得注意的细节:
- Flutter-OHOS 分支中,某些动画属性如
SpringSimulation在不同屏幕刷新率下的表现可能跟 Android 不同。我发现 OpenHarmony 真机高刷新率下跨平台动画会有轻微卡顿,原因是默认的SchedulerBinding时间戳粒度问题。解决办法是我给列表动画统一使用Duration控制,不用Curves里某些弹性曲线,保持帧率稳定。 - 如果同时启用了系统回弹效果,会出现行内容和 ListView 滚动位置同时回弹,视觉上双重位移。我在定制时干脆禁用了
ScrollConfiguration里的回弹效果,改用自定义的ClampingScrollPhysics,确保位移只作用于行偏移。 - 由于所有手势是自实现的,测试时一定要用真机而不是模拟器,模拟器的触摸事件采样频率和真机差距很大,容易误判滑动手势的下拉比例。
7. 最后的体验优化与性能注意事项
列表数量达到几百条后,滑动性能开始下降。检查下来,两个问题最明显:一个是我在 build 方法里生成了多个不透明的 Material 容器,导致滚动时 UI 图层过多;另一个是侧滑拖动时每次位移变化都触发按钮背景的重新绘制。
优化方案是给每一行的菜单按钮单独提取成 RepaintBoundary,并确保菜单背景是简单颜色而不是复杂的渐变。手势更新位移时,只用 AnimatedBuilder 包围受影响的 Transform 区域,不要让整个列表都进入构建范围。
另外我在做性能 profile 时发现,Flutter-OHOS 的 Texture 合成模式下,大量阴影效果会很费性能,所以菜单按钮不添加 BoxShadow,而用一条 1 像素的细线分隔作为视觉提示,还原度反而更好。
如果你准备把这个方案移植到自己的项目里,我的建议是:先专注于状态定义,把侧滑展开、回弹、批量选中这些状态做成显式模型,再去写 UI 渲染。不要一上来就考虑视觉细节,否则后续调整很容易陷入不断修 case 的泥潭。
对我来说,这套定制的最大收获不是“实现了侧滑”,而是明白了 Flutter 默认组件和“仿原生”之间隔着多少细节。这些细节只能来自真机反复调,来自对手势事件的逐帧观察。用现成库可以快速凑出功能,但要做到让用户感觉到“这就像系统自带”,必须亲自死磕状态管理和手势判定。
