在大屏阅读类应用中,列表与详情同时可见,操作距离短,误切换反而更容易发生。设想一份设备评审清单:左侧列出三条记录,右侧正在修改“设备测试说明”。用户刚输入“版本待确认”,手指又点了下一条“版本清单”。如果详情跟着列表选中项立即重建,编辑框中的变动就可能被新记录内容替换。此时问题并不是如何缓存每一条草稿,而是切换动作到底有没有获得用户确认。

默认聚焦 DOC-021,左侧另有 DOC-014 与 DOC-033。尝试从 DOC-021 跳转 DOC-033 时,业务先进入 PENDING DOC-033,右侧显示“继续编辑 / 放弃并切换”的确认层;选择继续编辑,焦点仍停在 DOC-021。

01

一、把“选择列表项”拆成两个动作

一般列表的点击处理很直接:selectedId = id。这种写法没有问题的前提,是详情页不存在需要保护的未保存输入。增加编辑行为以后,点击只是表达用户希望切换,而不是切换已经获得许可。假如把弹窗逻辑埋在每个 ListItem 中,后续再增加搜索结果、快捷键、历史回退入口,还会出现每条路径判断不一致的情况。

我倾向于在界面层之外单独建立一个选择准入器。它记录三个东西:当前业务编号、待切换编号和当前编辑内容相对已保存内容是否变化。业务编号始终使用 DOC-021 这样的稳定键,不把 List 的数组下标当作身份。对于列表排序变化的场景,下标只能表示此刻的位置,不能用于确认哪个草稿需要保护。

这里的 dirty 不直接用一个布尔量长期保存,而从 draftValue 与 savedValue 的差异计算。这样用户把文字改完再手工恢复成原内容时,不会被一枚忘记清除的标记挡在确认框前。比较字符串当然不能覆盖富文本、附件或服务端版本号,这个示例刻意限定在单行文本输入,复杂表单需要另外定义脏检查策略。

二、让待切换编号成为明确状态

先做一层与 ArkUI 无关的 ArkTS 业务代码。这一段解决“点击时先判断,确认后再落地”的核心问题,故意不涉及系统分屏能力;它放在普通页面中也能被调用。

interface PaneDoc { id: string; title: string; content: string }
type SelectResult = 'STAY' | 'PENDING' | 'SWITCHED'

class GuardPaneGate {
private docs: PaneDoc[] = [
{ id: 'DOC-014', title: '图集检索', content: '图片索引待复核' },
{ id: 'DOC-021', title: '设备测试', content: '设备测试说明' },
{ id: 'DOC-033', title: '版本清单', content: '更新清单待发布' }
]
currentId: string = 'DOC-021'
pendingId: string = ''
savedValue: string = '设备测试说明'
draftValue: string = '版本待确认'

get dirty(): boolean { return this.draftValue !== this.savedValue }
get items(): PaneDoc[] { return this.docs }

select(id: string): SelectResult {
if (!this.docs.some((doc: PaneDoc) => doc.id === id)) return 'STAY'
if (id === this.currentId) return 'STAY'
if (this.dirty) { this.pendingId = id; return 'PENDING' }
this.switchTo(id)
return 'SWITCHED'
}

cancel(): void { this.pendingId = '' }
discardAndSwitch(): void {
if (!this.pendingId) return
this.switchTo(this.pendingId)
}
private switchTo(id: string): void {
const doc: PaneDoc | undefined = this.docs.find((item: PaneDoc) => item.id === id)
if (!doc) return
this.currentId = id
this.savedValue = doc.content
this.draftValue = doc.content
this.pendingId = ''
}
}

PaneDoc 只是本地示例数据契约,docs 不是从云端同步的记录。select() 三个返回值分别表示忽略、等待确认和已经切换;业务侧可以据此更新提示文字。discardAndSwitch() 会把目标条目的已保存值载入输入框,因此“放弃”在本例意味着丢弃本地未提交变化,不是删除服务端数据。不存在待处理编号时直接返回,避免确认按钮被重复点击后发生二次跳转。

注意 pendingId 的生命周期:选中目标时创建,用户点击“继续编辑”时清除,放弃并切换后也清除。如果弹窗消失却保留旧目标,下次触发确认可能误切上一条记录。这里没有订阅、定时器或外部句柄需要释放;真正接入页面路由或异步保存时,离开页面应取消未决请求,同时定义保存失败后的继续编辑策略。

三、ArkUI 页面只消费准入器的决定

页面使用 @State 维护用于渲染的当前编号、草稿、待选编号和状态文字,准入器则保存业务约束。不要把 GuardPaneGate 实例内部普通字段的变化误认为能够自动触发 ArkUI 重绘;一次状态迁移之后必须显式把需要展示的快照写回 @State。这是示例里最值得检查的一处数据边界。

