HarmonyOS 智感握姿系列终章:折叠屏展开/折叠场景下的深度适配实践
HarmonyOS 智感握姿系列终章:折叠屏展开/折叠场景下的深度适配实践
前言
在前两篇文章中,我们分别讲解了智感握姿(Smart Reach)的基础概念与 API 用法,以及工业级自定义组件的适配方案。然而,智感握姿真正的价值高地,在于折叠屏设备——当屏幕从折叠态切换到展开态,可操作面积瞬间翻倍,但拇指可达区域反而相对缩小。此时,智感握姿的介入不再是"锦上添花",而是"雪中送炭"。
目前,鸿蒙版微信、小红书、Wind 金融终端、纵横小说、掌阅、QQ 音乐、腾讯视频等超百款应用已深度接入智感握姿,覆盖社交、阅读、理财、出行、娱乐等各大场景。本文将聚焦折叠屏展开/折叠场景下的适配实践,从屏幕状态感知、双态布局策略、折叠专属交互到悬停模式适配,逐一拆解。
一、折叠屏的交互挑战与智感握姿的价值
1.1 折叠屏的独特交互困境
折叠屏设备在展开状态下,屏幕尺寸远超直板手机。以华为 Pura X Max 为例,展开后的宽阔视野虽然带来了沉浸式体验,但也带来了三个核心交互困境:
| 困境 | 描述 | 传统方案的问题 |
|---|---|---|
| 拇指盲区扩大 | 展开后屏幕宽度增加,单手拇指覆盖面积占比从直板机的 40% 骤降至 15% 左右 | 用户被迫双手操作,失去折叠屏便携优势 |
| 远端操作疲劳 | 屏幕顶部和远端的按钮需要用户大幅度伸展拇指 | 长时间操作导致手部疲劳,误触率上升 |
| 握持切换频繁 | 用户在折叠态和展开态之间切换,握持手状态可能发生变化 | 静态布局无法适应形态变化,体验割裂 |
关键洞察:折叠屏展开后,智感握姿的重要性不是"翻倍",而是"指数级增长"。因为屏幕越大,拇指覆盖率的下降越剧烈,自适应布局的收益越显著。
1.2 智感握姿在折叠屏场景的核心价值

