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 展开态下的智感握姿策略

展开态下,智感握姿的介入策略与折叠态有显著差异:

  1. 位移幅度更大 —— 展开态屏幕宽度约为折叠态的 2 倍,按钮位移距离也相应增大
  2. 动画时长调整 —— 展开态位移距离更长,动画时长需适当增加(建议 350-400ms)
  3. 触达区域重新计算 —— 展开态下拇指舒适区重新划定,需要重新评估哪些组件需要跟手
/**
 * 展开态下的智感握姿视觉参数
 */
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 交互设计

在这里插入图片描述

图:折叠屏阅读场景——章节导航栏根据握持手自动切换左右位置

折叠屏阅读场景的智感握姿适配要点:

  1. 章节导航栏跟随握持手出现在屏幕左侧或右侧
  2. 展开态下导航栏宽度适当增大,容纳更多章节信息
  3. 折叠态下导航栏宽度收窄,保持简洁
  4. 导航栏展开/收起动画需与折叠状态切换动画协调一致

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 代码要点解析

上述实现的关键设计决策:

  1. 导航栏宽度自适应:展开态 280vp,折叠态 220vp,充分利用大屏空间
  2. 动画时长差异化:展开态 400ms,折叠态 300ms,位移距离越大动画越长
  3. 入场动画方向:左手握持时从左侧滑入,右手握持时从右侧滑入,与手势方向一致
  4. 圆角方向跟随:左侧导航仅右侧圆角,右侧导航仅左侧圆角,视觉上更贴合屏幕边缘

五、悬停模式下的智感握姿适配

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 状态切换的连续性挑战

折叠屏用户在折叠态和展开态之间切换时,面临以下连续性挑战:

  1. UI 跳变 —— 折叠态和展开态布局差异大,切换瞬间可能出现 UI 闪烁
  2. 动画冲突 —— 折叠状态切换动画和智感握姿动画可能同时触发,产生冲突
  3. 状态丢失 —— 用户在折叠态输入的内容,展开后可能丢失或错位
  4. 交互中断 —— 切换过程中用户正在进行的操作可能被打断

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 开发自检清单

在折叠屏设备上接入智感握姿后,请逐项检查以下要点:

  1. 折叠状态检测:是否正确获取了 display.getFoldStatus() 和监听了 foldStatusChange
  2. 握持手状态监听:是否在折叠态和展开态下都能正确接收握持手变化?
  3. 双态布局差异:折叠态和展开态的布局是否有合理的差异化设计?
  4. 动画协调:折叠状态切换动画和智感握姿动画是否冲突?
  5. 悬停模式适配:半折叠状态下 UI 是否正确分离上下半屏?
  6. 状态连续性:折叠/展开切换过程中 UI 是否无跳变、无闪烁?
  7. 降级处理:不支持折叠屏或智感握姿的设备是否正常降级?
  8. 真机验证:所有测试是否在真实的折叠屏设备上完成?

9.2 常见的适配错误

错误 现象 原因 修复方案
折叠/展开时 UI 闪烁 切换瞬间出现空白或错位 动画未覆盖所有变化的属性 widthheightposition 等属性统一添加动画
展开态按钮位置不更新 展开后按钮仍在折叠态位置 foldStatus 未正确传递到 UI 层 使用 @State + @WatchFoldableSmartReachManager
悬停态控制区错位 上半屏显示控制按钮 未正确判断悬停态 使用 display.FoldStatus.FOLD_STATUS_HALF_FOLDED 判断
换手时动画卡顿 握持手切换时 UI 不流畅 防抖时间过短或动画曲线参数不当 调整防抖延迟至 150ms,使用 Spring 曲线

十、总结与展望

本文作为智感握姿系列的终章,聚焦折叠屏展开/折叠场景下的深度适配,核心要点如下:

  • 折叠状态感知:通过 display.getFoldStatus()foldStatusChange 事件准确获取设备折叠状态,与握持手状态联合感知
  • 双态布局策略:折叠态和展开态采用差异化的布局参数,展开态下按钮位移幅度更大、动画时长更长
  • 悬停模式适配:半折叠状态下上下半屏分离,下半屏控制区跟随握持手,上半屏内容区保持不变
  • 动画协调:使用 FoldAnimationQueue 管理折叠状态切换和智感握姿动画的优先级,避免冲突
  • 连续性保障:通过状态持久化、操作保护和过渡动画衔接,确保折叠/展开切换过程流畅无跳变

截至 2026 年上半年,已有超百款应用接入鸿蒙智感握姿,覆盖社交、阅读、理财、出行、娱乐等全场景。折叠屏正在从"小众尝鲜"走向"主流体验",智感握姿作为折叠屏交互的关键基础设施,其重要性将持续提升。

未来,随着阔折叠(如华为 Pura X Max)等新形态设备的普及,智感握姿还将在以下方向持续演进:

  1. 眼动翻页 —— 结合智感握姿和眼动追踪,实现"注视即操作"的无接触交互
  2. 多设备协同 —— 折叠屏与平板、PC 协同工作时,跨越设备的智感握姿适配
  3. AI 预测 —— 基于用户握持习惯的 AI 预测模型,提前预判用户意图并调整布局

智感握姿正在用润物细无声的方式,让"黑科技"真正改变生活中的小场景——你的每一次操作,都有智感握姿默默加持,让单手操作更加顺手。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