08|HarmonyOS资料板拖拽会话释放与历史撤销【共创稿事节】
资料板里的“配电柜现场.jpg”从待整理栏移进设备照片栏后,用户最容易观察到的是卡片位置变了。真正需要关心的还有两件事:拖拽过程创建的悬停态、预览图与临时目标信息是否全部清理;如果用户发现分类错误,撤销能否准确地把这张卡片放回原来的位置。前者关乎下一次操作是否受到上一段手势污染,后者关乎资料板能否从错误编辑中恢复,而不是把撤销理解为再拖动一次。
前七篇已经为现场电气柜资料板确定了稳定cardId、附件类型与来源状态、有效投放区、跨栏位置计算、由BoardStore集中提交的状态更新,以及键鼠和无障碍操作入口。本篇要收束同一条工程主线:所有入口都产生可识别的业务移动命令,而临时拖拽会话随操作结束释放;每个已经提交的命令形成一条可逆历史。撤销不是直接替换页面数组,必须检查记录所依赖的资料板版本和卡片当前位置。

临时会话与持久记录分开保存
一次拖拽会话包含被拖起的卡片、来源栏位、当前经过的目标栏、悬停目标序号、临时预览内容以及会话状态。它们只在手势持续期间有意义。相比之下,资料卡片属于哪个栏位、排序位置是多少、板面修订号是多少,是持久业务数据。若把hoverLane直接写到BoardStore,指针每经过一个栏位都会成为一次业务修改;用户按返回取消时,又需要把一串根本不该提交的变化逐一回滚。
拖拽源负责开始会话并提供cardId,目标区域只描述准备接收的位置,最终提交仍交给BoardStore。结果无论是投放成功、投放失败、用户取消、组件卸载还是页面切换,都会进入同一套释放逻辑。释放意味着清空会话令牌、悬停状态、预览引用和相关定时器;已经提交的历史记录不能一起删掉。组件如果需要过渡动画,可以保留单独的视觉完成标志,但不得以此决定业务卡片是否移动。
下面的会话管理器使用普通JavaScript表示边界。每次开始分配新的会话标识,旧异步回调必须携带原标识才能更新当前会话。finish是幂等动作,多次进入也不会重新提交业务命令。实际ArkUI事件回调只需映射成这些方法,不把业务规则绑死在某个组件事件上。
const assert = require('node:assert/strict');
class DragSession {
constructor() { this.serial = 0; this.current = null; }
start(cardId, sourceLane) {
if (this.current) this.finish(this.current.id);
const id = ++this.serial;
this.current = { id, cardId, sourceLane, hoverLane: null,
targetIndex: null, timer: null };
return id;
}
hover(id, lane, index) {
if (!this.current || this.current.id !== id) return false;
this.current.hoverLane = lane;
this.current.targetIndex = index;
return true;
}
finish(id) {
if (!this.current || this.current.id !== id) return false;
if (this.current.timer) clearTimeout(this.current.timer);
this.current = null;
return true;
}
}
const session = new DragSession();
const oldId = session.start('photo-01', 'pending');
const nextId = session.start('photo-02', 'photos');
assert.equal(session.hover(oldId, 'photos', 1), false);
assert.equal(session.finish(nextId), true);
assert.equal(session.finish(nextId), false);
其中最值得保留的是迟到事件隔离。假如页面A发起拖拽后切到了另一张资料板,旧页面的结束回调才抵达,它不能清掉新页面正在使用的会话。会话令牌可以与boardId、页面实例标识一起检查;切换工程时主动使旧令牌失效。只有通过校验的最终投放事件才能形成真正的移动命令,悬停高亮本身没有改动板面权限。

