01|HarmonyOS现场资料板拖拽标识与投放语义【共创稿事节】
现场维保人员整理资料时,常把配电柜照片、巡查表和整改记录放到同一张资料板上。大屏里摆三列卡片,看上去只要按住卡片拖到另一列就能完成归类。真正容易出错的地方并不是拖拽动画:照片卡片被放到“巡查表”栏,列表仍然接受了它;用户从右边拖回左边,卡片显示重复;快速切换项目后,上一项目的投放事件又把新页面的数据改了。
这次先确定拖拽的身份与投放语义,不急着处理文件内容。工程采用同一个“现场资料板”:左列是“待整理”,中间为“设备照片”,右列是“巡查记录”。每张卡片都有独立itemId、boardId、kind、columnId和sortKey。这些信息属于资料板自身的业务模型。后续扩展附件元信息、跨组件数据交接、历史撤销,都不能让屏幕坐标或卡片标题取代这些字段。

拖起来的是卡片,不是整段展示文字
假设有一张“配电柜A巡查照片”,来源于当日巡检,原本在待整理栏。用户将它拖向设备照片栏时,拖拽预览可以显示缩略图和标题,但投放处理需要的是itemId和一次性拖拽代际。标题会改,图片可能换,视觉位置也可能因窗口伸缩变化,因此不能把标题或屏幕上的第几个卡片当作身份。后端或本地状态中的itemId才有长期意义。
同一应用内的拖拽也不该等同于“任意组件间复制文本”。第一轮只处理资料板内部的归类移动:来源与目标必须属于同一boardId,接收区必须允许该卡片类型,移动后原列不再包含该卡片。将来如果真的要跨应用接收文件,还需要单独处理统一数据格式、数据权限、附件失效和资源生命周期。这些条件不能靠onDrop事件名称自动满足。
让资料项和一次拖拽分开建模
资料项创建时获得稳定ID,随后可以反复被移动。一次拖拽则是临时会话,保存sessionId、源列、板ID及来源卡片的版本,投放完毕立即作废。这样,触摸按住失败、用户中途放弃、目标栏不接收,都不会误删资料项。应用重启以后只恢复资料项,不恢复上一轮仍悬空的拖拽会话。
要解决的问题是组件拿到同一个对象引用后,可能在拖动中修改原始卡片的列ID,导致取消时无法知道原位置。下面先构造不可变的业务载荷,只包含执行移动所需的最小字段;不要将完整文件字节、私有路径或可编辑对象放进拖拽载荷。
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
function beginBoardDrag(item, boardId) {
if (item.boardId !== boardId) throw new Error('资料板已切换');
if (!['photo','report'].includes(item.kind)) throw new Error('类型不受支持');
return Object.freeze({
sessionId: randomUUID(), boardId,
itemId: item.itemId, kind: item.kind,
fromColumn: item.columnId, fromRevision: item.revision
});
}
const card = { itemId:'CARD-17', boardId:'site-east', kind:'photo',
columnId:'inbox', revision:3 };
assert.equal(beginBoardDrag(card, 'site-east').fromColumn, 'inbox');
拖拽载荷中不包含卡片文本、文件地址,也没有把被拖卡片从来源列表提前删除。这里的临时sessionId用于排除重复的投放事件,而itemId承担长期身份职责。把二者合并会导致一次取消拖拽就像删除卡片,或是重新拖一次便创建新的资料项。会话标识的生成可以按应用需要选UUID;不能用事件触发时间代替,因为连续触摸可能共享很接近的时间点。