@Entry
@Component
struct GuardPanePage {
private gate: GuardPaneGate = new GuardPaneGate()
@State selectedId: string = 'DOC-021'
@State draftText: string = '版本待确认'
@State pendingId: string = ''
@State status: string = 'DIRTY DOC-021'

private sync(): void {
this.selectedId = this.gate.currentId
this.draftText = this.gate.draftValue
this.pendingId = this.gate.pendingId
this.status = this.pendingId ? `PENDING ${this.pendingId}` :
(this.gate.dirty ? `DIRTY ${this.selectedId}` : `READY ${this.selectedId}`)
}
private request(id: string): void {
this.gate.select(id)
this.sync()
}
build() {
Column({ space: 12 }) {
Text('GuardPane  /  GuardPane').fontSize(22).fontWeight(FontWeight.Bold)
Text(this.status).fontColor('#D17B12')
Row({ space: 12 }) {
List() {
ForEach(this.gate.items, (doc: PaneDoc) => {
ListItem() {
Text(`${doc.id}  ${doc.title}`).padding(12)
}.onClick(() => this.request(doc.id))
}, (doc: PaneDoc) => doc.id)
}.width('40%')
Column({ space: 12 }) {
Text(`正在编辑 ${this.selectedId}`)
TextInput({ text: this.draftText })
.onChange((text: string) => {
this.gate.draftValue = text
this.sync()
})
if (this.pendingId.length > 0) {
Text(`未保存更改,准备切换到 ${this.pendingId}`)
Row({ space: 8 }) {
Button('继续编辑').onClick(() => { this.gate.cancel(); this.sync() })
Button('放弃并切换').onClick(() => {
this.gate.discardAndSwitch(); this.sync()
})
}
}
}.layoutWeight(1)
}.width('100%')
}.padding(20)
}
}

这里的 List、ListItem、ForEach 和 TextInput.onChange 都属于通用 ArkUI 组件或事件;代码没有冒称调用某个“平行视界强制切栏”系统接口。页面在较宽容器中用左列表、右详情的方式实现交互。若实际工程要同时支持窄屏,需要在布局层根据容器宽度切成单列导航;无论视觉是双栏还是单列,都应调用同一套 request(),不能在某个分支直接修改 selectedId。

此示例故意保留一个工程限制:在页面结构体中长期持有可变类对象,比较适合解释状态迁移,并不自动等同于可跨路由恢复的持久化状态。正式产品可以把准入器迁到有明确生命周期的状态仓库,同时定义关于异常退出、窗口重建与远程数据更新的恢复策略。

02

四、按事件顺序检查,而不是只看弹窗

建议拿三条固定记录先画出完整时序。初始时 selectedId=DOC-021、savedValue=设备测试说明、draftValue=版本待确认,因此状态是 DIRTY DOC-021。点击 DOC-033 后,视图应该只新增 pendingId=DOC-033 和 PENDING DOC-033,右侧编辑框内容以及标题编号必须仍然属于 DOC-021。只有点击“放弃并切换”才允许将 selectedId 变成 DOC-033,并用该记录已保存内容覆盖草稿。

可以用一小段纯业务驱动代码整理预期日志,作为阅读代码时的对照。它不等同于已经在设备执行的日志;实际编译后应把相同事件序列交给自动化用例验证。

const gate = new GuardPaneGate()
console.info(`GuardPane DIRTY ${gate.currentId}: ${gate.dirty}`)
const decision: SelectResult = gate.select('DOC-033')
console.info(`GuardPane ${decision} ${gate.pendingId}`)
gate.cancel()
console.info(`GuardPane KEEP ${gate.currentId}; pending=${gate.pendingId}`)
// 预期:DIRTY DOC-021: true -> PENDING DOC-033 -> KEEP DOC-021

这里特别要测“快速连点第二目标”。在弹窗未关闭时如果再次点击 DOC-014,当前实现会把 pendingId 更新为最后一次请求。这种最后意图覆盖策略便于解释,但不一定适合金融审批或不可撤回流程。严肃场景可以在存在未决确认时拒绝新的目标,或把确认层做成不可重复提交状态。无论如何,产品要先明确策略,再设计代码,而不是交给点击顺序偶然决定。

五、状态画面应当说明了什么

本轮配图使用固定示例时刻 20:35。左边高亮 DOC-021,右边仍显示“版本待确认”,确认卡片提示“未保存更改,准备切换到 DOC-033”,两个按钮分别为“继续编辑”和“放弃并切换”。顶部状态写 PENDING DOC-033。用户选择继续编辑之后,pendingId 应为空,视图恢复为 DIRTY DOC-021,不会悄悄将草稿复制到 DOC-033。

03

这张纯手机屏幕是把双栏交互逻辑收敛成紧凑预览的预设图,不用它证明平板上平行视界路由已经接好。真正的平行视界除了业务选择状态,还有窗口管理、路由模式与页面栈的约束;这些要结合官方样例在目标设备完成独立验证。当前文章的交付价值是一个可复用的“未保存拦截”业务规则,而不是对系统分屏能力做未经核实的封装。

六、上线前需要补上的边界

正式接入时至少分清四种状态:没有修改可以立即切换;存在修改等待确认;用户同意丢弃后再切换;保存请求尚未完成时禁止把结果归到错误记录。当前代码只处理前三种,如果再加“保存并切换”,需要为请求附上源记录 ID 与请求代际,回包成功后才执行目标切换;失败应保留原草稿并展示错误,不得先切换后静默丢失。

对可访问性也要留一条检查路径:确认层出现时焦点应移动到提示与按钮区域,关闭后返回原编辑位置;系统返回手势应被解释为“继续编辑”还是“放弃”,必须有一致定义。这里没有把 onBackPress 或任何未验证回调硬写进示例。若要保存到 Preferences 或关系型数据库,需要额外处理写入失败、账号切换、同步冲突与敏感内容清理。

本文引用的华为官方资料包括 ArkUI List/ForEach 组件示例以及官方“平行视界”示例代码入口,分别用于确认组件形态与目标场景,不意味着样例页面已经集成完整系统分栏能力:

最后保留一个实践结论:在双栏应用中,选中项的视觉变化往往比编辑内容更容易被看见;真正应该先确定的,却是用户尚未完成的输入由谁负责保住。把意图、确认与落地分别建模,才能在后续扩展窗口形态时不反复重写保护规则。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