
1. 前言
番茄炖牛腩还在小火炖煮。用户伸手按住"下一步",看见按钮产生形变,随后改变主意,把手指拖到按钮外再松开。现在,界面应该告诉他什么?
我们希望当前步骤保持不变。但按钮已经回应过触碰。如果用户把那次回应理解为"上一步完成了",即使内部状态正确,界面仍然留下了歧义。
前两篇分别讨论材质在哪里生效,以及菜图经过底栏时文字能否读清。本篇沿用同一套"灶台菜谱":番茄炖牛腩、本地步骤数据,以及"备菜、烹饪、笔记"三个页签。问题向前移动了一点:当用户读懂按钮并开始操作,界面怎样说明操作的结果?
我会把这个过程拆成三件事:手指触碰了组件,组件识别出点击,应用接受点击并推进步骤。它们经常接连发生,却不能互相替代。取消操作恰好能暴露这种区别:触碰已经发生,业务变化却不应该发生。

图03-A:按下、拖出、松手的操作路径。四帧使用同一菜谱与初始步骤,关注最后的步骤文字是否保持原值。
本篇要建立的规则很简单:触摸反馈解释正在发生的接触,业务反馈解释已经提交的变化。这里的"完成"仅指用户确认前一步并推进了菜谱,不代表应用识别了锅里的食物,更不代表整道菜已经做好。
2. 方案
先比较两种设计。方案A保留基础菜谱文本,把操作结果的提示交给瞬时光效;方案B在按压反馈之外,持续显示当前步骤,并用文字和符号说明刚刚完成的上一步。两种方案使用相同的推进逻辑,区别在于它们给用户留下多少可供确认的信息。
| 比较项 | 方案A | 方案B |
|---|---|---|
| 手指接触入口 | 出现光效 | 出现光效与按压形变 |
| 成功推进之后 | 主要依靠刚才的视觉变化提示 | 当前步骤更新,并显示上一步完成标记 |
| 取消之后 | 需要重新阅读正文判断 | 当前步骤与已有完成标记保持原值 |
| 光效关闭之后 | 缺少独立的结果提示 | 仍可从文字与符号确认进度 |
我更倾向于方案B,因为灶台旁的阅读并不连续。用户可能按完按钮就转身拿锅盖,回头时动画已经结束。持续存在的状态信息让他不必记住一段短暂的视觉变化。
这仍然是需要检验的设计判断。比较时应固定按钮尺寸、文字资源、背景裁切、材质厚薄和初始步骤。若同时换成更清楚的背景,就无法判断改善来自反馈设计还是可读性。

