04|HarmonyOS资料板同板重排与跨栏位置计算【共创稿事节】
现场资料板经过前三篇,已经能识别被拖起的资料卡、筛选目标栏可接收的文件类型,并在松手取消时把卡片留在原处。实际排版时又出现一个很容易误判的细节:从“待整理”栏第二张卡拖到本栏末尾,释放后有时只向下挪一格;从“待整理”拖进“设备照片”栏两张图片之间,明明提示插入第二个位置,却被放到了第三个。用户看到的都是位置错乱,根因却不同。
关键区别是拖拽过程中计算的位置和最终写入数组的位置使用了不同的参照系。同栏移动需要先移除源卡,源卡原来占用的位置会消失;跨栏移动则从另一个数组取出卡片,目标栏长度不会因移除源卡而变化。若无论什么情况都直接把悬停序号传给splice,同栏向后拖就会多算一格。

卡片身份不能跟着位置变化
本系列使用同一份现场检修资料板,业务栏是“待整理”“设备照片”“检修记录”。卡片示例包括配电柜现场.jpg、开关柜总览.jpg、巡查记录.pdf和设备清单.xlsx。第二篇已记录名称、MIME类型、大小和来源,第三篇给拖拽建立会话代际并处理取消。第四篇继续复用稳定cardId、laneId和boardVersion,只新增位置计算逻辑。
同一文件在资料板中的身份由卡片标识决定,index只表示当前某栏中的排序位置。用户把“配电柜现场.jpg”从第零位移动到第三位,卡片标识、附件来源和修改时间不应跟着变;真正变化的是所属栏位、位置和资料板版本。如果用laneId + index当主键,每次重排就相当于删除旧对象、创建新对象,之前与这张卡关联的审核状态、备注和附件访问情况都会失去可靠锚点。
落点采用一种明确的语义:targetIndex表示释放之前目标栏的插入间隙编号。设某栏有A、B、C、D四张卡,间隙编号依次为零到四。拖动B到D后面,目标间隙编号是四;先移除B后,剩下A、C、D,正确插入位置是三。跨栏时不需要减一,因为源卡并不在目标栏数组里。拖拽位置和最终下标既然不能混用,就由领域函数集中处理,而不让每个卡片组件单独计算。
const assert=require('node:assert/strict');
function moveCard(lanes, cardId, targetLane, targetIndex) {
const copy=Object.fromEntries(Object.entries(lanes).map(([k,v])=>[k,[...v]]));
const sourceLane=Object.keys(copy).find(k=>copy[k].some(x=>x.id===cardId));
if(!sourceLane || !copy[targetLane]) throw Error('unknown source or target');
const sourceIndex=copy[sourceLane].findIndex(x=>x.id===cardId);
const originalTargetSize=copy[targetLane].length;
if(!Number.isInteger(targetIndex)||targetIndex<0||targetIndex>originalTargetSize) throw Error('bad gap');
const [card]=copy[sourceLane].splice(sourceIndex,1);
const adjusted=sourceLane===targetLane && sourceIndex<targetIndex ? targetIndex-1 : targetIndex;
copy[targetLane].splice(adjusted,0,card);
return {lanes:copy,sourceLane,sourceIndex,adjusted};
}
const initial={todo:[{id:'A'},{id:'B'},{id:'C'},{id:'D'}],photos:[{id:'E'}]};
assert.deepEqual(moveCard(initial,'B','todo',4).lanes.todo.map(x=>x.id),['A','C','D','B']);
assert.deepEqual(moveCard(initial,'B','photos',1).lanes.photos.map(x=>x.id),['E','B']);
这个函数不依赖任何UI拖拽回调,参数从目标区域计算后输入即可。它返回sourceIndex和adjusted,供界面展示插入标记,也方便记录一次移动前后的精确差异。若目标栏不存在、目标间隙越界或者原卡片找不到,函数抛出明确错误,资料板应保持原样,而不是让组件自己猜测应该回到哪个位置。
鼠标坐标需要先转换成间隙
第二个容易出错的地方在悬停检测。目标栏里可能存在不同高度的卡片:现场照片含缩略图,PDF只有文件图标,描述较长的记录卡甚至会换行。用“当前位置除以固定卡片高度”来计算目标序号,会在卡片尺寸变化后明显偏移。布局回调提供的实际矩形位置更适合转换为间隙:指针位于某卡中线以上,插在它前面;位于中线以下,继续和下一张卡比较;越过所有卡片,插入末尾。
下面把屏幕纵向坐标变成目标间隙编号。矩形由实际布局测量后提供,函数不依赖触屏还是鼠标事件。空栏没有卡片,返回零;滚动容器中的坐标应先统一到同一个视图坐标系,不能把相对屏幕坐标与内容坐标直接相减。
const assert=require('node:assert/strict');
function gapFromPointer(pointerY, cards) {
if(!Number.isFinite(pointerY)) throw Error('invalid pointer');
const ordered=[...cards].sort((a,b)=>a.top-b.top);
for(let i=0;i<ordered.length;i++) {
const r=ordered[i];
if(!Number.isFinite(r.top)||!Number.isFinite(r.height)||r.height<=0) throw Error('invalid rectangle');
if(pointerY < r.top+r.height/2) return i;
}
return ordered.length;
}
const bounds=[{top:80,height:100},{top:196,height:172},{top:386,height:80}];
assert.equal(gapFromPointer(110,bounds),0);
assert.equal(gapFromPointer(270,bounds),1);
assert.equal(gapFromPointer(500,bounds),3);
assert.equal(gapFromPointer(42,[]),0);
滚动时还存在一层时效性:用户拖动过程中滚动了目标栏,之前测量的矩形已经过期。可以在滚动完成或者布局更新后重算缓存,检测到布局代际不一致则先停止提交、刷新插入线。对于横向多栏的资料板,也要根据指针的横坐标确定唯一目标栏,不应在两个栏都高亮时接收一次释放事件。悬停提示应和提交判定调用同一套接收规则,图片不能插进只收文档的栏,不能让绿框提示与最终拒绝相互矛盾。

