HarmonyOS 座位重排与稳定身份(连载04)【共创稿事节】
会议名单已经能够按参会者保存独立草稿,但座位一调整,编辑归属仍可能出错。原来排在第一位的陈思远移到第二位后,如果表单继续按下标读取,就会把周明的资料放进当前编辑区。顺序只是排列,身份才决定内容属于谁。座签编辑器需要一份独立的座位序列,让重排只移动身份引用。

先把排列从个人资料里拿出来
NameplateDesk继续沿用前篇GuestRecord、EditorState和个人草稿操作。新增SeatPlan保存ID顺序、顺序修订及操作反馈,EditorState仍只负责名单与活动身份。座位号由当前位置计算,既不写进姓名,也不作为个人资料的主键。这样一个人换座位时,saved与draft都不需要复制到另一个下标。
本篇使用上移、下移按钮表达重排,便于手机和较宽窗口共用同一套业务动作。以后接入拖动手势时,手势只需要把操作转成身份和目标位置,再进入相同的顺序服务;不要在手势回调里直接修改姓名数组。输入方式可以变化,数据规则应保持稳定。
顺序修订只记录实际排列变化。边界处继续上移、传入不存在的身份或无效方向,都返回明确反馈并保留原序列。修订号不是人员身份,也不能拿来替代ForEach的条目键;它为后续发布快照判断排列是否变化提供依据。
重排函数只接受身份和方向
新增 model/SeatOrder.ets。EditorState从前篇完整的RosterEditor.ets导入,个人数据结构保持不变。校验函数要求顺序与名单形成一一对应关系,避免重复身份、遗漏人员或残留已经删除的条目进入后续发布。
import { EditorState } from './RosterEditor';
import { GuestRecord } from './RosterSeed';
export interface SeatPlan {
order: string[];
revision: number;
message: string;
}
export function createSeatPlan(editor: EditorState): SeatPlan {
const plan: SeatPlan = {
order: editor.guests.map((guest: GuestRecord): string => guest.id), revision: 0, message: ''
};
return plan;
}
export function validSeatPlan(editor: EditorState, plan: SeatPlan): boolean {
const ids: string[] = editor.guests.map((guest: GuestRecord): string => guest.id);
return plan.order.length === ids.length &&
plan.order.every((id: string, index: number) =>
ids.indexOf(id) >= 0 && plan.order.indexOf(id) === index);
}
export function moveGuest(plan: SeatPlan, id: string, direction: number): SeatPlan {
const from: number = plan.order.indexOf(id);
const to: number = from + direction;
let message: string = '';
if (from < 0 || !Number.isInteger(direction) || Math.abs(direction) !== 1) {
message = '调整参数无效';
} else if (to < 0 || to >= plan.order.length) {
message = '已到座位边界';
}
if (message.length > 0) {
const rejected: SeatPlan = {
order: plan.order, revision: plan.revision, message: message
};
return rejected;
}
const order: string[] = plan.order.slice();
order.splice(from, 1);
order.splice(to, 0, id);
const next: SeatPlan = {
order: order, revision: plan.revision + 1, message: '座位顺序已调整'
};
return next;
}
这里虽然使用了数组下标,但下标只在一次移动计算中表示位置。对外接口仍传ID,操作完成后也只返回ID序列。不要把“不能用下标作为身份”误解成完全禁止数组位置运算,关键是位置是否越过了它本应承担的职责。
失败分支可以共享原有order,因为服务没有修改它;成功分支先复制再移动,旧计划仍可用于比较。后续增加撤销时,应保存必要的顺序快照和对应修订,而不是引用一个随后会继续变化的数组。当前不可变更新约定为此保留了基础。
把座位号作为派生视图
新增 model/SeatView.ets。SeatRow只是页面需要的派生结果,包含当前座位、已应用姓名与草稿提示。它不拥有个人资料,也不负责切换活动身份。名单与排列不一致时直接报告数据问题,避免静默跳过人员后形成看似连续却缺人的座位表。
import { EditorState, isDirty } from './RosterEditor';
import { GuestRecord } from './RosterSeed';
import { SeatPlan, validSeatPlan } from './SeatOrder';
export interface SeatRow {
id: string;
seat: number;
name: string;
dirty: boolean;
}
export function seatRows(editor: EditorState, plan: SeatPlan): SeatRow[] {
if (!validSeatPlan(editor, plan)) { throw new Error('座位与名单不一致'); }
return plan.order.map((id: string, index: number): SeatRow => {
const guest: GuestRecord | undefined =
editor.guests.find((item: GuestRecord) => item.id === id);
if (guest === undefined) { throw new Error('座位指向未知参会者'); }
const row: SeatRow = {
id: id, seat: index + 1, name: guest.saved.name, dirty: isDirty(guest)
};
return row;
});
}
export function seatRow(editor: EditorState, plan: SeatPlan, id: string): SeatRow {
const row: SeatRow | undefined =
seatRows(editor, plan).find((item: SeatRow) => item.id === id);
if (row === undefined) { throw new Error('座位身份不存在'); }
return row;
}
当前两人样例直接派生整份视图,便于观察关系。名单规模扩大后,可以在名单或顺序修订变化时集中计算,再建立按ID读取的索引,减少每个控件重复查找。优化前应保留相同的校验条件,不能为了加快查找而允许顺序中出现重复身份。
页面排序后仍编辑原来的人
下面替换 pages/Index.ets。保留前篇完整的RosterEditor和前两篇的Nameplate、LayoutFit文件。初始样例将陈思远下移一次,活动身份仍是他,主持称谓仍只保存在草稿中。名单按钮的键使用ID,座位号则每次从当前计划派生。
import { EditorState, createEditor, activeGuest, selectGuest,
editDraft, applyGuest, isDirty } from '../model/RosterEditor';
import { SeatPlan, createSeatPlan, moveGuest } from '../model/SeatOrder';
import { SeatRow, seatRow } from '../model/SeatView';
import { FitResult, chooseFit } from '../model/LayoutFit';
@Entry
@Component
struct Index {
@State editor: EditorState = editDraft(createEditor(), 'name', '陈思远(主持)');
@State seats: SeatPlan = moveGuest(createSeatPlan(this.editor), 'guest-csy', 1);
@State fit: FitResult = { nameFp: 20, fits: false, reason: '等待版面测量' };
private panelWidthVp: number = 0;
private row(id: string): SeatRow {
return seatRow(this.editor, this.seats, id);
}
private recalculate(): void {
this.fit = chooseFit(this.getUIContext(), activeGuest(this.editor).saved,
this.panelWidthVp);
}
private choose(id: string): void {
this.editor = selectGuest(this.editor, id);
this.recalculate();
}
private applyCurrent(): void {
this.editor = applyGuest(this.editor);
this.recalculate();
}
build() {
Scroll() {
Column({ space: 12 }) {
Text('会议座签').fontSize(26).fontWeight(FontWeight.Bold)
Text('座位与身份分离').fontSize(16)
ForEach(this.seats.order, (id: string) => {
Row({ space: 8 }) {
Text(`座位${this.row(id).seat}`).fontSize(16)
Button(this.row(id).name).layoutWeight(1)
.backgroundColor(id === this.editor.activeId ? '#145A44' : '#6F8178')
.onClick(() => { this.choose(id); })
Button('上移').fontSize(14).onClick(() => {
this.seats = moveGuest(this.seats, id, -1);
})
Button('下移').fontSize(14).onClick(() => {
this.seats = moveGuest(this.seats, id, 1);
})
}.width('100%')
}, (id: string) => id)
Text(`当前人:${activeGuest(this.editor).saved.name}`).fontSize(18)
TextInput({ text: activeGuest(this.editor).draft.name })
.onChange((value: string) => {
this.editor = editDraft(this.editor, 'name', value);
})
TextInput({ text: activeGuest(this.editor).draft.role })
.onChange((value: string) => {
this.editor = editDraft(this.editor, 'role', value);
})
Text(isDirty(activeGuest(this.editor)) ? '草稿尚未应用' : '内容一致')
.fontSize(14).fontColor('#8C5A10')
Text('已应用内容').width('100%')
Column({ space: activeGuest(this.editor).saved.role.length > 0 ? 4 : 0 }) {
Text(activeGuest(this.editor).saved.name).fontSize(this.fit.nameFp)
.fontFamily('HarmonyOS Sans').fontWeight(FontWeight.Medium)
.width('100%').textAlign(TextAlign.Center).fontColor('#145A44')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
if (activeGuest(this.editor).saved.role.length > 0) {
Text(activeGuest(this.editor).saved.role).fontSize(14)
.fontFamily('HarmonyOS Sans').fontWeight(FontWeight.Normal)
.width('100%').textAlign(TextAlign.Center).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}.width('100%').aspectRatio(3).padding(8).backgroundColor('#FFFFFF')
.border({ width: 1, color: '#145A44' }).justifyContent(FlexAlign.Center)
.onAreaChange((_old: Area, area: Area) => {
const width: number = Number(area.width);
if (Number.isFinite(width) && width !== this.panelWidthVp) {
this.panelWidthVp = width;
this.recalculate();
}
})
Text(this.editor.message || this.seats.message).fontSize(14)
Button('应用当前人').width('100%').backgroundColor('#145A44')
.onClick(() => { this.applyCurrent(); })
}.width('100%').constraintSize({ maxWidth: 640 }).padding(20)
}.width('100%').height('100%').backgroundColor('#F7F6EF')
}
}

列表条目移动后,已有ID对应的控件可以继续复用。座位数字通过读取seats派生,不能在条目首次创建时缓存index再长期使用。稳定身份和动态位置需要同时表达,否则即使姓名没有串位,座位标注仍可能停留在旧位置。
用相反方向检查状态是否被误改
先把陈思远从第二位上移,再下移回去,预期只有顺序和顺序修订变化。活动ID、个人草稿和已应用姓名保持原样。随后选择周明修改职务,再调整座位,返回周明时草稿仍归他所有。这个操作组合比只检查一个上移按钮更容易暴露位置与身份混用。
边界动作也要观察。第一位上移应提示到达边界,不增加修订;未知ID应保持整份顺序;传入零或非整数方向应被拒绝。失败不是一次有效排列,若仍增加修订,会让发布侧误以为内容发生变化,增加不必要的确认和同步。
名单变更和座位变更采用不同动作

删除人员时,名单和座位序列必须同步协调,不能仅过滤可见列表。若保留一个指向已删除人的活动ID,下一次编辑就会遇到不存在的资料。新增人员时也应明确插入哪个座位,再将新的完整计划交给校验函数,不能依靠缺失项自动追加来掩盖输入问题。
用身份链路解释一次重排
样例中,调整前的序列是陈思远、周明,调整后是周明、陈思远。陈思远的ID始终相同,草稿中的主持称谓也始终归他所有。当前编辑区继续读取activeId对应的记录,因此不需要在移动结束后猜测“刚才编辑的人现在在哪一行”。位置查找只用于座位显示,内容读取只使用身份。
若把整个GuestRecord数组直接排序,也可以得到新的显示顺序,但它会同时改变编辑存储的排列,迫使更多代码理解同一次变化。独立序列让名单存储保持稳定,也方便为同一份参会资料准备不同会场的座位计划。多个计划可以引用同一身份集合,但应各自拥有顺序修订和发布确认。
座位标签应来自当前序列,参会者身份应来自名单。两者同时出现在一行,并不意味着可以互相替代。尤其不要把“座位二”写成条目的ID,否则调换座位就变成给两个人交换身份,草稿、收藏或后续发布回执都会面临归属混乱。
把操作反馈和业务结果分开
到达边界时仍然返回一份带反馈的计划,但其order和revision保持原值。界面可以更新提示,后续比较则能判断排列没有变化。若仅用对象引用判断是否需要发布,这类反馈更新也会被当成内容变化,因此发布侧应比较明确的业务修订或序列内容,而不是整份界面状态对象。
移动成功后,如果个人内容刚好也在编辑,两个状态仍分别推进。座位计划增加顺序修订,个人草稿保持待确认;用户可以只确认内容,也可以继续调整座位。把两项变化拆开后,发布入口才能清楚列出本次究竟变更了哪些人的内容、哪些人的位置。
如果两个窗口都能调整同一份计划,保存时需要检查各自依据的顺序修订。来自旧修订的移动不能直接覆盖较新的完整序列,可以拒绝并刷新,也可以在明确规则下重新应用单次移动。当前页面只维护本地计划,修订字段则让后续同步可以表达这个前提,而不必重新设计身份模型。
名单规模变大后的显示与操作
较窄窗口中,名单行需要为姓名和移动按钮分配稳定空间。长姓名可在选择区采用截断,但已应用预览仍保留原文。不能为了容纳按钮而把姓名字段改成缩略文本;导航标签和业务内容可以采用不同显示策略,但都应继续指向同一身份。
较宽窗口可以把座位列表和编辑区并排呈现,单一活动ID仍保持不变。键盘或辅助操作也应调用choose和moveGuest,而不是维护另一份选中下标。这样视觉布局改变时,输入方式与业务动作仍然是一一对应的,排查交互问题也能回到同一条身份链路。
对于大量名单,优先减少重复派生和无关区域刷新,再考虑更复杂的移动交互。当前按钮方案已经能覆盖顺序变更的核心规则,后续拖动只增加位置输入,不应重新实现校验、复制和修订更新。把规则放在独立服务中,可以让不同交互共享同样的失败处理。
座位序列只保存ID,使排序、个人编辑和排版各有归属。重排改变位置,应用改变个人内容,布局变化改变字号选择。等到发布快照加入后,系统就可以分别比较这些变化,而不必把一次换座位解释成所有人的资料都被重新编辑。
相关资料:ForEach循环渲染、避免使用下标作为条目键、状态管理。
更多推荐

所有评论(0)