1. 需求定界:要仿的不是样子,是那套交互肌肉记忆
先说背景。Flutter 在 OpenHarmony 上的移植早就不是"能不能跑"的阶段了,OpenHarmony SIG 维护的 flutter_flutter 仓已经能稳定跑不少商业项目。但"能跑"和"好用"之间差了很远,尤其是列表这种高频交互场景。做过鸿蒙原生开发的朋友应该知道,ArkUI 的 List 组件自带 swipeAction 侧滑菜单,长按进入多选态也是系统级的交互范式。用户从原生 App 切到 Flutter 版,第一感觉就是"这列表怎么这么木"——不是卡顿,而是交互节奏不对:侧滑没有阻尼感、松手后不会自动回弹到菜单展开态、也没有批量操作入口。
这个项目要解决的,就是让 Flutter for OpenHarmony 里的列表,从"能滑"变成"会滑得像原生",同时把批量删除这种管理类操作做到位。
1.1 先拆散原生的交互模型,再谈复刻
拿到这个需求别急着写代码,先把 ArkUI 里列表的交互范式拆成几个独立要素:
- 侧滑方向固定为水平,触达阈值后有"磁吸"效果,菜单展开后松手不回弹。
- 菜单项通常是两个:删除、置顶或者标记已读,点完菜单项要么执行操作,要么收起。
- 长按列表项进入编辑态,左侧出现选择框,底部出现操作栏(全选、删除、移动)。
- 编辑态下点击任意已选中的行会取消选择,但不会退出编辑态。
- 连续滑动:滑开 A 行再滑 B 行,A 行应该先自动收回,保证屏幕上同时只有一个菜单展开。
这里面最微妙的是"磁吸"和"连续滑动互斥"。Flutter 自带的 Dismissible 组件干不了这事,因为它只支持滑动超过阈值直接触发 dismiss 回调,没有"停在半路展开菜单"的中间态。flutter_slidable 这个三方库在标准 Flutter 上表现不错,但在 OpenHarmony 的移植版上,Overlay 插入机制、PlatformView 合成、路由动画这几块都有兼容性差异,实际跑一遍会发现菜单展开动画偶发掉帧,点击外部区域收起也不灵敏。
所以这次没有直接用三方库,而是基于手势识别器自己实现了一套可复用的侧滑容器,把控制权完全拿在自己手里。
1.2 为什么自研方案比魔改三方库更划算
很多团队遇到这个需求,第一反应是去给 flutter_slidable 提 PR 或者包一层补丁。我的建议是,除非你们团队时间特别紧,否则不要绕这个远路。原因有三:
- OpenHarmony 设备碎片化比 Android 还夸张。rk3568 平板、rk3588 开发板、PC 模拟器,触控采样率、屏幕刷新率、逻辑分辨率差异巨大。三方库的动画曲线和滑动阈值是拿 Android 真机调的,拿到这些设备上不是太"肉"就是太"贼"。
- flutter_slidable 提供了太多用不到的功能(滑出方向、多重嵌套、自动开关),这些 API 意味着它内部有大量分支判断。分支多,在非标准 Flutter 环境上踩到隐藏 bug 的概率就成倍增加。
- 列表这种核心模块,后续一定会出现产品经理提的新需求——比如"左滑出现三个按钮""滑动过程中显示删除区域背景色"。自己维护的代码,加需求就是改一个方法的事;魔改三方库,得先读懂它几百行的内部状态机。
提示:这里说的"自己实现"不是从零造轮子,而是利用 Flutter 的 GestureDetector + AnimationController 搭一个最小可用容器,代码量控制在一百行上下,后续所有扩展都基于自己的状态模型来做。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 侧滑手势的技术内核:把"滑"这件事控制到像素级
2.1 横向滑动与纵向滚动的冲突治理
列表嵌套在 scrollview 里时,系统用竞技场机制处理手势竞争。理论上 Flutter 会优先让水平方向的手势识别器胜出,但实际表现是:手指横滑的初速度如果不够快,ListView 会抢走手势,表现为列表上下轻微抖动。
复用网上常见的写法:给外层套 GestureDetector,onHorizontalDragStart 里立刻设置一个标志位 _horizontalDragging = true;然后在 ScrollView 的 physics 里做一个判断,当水平拖动时禁止纵向滚动。但这个办法有个副作用——手指还没真正水平移动时标志位已经置真,导致用户想上下滑却突然卡住。
我试过几种方案后,最后采用的是显式水平拖拽手势 + 滚动 physics 的条件判断双保险:
先给列表容器加一个 Listener,监听 PointerDownEvent 记录起点坐标,然后在 PointerMoveEvent 里做方向判定。只有水平位移绝对值大于垂直位移,且水平位移大于 8 像素时,才让水平手势接管。这个判定写起来不复杂,但能比较干净地解决"同一次触摸既要能上下滑又要能左右滑"的问题。
判定接管后,把水平拖拽信息同步给列表项的 AnimationController:
dart复制class _SlidableItemState extends State<SlidableItem>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
double _dragExtent = 0; // 当前手指拖动的水平位移
double _lastDragUpdate = 0;
void _onHorizontalDragUpdate(DragUpdateDetails details) {
final delta = details.delta.dx;
// 累加位移,后续转换成面板展开偏移量
_dragExtent = (_dragExtent + delta).clamp(-_actionWidth, 0);
_controller.value = _dragExtent.abs() / _actionWidth;
setState(() {});
}
void _onHorizontalDragEnd(DragEndDetails details) {
// 速度超过阈值或位移过半,展开菜单,否则回弹关闭
final shouldOpen = _dragExtent.abs() > _actionWidth * 0.45 ||
details.primaryVelocity! < -300;
_animateTo(shouldOpen);
}
void _animateTo(bool open) {
_controller.animateTo(
open ? 1 : 0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
}
}
关键点:列表项根容器用 Stack 叠加两层——底层是操作面板(放删除/置顶按钮),上层是内容卡片。内容卡片的 Transform.translate 偏移量直接绑定 _controller,这样能保证动画进程跟手:手指拖动时数值实时变化,松手后剩余的动画交给 AnimationController 完成,中间没有任何跳变。
2.2 状态互斥与点击外部收起
模仿原生的第二件事是"同一时刻只允许一个菜单展开"。这个需求如果在 StatefulWidget 里各自维护自己的开合状态很难做,因为你不知道别的列表项当前是什么状态。
我在页面顶层维护了一个"当前展开项控制器"的注册表:每个列表项组件创建时把自己的 open/close 方法注册进去;当某个项开始滑动时,先广播给其他项让他们立刻收起。实现上可以直接用一个 ChangeNotifier 或者最简单的回调列表。
dart复制class SlideController {
final List<VoidCallback> _closers = [];
void register(VoidCallback closer) => _closers.add(closer);
void unregister(VoidCallback closer) => _closers.remove(closer);
void closeOthers(VoidCallback except) {
for (final closer in _closers) {
if (closer != except) closer();
}
}
}
这个控制器还有个用处:点击空白区域收起菜单。给列表外层包一个全局的 TapRegion,或者更简单——在页面根部用 GestureDetector 包住整个列表区域,onTap 里调用 closeAll。不过要小心手势嵌套,如果列表项内部有按钮,得确保按钮的点击事件不会被外层 GestureDetector 吃掉。Flutter 3.x 之后推荐用 TapRegion,它能自动判断点击是否落在某个区域内,不会干扰子级的手势竞争。
2.3 滑动面板的按钮实现细节
滑出的面板我建议用普通 Row 包两个 Material 按钮,而不是用 InkWell 自定义背景。Material 在鸿蒙设备上的水波纹渲染已经适配得不错,直接用主题色能省掉很多自绘的工作量。按钮宽度我固定是 84 或者 88,不同分辨率下直接用逻辑像素,OpenHarmony 的 Flutter 引擎会处理 dp 转换。
面板背景色我用了主题色的 colorScheme.errorContainer,配合白色的删除文字。为了一眼区分删除和置顶,我把删除按钮放最右侧,用红色系;置顶按钮放左侧,用灰色系。按原生的习惯,最危险的操作永远在离手指最近的位置。
注意:不要在一个列表项上同时开启 ListView 的
Dismissible和自绘手势容器,两个手势识别器会互相竞争,出现很奇怪的闪断。OpenHarmony 移植版里这种竞争更容易暴露,底层事件管道和主线 Flutter 在 edge 处理上有细微差异。
3. 卡片滑出后,"删除"是怎么落库又能反悔的
3.1 删除流程的乐观更新与 SnackBar 撤销
滑出面板后点击删除,在原生 ArkUI 里默认行为是无动画直接移除数据并弹一个 SnackBar,提供"撤销"能力。但在 Flutter 里 SnackBar 的问题在于——OpenHarmony 移植版的 ScaffoldMessenger 整体可用,但 SnackBar 的 behavior: SnackBarBehavior.floating 偶发在无底部导航时出现位置偏移。实测下来这个 bug 在 rk3568 上偶现,在 rk3588 上概率低一些,怀疑是底部安全区计算触发的。
解法是你自己做一个挂在页面底部的撤销条,别用 ScaffoldMessenger。我写了一个轻量的 UndoBar,插入在页面 Stack 的最上层。逻辑顺序:
- 点击删除按钮:先从列表数据源中移除该项。
- 插入一条撤销记录到队列:包含被删除的数据对象和它的索引位置。
- UndoBar 显示"已删除 1 项 撤销",同时起一个 4 秒的定时器。
- 用户点撤销:把数据插回原索引,刷新列表。
- 定时器到点或者用户继续操作其他项:清空撤销队列。
这里有个细节一定要处理:如果删除数据后立刻执行了别的批量操作,撤销队列就必须清空。否则用户连删三条再点一次撤销,数据源对不上,会出现奇怪的脏数据。我在每次删除动作后都会检查队列里的索引是否仍然小于当前列表长度,不满足就丢弃撤销能力。
3.2 列表项收起的方向差异
点删除按钮前,滑出的面板默认应该先收回再执行动画,不然视觉上很突兀。这个顺序还能在删除数据后避免 Flutter 报"列表项索引越界"的警告。
还有一个经验:删除项对应的 widget 在数据源移除后还在屏幕上那一帧,Flutter 会走 element 更新流程,如果 item 的 key 没绑对,会造成列表位置错乱。给 Item 构造时记得用 ValueKey(item.id),不要用 ObjectKey(item) 或者干脆不传 key。OpenHarmony 的 Flutter 引擎对显式 key 依赖更强,不传 key 在列表项跨层复用时偶尔会触发渲染残留。
另外,操作完删除之后刷新列表建议用 ListView.builder 的 itemCount 变化触发重建,而不是手动置空 List 再重新赋值。因为 builder 建的列表项做差量更新,能有效避免整页刷新带来的白屏闪烁。
4. 批量操作模式:长按进入编辑态后的状态管理
4.1 批量模式的整体设计
批量操作的核心不是怎么画复选框,而是怎么设计状态。整个列表集合需要维护以下状态:
- 是否处于编辑模式(长按某一行进入)。
- 当前被选中的项 id 列表(用 Set 保证幂等)。
- 是否全选(由选中数总和决定,而不是单独维护一个布尔值)。
常规做法是把这三个状态放在页面级 State 或者一个 ChangeNotifier 里。不建议用 bloc/riverpod 这种重状态管理,列表状态是页面的局部问题,引入全局状态反而增加复杂度。
dart复制class BatchModeModel extends ChangeNotifier {
bool isEditing = false;
final Set<String> selectedIds = {};
bool get isAllSelected {
if (totalCount == 0) return false;
return selectedIds.length == totalCount;
}
bool get isPartialSelected =>
selectedIds.isNotEmpty && !isAllSelected;
void enterEditMode(String id) {
isEditing = true;
selectedIds.add(id);
notifyListeners();
}
void toggleSelection(String id) {
if (selectedIds.contains(id)) {
selectedIds.remove(id);
} else {
selectedIds.add(id);
}
notifyListeners();
}
}
注意点:进入编辑态时要把原来的长按手势屏蔽掉。否则编辑态下手势冲突,长按一行会又触发进入编辑态又把列表搞乱。我习惯在 Item 的 build 里根据 model.isEditing 决定是否给根节点包上长按手势和侧滑手势;编辑态下只保留点击事件,点击列表项只是切换选中状态。
4.2 自定义复选框与半选状态
OpenHarmony 的 Flutter 版已经有 Checkbox 组件,不过默认的复选框样式和鸿蒙原生不太一样。鸿蒙原生的勾选框偏圆润一些,选中时对勾的粗细也不一样。要完全复刻原生,建议直接用 CustomPaint 画一个 20x20 的圆形选择框,选中画一个白色的对勾。代码量不大,但视觉亲和力提升明显。
半选状态一定要处理。当用户全选后再取消其中一个,顶部全选框应该显示为半选(横杠),而不是空框。很多 Demo 只实现了全选/全不选,这个状态边界很容易漏。判断逻辑我写在上面了,isPartialSelected 为 true 时全选框画横杠,同时点击全选框的行为是"全部选中"而不是"取消全选"。
4.3 跨页全选与顶部栏的联动
当列表数据量过千,就需要分页。批量删除最容易被吐槽的点是"全选"只选了当前页,用户误以为全选了所有数据,结果删完发现还剩几百条。
我的做法是在顶栏全选按钮上做区分:如果当前页已经全选,再把按钮点一次,弹一个底部动作条询问"是否选择全部 N 项"。这里 N 通过接口返回的 total 字段得知。如果用户确认,实际执行删除时后端按条件删除,前端清空整个列表;如果取消,保持当前页全选状态。
这里有个隐藏坑:Flutter 底部弹窗在 OpenHarmony 上如果页面有正在播放的 AnimationController 隐藏可能会有掉帧。做这种弹层交互时,先关闭列表项所有动画,再 showModalBottomSheet。实测这个顺序能规避部分 rk3568 上的画面残影问题。
批量删除的销毁逻辑也要留心。删除前把选中的 id 列表拷贝出来,调用接口成功后按 id 逐条从本地列表数据源里移除。逐条移除虽然 O(n²),但数据结构层级不深时性能完全够用,而且能保证列表项索引不乱。如果直接整表重新赋值,ListView 的差量更新在某些版本上会闪一下,体验不好。
4.4 批量操作栏的交互细节
底部批量操作栏建议固定高度 56,背景用毛玻璃效果。注意,OpenHarmony 的 Flutter 引擎对 BackdropFilter 的支持虽然已经可用,但模糊半径太大会导致列表滚动时掉帧。毛玻璃半径控制在 20 以内,且在批量操作栏这种静态元素上使用,不要拿它包整个底部导航。
操作栏的按钮要按重要性排序:全选在左,删除在右。删除按钮在选中数为 0 时置灰且不可点击。这个置灰状态不能只是改颜色,还要去掉点击事件,否则用户点空删除按钮弹个空 Toast 显得很蠢。
5. 在 OpenHarmony 不同设备上的适配与调优
5.1 不同 CPU 型号的性能差异
实际项目里跑过 rk3568 和 rk3588 两种设备,差异比想象中大。rk3568 的 GPU 相对弱,列表项在做侧滑动画和批量删除动画时,帧率偶尔会掉到 45fps 左右。调优手段方向就两个:减少动画期间的布局计算,减少不必要的重绘。
动画期间减少布局计算,核心是保证侧滑时不要触发列表项内部的文本量变化。比如删除按钮上的文字如果是"删除"这种固定文案,Text 不要创建两次;将按钮的内容做 const 构造,动画只改变 Transform.translate 的偏移值,不触发子树重建。
另外一个很实用的技巧是开启 RepaintBoundary。每个列表项的内容层包一层 RepaintBoundary,当 Transform 做位移动画时,引擎会缓存内容层为纹理,只把纹理挪动位置,不需要重新走一遍组件树的 layout 和 paint。这个优化天然适合"侧滑"这种整块内容平移的场景。
dart复制Widget build(BuildContext context) {
return RepaintBoundary(
child: Stack(
children: [
_buildActionPanel(),
RepaintBoundary(
child: Transform.translate(
offset: Offset(-_actionWidth * _controller.value, 0),
child: _buildCard(),
),
),
],
),
);
}
注意:不是所有地方都适合套 RepaintBoundary。用多了会占用大量 GPU 内存,在 rk3568 这种设备上反而会因为纹理数量太多触发闪烁。只需要给列表页顶部的编辑态容器和侧滑容器这类高频变化的组件加,普通文本列表不需要。
5.2 深色模式与主题色适配
OpenHarmony 的深色模式切换和 Android 不完全一样,部分设备的三方应用拿不到系统深色模式切换回调。Flutter 端通常依赖 MediaQuery.platformBrightnessOf 来感知,但在某些鸿蒙设备上这个值不会实时刷新。我的处理方案是:增加一个页面内的主题状态,App 启动时读取本地配置,不跟随系统自动切换;这样虽然牺牲了一点"自动跟随"的体验,但至少不会出现在深色模式列表下,白卡片配黑文字这种对比失衡。
5.3 列表空状态的编排
批量删除完了列表会空。空态别只给一行"暂无数据",至少要给个图标加一句引导语,配合一个"刷新"按钮。这个需求产品经理大概率不会写在最初的需求文档里,但做列表模块的都知道,处理完空态,这个功能才算真正闭环。
6. 踩坑记录:OpenHarmony 移植版上那些奇奇怪怪的问题
6.1 列表项点击穿透
开发中遇到最诡异的问题是:侧滑菜单展开后,点击菜单下面的列表项区域,会同时触发菜单按钮的点击事件和下方面列表项的点击事件。排查下来发现是 OpenHarmony 的 Flutter 引擎命中测试在处理 Stack 层级时,对 Transform.translate 后的区域判定与标准 Flutter 略有差异。
解决方式是在菜单展开时给上层内容卡片加一个 IgnorePointer,展开状态下内容层不响应点击,这样就不会穿透到菜单按钮了。这个方案简单粗暴,而且符合直觉——菜单展开时用户点内容区本来就该关闭菜单,而不是触发内容区本身的点击。
6.2 动画回弹不跟手
另外一个问题:快速滑动列表项后松手,AnimationController 的 animateTo 在目标值接近当前值时,动画时长会按比例缩短。本来该有的 180ms 回弹变成一帧跳到位,视觉上生硬。这是因为 animateTo 内部按距离比例压缩时长。改法是在松手后固定时长执行动画,不管起点在哪:
dart复制_controller.animateTo(
target,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
如果你因为动画时长被压缩的问题想换 animateBack,建议不要;animateBack 在 OpenHarmony 引擎上有极低概率报 ticker 未取消的异常,排查起来很费劲。
6.3 批量删除后的列表滚动位置漂移
删除中间几条数据后,列表会自动调整滚动偏移量。但 Flutter 的 ListView 在 itemCount 变化后不会主动维持原先可视区域的锚点,导致用户看到列表"自己跳了一下"。
处理方式是删除前记录当前列表的 scroll offset,删除后跳回原来的位移。如果想要更精细的效果,可以在删除完成后判断首个可见项的索引和偏移,手动用 ScrollController.jumpTo 恢复。批量删除的场景下,操作后回到顶部往往比维持原滚动位置更符合预期,产品没有特殊要求的话直接回到顶部就行。
6.4 常见问题速查
| 问题 | 可能原因 | 处理方式 |
|---|---|---|
| 侧滑菜单展开后点击内容区不收起 | 内容层点击事件被按钮吃掉 | 用 TapRegion 监听区域外点击 |
| 快速滑动列表时偶尔出现水平震动 | 手势判定阈值太小 | 把水平接管判定阈值从 8 提到 12 |
| 批量删除几十条后列表闪烁 | 数据源没有用拷贝直接 state 修改 | setState 前先 copy List |
| 菜单按钮背景色在深色模式下显示异常 | 用了硬编码色值 | 改用 ColorScheme 系列色板 |
| rk3568 上全选动画掉帧 | 全选时每个 item 同时 rebuild | 拆分复选框状态,只更新选中项 |
| SnackBar 位置偶发偏移 | 移植版安全区计算 bug | 自绘底部撤销条替代 |
7. 实现过程中我的一些体会
项目最后跑通的那一刻,最大的感受是:OpenHarmony 上做 Flutter 开发,最费劲的不是适配 API,而是把交互的"手感"调到跟原生一致。这些观感层面的问题,单测测不出来,真机对比才能发现。
如果你是从零开始接这个需求,我的建议是先别写代码,拿着装有原生鸿蒙应用的设备列表页划几下,感受侧滑的阻力、回弹的阻尼、按钮的间距,然后把这些感觉量化成代码里的数值:位移阈值设多少、动画时长设多少、菜单展开后的遮罩透明度多少。这些参数没有标准答案,不同分辨率设备差别也大,但方向上一定是"宁可轻快一些,不可拖泥带水"。
这个功能后续还能做的事其实很多:比如滑出菜单后支持继续上滑/下滑,或者在批量操作栏里接入移动分组、导出数据这类企业级功能。底层的滑动手势容器设计得足够独立,往上加都很方便。项目的核心难点不在某个 API 用没用对,而在于能不能用手势控制器把"用户操作——列表反馈"这条链路打磨得足够顺滑,这条路走通了,其他交互模块也可以照这个思路来。