图:折叠屏展开态下,智感握姿将操作按钮自动位移到拇指舒适可达区域
鸿蒙版微信来电时,系统会自动识别用户左手还是右手握持,将接听键动态调整至拇指舒适区。无论单手提着咖啡赶路,还是在地铁上抓着扶手,无需调整握姿就能轻松接听。这种体验在折叠屏大屏上尤为关键——因为展开后拇指离屏幕远端的距离更远,如果没有智感握姿,用户几乎无法单手完成接听操作。
二、折叠屏状态感知技术栈
2.1 折叠状态检测 API
在接入智感握姿之前,首先需要感知设备的折叠状态。HarmonyOS 提供了 display 模块的折叠状态 API:
import { display } from '@kit.ArkUI';
/**
* 折叠屏状态管理器
*/
export class FoldableStateManager {
private static foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
/**
* 获取当前折叠状态
*/
static getFoldStatus(): display.FoldStatus {
try {
const status = display.getFoldStatus();
FoldableStateManager.foldStatus = status;
return status;
} catch (err) {
console.error(`获取折叠状态失败: ${JSON.stringify(err)}`);
return display.FoldStatus.FOLD_STATUS_UNKNOWN;
}
}
/**
* 监听折叠状态变化
*/
static onFoldStatusChange(callback: (status: display.FoldStatus) => void): void {
try {
display.on('foldStatusChange', (status: display.FoldStatus) => {
console.info(`折叠状态变化: ${status}`);
callback(status);
});
} catch (err) {
console.error(`注册折叠状态监听失败: ${JSON.stringify(err)}`);
}
}
/**
* 取消监听折叠状态变化
*/
static offFoldStatusChange(): void {
try {
display.off('foldStatusChange');
} catch (err) {
console.error(`取消折叠状态监听失败: ${JSON.stringify(err)}`);
}
}
/**
* 判断是否为折叠屏设备
*/
static isFoldableDevice(): boolean {
return display.isFoldable();
}
/**
* 判断当前是否处于展开状态
*/
static isExpanded(): boolean {
const status = FoldableStateManager.getFoldStatus();
return status === display.FoldStatus.FOLD_STATUS_EXPANDED;
}
/**
* 判断当前是否处于半折叠(悬停)状态
*/
static isHalfFolded(): boolean {
const status = FoldableStateManager.getFoldStatus();
return status === display.FoldStatus.FOLD_STATUS_HALF_FOLDED;
}
/**
* 判断当前是否处于折叠状态
*/
static isFolded(): boolean {
const status = FoldableStateManager.getFoldStatus();
return status === display.FoldStatus.FOLD_STATUS_FOLDED;
}
}
2.2 折叠状态枚举详解
| 枚举值 | 含义 | 典型场景 | 智感握姿策略 |
|---|---|---|---|
FOLD_STATUS_EXPANDED |
完全展开 | 大屏阅读、视频、游戏 | 全面启用,按钮位移幅度大 |
FOLD_STATUS_FOLDED |
完全折叠 | 外屏快捷操作 | 与直板机策略一致 |
FOLD_STATUS_HALF_FOLDED |
半折叠(悬停) | 桌面支架、拍摄 | 下半屏适配,上半屏保持 |
FOLD_STATUS_UNKNOWN |
未知状态 | 初始化中 | 降级处理,使用默认布局 |
2.3 折叠状态与握持手的联合感知
折叠屏适配的核心难点在于:折叠状态和握持手状态是两个独立变化的维度,必须联合处理才能给出正确的 UI 响应。
import { motion } from '@kit.MultimodalAwarenessKit';
import { display } from '@kit.ArkUI';
import { GripPoseTracker } from '../utils/GripPoseTracker';
/**
* 折叠屏 + 智感握姿联合感知管理器
* 同时监听折叠状态和握持手状态,统筹决策
*/
export class FoldableSmartReachManager {
private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.UNKNOWN_STATUS;
private listeners: Set<(foldStatus: display.FoldStatus, gripStatus: motion.HoldingHandStatus) => void> = new Set();
/**
* 启动联合感知
*/
start(): void {
// 1. 先获取初始折叠状态
this.foldStatus = display.getFoldStatus();
// 2. 监听折叠状态变化
display.on('foldStatusChange', (status: display.FoldStatus) => {
console.info(`折叠状态变化: ${this.foldStatus} -> ${status}`);
this.foldStatus = status;
this.notifyListeners();
});
// 3. 注册握持手状态监听
if (GripPoseTracker.isSupported()) {
const tracker = GripPoseTracker.getInstance();
tracker.addListener((status: motion.HoldingHandStatus) => {
this.gripStatus = status;
this.notifyListeners();
});
}
console.info('✅ 折叠屏联合感知管理器已启动');
}
/**
* 停止联合感知
*/
stop(): void {
display.off('foldStatusChange');
this.listeners.clear();
console.info('✅ 折叠屏联合感知管理器已停止');
}
/**
* 注册联合监听器
*/
addListener(
listener: (foldStatus: display.FoldStatus, gripStatus: motion.HoldingHandStatus) => void
): void {
this.listeners.add(listener);
// 立即通知当前状态
listener(this.foldStatus, this.gripStatus);
}
/**
* 移除联合监听器
*/
removeListener(
listener: (foldStatus: display.FoldStatus, gripStatus: motion.HoldingHandStatus) => void
): void {
this.listeners.delete(listener);
}
/**
* 通知所有监听器
*/
private notifyListeners(): void {
this.listeners.forEach((listener) => {
listener(this.foldStatus, this.gripStatus);
});
}
/**
* 获取当前折叠状态
*/
getFoldStatus(): display.FoldStatus {
return this.foldStatus;
}
/**
* 获取当前握持状态
*/
getGripStatus(): motion.HoldingHandStatus {
return this.gripStatus;
}
/**
* 判断是否为展开态 + 左手握持
*/
isExpandedAndLeftHand(): boolean {
return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED
&& this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD;
}
/**
* 判断是否为展开态 + 右手握持
*/
isExpandedAndRightHand(): boolean {
return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED
&& this.gripStatus === motion.HoldingHandStatus.RIGHT_HAND_HELD;
}
}
三、折叠态/展开态双布局策略
3.1 布局策略总览
折叠屏适配的核心是折叠态和展开态采用不同的布局策略,同时结合智感握姿做精细化调整:
| 屏幕状态 | 布局策略 | 智感握姿介入程度 | 典型案例 |
|---|---|---|---|
| 折叠态(外屏) | 与直板机一致,标准布局 | 正常介入,按钮位移幅度小 | 外屏快捷操作 |
| 展开态(内屏) | 大屏优化布局,内容密度提升 | 深度介入,按钮位移幅度大 | 阅读、视频、社交 |
| 半折叠(悬停) | 上下分屏布局 | 下半屏深度介入,上半屏保持 | 视频播放、拍摄 |
3.2 展开态下的智感握姿策略
展开态下,智感握姿的介入策略与折叠态有显著差异:
- 位移幅度更大 —— 展开态屏幕宽度约为折叠态的 2 倍,按钮位移距离也相应增大
- 动画时长调整 —— 展开态位移距离更长,动画时长需适当增加(建议 350-400ms)
- 触达区域重新计算 —— 展开态下拇指舒适区重新划定,需要重新评估哪些组件需要跟手
/**
* 展开态下的智感握姿视觉参数
*/
const EXPANDED_SMART_REACH_CONFIG = {
// 展开态下按钮位移动画时长更长
animationDuration: 400,
// 展开态下按钮位移距离更大
sideMargin: 24,
// 展开态下底部间距更大
bottomMargin: 100,
// 展开态专用动画曲线
animationCurve: curves.interpolatingSpring(0, 1, 170, 17)
};
/**
* 折叠态下的智感握姿视觉参数
*/
const FOLDED_SMART_REACH_CONFIG = {
// 折叠态下按钮位移动画时长较短
animationDuration: 300,
// 折叠态下按钮位移距离较小
sideMargin: 16,
// 折叠态下底部间距较小
bottomMargin: 80,
// 折叠态专用动画曲线
animationCurve: curves.interpolatingSpring(0, 1, 200, 17)
};
3.3 双态自适应 FAB 组件实现
import { motion } from '@kit.MultimodalAwarenessKit';
import { display } from '@kit.ArkUI';
import { FoldableSmartReachManager } from '../utils/FoldableSmartReachManager';
/**
* 折叠屏双态自适应 FAB 组件
* 根据折叠状态和握持手状态联合调整位置和样式
*/
@Component
export struct FoldableSmartReachFAB {
@State private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
@State private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.RIGHT_HAND_HELD;
@State private fabScale: number = 1.0;
private smartReachManager: FoldableSmartReachManager = new FoldableSmartReachManager();
aboutToAppear(): void {
this.smartReachManager.addListener(
(foldStatus: display.FoldStatus, gripStatus: motion.HoldingHandStatus) => {
this.foldStatus = foldStatus;
this.gripStatus = gripStatus;
// 展开态下 FAB 稍大一些,提升可点击区域
this.fabScale = foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED ? 1.15 : 1.0;
}
);
this.smartReachManager.start();
}
aboutToDisappear(): void {
this.smartReachManager.stop();
}
/**
* 获取当前 FAB 的位置参数
*/
private getFABLayoutParams(): FABLayoutParams {
const isExpanded = this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED;
const isLeftHand = this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD;
// 根据折叠状态选择不同的配置参数
const config = isExpanded ? EXPANDED_SMART_REACH_CONFIG : FOLDED_SMART_REACH_CONFIG;
return {
sideMargin: config.sideMargin,
bottomMargin: config.bottomMargin,
animationDuration: config.animationDuration,
animationCurve: config.animationCurve,
isLeftAligned: isLeftHand,
scale: isExpanded ? 1.15 : 1.0
};
}
build() {
// 获取布局参数
const layout = this.getFABLayoutParams();
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.plus'))
.fontSize(28)
.fontColor([Color.White])
}
.width(56 * layout.scale)
.height(56 * layout.scale)
.backgroundColor('#007AFF')
.shadow({
radius: 16,
color: 'rgba(0, 0, 0, 0.15)',
offsetY: 4
})
.position({
x: layout.isLeftAligned ? layout.sideMargin : '100%',
y: '100%'
})
.translate({
x: layout.isLeftAligned ? 0 : `-100%-${layout.sideMargin}`,
y: `-100%-${layout.bottomMargin}`
})
.animation({
duration: layout.animationDuration,
curve: layout.animationCurve
})
.onClick(() => {
console.info(`FAB 点击 - 折叠状态: ${this.foldStatus}, 握持手: ${this.gripStatus}`);
})
}
}
// 布局参数接口
interface FABLayoutParams {
sideMargin: number;
bottomMargin: number;
animationDuration: number;
animationCurve: ICurve;
isLeftAligned: boolean;
scale: number;
}
四、阅读场景实战:章节导航栏自适应
4.1 场景描述
阅读类应用(如鸿蒙版纵横小说、掌阅)在折叠屏展开后,屏幕宽度增大,用户通常用单手握住设备一侧。此时,阅读页面的章节导航栏和目录按钮如果固定在屏幕远端,用户几乎无法单手触达。通过智感握姿,可以让导航栏跟随握持手自动调整位置。
4.2 交互设计

