折叠屏手机有一个很容易被应用忽略的形态:半折立起来。用户把它像小笔记本电脑一样立在桌面上——追剧时上半屏放画面、下半屏放进度条;视频通话时上半屏看对方、下半屏放挂断和静音;厨房里跟着菜谱做菜,上半屏看步骤图,下半屏一步步翻;健身跟练更是典型,手机往地上一立,上半屏是教练的动作演示,下半屏是暂停和计数。
系统层面这个状态叫悬停态(半折叠),但对应用来说,它意味着一件具体的事:屏幕被铰链分成了上下两块,上块放给眼睛看的展示内容,下块放给手指点的操作控件。
不专门适配的应用在悬停态是什么样?内容全屏铺开,被折痕拦腰截断——一张照片正好压在铰链上,看也不是点也不是;操作按钮沉在屏幕最底部,手指得跨越整块下半屏才够得着。更别扭的是滚动手势:列表横穿折痕,上半屏滚动起来的时候,铰链两侧的画面在物理上有轻微的高差,滑动会显得"错位"。功能上没坏,体验上就是"这应用没想过我会把手机折一半用"。
下面我们把悬停态适配完整做一遍,先来看看效果图:
一、先把形态这件事说清楚
折叠屏对应用暴露的形态信号,全在一个枚举里:display.FoldStatus。枚举成员带 FOLD_STATUS_ 前缀:
enum FoldStatus {FOLD_STATUS_UNKNOWN = 0, // 状态未知,或设备不支持折叠FOLD_STATUS_EXPANDED = 1, // 完全展开FOLD_STATUS_FOLDED = 2, // 完全合拢(此时应用显示在外屏上)FOLD_STATUS_HALF_FOLDED = 3, // 半折叠:介于全开与合拢之间——悬停态就在这里// 以下为双轴折叠设备的组合值(since 15):FOLD_STATUS_EXPANDED_WITH_SECOND_EXPANDED = 11,FOLD_STATUS_EXPANDED_WITH_SECOND_HALF_FOLDED = 21,FOLD_STATUS_FOLDED_WITH_SECOND_HALF_FOLDED = 22,FOLD_STATUS_HALF_FOLDED_WITH_SECOND_HALF_FOLDED = 23,// …双轴还有其它组合}
*可左右滑动查看
三个基础值对应到界面上的三种状态。先把机型范围说清楚:这套描述以双折叠机型为蓝本——横向对折、内外双屏、展开后是一块接近方形的大屏。其它折叠形态对不上号:竖向翻盖的小折叠展开后是普通长条屏,半折立起的分区方向也不同;双轴折叠屏还有自己的组合值(第二节展开)。机型不同,三态对应的界面策略要重新映射,但枚举值和事件机制是同一套。沿用这套叫法:
折叠态(folded):屏幕合拢,应用跑在外屏上。外屏普遍 6 英寸上下、窄长条,和普通直板手机接近,但用户的使用姿态往往是"拿着看一眼就合上"——适合高信息密度的单栏浏览,不适合复杂操作。
展开态(expanded):屏幕全开,一块接近方形的大屏。空间充裕,适合多列网格、双栏结构。
悬停态(hover):屏幕半折立起。上下两块区域,各司其职。
还有一个概念要先分清:形态信号和宽度信号是两回事。窗口宽度(vp)能告诉你"现在是窄屏还是宽屏",这是断点体系的基础;但它区分不了"展开的大屏"和"半折立起来的大屏"——两者宽度几乎一样,差别只在铰链。反过来,foldStatusChange 事件能精确区分这三种折叠状态,但在不支持折叠的设备上永远不会触发。
二、感知链路:形态只认事件,宽度管布局断点
先说结论:折叠形态只有事件一个权威来源;窗口宽度只负责布局断点;手动开关是调试工具。三者不是层层替补,是各管各的:
1. 主力:foldStatusChange 事件 + 进入页面时读一次 getFoldStatus()。折叠屏设备上,开合、半折都会实时回调。
2. 宽度:布局断点,不是形态探测。窗口窄于 600vp 走单栏,宽于 600vp 走宫格,这是一多的常规断点,跟铰链无关。它给不出悬停态(宽度感知不了折叠),更不能拿它猜设备形态:竖折机型(上下折的小折叠)展开的内屏同样窄于 600vp,会被分进单栏——形态上叫错了,布局上恰好是对的,窄屏本就该单栏;折叠电脑这类宽屏设备则自然落进宫格。宽度层只用来判断「这个窗口该用什么布局」上,不用来判断「设备折没折」上。
3. 手动开关:调试与演示工具,不参与产品逻辑。任何设备上都能瞬间复现三种形态,开发调试、截图、录演示靠它;正式版本里可以直接隐藏。
为什么要设计宽度这一层?因为渲染开始前界面总得选一副面孔。普通手机上 foldStatusChange 永远不触发,getFoldStatus() 返回 FOLD_STATUS_UNKNOWN(0),形态没有来源——此时布局按宽度断点走:窄屏单栏、宽屏宫格,与普通的一多适配没有区别。折叠设备上事件一到,形态立刻只认事件。实现上用一个事件计数做移交:收到过至少一次事件(foldEventCount > 0)之后,形态判断不再参考宽度,宽度回归纯粹的布局职责。
事件注册放在 aboutToAppear,注销放在 aboutToDisappear,回调引用存成成员变量——off 必须传同一个函数引用才注销得掉。
双轴折叠的组合值(11、21、22 这些)没有一一映射,而是整体按宽度断点处理。这是个务实的选择:双轴设备的每个组合值该展示成什么形态,取决于产品的具体设计——第二根轴半折时第一根轴全开,你可以说它"接近展开"也可以说它"带有悬停特征",没有普适答案。示例应用里假装它们不存在,行为也不会错:宽度断点给出的布局在大方向上总是合理的,真要适配双轴设备时,往 mapRawStatus 里加分支就行,翻译层的存在让这件事是加法而不是改架构。
三、悬停布局:五五分屏,上下各司其职
形态拿到了,悬停态的界面怎么组织?核心就一句话:上半屏给眼睛,下半屏给手指。所有只读的、展示性的内容放上半屏;所有可点的、可滑的操作放下半屏。折痕两侧各自完整,用户视线在上、手指在下,互不打架。
布局上用最朴素的结构实现——一个 Column 装两个 layoutWeight(1) 的区域,中间夹一条铰链分隔带。
几个设计决策展开说说。
为什么是五五分,而不是"上大下小"? 悬停态的物理现实是铰链停在中间附近,屏幕被物理地分成面积接近的两块。界面分割线和物理折痕错位得越多,"内容跨在缝上"的违和感越强。部分机型的铰链停摆角度偏上或偏下,屏幕上下两块并不严格等分——严谨的做法是查询折痕区域按实际位置分割;但以五五分起步是最稳的通用解,视觉误差在多数机型上可以接受,真要精调再单独处理。
铰链分隔带是一道视觉暗示,不是装饰。 悬停态下屏幕中间横着一道物理折痕,界面如果对此毫无表示,用户会怀疑内容是不是被"截断"了。分隔带做一条 4vp 高、左右各留 24vp 边距的微亮渐变线,等于告诉用户"这里本来就有条缝,内容没丢"。实现就是一个空 Column 挂渐变。
上半屏不放任何交互。悬停态的上半屏往往离用户半臂远(设备立在桌上),点它本身就是反人体工学的操作;切照片、收藏全部走下半屏的胶片条和按钮。上半屏唯一要做的是把照片 objectFit(ImageFit.Contain) 完整放下,宁可留黑边不裁切——照片的构图信息比屏幕利用率重要。
下半屏内容垂直居中。胶片条和操作按钮整体落在下半屏中央,上下各留呼吸空间。有的实现会把操作条贴着屏幕最底边放,那是手机单手握持时代的惯性——悬停态设备立在桌面上,没有"拇指够不到顶部"的问题,居中反而最舒服,也离上半屏的视线动线更近,"看一眼、点一下"的往返距离最短。
下半屏顶部的信息行(第几张 / 共几张 · 名字 · 收藏状态)放在操作区而不是照片上,有三个用处:读数贴着操作放,视线不用跨折痕往返;折叠外屏上它原样复用,一行代码不用改;它还是状态保持的"活证据"——开合来回切,这行字不动,说明数据没丢,比任何日志都直观。
形态切换带个轻动画。 三种形态在 build() 里用 if/else 分发,每个舞台根部挂 TransitionEffect.OPACITY 和 scale 的组合过渡,260ms、EaseOut。折叠开合本身是个物理动作,界面硬切会显得突兀,轻微的缩放淡入把这个过程抹平。用 TransitionEffect 的链式 API 而不是对象字面量——后者在新版本工具链上直接不给过。
四、状态保持:形态是视图层的事,数据层一个字段都不动
三形态适配最容易出现的问题不是布局写不出来,而是切换形态后状态丢了:展开态看到第 7 张照片,折起来再展开,回到第 1 张;刚收藏的照片,半折一下再展开,红心没了。病根通常是把状态放在了各形态组件的内部——形态一切换,组件销毁重建,状态跟着陪葬。
解法是把状态的归属想清楚:正在看第几张、哪些照片被收藏,这些是"数据层"的事;用什么布局展示,这些是"视图层"的事。形态切换只该影响视图层。
修改状态的函数全遵循同一个范式:显式拷贝每个字段 → 修改拷贝 → 返回新对象。
五、三个舞台的组装:宫格、浏览层和调试条
最后把页面骨架串起来。build() 里按形态分发到三个舞台,共享状态和动作以参数与回调的形式喂进去。
FoldedStage(折叠外屏):竖向单栏,大图占主体(layoutWeight(1)),下方一行 44vp 的小胶片和紧凑操作行。外屏窄,信息密度要克制,一屏只做一件事:看这张照片。
HoverStage(悬停):第三节详述的五五分屏。
ExpandedStage(展开)是三个里结构最丰富的,值得完整看一遍——宫格和大图浏览并存,浏览层像一张盖在宫格上的"纸"。
调试条(FormDebugBar)实时显示四个值:原始事件值、折叠事件计数、窗口宽度、当前生效的形态来源(传感器还是手动)。
四格指标各有用途:事件值告诉你系统现在报的是哪个数(对照枚举表就知道设备处于什么物理状态);事件计数为 0 意味着事件从没来过、布局正按宽度断点走——悬停态显示不出来是正常表现,不是缺陷;窗口宽度用来对照断点核验布局选择;最终形态是翻译层的输出结果。
六、还能往哪扩展
这个相册把悬停态的主干走通了,往深做还有几个方向。
按真实折痕位置分割。 五五分是通用近似,部分机型铰链停摆位置不在正中,系统提供折痕区域的查询能力。拿到折痕实际位置后,把上下两块的 layoutWeight 从 1:1 换成按比例分配,分隔带挪到真实折痕处——结构完全不变,只是数字从常量变成读数。建议先把五五分的版本做扎实,有实测机型数据再精调,上来就追精确折痕容易把主流程搅复杂。
双轴设备的形态映射。 FoldStatus 里那组 11、21、22、23 的组合值,对应两根铰链各自的姿态。示例里统一按宽度断点处理,真实产品要逐个决策:EXPANDED_WITH_SECOND_HALF_FOLDED 到底算"大屏"还是"带悬停的大屏",每个组合值给一个明确答案再加分支映射。
悬停态的专属交互。 上下分屏稳定之后,可以更进一步:上屏做沉浸(全屏照片、隐藏信息条),下屏做效率(双排胶片、快捷手势)。分屏的价值不是"把一个界面切成两半",而是上下两块可以有不同的信息密度和交互节奏——上屏慢(浏览),下屏快(操作),这个对比才是悬停态体验的上限。
这些场景里有一类特别顺:上下两块内容在三态之间就是同一批组件换位置,系统为此封装了现成容器,下一节我们用视频播放讲解一下;其余场景(跟练、提词)的下屏内容随阶段切换、结构在重组,仍走本文的自绘路线。
七、悬停观影:系统封装的 FolderStack,声明式完成整块迁移
相册走的是自绘路线——三种形态三套内容结构,只能自己画。还有一类悬停场景天然适合声明式:上下两块内容在展开、折叠、悬停之间就是"同一批组件换位置",比如视频播放——画面块和控制块在任何形态里都是这两块,悬停时画面挪上去、控制沉下来。ArkUI 为这类场景封装了现成容器 FolderStack(API 11 起提供)。
简单总结一下:子组件用 id 标记,构造参数 upperItems 列出"悬停时挪到上屏"的那批 id,其余子组件自动堆到下屏,中间隔着折痕。开发者不写一行形态判断,迁移由系统的悬停模式驱动。组件自带两个回调:onHoverStatusChange 给出 isHoverMode、旋转方向和窗口模式;onFolderStateChange 在 d.ts 里注明仅横屏生效。两个需要注意的地方:1,FolderStack 的父节点不能是 if/else 条件渲染节点,否则悬停能力整体失效;2,enableAnimation 控制迁移是否带动画,默认是打开的。
我们用视频播放来演示一下,布局层就这么多:
FolderStack({ upperItems: ['videoStage'] }).alignContent(Alignment.Bottom).enableAnimation(true).onHoverStatusChange((param: HoverEventParam) => {this.isHover = param.isHoverMode}).width('100%').layoutWeight(1) {XComponent({ id: 'hover_video_surface', type: XComponentType.SURFACE, controller: this.controller }).id('videoStage').width('100%').height('100%')Column({ space: 10 }) {// 标题、进度条、播放按钮、换源胶囊……}.height(this.isHover ? '50%': '100%').justifyContent(this.isHover ? FlexAlign.Start : FlexAlign.End).backgroundColor(this.isHover ? '#101826' : '#8C000000')}
*可左右滑动查看
展开态时它是普通 Stack:画面全屏,控制条经 alignContent(Bottom) 贴底、半透明叠在画面上,就是常规播放器的样子;进入悬停态,videoStage 自动转至上半区避开折痕,控制条落进下半屏,面板换成不透明底色——高度、排布、颜色都跟着 onHoverStatusChange 给的 isHoverMode 走。
播放器这边需要注意的是:AVPlayer 挂在 XComponent 的 surface 上,形态迁移只是重新布局,surface 不重建,视频流不断。
常见悬停场景的内容分工与适配路线,可以参考下面这张表:
判断标准只有一条——三态之间是不是同一批组件换位置:是,FolderStack 天然支持;不是,自绘分区。实际开发时根据真实场景自己选择即可。
八、动手前过一遍这份清单
悬停态适配的全部内容到这里就闭环了。回头看,这条链路一共四段:系统报一个带前缀的枚举值,翻译层把它变成自家形态;事件缺席时宽度顶上,事件到场后接管权威;悬停布局上下分区、各司其职;三副面孔共享一份状态机,怎么折都不丢数据。每一段单独看都不复杂,串起来就是一条完整的悬停态链路,断在哪一环都查得着、补得上。收尾把容易漏的点压成一份清单,接新页面时对着走,比出问题再翻代码快得多:
感知层
取当前折叠状态用 display.getFoldStatus()(模块函数),不是 Display 对象属性;
枚举成员带 FOLD_STATUS_ 前缀:FOLD_STATUS_HALF_FOLDED 才是悬停/半折,没有 HOVER 这个值;
事件回调存成员变量,off 时传同一个引用;
进页面先读一次当前值,别等第一次开合才有形态;
Area.width 是 vp,display.width 是 px——除密度只除一次,固定在一层做
普通设备永远收不到折叠事件:布局按宽度断点走(一多常规),事件到来后形态只认事件(事件计数是移交开关);
初始形态从窄往宽切(先 folded 后 expanded),视觉上更稳;
留一排手动形态开关,调试、演示、截图三得。
布局层
悬停态五五分:上屏只读、下屏操作,折痕处放视觉分隔带;
上屏不做交互、内容宁可 Contain 留边不裁切;
形态切换用 if/else 分发 + TransitionEffect 过渡,别做三套页面;
上下两块内容在三态间是同一批组件换位置 → 优先 FolderStack(父节点不能是 if/else);内容结构随形态重组 → 自绘分区;
三形态共享一份状态机,舞台组件只留 @Param + @Event,自己不存业务数据。
数据层
修改走"拷贝字段 → 改拷贝 → 赋新对象",@Local 赋新引用才刷新;
ForEach 键值编入会变的字段(如 liked),否则该字段变化界面不动。
回到开头那句话:悬停态适配不是"多支持一种屏幕",而是承认屏幕中间多了一条缝,把内容区和操作区安放到缝的两侧。想通这一点,所有悬停场景都是同一个心智模型——上屏给眼睛,下屏给手指,状态机端坐中央,任凭屏幕折来折去。
*文中代码示例仅用于演示核心逻辑,实际开发请结合业务具体需求进行调整。
来源:HarmonyOS开发者社区
本文作者:@袁震_harmony
声明:经授权发布,观点仅代表作者本人,不代表官方立场
关注公众号,获取更多 HarmonyOS 技术内容
*广告
往期回顾 | HarmonyOS
HarmonyOS 7(API 26)平行视界 EasyGo 实战:配置文件接入应用内分屏,1:2/2:1 随意切
点击下方??【】,直达原文。
股票
板块
用户
资讯
评论 (0)