图03-D:两种方案使用同一操作入口和业务规则。对照重点是手指离开后还留下哪些信息,而不是哪一侧的光效更显眼。
在写代码之前,还要约定几个容易被忽略的行为。一次有效点击推进一项;连续有效点击逐次推进,到末步停止。这里没有防抖,也没有"一连串点击只算一次"的隐藏规则。取消不推进,禁用不推进,末步不循环回第一步。
到达最后一步也不等于最后一步已经做完。因此,提示使用"已到最后一步",正文继续展示末步骤操作。若将来需要"完成烹饪",应为它设计独立动作,而不是让索引到达末尾就顺便完成另一件事。
3. 实现
先安放视觉配置。沉浸光感沿用前篇的底栏结构,应确认它处于横向Tabs、barPosition为BarPosition.End的底部TabBar区域。屏幕底部的一块Column并不会因为位置靠下就自动获得这一资格。
三个页签继续负责导航,"下一步"继续负责推进。本文只展开材质配置和业务处理片段,底栏的完整组件树沿用前篇结构;接入时需按工程SDK核对类型与承载方式,不能把以下片段移到任意正文区域就期待相同效果。
沉浸式系统材质两种交互效果:interactive控制按压弹性形变,lightEffect控制触摸时的点光源。后者传入有效对象即启用,null或undefined不启用;对象中的color默认白色。
ts
import { uiMaterial } from '@kit.ArkUI';
// 材质配置片段:接入已确认支持的组件区域。
function createStepMaterial(lightEnabled: boolean) {
return new uiMaterial.ImmersiveMaterial({
style: uiMaterial.ImmersiveStyle.THIN,
colorInvert: true,
interactive: true,
lightEffect: lightEnabled ? {} : undefined
});
}
这里保留第2篇薄材质方案的配置,文字继续使用资源颜色。若前篇的可读性选择表要求厚材质或实色回退,就沿用该选择,保持下面的业务逻辑不变。
关闭lightEffect时,interactive仍然为true。因此,"没有流光"不意味着"没有按压形变"。同样,这里的undefined仅表示不启用点光源,不能借用它解释systemMaterial的默认行为;关闭材质与关闭某一种交互效果是不同操作。
视觉配置确定后,步骤推进可以独立表达。下面的函数只接收当前位置、步骤总数与禁用条件,不接收材质或动画参数。
ts
interface AdvanceResult {
next: number;
accepted: boolean;
reason: string;
}
function advanceStep(
current: number,
count: number,
disabled: boolean
): AdvanceResult {
if (count <= 0 || current < 0 || current >= count) {
return { next: current, accepted: false, reason: 'invalid' };
}
if (disabled) {
return { next: current, accepted: false, reason: 'disabled' };
}
if (current === count - 1) {
return { next: current, accepted: false, reason: 'last-step' };
}
return { next: current + 1, accepted: true, reason: 'advanced' };
}
这个函数的价值在于把规则放到了一个能直接检查的位置。切换主题、菜图或光效不需要调用它;收到一次符合交互约定的点击才调用它。函数返回拒绝原因,便于区分"不可操作"和"已经到了末步"。
在页面中,currentStep仍是RecipeData步骤数组的当前索引。以下成员片段中的steps引用同一份非空步骤列表,每项包含title;禁用状态与原因由页面管理。状态应放在三个页签共同使用的页面层,避免切换页签时重新初始化。
ts
@State currentStep: number = 0;
@State advanceDisabled: boolean = false;
@State disabledReason: string = '';
private eventSequence: number = 0;
private handleNext(): void {
const before = this.currentStep;
const result = advanceStep(
before, this.steps.length, this.advanceDisabled
);
if (result.accepted) {
this.currentStep = result.next;
}
const sequence = ++this.eventSequence;
console.info(
`[step] seq=${sequence} time=${Date.now()} ` +
`before=${before} after=${result.next} ` +
`accepted=${result.accepted} reason=${result.reason}`
);
}
日志保存处理时的前后值,不在稍后的异步任务里重新读取currentStep。否则,快速点击留下的多条记录可能全部指向最后一个状态,丢掉我们真正想检查的变化过程。
按钮的onClick绑定handleNext。组件层使用enabled表达可操作性,业务入口仍检查禁用与末步边界。这两层分别负责交互入口和状态规则;以后增加其他调用入口时,也能复用同一份判断。
触摸事件可以用于记录按下、移动、抬起与取消,但不要在其中再次增加步骤。尤其不能把"抬起"直接解释成"推进",或等待光效结束再提交状态。动画不是业务事务的完成通知。