数据写回要有版本与操作身份
只算对下标还不够。同一资料板可以从侧栏菜单重命名文件,也可以在另一个组件删除卡片。拖拽开始时记下的卡片仍可能在释放前失效。提交移动时,应根据稳定cardId重新定位当前源栏,并校验boardVersion是否仍为拖拽开始时读取的版本;如果发生变化,让用户重新确认目标位置,比盲目覆盖最新排序可靠。
这里为资料板建立一个应用层版本闸门:一次移动对应一个actionId。同一释放事件由卡片自身和栏容器各触发一次时,相同操作号只能提交一遍;前一次已提交的响应重复到达也只返回已有结果。这个逻辑与系统拖拽结束结果属于不同层次,不能指望某个UI回调替业务自动去重。
const assert=require('node:assert/strict');
class BoardState {
constructor(lanes){this.lanes=lanes;this.version=6;this.completed=new Map();}
apply(action, move) {
if(this.completed.has(action.actionId))return this.completed.get(action.actionId);
if(action.expectedVersion!==this.version) return {status:'stale',version:this.version};
const result=move(this.lanes,action.cardId,action.targetLane,action.targetIndex);
this.lanes=result.lanes;this.version++;
const receipt={status:'committed',version:this.version,position:result.adjusted};
this.completed.set(action.actionId,receipt);
return receipt;
}
}
function simpleMove(lanes,id,to,pos){
const x=Object.fromEntries(Object.entries(lanes).map(([k,v])=>[k,[...v]]));
const from=Object.keys(x).find(k=>x[k].includes(id));
if(!from || !x[to]) throw Error('missing card or lane');
const i=x[from].indexOf(id);x[from].splice(i,1);
const dest=Math.max(0,Math.min(pos-(from===to && i<pos?1:0),x[to].length));
x[to].splice(dest,0,id);return {lanes:x,adjusted:dest};
}
const board=new BoardState({todo:['photo-1','sheet-1'],photos:['photo-2']});
const action={actionId:'move-41',expectedVersion:6,cardId:'photo-1',targetLane:'photos',targetIndex:1};
assert.equal(board.apply(action,simpleMove).status,'committed');
assert.equal(board.apply(action,simpleMove).version,7);
assert.deepEqual(board.lanes.photos,['photo-2','photo-1']);
服务层更新成功后,三栏卡片的计数、排序和视觉高亮应从同一份新版资料板状态派生,不应先在来源组件删卡,等目标组件异步插入后再更新总数。否则两个组件的渲染时机稍有不同,就会短暂出现卡片消失或重复。移动失败时保留原状态,清理插入线和悬停提示;取消拖拽时不产生版本变更。开发中要区分“已经算出目标间隙”和“已经完成业务写入”这两个时点。

从一个易错例子做排查
可以把重排过程的关键值按次序记录下来:拖拽开始的boardVersion、卡片cardId、sourceLane、sourceIndex;经过接收类型过滤得到的targetLane;由布局矩形算出的targetIndex;提交时修正后的adjusted与新版本。看到“移动偏一位”,先确认目标间隙是按移除前还是移除后计数;看到“卡片跑到错误栏”,再检查坐标系和目标栏命中。两类错误不应都靠增加延时来处理。
还需对四种边界输入作检查:同栏移动到自己前后的间隙、把最后一张卡放到第一位、目标栏为空、滚动期间释放。并发修改造成的版本不匹配要展示“资料板内容已变化,请重新放置”,不是悄悄提交到旧位置。系统拖拽事件的使用条件、目标区反馈和交互流程,可结合华为开发者拖拽交互设计文档及ArkUI跨设备拖拽开发指南阅读;本篇的位置算法是应用数据结构自身的职责。
从这一篇起,资料板真正具备了“顺序可解释”的业务语义。卡片身份稳定、间隙计算明确、同栏移动修正一次、提交版本推进一次,后续跨组件交接时才有可靠依据。下一篇会把一次拖拽如何影响源组件、目标组件和共享数据源的问题继续拆开,而不是让每个栏各自维护一套互相矛盾的卡片列表。
更多推荐

所有评论(0)