HarmonyOS 7视线追踪交互设计:凝视点选与注视触发
用手指点屏幕,"点"和"选"是同一个动作,用户不会怀疑自己到底点了没有。换成视线就麻烦了:人眼每分钟要做上万次无意识的扫视,如果每看一眼就触发一次,界面会疯掉。这就是视线交互要解决的核心矛盾——如何从连绵不断的眼动里,分辨出哪一次才是"我想点它"。这篇讲凝视点选(Gaze Selection)的原理、注视触发(Dwell)的参数设计,以及怎么把抖动和误触压下去。
凝视点选与迈达斯之触
视线交互最早、也最直接的实现是凝视点选:系统持续计算注视点坐标,落在哪个可交互目标上,就把哪个目标当作当前选择。
这个方案会遇到一个著名的难题,叫迈达斯之触(Midas Touch)。古希腊神话里迈达斯王碰什么什么变金子,最后连食物都吃不了。视线交互也是这个困境:眼睛看到哪里,哪里就被"碰"了。用户只是习惯性地瞟一眼时间,结果打开了日历;想看清楚某个按钮的字,结果把它按了。
迈达斯之触的根子在于:注视(Fixation)和意图(Intent)不等价。人看东西分两类动作:
- 扫视(Saccade):快速跳动,视线从 A 跳到 B,持续 20~40ms,是无意识的。
- 注视(Fixation):视线在某点附近停留,持续 100ms 以上,通常对应认知加工。
但即便是一次 300ms 的注视,也可能只是"我在读它",不等于"我要激活它"。所以纯凝视点选几乎不可用。工程上必须引入一个额外的、明确的信号来区分"看"和"选"。
常见的四种解法:
| 方案 | 触发信号 | 优点 | 缺点 |
|---|---|---|---|
| 纯凝视 | 注视达阈值即触发 | 无需其他设备 | 迈达斯之触严重,基本不可用 |
| 注视 + 停留(Dwell) | 注视持续超过 N 毫秒 | 仅靠眼动即可完成 | 等待感强,误触仍在 |
| 注视 + 手势确认 | 注视选目标,手势提交 | 误触率极低,心智清晰 | 需手势设备,多一步操作 |
| 注视 + 眨眼 | 识别特定眨眼模式 | 单手可完成 | 眨眼误识别率高,疲劳 |
实际产品里,"注视 + 停留"和"注视 + 手势"用得最多,前者适合无手的浏览场景,后者适合精确操作。
注视触发的时长与反馈
如果真的要用 Dwell(停留触发),时长就是最关键的参数。设短了误触,设长了让人等。
业界摸索出的经验区间在 400ms 到 1000ms 之间:
- 低于 400ms,正常阅读过程中的停驻就会被判为触发,误触率飙升。
- 高于 1000ms,用户会产生明显的"卡顿感",尤其连续操作时很难受。
- 600ms 左右是较常用的折中值,适合大多数点击类操作。
- 对于有破坏性的操作(删除、支付),可以拉长到 800~1000ms,并且加二次确认。
比时长更重要的,是反馈。Dwell 最大的体验问题是等待期间界面毫无动静,用户不知道系统在数数。好的做法是给一个与计时同步的视觉反馈,让"等待"变成"进度":
- 环形进度:目标周围画一个圈,随时间填充,填满即触发。
- 径向放大:目标随停留逐渐放大,到达阈值时"弹"一下确认。
- 颜色渐深:背景色随停留加深。
反馈要让用户能预判"还差多少"。用户一旦看到圈快满了,就会下意识多看半秒——这一下恰好把误触和漏触都降低了。
还有几个细节:
- 计时必须在注视离开目标的瞬间清零,不能跨目标累积。
- 阈值可以动态:对重要目标用较长阈值,对高频操作用较短阈值。
- 提供全局 Dwell 开关,让不喜欢停留触发的用户关掉,改用显式确认。
抖动与误触的抑制
眼动追踪的原始数据带有明显噪声,直接拿来算注视点,光标会像喝了咖啡一样抖。抑制分两层。
数据层:滤波与去噪
最常用的是滑动平均或指数平滑(EMA)。视线信号的抖动频率比真实注视转移频率高,低通滤波能压掉大部分。但滤波有代价——它引入延迟,而视线交互对延迟极其敏感(超过 100ms 用户就会觉得"眼睛和光标不同步")。所以要平衡:平滑窗口不能太大。
一个更聪明的做法是基于速度的过滤。眼动的速度分布是双峰的:扫视时速度极高(几百 deg/s),注视时极低(几 deg/s)。设定一个速度阈值,只有速度低于阈值时才算"可能是注视",高速段直接标记为扫视并跳过。
逻辑层:注视聚类与目标吸附
原始注视点是连续坐标,需要聚类成"一次注视事件"。常用算法是 I-DT(Dispersion-Threshold Identification):在时间窗内,如果注视点的离散度(最大最小坐标差)小于某个阈值,就判定为一次注视。
聚类出注视后,再把它吸附到最近的交互目标上。吸附有一个磁吸半径(Magnet Radius),超出半径就不算落在目标上。这样即使原始坐标略有偏差,只要落在目标附近就能正确命中。
flowchart LR
A["原始注视点流 60~120Hz"] --> B{"瞬时速度是否低于阈值"}
B -- "否 扫视" --> C["丢弃 不参与选择"]
B -- "是 可能注视" --> D["I-DT 聚类 时间窗离散度判断"]
D --> E{"离散度是否小于阈值"}
E -- "否" --> C
E -- "是" --> F["生成一次注视事件"]
F --> G["吸附到最近目标 磁吸半径判定"]
G --> H{"是否命中目标"}
H -- "否" --> C
H -- "是" --> I["启动 Dwell 计时 显示环形反馈"]
误触防线:三重保险
数据滤波和聚类之后,再加三道防线:
- 目标尺寸下限。视线精度有限(通常 1~2 度视角误差),小目标不要用纯视线选。可交互目标在视锥里至少要占 3 度以上。
- 二次确认。破坏性操作必须二次确认,Dwell 一次是"选中",再 Dwell 一次或加手势才是"执行"。
- 可撤销。触发后提供明确、及时、低成本的撤销入口,且撤销窗口不能太短。
代码:注视焦点状态机与 Dwell 反馈
下面用 ArkUI 的状态管理与动画,实现一个"注视焦点 + 停留进度"的可视化组件。onHover 在这里作为注视点进入/离开目标的代理事件(真实项目接入眼动 SDK 后替换数据源即可),Dwell 进度用 @State 驱动环形动画。
@Component
export struct GazeTarget {
@Prop targetId: string;
@Prop label: string;
@State isFocusing: boolean = false; // 注视是否落在本目标
@State dwellProgress: number = 0; // 停留进度 0.0~1.0
@State confirmed: boolean = false; // 是否已触发
// 触发前参数:停留阈值越长,误触越少,等待感越强
private readonly DWELL_MS: number = 600;
private dwellTimer: number = -1;
private startTs: number = 0;
onConfirm?: (id: string) => void;
// 注视进入:启动计时;离开:立即清零,防止跨目标累积
private handleFocusChange(focusing: boolean): void {
if (focusing === this.isFocusing) {
return;
}
this.isFocusing = focusing;
if (focusing) {
this.startDwell();
} else {
this.stopDwell();
}
}
private startDwell(): void {
this.stopDwell();
this.confirmed = false;
this.startTs = Date.now();
this.dwellTimer = setInterval(() => {
const elapsed = Date.now() - this.startTs;
this.dwellProgress = Math.min(elapsed / this.DWELL_MS, 1);
if (this.dwellProgress >= 1) {
this.stopDwell();
this.confirmed = true;
this.onConfirm?.(this.targetId); // 触发确认回调
}
}, 16); // 约 60fps,保证进度条顺滑
}
private stopDwell(): void {
if (this.dwellTimer !== -1) {
clearInterval(this.dwellTimer);
this.dwellTimer = -1;
}
this.dwellProgress = 0;
}
aboutToDisappear(): void {
this.stopDwell();
}
build() {
Stack() {
// 目标卡片:注视时高亮并轻微放大
Text(this.label)
.width(180)
.height(100)
.textAlign(TextAlign.Center)
.borderRadius(16)
.backgroundColor(this.confirmed ? '#2E9E5B'
: (this.isFocusing ? '#4A90D9' : '#E8E8E8'))
.scale(this.isFocusing ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
.onHover((isHover: boolean) => {
this.handleFocusChange(isHover); // 真实场景替换为注视点命中判定
})
// 环形停留进度:只在注视且未确认时显示
if (this.isFocusing && !this.confirmed) {
Progress({ value: this.dwellProgress * 100, total: 100, type: ProgressType.Ring })
.width(120)
.height(120)
.color('#FFFFFF')
.style({ strokeWidth: 6 })
.hitTestBehavior(HitTestMode.None) // 不拦截事件,避免影响定位
}
}
}
}
这段代码有两个设计点。一是 stopDwell 在每次注视离开时把 dwellProgress 归零,杜绝了"扫过多个目标累计凑满进度"这类误触。二是进度环 hitTestBehavior 设为 None,反馈层不能干扰命中测试,否则动画元素自己会把注视点"吃掉"。真实接入眼动 SDK 时,onHover 换成注视点坐标与目标包围盒的相交判断即可,其余逻辑不变。
配合一个完整的"注视 + 确认"场景,宿主组件负责把注视命中和手势确认拼起来:
@Entry
@Component
struct GazeDemo {
@State confirmedId: string = '';
build() {
Column({ space: 20 }) {
Text(this.confirmedId === '' ? '注视目标并保持 0.6 秒' : `已选中:${this.confirmedId}`)
.fontSize(18)
Row({ space: 16 }) {
GazeTarget({ targetId: 'A', label: '商品 A', onConfirm: (id: string) => {
this.confirmedId = id;
}})
GazeTarget({ targetId: 'B', label: '商品 B', onConfirm: (id: string) => {
this.confirmedId = id;
}})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
案例:博物馆导览里的"凝视展品看详情"
一个文旅展馆的空间应用,面前陈列着若干 3D 展品模型,每件展品旁边悬浮一块信息牌。用户戴着头显或者站在大屏前浏览。
如果每件展品都用"看一眼就展开详情",用户会崩溃——浏览时眼睛到处扫,详情面板会不停跳出来又收回去。这里的做法是分开两件事:
- 凝视预览:注视展品超过 400ms,展品缓慢旋转一圈,信息牌显示一行简短的年代与名称。这一步不打开详情,只是"我注意到了"。
- 注视确认:用户确实想看详情时,注视信息牌上的"展开"按钮停留 600ms。环形进度填满,详情面板从展品后方滑出。
为什么给不同的目标不同阈值?因为展品本体很大,误触无所谓;而"展开"按钮一旦误触,会打断浏览节奏,值得让用户多等 200ms 换更低的误触率。这种按目标重要程度分配阈值的做法,在视线交互里很常见。
细节上还加了两个保护:详情面板打开后,注视停留在面板外超过 1 秒才自动收起(避免用户看别处一眼面板就没了);面板收起有 200ms 的淡出过渡,防止频繁闪烁。
总结一下下
先决定"看"和"选"分不分家。如果场景允许手势,优先做注视+手势,把副作用交给显式动作;只有在免手场景下才考虑 Dwell,并且接受它必然存在的误触残余。
Dwell 阈值按目标重要性分层。不要全局一个值。高频、低风险的操作可以短,低频、高风险的操作要长。
反馈是 Dwell 的一部分,不是装饰。没有进度的等待对用户就是卡顿,环形进度、径向放大这类反馈直接决定了 Dwell 能不能用。
滤波延迟和抖动要一起调。平滑窗口开大压抖动,但同时增加延迟;视线交互对延迟的敏感度比想象中高,找到平衡点需要配合真机实测,纸面参数不可靠。
一定要留开关。眼动追踪的个体差异很大,戴眼镜、眼睛疲劳、光线变化都会影响精度。给用户一个"关闭视线触发,改用手势"的入口,比强行优化所有边缘情况划算。
容易出问题的地方O
计时跨目标累积。注视在 A 停了 300ms,移到 B 又停 300ms,如果计时不清零,B 会在 600ms 时被误触发。这是最常见的实现 bug,务必在目标切换时归零。
反馈层拦截了命中测试。动画、进度环、粒子效果这些叠加在目标上的图层,如果没有设置 hitTestBehavior(HitTestMode.None),会把原本该落到目标上的交互"截胡",表现出来就是"看着明明点中了却没反应"。
忘记处理数据缺失。用户眨眼、追踪丢失(比如手遮住摄像头)时,数据流会中断。如果不做超时和重连处理,Dwell 计时会用旧数据继续跑,导致莫名其妙的触发。数据中断超过 100ms 就该清除状态。
用线性插值处理注视点。注视点从 A 平滑移到 B 的过程中,路径上会经过中间的目标,如果对插值结果做命中判定,会连续触发一串目标。要么只在"注视事件"级别判定,要么对插值过程做屏蔽。
忽略可访问性。不是所有人的眼动特征都标准,也不是所有设备都有高质量眼动追踪。视线交互必须有一条不依赖它的降级路径,否则就是把一部分用户挡在门外。
- 点赞
- 收藏
- 关注作者
评论(0)