投放区域必须知道自己接收什么
视觉上把列边框点亮,仅说明指针进入了区域,并不意味着业务可以接收。设备照片栏要求kind=photo,巡查记录栏要求kind=report,待整理栏可接收两类资料。将接收规则写成一份白名单,可以让触控、鼠标与键盘替代操作共用同一套决策,避免不同输入方式发生不同结果。新加“待复核”栏时,只扩充规则表,而不用到处复制条件表达式。
下面是投放区域判定的应用层函数,明确区分成功移动、类型拒绝、跨板拒绝和已失效会话。acceptDrop只返回决定,不直接修改列表。这一点很关键:UI可以用这个结果绘制边框、提示原因或取消高亮,最后由一个提交入口负责真正更新资料板。
const assert = require('node:assert/strict');
const accepted = {
inbox: new Set(['photo','report']),
photos: new Set(['photo']),
reports: new Set(['report'])
};
function acceptDrop(payload, target, activeSession) {
if (!payload || payload.sessionId !== activeSession) return 'stale-session';
if (payload.boardId !== target.boardId) return 'cross-board';
const kinds = accepted[target.columnId];
if (!kinds || !kinds.has(payload.kind)) return 'unsupported-kind';
return 'accepted';
}
const payload = { sessionId:'moving', boardId:'site-east', kind:'photo' };
assert.equal(acceptDrop(payload, { boardId:'site-east', columnId:'photos' }, 'moving'), 'accepted');
assert.equal(acceptDrop(payload, { boardId:'site-east', columnId:'reports' }, 'moving'), 'unsupported-kind');
目标栏名称只是展示标签,代码使用稳定列ID。若某列需要权限或只读状态,应在acceptDrop之后附加权限和项目阶段判定:例如归档后的工程不再允许修改,某些资料需要先经负责人确认。拒绝投放时应原样保留来源记录,并告诉用户具体原因;让UI悄悄恢复原位虽然减少提示,却容易让人误以为移动成功。
一个投放动作只修改一次状态
组件回调可能多次触发,用户也可能在拖动过程中看到目标栏重排。提交时应重新从当前资料板快照读取卡片,检查卡片是不是还存在、修订是否变化、来源列是否仍然一致。不要直接信任拖拽开始时保存的整张卡片。如果有人在另一窗口提前归类,旧拖拽需要被拒绝并提示刷新,不能把人家的修改盖回去。
接收成功也不能直接在目标数组push一次便结束。原列必须同步移除,目标列必须恰好增加一项。最简单的做法是把卡片位置当作数据本身,只维护一份items映射,再按columnId和sortKey生成列视图。这样跨列移动不需要保留三份可写数组,重复回调也不会制造重复卡片。下一篇再进一步讨论附件类型与元信息。
const assert = require('node:assert/strict');
function moveCard(board, payload, targetColumn) {
if (board.id !== payload.boardId) throw new Error('资料板不一致');
const old = board.items[payload.itemId];
if (!old || old.revision !== payload.fromRevision) throw new Error('资料项已变化');
if (old.columnId !== payload.fromColumn) throw new Error('来源位置已变化');
if (!['inbox','photos','reports'].includes(targetColumn)) throw new Error('目标栏不存在');
if (old.columnId === targetColumn) return board;
const next = { ...old, columnId: targetColumn, revision: old.revision + 1 };
return { ...board, version: board.version + 1,
items: { ...board.items, [old.itemId]: next } };
}
const board = { id:'site-east', version:7, items: {
'CARD-17': { itemId:'CARD-17', columnId:'inbox', revision:3 }
} };
const moved = moveCard(board, { boardId:'site-east', itemId:'CARD-17',
fromRevision:3, fromColumn:'inbox' }, 'photos');
assert.equal(moved.items['CARD-17'].revision, 4);
assert.equal(Object.keys(moved.items).length, 1);
提交时仍需要调用前面的白名单判定,不能因为moveCard知道目标列存在就绕过类型规则。实际工程可把校验与更新封装在同一个状态动作中,同时携带原始版本,减少跨组件竞争。若希望支持撤销,应在成功移动后记录操作前后的列ID与修订,且只允许符合当前版本条件的撤销;这些属于后续篇章的主题,不应在第一轮就用全局可变数组草率实现。
平台拖拽事件与业务动作有明确分工
ArkUI有拖拽相关事件,开发者可以在组件触发拖动时建立会话、进入目标区域时呈现接收反馈、投放时执行上述规则。华为开发者的拖拽事件参考列出onDragStart、onDragEnter、onDragMove、onDragLeave和onDrop等事件;拖拽交互设计介绍了应用内移动和复制的区别。正式接入时仍要根据目标API版本核对事件参数和组件支持条件。
拖拽预览负责告诉用户“正在拿起哪张卡片”,接收区边框负责表达“这里可能接受”,最终业务校验负责决定“是否真的移动”。这三个阶段不要互相替代。尤其不能只靠悬停颜色改变数据:鼠标经过目标栏而后离开,来源卡片应保持原状。投放被拒绝时,清掉当前临时会话及高亮;投放成功时,先提交状态,再让UI根据新快照重新绘制。涉及跨应用数据交换时还要核对统一数据类型与数据交互文档中的场景条件。

排查拖拽异常,先看身份和判定结果
如果发生一拖就多出两张卡片,优先检查列表是否直接操作了可变数组、itemId是否重建,以及同一会话的onDrop是否重复提交。如果照片被放到了巡查记录栏,检查目标列的白名单和投放时的再次校验,而不是只调整按钮颜色。若切换工地后卡片突然回到了原项目,检查旧会话携带的boardId是否与当前板一致、来源修订是否过期。
更容易漏掉的是操作中断后的状态清理。用户按返回、缩小窗口或目标栏销毁时,临时拖拽会话应失效;投放完成后不应继续显示上一次悬停高亮。可以分别记录“请求投放的目标列”“白名单判定结果”“实际写入的卡片版本”,诊断时就能区分事件没到达、业务拒绝与更新覆盖,不需要从一串屏幕坐标猜测原因。
第一篇最终只确立两条秩序:资料项由稳定ID标识,拖拽动作由短期会话标识;目标区域表达业务接收规则,而不是一个恰好被指针命中的矩形。带着这个结构进入下一篇,处理图片、表单等附件资源时,类型过滤和来源授权才能落在明确的业务字段上,而不会污染资料板的长期身份。
更多推荐

所有评论(0)