触摸屏上的手势是"实"的------手指确实碰到了玻璃,坐标、压力、速度都是确定的数值。空中手势是"虚"的:传感器看到一只手在动,系统得猜这动作是什么意思,还得猜得足够准,准到用户敢把删除操作交给它。这篇讲空中手势的语义怎么设计、误识别怎么兜底,以及它和 2D 触摸手势到底差在哪。
空中手势的语义映射
先明确一组基础动作。空间里能稳定识别、用户也容易理解的手势其实不多,常见的有这几类:
- 点指(Point / Air Tap):食指前伸或轻点,作用等价于单击,是最轻量的选择动作。
- 捏合(Pinch):拇指与食指合拢,作用等价于确认、拾取或缩放。
- 抓取(Grab):整只手呈抓握状,用于"拿起"一个对象并跟随移动。
- 松手(Release):从抓握张开,用于放下对象,触发落位。
- 滑移(Swipe / Slide):手掌沿某方向平移,用于翻页、切换、推动。
- 挥动(Wave):左右摆动,常用于呼出/收起系统级面板。
- 双手缩放(Two-hand Scale):两手距离变化,用于整体缩放与旋转。
语义映射的核心原则是动作与语义要有物理隐喻。抓取对应"拿起",松手对应"放下",两手拉开对应"放大"------用户不需要学习,凭直觉就能猜对。反过来,如果一个手势的语义需要教程才能记住,那它在真实场景里基本不会被用起来。
每一类手势的语义设计记得留出方向维度。同样是滑移,向左和向右可以是"下一页"和"上一页",向上和向下可以是"滚动"和"拉出"。但在空间里要小心:滑移方向要和内容的布局方向一致,不然用户会感觉"推错了方向"。
| 手势 | 物理隐喻 | 典型语义 | 触发代价 | 适合的操作 |
|---|---|---|---|---|
| 点指 | 指向 | 选择、高亮 | 最低 | 预览、轻选择 |
| 捏合 | 捏住 | 确认、拾取、缩放 | 低 | 提交操作、进入详情 |
| 抓取 + 移动 | 拿起 | 拖拽、重排 | 中 | 对象位置调整 |
| 松手 | 放下 | 落位、提交 | 低 | 完成拖拽 |
| 滑移 | 推拉 | 翻页、切换 | 中 | 列表浏览、面板切换 |
| 挥动 | 挥手 | 呼出/收起 | 中 | 系统级面板 |
| 双手缩放 | 拉伸 | 整体缩放、旋转 | 高 | 场景级视角调整 |
置信度与容错:让系统学会"拿不准就不做"
手势识别模型输出的不是一个确定的类别,而是每个候选手势的置信度(Confidence),通常是一个 0~1 的概率。设计容错,本质是围绕这个置信度做文章。
阈值与迟滞
最简单的是单阈值:置信度超过 0.8 才触发。但它有个毛病------用户的手停在临界状态时,置信度在 0.79 和 0.81 之间反复横跳,界面就会疯狂闪烁。
解决办法是迟滞(Hysteresis),也叫双阈值。进入要 0.85,退出只要 0.65。两个阈值之间是"保持区",一旦进入就不再反复,直到跌出下限才退出。这和按钮的消抖是同一个思路。
时间维度:确认窗口
手势是时间上的过程,不是瞬时事件。可以在时间上加两道约束:
- 最短持续时间:动作至少保持 N 毫秒才算数,过滤掉手抖造成的瞬时误识别。
- 稳定窗口:在某个时间窗内,识别结果要保持一致,避免单帧噪声造成误判。
二次确认与撤销
破坏性操作必须二次确认。这里的"二次"可以换一个通道,比如第一次捏合是选中,第二次捏合(或加一个语音确认)才执行删除,且中间界面要有明显的"待确认"状态。
撤销同样重要。空间交互没有物理触感,用户做完动作后往往不确定自己做了什么。撤销入口要及时 (操作后立刻出现)、低成本 (不需要复杂手势)、可见(界面上有明确提示)。
手势识别的容错链路可以画成这样:
#mermaid-svg-er4YvsD6RMvfxoFz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-er4YvsD6RMvfxoFz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-er4YvsD6RMvfxoFz .error-icon{fill:#552222;}#mermaid-svg-er4YvsD6RMvfxoFz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-er4YvsD6RMvfxoFz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-er4YvsD6RMvfxoFz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-er4YvsD6RMvfxoFz .marker.cross{stroke:#333333;}#mermaid-svg-er4YvsD6RMvfxoFz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-er4YvsD6RMvfxoFz p{margin:0;}#mermaid-svg-er4YvsD6RMvfxoFz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-er4YvsD6RMvfxoFz .cluster-label text{fill:#333;}#mermaid-svg-er4YvsD6RMvfxoFz .cluster-label span{color:#333;}#mermaid-svg-er4YvsD6RMvfxoFz .cluster-label span p{background-color:transparent;}#mermaid-svg-er4YvsD6RMvfxoFz .label text,#mermaid-svg-er4YvsD6RMvfxoFz span{fill:#333;color:#333;}#mermaid-svg-er4YvsD6RMvfxoFz .node rect,#mermaid-svg-er4YvsD6RMvfxoFz .node circle,#mermaid-svg-er4YvsD6RMvfxoFz .node ellipse,#mermaid-svg-er4YvsD6RMvfxoFz .node polygon,#mermaid-svg-er4YvsD6RMvfxoFz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-er4YvsD6RMvfxoFz .rough-node .label text,#mermaid-svg-er4YvsD6RMvfxoFz .node .label text,#mermaid-svg-er4YvsD6RMvfxoFz .image-shape .label,#mermaid-svg-er4YvsD6RMvfxoFz .icon-shape .label{text-anchor:middle;}#mermaid-svg-er4YvsD6RMvfxoFz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-er4YvsD6RMvfxoFz .rough-node .label,#mermaid-svg-er4YvsD6RMvfxoFz .node .label,#mermaid-svg-er4YvsD6RMvfxoFz .image-shape .label,#mermaid-svg-er4YvsD6RMvfxoFz .icon-shape .label{text-align:center;}#mermaid-svg-er4YvsD6RMvfxoFz .node.clickable{cursor:pointer;}#mermaid-svg-er4YvsD6RMvfxoFz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-er4YvsD6RMvfxoFz .arrowheadPath{fill:#333333;}#mermaid-svg-er4YvsD6RMvfxoFz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-er4YvsD6RMvfxoFz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-er4YvsD6RMvfxoFz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-er4YvsD6RMvfxoFz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-er4YvsD6RMvfxoFz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-er4YvsD6RMvfxoFz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-er4YvsD6RMvfxoFz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-er4YvsD6RMvfxoFz .cluster text{fill:#333;}#mermaid-svg-er4YvsD6RMvfxoFz .cluster span{color:#333;}#mermaid-svg-er4YvsD6RMvfxoFz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-er4YvsD6RMvfxoFz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-er4YvsD6RMvfxoFz rect.text{fill:none;stroke-width:0;}#mermaid-svg-er4YvsD6RMvfxoFz .icon-shape,#mermaid-svg-er4YvsD6RMvfxoFz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-er4YvsD6RMvfxoFz .icon-shape p,#mermaid-svg-er4YvsD6RMvfxoFz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-er4YvsD6RMvfxoFz .icon-shape .label rect,#mermaid-svg-er4YvsD6RMvfxoFz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-er4YvsD6RMvfxoFz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-er4YvsD6RMvfxoFz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-er4YvsD6RMvfxoFz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
跌落退出阈值
稳定
否
是
传感器采集手部关键点
识别模型输出候选手势与置信度
置信度是否高于进入阈值
丢弃 不产生任何界面变化
持续时间是否超过最小窗口
进入待确认状态 高亮反馈
手势是否稳定 在保持区内
判定为误识别 回滚状态
是否为破坏性操作
直接执行 显示结果
要求二次确认 换通道或再确认
执行 并提供撤销入口
空中手势与 2D 触摸手势的差异
很多人以为把 TapGesture 换成"空中点指"就完事了,实际上两者在工程上有本质区别。
事件是连续的,不是离散的。 触摸事件有明确的 down 和 up,起止清晰。空中手势没有"接触"这个动作,全靠识别模型划定时序边界。所以空中手势多了"识别延迟"这一项开销------系统必须先看到一段完整动作,才能判断它是什么。
没有触点,只有姿态。 触摸有精确坐标,空中手势只有关节角度和手部位置。精度差一个量级,注定不能做像素级精细操作。
误识别是常态,不是异常。 触摸手势误触是边缘情况,空中手势误识别是家常便饭,设计基线就得假设"随时可能认错"。
缺乏触觉反馈。 触摸有震动和按压的物理反馈,空中手势没有。用户只能靠视觉和声音确认,反馈设计要更主动。
| 维度 | 2D 触摸手势 | 空中手势 |
|---|---|---|
| 事件边界 | 明确(down/up) | 需模型划定时序 |
| 输入精度 | 像素级 | 关节角度级,较粗 |
| 识别延迟 | 低(即时) | 有,需完整动作 |
| 误识别率 | 低 | 相对高,属常态 |
| 触觉反馈 | 有(震动、压力) | 无,依赖视觉/听觉 |
| 用户疲劳 | 低 | 长时悬空会累 |
最后一行是容易被低估的。空中手势需要手臂悬空,连续操作几分钟就会酸。所以空中手势应尽量用在"低频、粗粒度"的操作上,高频操作还是留给触摸或者视线。
代码:手势识别与容错处理
ArkUI 的手势框架天然支持这类场景。下面用 PanGesture 做滑移(带方向判定与阈值),用 PinchGesture 做缩放,并在回调里演示迟滞、最短持续时间和撤销的实现思路。
typescript
@Entry
@Component
struct GestureInteractionDemo {
@State scale: number = 1.0;
@State offsetX: number = 0;
@State offsetY: number = 0;
@State gestureState: string = 'idle'; // idle | pending | active
@State toast: string = '';
// 迟滞阈值:进入难、退出易,避免临界状态反复触发
private readonly ENTER_THRESHOLD: number = 0.85;
private readonly EXIT_THRESHOLD: number = 0.65;
private readonly MIN_HOLD_MS: number = 120; // 最短持续时间,过滤手抖
private gestureStartTs: number = 0;
private lastScale: number = 1.0;
private lastOffsetX: number = 0;
private lastOffsetY: number = 0;
// 破坏性操作前的待确认状态
@State pendingDelete: string = '';
build() {
Column({ space: 16 }) {
Text(`state=${this.gestureState} scale=${this.scale.toFixed(2)}`)
.fontSize(16)
// 可操作对象:同时绑定滑动(平移)与捏合(缩放)
Column()
.width(160)
.height(160)
.borderRadius(20)
.backgroundColor('#4A90D9')
.translate({ x: this.offsetX, y: this.offsetY })
.scale({ x: this.scale, y: this.scale })
.gesture(
// 多个手势必须用 GestureGroup 显式绑定:对同一组件连续调用多次 .gesture()
// 时,后面的会覆盖前面的,只有最后一个生效。Parallel 表示两者可同时响应。
GestureGroup(GestureMode.Parallel,
// 滑移手势:用于拖拽对象,direction 限制只在水平方向响应
PanGesture({ direction: PanDirection.Horizontal, distance: 8 })
.onActionStart(() => {
this.gestureStartTs = Date.now();
this.lastOffsetX = this.offsetX;
this.gestureState = 'pending';
})
.onActionUpdate((event: GestureEvent) => {
// 最短持续时间未到,不进入活跃态,避免误识别
if (Date.now() - this.gestureStartTs < this.MIN_HOLD_MS) {
return;
}
this.gestureState = 'active';
this.offsetX = this.lastOffsetX + event.offsetX;
})
.onActionEnd(() => {
this.gestureState = 'idle';
})
.onActionCancel(() => {
// 手势被打断(被其他手势竞争或系统回收)时回滚,防止状态卡死
this.offsetX = this.lastOffsetX;
this.gestureState = 'idle';
}),
// 捏合手势:用于缩放
PinchGesture({ fingers: 2, distance: 5 })
.onActionStart(() => {
this.lastScale = this.scale;
})
.onActionUpdate((event: GestureEvent) => {
// 缩放限制在合理区间,防止用户拉到极端值
const next = this.lastScale * event.scale;
this.scale = Math.min(3.0, Math.max(0.5, next));
})
)
)
// 破坏性操作:删除按钮,二次确认后才执行
Button(this.pendingDelete === 'card' ? '再次点击确认删除' : '删除对象')
.backgroundColor(this.pendingDelete === 'card' ? '#D9534F' : '#8A8A8A')
.onClick(() => {
if (this.pendingDelete === 'card') {
this.toast = '已删除,可撤销';
this.pendingDelete = '';
} else {
this.pendingDelete = 'card'; // 第一次点击进入待确认
this.toast = '请再次确认';
}
})
if (this.toast !== '') {
Text(this.toast).fontSize(14).fontColor('#666666')
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
代码里的几个动作对应了前面讲的容错策略:
PanGesture的distance: 8是识别阈值,滑动位移超过 8vp 才认,压掉微小手抖------这就是"进入阈值"在位移维度的体现。MIN_HOLD_MS的最短持续时间判断,是时间维度的过滤,未达时长不进入活跃态。onActionCancel里回滚offsetX,处理的是手势竞争被打断的情况,防止"拖到一半卡住"。- 删除按钮的
pendingDelete状态,就是破坏性操作的二次确认,第一次点击只切状态,第二次才真正执行。
PanGesture 和 PinchGesture 通过 GestureGroup(GestureMode.Parallel) 并行绑定在同一个组件上。这里有个容易踩的坑:不要对同一组件连续调用两次 .gesture()------后面的调用会覆盖前面的,表面上"两个手势都写上了",实际只剩最后一个生效。需要多个手势时一律用 GestureGroup 显式声明并行或串行关系,或者拆到不同命中区域。
案例:空间相册的"抓取---移动---落位"
一个空间化的相册应用,照片以卡片形式悬浮在用户面前。用户想把某张照片从"最近"分组移到"收藏"分组。这个操作如果用触摸,就是长按拖动;换成空中手势,设计要重做一遍。
方案是"抓取---移动---落位"三段式:
- 抓取:手掌呈抓握状对准一张照片,置信度超过 0.85 且保持 150ms 后,照片轻微上浮并跟随手部移动,同时源分组出现一个虚线空位。
- 移动:照片跟随手部水平移动,经过其他分组时,该分组整体提亮,表示"可以放这里"。
- 落位 :手张开(松手),照片落入当前高亮的分组,分组做一次收拢动画。若在手张开时没有任何分组被高亮,照片飞回原位并提示"未放入任何分组",而不是随便找个位置丢下。
这里的关键容错有两点。一是跟随有阻尼 :照片不会严格贴着手走,而是带一点延迟和柔化,这样手部追踪的抖动不会让照片疯狂晃,视觉上也更像"有重量"的实物。二是落位有明确判定:松手时如果没有合法目标,一律回退,不做猜测性放置。空间交互里"猜"比"不响应"更伤体验,因为用户无法理解系统为什么把东西放到了那里。
撤销也做了:落位后相册顶部出现"已移入收藏"的提示条,持续 5 秒,点它即可放回。5 秒足够用户反应过来,又不至于长期占位。
总结一下下
手势数量宁少勿多。识别能支持几十种手势不代表用户记得住。常用手势控制在 7 个以内,超出部分要么合并语义,要么让用户自定义。
每个手势都要有视觉预览。用户做手势的过程中,界面要实时反映"系统现在认为你在做什么",比如抓取时对象上浮、滑移时内容跟手。没有预览,用户不知道自己有没有做对。
容错的重心在"不做"而不是"纠正"。系统认不准的时候,最好的选择是保持现状,等用户重新做一次。自动"纠正"用户的意图,十次里有八次是错的。
给手势留退路。任何手势产生的状态变化都要能撤销,且撤销入口要一直可见到操作确认。空间里没有 Ctrl+Z 的肌肉记忆,得靠界面提醒。
容易出问题的地方哦
忽视手势竞争 。同一区域绑定多个手势,用户做一个动作可能同时满足两个的触发条件。要用 GestureGroup 明确声明串行或并行关系,不要依赖默认行为,默认行为在版本迭代里可能变。
onActionCancel 没写回滚。手势被系统接管或被打断时只触发 cancel 而不触发 end,如果只在 end 里提交状态,就会出现"操作做了一半没结束"的脏状态。cancel 回调必须把中间态清理干净。
把识别置信度当固定值。不同光照、不同手速、不同用户,识别置信度的分布都不一样。写死的 0.8 在某些用户身上会频繁误触,在另一些用户身上又怎么都触发不了。至少要提供灵敏度调节,理想情况下做自适应校准。
长时间不释放传感器和相机。手部追踪依赖相机或专用传感器,页面不可见时要及时暂停,否则既耗电又涉及隐私问题。追随时也要限制最大时长,用户手放下半天系统还在等,容易出意外。
手势反馈只做视觉。没有触觉,纯视觉反馈在快速连续操作时会漏看。震动(Vibrator)和短促音效是低成本的补充,一次"咔哒"就能让用户确认动作生效,这比一闪而过的动画可靠。