图:折叠屏阅读场景——章节导航栏根据握持手自动切换左右位置
折叠屏阅读场景的智感握姿适配要点:
- 章节导航栏跟随握持手出现在屏幕左侧或右侧
- 展开态下导航栏宽度适当增大,容纳更多章节信息
- 折叠态下导航栏宽度收窄,保持简洁
- 导航栏展开/收起动画需与折叠状态切换动画协调一致
4.3 完整实现代码
import { motion } from '@kit.MultimodalAwarenessKit';
import { display } from '@kit.ArkUI';
import { FoldableSmartReachManager } from '../utils/FoldableSmartReachManager';
// 章节目录数据接口
interface ChapterInfo {
id: string;
title: string;
pageNumber: number;
}
@Component
export struct FoldableChapterNav {
@State private isNavVisible: boolean = false;
@State private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
@State private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.RIGHT_HAND_HELD;
@State private chapters: ChapterInfo[] = [];
private smartReachManager: FoldableSmartReachManager = new FoldableSmartReachManager();
aboutToAppear(): void {
this.smartReachManager.addListener(
(foldStatus: display.FoldStatus, gripStatus: motion.HoldingHandStatus) => {
this.foldStatus = foldStatus;
this.gripStatus = gripStatus;
}
);
this.smartReachManager.start();
this.loadChapters();
}
aboutToDisappear(): void {
this.smartReachManager.stop();
}
/**
* 加载章节目录数据(示例)
*/
private loadChapters(): void {
this.chapters = [
{ id: '1', title: '第一章 引言', pageNumber: 1 },
{ id: '2', title: '第二章 智感握姿概述', pageNumber: 12 },
{ id: '3', title: '第三章 API 详解', pageNumber: 25 },
{ id: '4', title: '第四章 折叠屏适配', pageNumber: 42 },
{ id: '5', title: '第五章 最佳实践', pageNumber: 58 }
];
}
/**
* 判断导航栏是否靠左
*/
private isNavLeftAligned(): boolean {
return this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD;
}
/**
* 获取导航栏宽度(展开态更宽)
*/
private getNavWidth(): number {
return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED ? 280 : 220;
}
/**
* 获取导航栏动画时长(展开态更长)
*/
private getNavAnimationDuration(): number {
return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED ? 400 : 300;
}
@Builder
buildNavToggleButton() {
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.listbullet'))
.fontSize(22)
.fontColor([Color.White])
}
.width(44)
.height(44)
.backgroundColor(this.isNavVisible ? '#FF9500' : '#007AFF')
.shadow({
radius: 8,
color: 'rgba(0, 0, 0, 0.15)',
offsetY: 2
})
.position({
x: this.isNavLeftAligned() ? 16 : '100%',
y: 100
})
.translate({
x: this.isNavLeftAligned() ? 0 : '-100%-16',
y: 0
})
.animation({
duration: this.getNavAnimationDuration(),
curve: curves.interpolatingSpring(0, 1, 200, 17)
})
.onClick(() => {
this.isNavVisible = !this.isNavVisible;
})
}
@Builder
buildChapterNavPanel() {
if (!this.isNavVisible) {
return;
}
Column() {
// 导航栏标题
Row() {
Text('章节目录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333')
Blank()
Button({ type: ButtonType.Normal }) {
Text('关闭')
.fontSize(14)
.fontColor('#007AFF')
}
.backgroundColor(Color.Transparent)
.onClick(() => {
this.isNavVisible = false;
})
}
.width('100%')
.padding(16)
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
// 章节列表
List() {
ForEach(this.chapters, (chapter: ChapterInfo, index: number) => {
ListItem() {
Row() {
Text(`${index + 1}. ${chapter.title}`)
.fontSize(15)
.fontColor('#333')
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`第${chapter.pageNumber}页`)
.fontSize(12)
.fontColor('#999')
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
.onClick(() => {
console.info(`跳转到: ${chapter.title}`);
this.isNavVisible = false;
})
}, (chapter: ChapterInfo) => chapter.id)
}
.width('100%')
.layoutWeight(1)
}
.width(this.getNavWidth())
.height('100%')
.backgroundColor(Color.White)
.borderRadius(this.isNavLeftAligned()
? { topRight: 16, bottomRight: 16 }
: { topLeft: 16, bottomLeft: 16 })
.shadow({
radius: 16,
color: 'rgba(0, 0, 0, 0.1)',
offsetX: this.isNavLeftAligned() ? 4 : -4
})
.position({
x: this.isNavLeftAligned() ? 0 : '100%',
y: 0
})
.translate({
x: this.isNavLeftAligned() ? 0 : '-100%',
y: 0
})
.transition(
this.isNavLeftAligned()
? TransitionEffect.OPACITY.animation({ duration: 300 })
.combine(TransitionEffect.translate({ x: -50 }))
: TransitionEffect.OPACITY.animation({ duration: 300 })
.combine(TransitionEffect.translate({ x: 50 }))
)
}
build() {
Stack() {
// 阅读内容区域(示意)
Column() {
Text('阅读内容区域')
.fontSize(16)
.fontColor('#999')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F8F8F8')
// 章节导航面板
this.buildChapterNavPanel()
// 导航切换按钮
this.buildNavToggleButton()
}
.width('100%')
.height('100%')
}
}
4.4 代码要点解析
上述实现的关键设计决策:
- 导航栏宽度自适应:展开态 280vp,折叠态 220vp,充分利用大屏空间
- 动画时长差异化:展开态 400ms,折叠态 300ms,位移距离越大动画越长
- 入场动画方向:左手握持时从左侧滑入,右手握持时从右侧滑入,与手势方向一致
- 圆角方向跟随:左侧导航仅右侧圆角,右侧导航仅左侧圆角,视觉上更贴合屏幕边缘
五、悬停模式下的智感握姿适配
5.1 悬停模式场景
折叠屏的悬停模式(半折叠状态)是折叠屏的独特形态。设备半折叠立在桌面上,上半屏显示内容,下半屏变为控制区。这种场景下,智感握姿的适配策略需要重新设计。
5.2 悬停模式下的适配策略
| 屏幕区域 | 适配策略 | 智感握姿角色 |
|---|---|---|
| 上半屏(内容区) | 保持原有布局,不跟手 | 不介入,避免干扰内容浏览 |
| 下半屏(控制区) | 控制按钮跟随握持手 | 深度介入,提升操作便捷性 |
| 折叠线区域 | 避免交互元素,防止误触 | 不放置任何可交互组件 |
5.3 悬停模式视频播放器实现
import { motion } from '@kit.MultimodalAwarenessKit';
import { display } from '@kit.ArkUI';
/**
* 悬停模式视频播放器控制栏
* 上半屏:视频画面
* 下半屏:控制按钮(跟随握持手)
*/
@Component
export struct HoverVideoPlayer {
@State private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
@State private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.RIGHT_HAND_HELD;
@State private isPlaying: boolean = false;
@State private currentTime: number = 0;
@State private totalTime: number = 120; // 示例:2分钟
aboutToAppear(): void {
// 监听折叠状态
display.on('foldStatusChange', (status: display.FoldStatus) => {
this.foldStatus = status;
});
this.foldStatus = display.getFoldStatus();
// 监听握持手状态
if (GripPoseTracker.isSupported()) {
GripPoseTracker.getInstance().addListener((status: motion.HoldingHandStatus) => {
this.gripStatus = status;
});
}
}
/**
* 判断是否为悬停模式
*/
private isHoverMode(): boolean {
return this.foldStatus === display.FoldStatus.FOLD_STATUS_HALF_FOLDED;
}
/**
* 格式化时间
*/
private formatTime(seconds: number): string {
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
@Builder
buildVideoArea() {
Column() {
Text('视频播放区域')
.fontSize(20)
.fontColor('#FFF')
}
.width('100%')
// 悬停模式:上半屏占 60%
.height(this.isHoverMode() ? '60%' : '100%')
.backgroundColor('#1A1A1A')
.justifyContent(FlexAlign.Center)
}
@Builder
buildHoverControlBar() {
if (!this.isHoverMode()) {
return;
}
Column() {
// 进度条
Slider({
value: this.currentTime,
min: 0,
max: this.totalTime,
style: SliderStyle.OutSet
})
.width('90%')
.trackColor('#E5E5E5')
.selectedColor('#007AFF')
.onChange((value: number) => {
this.currentTime = value;
})
// 时间显示
Row() {
Text(this.formatTime(this.currentTime))
.fontSize(12)
.fontColor('#999')
Blank()
Text(this.formatTime(this.totalTime))
.fontSize(12)
.fontColor('#999')
}
.width('90%')
.margin({ top: 4, bottom: 8 })
// 控制按钮组(跟随握持手)
Row({ space: 24 }) {
// 快退 15 秒
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.gobackward15'))
.fontSize(24)
.fontColor([Color.White])
}
.width(48)
.height(48)
.backgroundColor('#333')
.onClick(() => {
this.currentTime = Math.max(0, this.currentTime - 15);
})
// 播放/暂停
Button({ type: ButtonType.Circle }) {
SymbolGlyph(this.isPlaying
? $r('sys.symbol.pause')
: $r('sys.symbol.play'))
.fontSize(32)
.fontColor([Color.White])
}
.width(64)
.height(64)
.backgroundColor('#007AFF')
.onClick(() => {
this.isPlaying = !this.isPlaying;
})
// 快进 15 秒
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.goforward15'))
.fontSize(24)
.fontColor([Color.White])
}
.width(48)
.height(48)
.backgroundColor('#333')
.onClick(() => {
this.currentTime = Math.min(this.totalTime, this.currentTime + 15);
})
}
// 智感握姿:控制按钮组跟随握持手位移
.justifyContent(
this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD
? FlexAlign.Start : FlexAlign.End
)
.width('90%')
.animation({
duration: 350,
curve: curves.interpolatingSpring(0, 1, 170, 17)
})
// 音量调节
Row({ space: 8 }) {
SymbolGlyph($r('sys.symbol.speaker'))
.fontSize(18)
.fontColor([Color.White])
Slider({
value: 50,
min: 0,
max: 100,
style: SliderStyle.OutSet
})
.width(120)
.trackColor('#555')
.selectedColor('#007AFF')
}
.margin({ top: 12 })
}
.width('100%')
.height('40%')
.backgroundColor('#222')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 24 })
}
build() {
Column() {
this.buildVideoArea()
this.buildHoverControlBar()
}
.width('100%')
.height('100%')
.backgroundColor('#000')
}
}
六、折叠屏输入法键盘适配
6.1 场景描述
小艺输入法、搜狗输入法、讯飞输入法已支持鸿蒙智感握姿。在折叠屏展开态下,切换到单手模式后,键盘能自动适配握持方位,主动调整布局,贴合至手掌握持侧。地铁上拉吊环、超市里拎购物袋、遛狗时牵绳子——这些场景下,用户依然能单手流畅打字。
6.2 折叠屏输入法键盘适配策略
| 设备状态 | 键盘模式 | 智感握姿行为 | 键盘宽度 |
|---|---|---|---|
| 折叠态 + 左手握持 | 单手模式 | 键盘靠左收缩 | 屏幕宽度的 60% |
| 折叠态 + 右手握持 | 单手模式 | 键盘靠右收缩 | 屏幕宽度的 60% |
| 展开态 + 左手握持 | 单手模式 | 键盘靠左收缩 | 屏幕宽度的 45% |
| 展开态 + 右手握持 | 单手模式 | 键盘靠右收缩 | 屏幕宽度的 45% |
| 任意状态 + 双手握持 | 全键盘模式 | 键盘居中铺满 | 屏幕宽度的 90% |
6.3 自定义输入面板适配示例
import { motion } from '@kit.MultimodalAwarenessKit';
import { display } from '@kit.ArkUI';
/**
* 折叠屏自适应输入面板
* 模拟输入法键盘在折叠屏各状态下的适配行为
*/
@Component
export struct FoldableInputPanel {
@State private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
@State private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.RIGHT_HAND_HELD;
@State private isSingleHandMode: boolean = false;
@State private inputText: string = '';
// 键盘按键布局
private readonly keyRows: string[][] = [
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
['⇧', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '⌫'],
['123', '🌐', '空格', '。', '↵']
];
aboutToAppear(): void {
display.on('foldStatusChange', (status: display.FoldStatus) => {
this.foldStatus = status;
});
this.foldStatus = display.getFoldStatus();
if (GripPoseTracker.isSupported()) {
GripPoseTracker.getInstance().addListener((status: motion.HoldingHandStatus) => {
this.gripStatus = status;
});
}
}
/**
* 获取键盘宽度百分比
*/
private getKeyboardWidthPercent(): string {
if (!this.isSingleHandMode) {
// 全键盘模式:90%
return '90%';
}
// 单手模式:展开态 45%,折叠态 60%
return this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED ? '45%' : '60%';
}
/**
* 获取键盘对齐方式
*/
private getKeyboardAlignment(): FlexAlign {
if (!this.isSingleHandMode) {
return FlexAlign.Center;
}
return this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD
? FlexAlign.Start : FlexAlign.End;
}
/**
* 获取键盘圆角方向
*/
private getKeyboardBorderRadius(): BorderRadiuses {
if (!this.isSingleHandMode) {
return { topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 };
}
return this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD
? { topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 }
: { topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 };
}
@Builder
buildKeyButton(key: string) {
Button(key)
.fontSize(18)
.fontColor('#333')
.backgroundColor('#FFF')
.borderRadius(8)
.width(key === '空格' ? 120 : (key === '↵' || key === '⇧' || key === '⌫' ? 52 : 40))
.height(44)
.shadow({
radius: 1,
color: 'rgba(0, 0, 0, 0.1)',
offsetY: 1
})
.onClick(() => {
this.handleKeyPress(key);
})
}
/**
* 处理按键按下
*/
private handleKeyPress(key: string): void {
switch (key) {
case '⌫':
this.inputText = this.inputText.slice(0, -1);
break;
case '空格':
this.inputText += ' ';
break;
case '↵':
console.info(`提交文本: ${this.inputText}`);
break;
case '⇧':
case '123':
case '🌐':
case '。':
break;
default:
this.inputText += key;
break;
}
}
build() {
Column() {
// 输入文本显示区
Column() {
Text(this.inputText || '请输入文本...')
.fontSize(16)
.fontColor(this.inputText ? '#333' : '#CCC')
.width('100%')
.padding(16)
.textAlign(TextAlign.Start)
}
.width('100%')
.height(120)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.margin({ left: 16, right: 16, top: 50 })
// 模式切换按钮
Row() {
Text(this.isSingleHandMode ? '单手模式' : '全键盘模式')
.fontSize(14)
.fontColor('#007AFF')
Toggle({ type: ToggleType.Switch, isOn: this.isSingleHandMode })
.onChange((isOn: boolean) => {
this.isSingleHandMode = isOn;
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 16, top: 16, bottom: 8 })
Blank()
// 键盘区域
Column({ space: 8 }) {
ForEach(this.keyRows, (row: string[]) => {
Row({ space: 6 }) {
ForEach(row, (key: string) => {
this.buildKeyButton(key)
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
})
}
.width(this.getKeyboardWidthPercent())
.padding({ top: 12, bottom: 24, left: 8, right: 8 })
.backgroundColor('#E8E8ED')
.borderRadius(this.getKeyboardBorderRadius())
.animation({
duration: 350,
curve: curves.interpolatingSpring(0, 1, 200, 17)
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End)
.alignItems(
this.isSingleHandMode
? (this.gripStatus === motion.HoldingHandStatus.LEFT_HAND_HELD
? HorizontalAlign.Start : HorizontalAlign.End)
: HorizontalAlign.Center
)
.animation({
duration: 350,
curve: curves.interpolatingSpring(0, 1, 200, 17)
})
}
}
七、折叠屏状态切换的连续性保障
7.1 状态切换的连续性挑战
折叠屏用户在折叠态和展开态之间切换时,面临以下连续性挑战:
- UI 跳变 —— 折叠态和展开态布局差异大,切换瞬间可能出现 UI 闪烁
- 动画冲突 —— 折叠状态切换动画和智感握姿动画可能同时触发,产生冲突
- 状态丢失 —— 用户在折叠态输入的内容,展开后可能丢失或错位
- 交互中断 —— 切换过程中用户正在进行的操作可能被打断
7.2 连续性保障方案
| 挑战 | 解决方案 | 实现方式 |
|---|---|---|
| UI 跳变 | 过渡动画衔接 | 使用 animateTo 在折叠状态切换时统一驱动 UI 变化 |
| 动画冲突 | 动画队列管理 | 折叠状态切换动画优先执行,智感握姿动画延迟 200ms 启动 |
| 状态丢失 | 状态持久化 | 关键状态存入 AppStorage,切换前后保持一致 |
| 交互中断 | 操作保护 | 正在进行的操作不响应状态切换,等操作完成后再切换 |
7.3 状态切换动画队列实现
/**
* 折叠屏状态切换动画队列管理器
* 确保折叠状态切换动画和智感握姿动画不冲突
*/
export class FoldAnimationQueue {
private isAnimating: boolean = false;
private pendingFoldChange: display.FoldStatus | null = null;
private pendingGripChange: motion.HoldingHandStatus | null = null;
/**
* 处理折叠状态变化
*/
async handleFoldStatusChange(
newStatus: display.FoldStatus,
uiContext: UIContext
): Promise<void> {
if (this.isAnimating) {
this.pendingFoldChange = newStatus;
return;
}
this.isAnimating = true;
// 折叠状态切换动画(优先执行)
await this.animateFoldTransition(newStatus, uiContext);
// 延迟 200ms 再处理智感握姿动画
await this.delay(200);
// 处理挂起的智感握姿变化
if (this.pendingGripChange !== null) {
await this.animateGripChange(this.pendingGripChange, uiContext);
this.pendingGripChange = null;
}
this.isAnimating = false;
// 处理挂起的折叠状态变化
if (this.pendingFoldChange !== null) {
const pending = this.pendingFoldChange;
this.pendingFoldChange = null;
await this.handleFoldStatusChange(pending, uiContext);
}
}
/**
* 处理握持手状态变化
*/
async handleGripChange(
newStatus: motion.HoldingHandStatus,
uiContext: UIContext
): Promise<void> {
if (this.isAnimating) {
this.pendingGripChange = newStatus;
return;
}
await this.animateGripChange(newStatus, uiContext);
}
/**
* 折叠状态切换动画
*/
private animateFoldTransition(
status: display.FoldStatus,
uiContext: UIContext
): Promise<void> {
return new Promise<void>((resolve) => {
uiContext.animateTo({
duration: 400,
curve: curves.interpolatingSpring(0, 1, 170, 17),
onFinish: () => resolve()
}, () => {
// 在此处更新折叠状态相关的 UI 属性
console.info(`折叠状态切换动画完成: ${status}`);
});
});
}
/**
* 握持手状态变化动画
*/
private animateGripChange(
status: motion.HoldingHandStatus,
uiContext: UIContext
): Promise<void> {
return new Promise<void>((resolve) => {
uiContext.animateTo({
duration: 300,
curve: curves.interpolatingSpring(0, 1, 200, 17),
onFinish: () => resolve()
}, () => {
// 在此处更新握持手状态相关的 UI 属性
console.info(`握持手动画完成: ${status}`);
});
});
}
/**
* 延迟工具函数
*/
private delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), ms);
});
}
}
八、折叠屏适配的测试与验证
8.1 测试矩阵
折叠屏智感握姿的测试需要覆盖折叠状态 × 握持手状态的组合:
| 测试场景 | 折叠状态 | 握持手 | 验证要点 |
|---|---|---|---|
| 场景 1 | 折叠态 | 左手 | 按钮是否正确靠左、动画是否流畅 |
| 场景 2 | 折叠态 | 右手 | 按钮是否正确靠右、动画是否流畅 |
| 场景 3 | 展开态 | 左手 | 按钮位移幅度是否更大、大屏布局是否合理 |
| 场景 4 | 展开态 | 右手 | 按钮位移幅度是否更大、大屏布局是否合理 |
| 场景 5 | 折叠 → 展开 | 左手(不变) | 切换过程是否流畅、UI 是否跳变 |
| 场景 6 | 展开 → 折叠 | 右手(不变) | 切换过程是否流畅、UI 是否跳变 |
| 场景 7 | 展开态 | 左手 → 右手 | 换手动画是否平滑、不冲突 |
| 场景 8 | 悬停态 | 左手 | 上下半屏是否正确分离、控制区是否跟手 |
8.2 调试工具
/**
* 折叠屏智感握姿调试面板
* 在开发阶段可视化显示当前折叠状态和握持手状态
*/
@Component
export struct FoldableReachDebugPanel {
@State private foldStatus: display.FoldStatus = display.FoldStatus.FOLD_STATUS_UNKNOWN;
@State private gripStatus: motion.HoldingHandStatus = motion.HoldingHandStatus.UNKNOWN_STATUS;
@State private isDebugVisible: boolean = true;
aboutToAppear(): void {
display.on('foldStatusChange', (status: display.FoldStatus) => {
this.foldStatus = status;
});
this.foldStatus = display.getFoldStatus();
if (GripPoseTracker.isSupported()) {
GripPoseTracker.getInstance().addListener((status: motion.HoldingHandStatus) => {
this.gripStatus = status;
});
}
}
/**
* 获取折叠状态文本
*/
private getFoldStatusText(): string {
switch (this.foldStatus) {
case display.FoldStatus.FOLD_STATUS_EXPANDED:
return '展开';
case display.FoldStatus.FOLD_STATUS_FOLDED:
return '折叠';
case display.FoldStatus.FOLD_STATUS_HALF_FOLDED:
return '悬停';
default:
return '未知';
}
}
/**
* 获取握持手状态文本
*/
private getGripStatusText(): string {
switch (this.gripStatus) {
case motion.HoldingHandStatus.LEFT_HAND_HELD:
return '左手';
case motion.HoldingHandStatus.RIGHT_HAND_HELD:
return '右手';
case motion.HoldingHandStatus.BOTH_HANDS_HELD:
return '双手';
case motion.HoldingHandStatus.NOT_HELD:
return '未握持';
default:
return '未知';
}
}
build() {
if (!this.isDebugVisible) {
return;
}
Row({ space: 12 }) {
// 折叠状态指示
Row({ space: 4 }) {
Circle({ width: 8, height: 8 })
.fill(this.foldStatus === display.FoldStatus.FOLD_STATUS_EXPANDED
? '#34C759' : '#FF9500')
Text(`折叠: ${this.getFoldStatusText()}`)
.fontSize(11)
.fontColor('#FFF')
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(0, 0, 0, 0.6)')
.borderRadius(12)
// 握持手状态指示
Row({ space: 4 }) {
Text(`握持: ${this.getGripStatusText()}`)
.fontSize(11)
.fontColor('#FFF')
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(0, 0, 0, 0.6)')
.borderRadius(12)
}
.position({ x: '50%', y: 50 })
.translate({ x: '-50%', y: 0 })
}
}
九、折叠屏适配检查清单
9.1 开发自检清单
在折叠屏设备上接入智感握姿后,请逐项检查以下要点:
- 折叠状态检测:是否正确获取了
display.getFoldStatus()和监听了foldStatusChange? - 握持手状态监听:是否在折叠态和展开态下都能正确接收握持手变化?
- 双态布局差异:折叠态和展开态的布局是否有合理的差异化设计?
- 动画协调:折叠状态切换动画和智感握姿动画是否冲突?
- 悬停模式适配:半折叠状态下 UI 是否正确分离上下半屏?
- 状态连续性:折叠/展开切换过程中 UI 是否无跳变、无闪烁?
- 降级处理:不支持折叠屏或智感握姿的设备是否正常降级?
- 真机验证:所有测试是否在真实的折叠屏设备上完成?
9.2 常见的适配错误
| 错误 | 现象 | 原因 | 修复方案 |
|---|---|---|---|
| 折叠/展开时 UI 闪烁 | 切换瞬间出现空白或错位 | 动画未覆盖所有变化的属性 | 对 width、height、position 等属性统一添加动画 |
| 展开态按钮位置不更新 | 展开后按钮仍在折叠态位置 | foldStatus 未正确传递到 UI 层 |
使用 @State + @Watch 或 FoldableSmartReachManager |
| 悬停态控制区错位 | 上半屏显示控制按钮 | 未正确判断悬停态 | 使用 display.FoldStatus.FOLD_STATUS_HALF_FOLDED 判断 |
| 换手时动画卡顿 | 握持手切换时 UI 不流畅 | 防抖时间过短或动画曲线参数不当 | 调整防抖延迟至 150ms,使用 Spring 曲线 |
十、总结与展望
本文作为智感握姿系列的终章,聚焦折叠屏展开/折叠场景下的深度适配,核心要点如下:
- 折叠状态感知:通过
display.getFoldStatus()和foldStatusChange事件准确获取设备折叠状态,与握持手状态联合感知 - 双态布局策略:折叠态和展开态采用差异化的布局参数,展开态下按钮位移幅度更大、动画时长更长
- 悬停模式适配:半折叠状态下上下半屏分离,下半屏控制区跟随握持手,上半屏内容区保持不变
- 动画协调:使用
FoldAnimationQueue管理折叠状态切换和智感握姿动画的优先级,避免冲突 - 连续性保障:通过状态持久化、操作保护和过渡动画衔接,确保折叠/展开切换过程流畅无跳变
截至 2026 年上半年,已有超百款应用接入鸿蒙智感握姿,覆盖社交、阅读、理财、出行、娱乐等全场景。折叠屏正在从"小众尝鲜"走向"主流体验",智感握姿作为折叠屏交互的关键基础设施,其重要性将持续提升。
未来,随着阔折叠(如华为 Pura X Max)等新形态设备的普及,智感握姿还将在以下方向持续演进:
- 眼动翻页 —— 结合智感握姿和眼动追踪,实现"注视即操作"的无接触交互
- 多设备协同 —— 折叠屏与平板、PC 协同工作时,跨越设备的智感握姿适配
- AI 预测 —— 基于用户握持习惯的 AI 预测模型,提前预判用户意图并调整布局
智感握姿正在用润物细无声的方式,让"黑科技"真正改变生活中的小场景——你的每一次操作,都有智感握姿默默加持,让单手操作更加顺手。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
更多推荐

所有评论(0)