一条撤销记录需要保存哪些信息
资料板中的移动涉及来源栏、来源序号、目标栏、目标序号,以及提交时的板面版本。只存“把照片从待整理移到设备照片”远远不够:待整理栏随后新增一张卡片,原序号可能已经改变;设备照片栏也可能重排,按过期序号执行反向移动会把别的卡片挤到错误位置。较稳妥的做法是保存稳定cardId与移动前后的邻居锚点,在可以确定当前布局与历史记录相容时才计算新的插入位置。
撤销记录应是业务命令的逆操作描述,而不是页面渲染树的快照。它记录boardId、cardId、beforeLane、beforeIndex、afterLane、afterIndex、afterRevision等字段。资料卡在移动后还被别人改过分类,撤销必须先检查当前板面版本;一旦版本不匹配,进入人工确认或重新计算路径,不允许悄悄覆盖别人的修改。这里的版本号属于BoardStore自有的状态协议,与系统拖拽事件版本无关。
下面通过纯函数完成一次跨栏移动,确保只修改一次逻辑状态,并输出可用于撤销的操作记录。真实页面里的数组只从返回的新状态渲染,组件自身不会再次调整卡片顺序。此处为方便看清索引,用小型数组表示栏位;大列表采用锚点定位时,仍应坚持“先核对身份再计算位置”的原则。
const assert = require('node:assert/strict');
function moveWithHistory(board, cardId, targetLane, targetIndex) {
const lanes = Object.fromEntries(Object.entries(board.lanes)
.map(([name, cards]) => [name, cards.slice()]));
const sourceLane = Object.keys(lanes).find(name => lanes[name].includes(cardId));
if (!sourceLane || !lanes[targetLane]) throw Error('unknown card or lane');
const beforeIndex = lanes[sourceLane].indexOf(cardId);
lanes[sourceLane].splice(beforeIndex, 1);
const adjusted = sourceLane === targetLane && beforeIndex < targetIndex
? targetIndex - 1 : targetIndex;
const afterIndex = Math.max(0, Math.min(adjusted, lanes[targetLane].length));
lanes[targetLane].splice(afterIndex, 0, cardId);
const next = { revision: board.revision + 1, lanes };
return { next, history: { cardId, sourceLane, beforeIndex,
targetLane, afterIndex, afterRevision: next.revision } };
}
const initial = { revision: 9, lanes: {
pending: ['photo-01', 'form-01'], photos: ['photo-02'], records: []
} };
const result = moveWithHistory(initial, 'photo-01', 'photos', 1);
assert.deepEqual(result.next.lanes.photos, ['photo-02', 'photo-01']);
assert.equal(result.history.afterRevision, 10);
无论通过手指、鼠标还是键盘菜单移动卡片,最终都调用同一条移动命令,因而可以共享历史记录结构。第七篇补充的无障碍提示也可以读取这条记录,向用户说明“配电柜现场图片已移至设备照片第2项”,而不是只广播一个无上下文的完成状态。这样,撤销之后恢复焦点也有了稳定参照:优先定位相同的cardId,再落到其所在栏的可操作控件。
撤销产生新版本,不回拨版本计数
假设资料板从版本9移动到了版本10,撤销之后板面内容可能重新接近版本9,但它本身是一条新的修改,应该生成版本11。直接把版本号改回9会破坏历史的单调性,也让随后到达的版本10事件误以为仍能合法覆盖。撤销前还要检查卡片仍在移动记录所述的目标栏与序号;若后续操作改变了它的位置,就停止自动撤销,并提示用户查看冲突。
重做也不是对一条旧事件不加检查地重新投放。正常撤销后,可以保存一个与当前版本相绑定的重做候选;如果此后新增了其他移动或附件绑定操作,就清除旧重做栈。这样可以避免操作历史分支交错。对新增附件、删除卡片或更换失效资源等不可简单逆转的业务动作,不应复用移动卡片的撤销规则,而应定义各自的可逆条件。
const assert = require('node:assert/strict');
function undoMove(board, h) {
if (board.revision !== h.afterRevision) return { ok: false, reason: 'STALE' };
const lanes = Object.fromEntries(Object.entries(board.lanes)
.map(([k, arr]) => [k, arr.slice()]));
const inTarget = lanes[h.targetLane]?.indexOf(h.cardId) ?? -1;
if (inTarget !== h.afterIndex) return { ok: false, reason: 'MOVED' };
lanes[h.targetLane].splice(inTarget, 1);
const index = Math.min(h.beforeIndex, lanes[h.sourceLane].length);
lanes[h.sourceLane].splice(index, 0, h.cardId);
return { ok: true, next: { revision: board.revision + 1, lanes },
redo: { cardId: h.cardId, targetLane: h.targetLane,
targetIndex: h.afterIndex, expectedRevision: board.revision + 1 } };
}
const board = { revision: 10, lanes: {
pending: ['form-01'], photos: ['photo-02', 'photo-01'], records: []
} };
const h = { cardId: 'photo-01', sourceLane: 'pending', beforeIndex: 0,
targetLane: 'photos', afterIndex: 1, afterRevision: 10 };
const undone = undoMove(board, h);
assert.equal(undone.ok, true);
assert.equal(undone.next.revision, 11);
assert.deepEqual(undone.next.lanes.pending, ['photo-01', 'form-01']);
页面离开时释放什么,保留什么
拖拽会话对象、预览图持有者、悬停定时器、候选投放区域、自动滚动计时器都属于短期资源。结束拖拽后应立即释放,页面离开时还要设置失效标记,拒绝之后到达的回调。已经提交的BoardStore状态、附件元信息和必要的操作历史则属于业务数据,不能因为页面卸载就被清空。如果预览持有了较大的图片对象,应该在取消或完成时尽早解除引用,避免多次整理后界面占用逐步增大。
操作历史也需要边界。无限制把所有卡片移动保存为完整板面副本,会占用越来越多存储空间;只保存操作的最小逆向字段、合理限制可撤销步数,并保留必要的业务审计信息更合适。撤销栈满时可以淘汰较旧的交互撤销项,但已发生的合规审计记录是否可以删除,应遵循独立的数据管理策略,而不能与UI撤销栈的空间回收混在一起。

检查顺序要覆盖真正棘手的竞态
联调时建议把四类边界放在一起看。第一类是松手后连续收到多个结束回调,业务变更仍应只出现一条;第二类是取消后旧悬停事件迟到,新会话不能受影响;第三类是移动提交之后另一组件修改了板面,再按撤销必须发现版本不一致;第四类是用户撤销后又新增编辑,重做入口必须失效。与其只看拖拽动画,不如检查cardId在所有栏位中恰好出现一次、boardRevision单调增加以及历史记录对应真实提交。
对手机、平板和带键鼠的设备,事件触发形式会变化,但业务命令应一致。用户从卡片菜单选择“移到设备照片”,也必须经过类型过滤、目标栏版本检查和BoardStore提交,再进入撤销历史;不能因为绕开了拖拽手势就少做一次校验。系统层面可参考华为开发者拖拽交互设计文档以及ArkUI Drag Event API参考,具体回调绑定时应结合目标SDK文档核对事件可用性。这里的BoardStore、会话令牌和历史记录是应用自己的数据协议。
资料板连载到这里,已经把“拖得动一张卡”延伸成了一条可维护的数据交接链。卡片身份与类型决定能不能放,位置计算决定放在哪里,集中状态更新避免多组件各说各话,文件来源失效有重新关联路径,键鼠用户也有等效入口。最后再把会话释放与历史撤销接上,才能避免一段短暂的拖拽过程在下一次操作中留下隐患,也让一次分类错误有安全、清晰的恢复方式。
更多推荐



所有评论(0)