本文涉及 HarmonyOS 6.0(API 20) 的 MultimodalAwarenessKit 握持手感知能力。文中代码是为说明问题编写的完整示例,不是官方示例的搬运;API 名称、枚举取值与版本号等事实性信息均标注官方出处;智感握姿依赖真实传感器、部分机型不支持,涉及运行表现的部分已明确标注,未做任何实测数据编造。
本文涉及 HarmonyOS 6.0(API 20) 的 MultimodalAwarenessKit 握持手感知能力。文中代码是为说明问题编写的完整示例,不是官方示例的搬运;API 名称、枚举取值与版本号等事实性信息均标注官方出处;智感握姿依赖真实传感器、部分机型不支持,涉及运行表现的部分已明确标注,未做任何实测数据编造。
设想一个场景:你做了一个新闻阅读应用,右下方放了一个"写评论"的悬浮按钮。用户用左手单手握着一台 6.8 寸的大屏,拇指尖刚好够到屏幕中线偏左。那个按钮在右下角——离拇指差了小半个屏幕。
这不是你的布局写错了,是物理距离摆在那。大屏和折叠屏越来越普及,单手握持时,屏幕顶部和远离握持手的那一侧,拇指天然够不着。
HarmonyOS 6.0 给了一个标准解法:系统通过传感器识别设备被哪只手握着,把状态告诉应用,你再把高频按钮挪到拇指可达的位置。这件事官方叫 智感握姿 的握持手识别。
V哥原本以为这就是"监听一个事件、改个位置"。真正动手之后发现,难的是三件事: 先确认这台机器能不能感知、再把五种状态分诊清楚、最后别忘了在页面销毁时拆掉监听 ——任何一步漏了,表现都是"按钮不动",但原因完全不同。
这篇文章把这三步拆开,外加一个V哥自写的 HandAwareController 封装和一份上线自检清单。
握持手感知不是所有机器都有。官方在最佳实践中明确写到:"智感握姿依赖设备硬件传感器的支持,部分设备可能不具备握持检测能力。"( 智感握姿 · 最佳实践 )
所以订阅事件之前,第一件事是问系统:"你这台设备支持动作感知吗?"
import
{ canIUse }
from
'@kit.ArkTS'
;
const
supported =
canIUse
(
'SystemCapability.MultimodalAwareness.Motion'
);
这个字符串 SystemCapability.MultimodalAwareness.Motion 是系统能力标识,从 API 20(也就是 6.0)开始提供握持手状态获取。V哥的判断是: 把 canIUse 当成一道安检门,门没开就别订阅,直接走默认布局 。不要抱着"订阅了再说、不支持报错再处理"的心态——那样也能跑,但把降级逻辑分散到了 catch 里,不如在进门之前就分流干净。
这里还有个版本细节值得记:自定义交互感知(监听 holdingHandChanged )从 API 20 起可用,而 HDS 组件(如 HdsTabs )的原生智感握姿属性从 API 23 起才支持。也就是说, 如果你想兼容 6.0,就用自定义监听;想用组件属性零代码适配,得升到 6.1 。本文主线走 6.0 的自定义监听方案。
订阅之后,回调每次吐出来的是一个 motion.HoldingHandStatus 枚举值。官方 API 参考给出的五个取值如下( 动作感知能力 · API 参考 ):
| 状态 | 枚举 | 取值 | 含义 | V哥的处理策略 | | --- | --- | --- | --- | --- | | 未握持 | NOT_HELD | 0 | 手机放支架上 / 平躺桌面 | 回到默认锚点(按钮靠右),不做左右切换 | | 左手握持 | LEFT_HAND_HELD | 1 | 左手单手握着 | 按钮移到左侧,贴着握持手那侧 | | 右手握持 | RIGHT_HAND_HELD | 2 | 右手单手握着 | 按钮在右侧(即默认位置),无需移动 | | 双手握持 | BOTH_HANDS_HELD | 3 | 两只手一起握 | 屏幕基本够得到, 不切换 ,避免来回抖动 | | 未识别 | UNKNOWN_STATUS | 16 | 传感器没算出来 | 保持上一帧状态 ,别乱跳 |
这张表最关键的是最后两行,也是最容易翻车的地方:
所以五态分诊的口诀是:只对"明确的单手"做出反应,对"双手"和"没识别"保持上一帧。 这是V哥踩了一次抖动之后自己定的规则,比"五个状态各写一套"稳得多。
官方示例里,订阅、回调、反注册都是直接写在页面里的。V哥更建议把它们收进一个独立控制器,页面只关心"按钮该靠左还是靠右"。好处有三个:注册/反注册成对出现不容易漏、错误码集中处理、状态分发可以给多个页面复用。
import
{ motion }
from
'@kit.MultimodalAwarenessKit'
;
import
{
BusinessError
}
from
'@kit.BasicServicesKit'
;
export
type
HandStateListener
=
(
status: motion.HoldingHandStatus
) =>
void
;
export
class
HandAwareController
{
private
listeners
:
Set
<
HandStateListener
> =
new
Set
();
private
registered
:
boolean
=
false
;
private
readonly
callback = (
status
: motion.
HoldingHandStatus
):
void
=>
{
this
.
listeners
.
forEach
(
(
fn
) =>
fn
(status));
};
register
():
void
{
if
(
this
.
registered
) {
return
;
}
if
(!
canIUse
(
'SystemCapability.MultimodalAwareness.Motion'
)) {
return
;
}
try
{
motion.
on
(
'holdingHandChanged'
,
this
.
callback
);
this
.
registered
=
true
;
}
catch
(err) {
const
e = err
as
BusinessError
;
console
.
error
(
`hand-aware register failed, code=
${e.code}
`
);
}
}
unregister
():
void
{
if
(!
this
.
registered
) {
return
;
}
try
{
motion.
off
(
'holdingHandChanged'
,
this
.
callback
);
}
catch
(err) {
const
e = err
as
BusinessError
;
console
.
error
(
`hand-aware unregister failed, code=
${e.code}
`
);
}
this
.
registered
=
false
;
}
subscribe
(
fn
:
HandStateListener
):
void
{
this
.
listeners
.
add
(fn);
}
unsubscribe
(
fn
:
HandStateListener
):
void
{
this
.
listeners
.
delete
(fn);
}
static
preferRight
(
status
: motion.
HoldingHandStatus
):
boolean
{
switch
(status) {
case
motion.
HoldingHandStatus
.
RIGHT_HAND_HELD
:
case
motion.
HoldingHandStatus
.
BOTH_HANDS_HELD
:
return
true
;
case
motion.
HoldingHandStatus
.
LEFT_HAND_HELD
:
return
false
;
default
:
return
true
;
}
}
}
import
{
HandAwareController
}
from
'../common/HandAwareController'
;
import
{ curves }
from
'@kit.ArkUI'
;
@Entry
@Component
struct
ReaderPage
{
private
handCtrl
:
HandAwareController
=
new
HandAwareController
();
@State
isFloatingRight
:
boolean
=
true
;
aboutToAppear
():
void
{
this
.
handCtrl
.
subscribe
(
(
status
) =>
{
this
.
isFloatingRight
=
HandAwareController
.
preferRight
(status);
});
this
.
handCtrl
.
register
();
}
aboutToDisappear
():
void
{
this
.
handCtrl
.
unregister
();
}
build
(
) {
RelativeContainer
() {
if
(
this
.
isFloatingRight
) {
this
.
fab
(
TransitionEdge
.
END
, curves.
interpolatingSpring
(
0
,
1
,
170
,
17
));
}
else
{
this
.
fab
(
TransitionEdge
.
START
, curves.
interpolatingSpring
(
0
,
1
,
170
,
17
));
}
}
}
@Builder
fab
(
edge: TransitionEdge, curve: ICurve
) {
Row
() {
}
.
alignRules
({
right
: {
anchor
:
'__container__'
,
align
:
HorizontalAlign
.
End
},
bottom
: {
anchor
:
'__container__'
,
align
:
VerticalAlign
.
Bottom
}
})
.
transition
(
TransitionEffect
.
move
(edge).
animation
({ curve }))
.
width
(
56
)
.
aspectRatio
(
1
)
.
borderRadius
(
'50%'
)
.
backgroundColor
($r(
'sys.color.background_emphasize'
))
}
}
讲到注册和反注册,必须单挑出来说——这是V哥整篇最想让你记住的一点。
事件名拼错是静默失败,忘了 off 是内存泄漏——一静一响都要命。
V哥的做法是把 on/off 锁进 HandAwareController 的 register/unregister ,页面只在生命周期里调这两个方法,拼错事件名的概率从源头降为零——因为字符串只在控制器里出现一次。
按钮从右边绕到左边,不能"啪"地闪现。官方设计指南给了明确的动效规则:出场用 interpolatingSpring(0, 1, 200, 17) (stiffness=200,弹性稍强、出场利落),从屏幕外移入/移出用 interpolatingSpring(0, 1, 170, 17) (stiffness=170,弹性较柔、侧边绕行更自然)。
V哥自己的体会是: 这条曲线选错,按钮会"蹦"一下砸了体验。 比如把出场那根 200 的曲线套到侧边绕行上,按钮会像被弹弓打过去一样猛地弹到对面;反过来用 170 的做出场,首次出现又显得"软绵绵没精神"。两根弹簧的差别就在 stiffness 那一个数字,但用户手指能直接感觉到。
另一个要点:官方示例用的是"从屏外绕行"——旧按钮沿当前侧边滑出去消失,新按钮从对侧屏幕外滑进来,垂直高度不变、左右边距一致。这种做法比"原地左右平移"更自然,因为原地平移会让按钮横穿整个屏幕,视觉上更乱。
大屏和折叠屏上,握持手状态还要叠加"当前是折叠还是展开"。V哥的处理思路是 两层状态相乘,但只在必要时反应 :
实现上不复杂:把折叠态也作为一个状态变量,和 isFloatingRight 一起决定按钮位置。但要注意—— 展开态下如果仍频繁切换,抖动会比小屏更明显 ,因为按钮横移的绝对距离更长。V哥的判断是:展开态可以只保留"左手握持才移到左",其余一律默认,把切换频率压到最低。
不是所有组件都该接智感握姿。官方在最佳实践中明确写了两点不该接:低频/非操作类组件、广告/诱导类组件( 智感握姿 · 最佳实践 )。V哥补一条自己的: 涉及输入法和支付的敏感操作区,不要因为换手就挪位置 ——用户正输密码,按钮突然跳到左边,恐慌感远大于便利。
降级路径要写清楚,别等真机不支持时抓瞎:
V哥把上面的要点整理成一份可以贴进 PR 描述的清单:
本文涉及的事实性信息(API 名称、枚举取值、版本号、权限与系统能力标识)来自以下官方文档,文中的结构、代码示例、决策流程与自检清单为本人整理编写:
最后一句 :这个能力真正难的不是 API—— on 和 off 就两行。难的是 在动手前把三件事想清楚 :这台机器感不感知、五种状态各往哪摆、页面销毁时监听拆没拆。这三件事想清楚了,按钮才会乖乖跟着手跑;漏一件,按钮就一动不动,你还查不出为什么。
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Juejin HarmonyOS
原文作者:威哥爱编程;如有侵权,请联系管理员删除。