图03-B:上轨记录触摸和视效,下轨记录点击识别、应用判断与步骤更新。正常点击和拖出取消分别成图,不推断系统内部动画时序。
需要特别注意,onClick并不是"任何拖出动作都会被正确取消"的证明。应按实际组件的命中范围与事件规则核对拖出路径。如果外部松手仍产生点击,且违反本篇约定,就需要补充应用侧的取消处理,再执行全部回归。不能靠换一个材质参数解决事件识别问题。
显示内容则尽量由同一状态推导。下面是页面中的文字构建片段,完成标记指向前一个步骤,不维护第二份容易失配的进度列表。
ts
@Builder
StepStatus() {
Column() {
Text(`当前步骤:${this.steps[this.currentStep].title}`)
if (this.currentStep > 0) {
Text(`✓ 上一步已完成:${this.steps[this.currentStep - 1].title}`)
}
if (this.currentStep === this.steps.length - 1) {
Text('已到最后一步')
} else if (this.advanceDisabled) {
Text(this.disabledReason)
}
}
}
这种推导依赖现有的单向顺序推进规则。若以后加入跳步、撤销或直接定位,当前索引就不足以证明前一步完成,届时应引入独立完成记录。眼下保持模型与功能规模一致,比提前维护多份状态更容易看清错误。
4. 检验
我会从取消路径开始检查,因为它迫使视觉和业务给出不同答案:可以出现触摸反馈,但不能推进步骤。随后再检查正常点击与其他边界。下表列出验收预期,用来逐项填写运行记录。
| 用例 | 操作条件 | 应满足的状态规则 |
|---|---|---|
| 按下拖出 | 非末步,按下后拖出,在外部松手 | 步骤与完成标记不变 |
| 正常点击 | 非末步,可操作,完成一次点击 | 只推进一项,摘要同步更新 |
| 快速连续点击 | 从前部步骤开始连续点击 | 逐次处理有效点击,末步停止 |
| 末步骤 | 到达最后一项后尝试继续 | 不越界、不循环、不新增完成结果 |
| 禁用态 | 非末步,设置禁用条件后操作 | 不推进,并能读到禁用原因 |
| 关闭光效 | 保持其他配置,重复点击、取消和末步路径 | 业务结果与开启光效时一致 |
连续点击至少要区分三个计数:手指动作次数、识别出的点击次数、应用接受的推进次数。假设初始索引为s,总步数为N,识别出k次符合交互约定的点击,期间没有其他禁用条件,那么最终索引应为min(s+k, N-1)。到达末步后的点击不会再增加推进次数。
只从倒数一步开始连点不够。无论中间漏掉多少操作,最终都可能停在末步。应从前部步骤开始,逐条检查日志中的before、after及拒绝原因,再与录屏中的步骤文字对齐。

图03-E:每条操作独立记录初始条件和状态变化。事件序号关联录屏位置,区分预期与实际结果,不用一张最终截图替代中间过程。
运行记录还需要工程版本、目标与构建SDK、设备、运行系统、主题和系统光感设置。配置栏分别填写interactive、lightEffect及材质方案;事件栏填写时间、事件类型、是否接受和拒绝原因;证据栏保存录屏路径与对应位置。
仅关闭点光源时,先保持interactive不变,再分别检查null和undefined路径。若还要关闭形变,应另开一组配置。一次改变两个参数,失败时就多了一种解释。

图03-C:左右两列使用相同业务条件,只改变点光源配置。比较步骤文字和完成标记是否一致,不要求两侧呈现相同视效。
录屏能帮助确认用户看到了什么,日志能帮助确认应用做了什么。它们都不能单独证明用户理解得更快。如果要评价理解程度,可以让参与者回答"刚才是否推进""现在做到哪一步",记录任务、回答与错误,而不是根据动画是否顺滑估计收益。
拖出仍推进、一次点击更新两次、禁用后状态变化,都应保留完整操作路径。先定位是哪一层违背规则,再修改对应实现,最后重跑六类用例。视觉变化与业务变化分开记录,才能避免把事件错误归咎于材质。
5. 结尾
灶台菜谱需要一套可以在动画消失之后继续成立的解释。手指接触入口时,材质可以回应;应用接受推进时,步骤文字和完成标记必须给出持久的结果。取消、禁用和末步边界则通过保持状态与明确说明,让用户理解为什么没有推进。
| 语义 | 判断依据 | 界面表达 |
|---|---|---|
| 触碰 | 触摸与可观察视效 | 正在接触操作入口 |
| 有效操作 | 点击通过应用条件检查 | 接受本次推进 |
| 业务完成 | 一次合法状态更新已提交 | 当前步骤更新,前一步出现完成标记 |
| 取消或不可推进 | 取消、禁用或末步条件 | 状态保持,必要时解释原因 |
这张语义表、事件与状态记录字段,以及六类回归用例,共同限定了反馈设计的职责。光效能够增强接触感,但不会因此提供振动、湿手识别或更可靠的触控,也不能判断食物是否烹饪完成。
下一篇讨论信息折叠与展开。按钮可能换一个位置,摘要可能进入更紧凑的布局,但逐次推进、取消不推进、禁用守卫与末步停止的规则继续保留。先让操作的含义稳定,再改变它的呈现位置,布局调整就有了明确的回归依据。