HarmonyOS ArkTS + Display Kit:半折叠态折痕区域的命中规避与布局回流【鸿蒙心迹】
半折叠设备上的“按钮看得见却不好点”,往往不是手势系统失灵,而是交互组件被放进了折痕区域。折痕不是一条抽象分隔线,它由系统返回一个或多个矩形;其位置会随显示模式、方向和设备形态变化。把某个机型的 y 坐标写进布局,只能得到一张看起来正确的截图,无法得到稳定的工程实现。
本文围绕演示项目 CreaseSafe 的 CheckoutPage 展开。示例任务编号为 FOLD-CREASE-0056,窗口演示尺寸为 1276 × 1840 px。设备从 EXPANDED 进入 HALF_FOLDED 后,折痕矩形为 top=878 px, height=36 px,原确认按钮中心位于 y=896 px,恰好落入折痕。布局回流后按钮中心移动到 y=822 px,并保留 24 vp 安全间距。文中数值来自可复现的演示夹具,不冒充特定量产设备的实测结论。

一、现象不是“偶尔点不中”,而是命中区域放错了
在完全展开状态,结算页由商品摘要、地址、优惠信息和底部确认区构成。设计稿把主按钮固定在视觉中心偏下的位置,这在普通直板屏和展开态上没有问题。半折叠后,页面仍能完整绘制,按钮也没有被系统裁掉,于是第一眼很容易把故障归因于点击节流、手势竞争或透明遮罩。
诊断页给出的线索更直接:状态从 EXPANDED 变为 HALF_FOLDED,折痕上边界是 878 px,高度为 36 px,因此禁入区是 [878, 914)。按钮中心 896 px 正好位于禁入区中间。视觉上它仍属于应用窗口,物理上却跨过了铰链及屏幕形变最明显的位置。此时继续放大按钮热区,只会把错误布局包装成更大的错误布局。
这类问题需要拆成三个判定:设备是否可折叠、物理折叠状态是否进入目标形态、当前显示模式下是否真的返回折痕矩形。三者不能互相替代。display.on('foldStatusChange') 报告的是物理状态变化;官方说明中也明确区分了折叠状态与显示模式变化的时序。工程上应在事件到达后重新读取当前折痕,而不是缓存启动时的几何数据。
另一个常见误区是只读取 creaseRects[0] 后永久假设设备只有一条折痕。接口返回数组本身就是重要信号:布局算法应能够处理零个、一个或多个矩形。单折设备可以只用第一个矩形演示,但数据结构不要提前把多折形态排除掉。
二、把折痕转换成页面自己的安全区
官方 Display API 返回的矩形单位是 px,而 ArkUI 布局常用 vp。若直接把 rect.top 填进 .position(),密度变化后会出现看似随机的偏移。CreaseSafe 选择在一个 CreaseMonitor 中完成单位转换,再把不可交互区发布给页面。
这段代码解决什么问题:监听折叠状态,并在每次变化后读取当前折痕矩形,转换为 ArkUI 可用的 vp 数据。
import { display } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
export interface CreaseBand {
topVp: number
heightVp: number
sourceTopPx: number
sourceHeightPx: number
}
export class CreaseMonitor {
private ui?: UIContext
private callback?: (status: display.FoldStatus) => void
private sink: (status: display.FoldStatus, bands: CreaseBand[]) => void
constructor(sink: (status: display.FoldStatus, bands: CreaseBand[]) => void) {
this.sink = sink
}
start(ui: UIContext): void {
if (this.callback) return
this.ui = ui
this.callback = (status: display.FoldStatus): void => this.publish(status)
display.on('foldStatusChange', this.callback)
this.publish(display.getFoldStatus())
}
private publish(status: display.FoldStatus): void {
try {
if (!display.isFoldable() || !this.ui) {
this.sink(status, [])
return
}
const region = display.getCurrentFoldCreaseRegion()
const bands: CreaseBand[] = region.creaseRects.map((rect: display.Rect) => ({
topVp: this.ui!.px2vp(rect.top),
heightVp: this.ui!.px2vp(rect.height),
sourceTopPx: rect.top,
sourceHeightPx: rect.height
}))
this.sink(status, bands)
} catch (e) {
hilog.error(0x0000, 'CreaseSafe', 'read crease failed: %{public}s', JSON.stringify(e))
this.sink(status, [])
}
}
stop(): void {
if (this.callback) display.off('foldStatusChange', this.callback)
this.callback = undefined
this.ui = undefined
}
}
这里刻意保留了 px 原值。vp 用于布局,px 用于日志和诊断,两者职责不同。若日志只记转换后的数字,真机问题回放时很难与系统返回值对齐。start() 还做了幂等保护,避免页面重复出现时注册多份回调。
状态变化是 EXPANDED → HALF_FOLDED,但安全区数组并不保证同步地从空变为非空。系统服务异常会抛出错误,非折叠设备也可能返回无折痕语义。示例采用“读取失败时不做自定义避让,同时记录诊断”的保守策略;业务若把确认按钮放在固定危险位置,则还应在安全区未知时切换到普通流式布局,而不是维持绝对定位。
最容易漏掉的是释放。监听在 aboutToAppear() 建立,就应在 aboutToDisappear() 中成对解除。仅把页面变量置空并不能移除系统回调,重复进入页面后会出现一条事件触发多次重排的现象。
三、不要让一次折叠动作触发四次昂贵重排
物理折叠、显示模式、窗口尺寸与方向变化可能在很短时间内连续到达。如果每个回调都立即重算页面树,用户会看到按钮先跳到上方、又短暂回落、最后再次上移。CreaseSafe 将原始事件先合并为一个布局事务,演示中四次输入只提交一次,layoutEpoch 从 21 增长到 22。
这段代码解决什么问题:把连续到达的折叠与窗口事件合并,避免同一形态切换反复提交布局。
export interface CreaseLayoutSnapshot {
status: display.FoldStatus
bands: CreaseBand[]
windowHeightVp: number
}
export class LayoutCommitter {
private timer: number = -1
private pending?: CreaseLayoutSnapshot
private readonly commit: (value: CreaseLayoutSnapshot) => void
constructor(commit: (value: CreaseLayoutSnapshot) => void) {
this.commit = commit
}
offer(value: CreaseLayoutSnapshot): void {
this.pending = value
if (this.timer !== -1) clearTimeout(this.timer)
this.timer = setTimeout(() => {
const latest = this.pending
this.timer = -1
this.pending = undefined
if (latest) this.commit(latest)
}, 80)
}
dispose(): void {
if (this.timer !== -1) clearTimeout(this.timer)
this.timer = -1
this.pending = undefined
}
}
这不是为了“拖慢响应”,而是为了让一次物理动作只产生一个稳定快照。80 ms 是演示参数,不是平台推荐值;实际项目要结合转场动画和窗口回调密度调节。状态在 offer() 时仍属于待确认,只有 commit() 执行后才成为页面使用的布局状态。
这里不能使用持续节流只保留第一帧,因为第一帧可能只有折叠状态,尚未得到最终窗口尺寸。采用尾部合并后,提交的是最新快照。反过来,若业务包含需要立即停止的危险操作,例如录像或支付确认,安全逻辑不应等待 80 ms;只应把视觉重排延后。
dispose() 与监听释放同样重要。页面离开后计时器若继续执行,会把已经失效的快照写入下一实例。实际项目中还要考虑多窗口:窗口尺寸变化不能只靠全局 display 推断,页面应同时接入当前窗口的区域变化,并把它合并到同一个快照中。

图中的开发界面是与本文数据一致的演示配图,不是实际 IDE 截图或真机测试证据。左侧项目树包含 CreaseMonitor.ets 与 CheckoutPage.ets,中间突出 getCurrentFoldCreaseRegion() 和 layoutEpoch,右侧模拟器展示半折叠态,底部 HiLog 对齐任务 FOLD-CREASE-0056。
四、命中规避不等于把所有内容挤到上半屏
折痕避让的核心不是“碰到就上移”,而是把组件按职责分组。预览内容适合停留在上半屏,表单和主要操作适合落在下半屏;若业务必须维持单列阅读,才需要计算最近的安全位置。本文的结算页保留商品摘要在上方,将主按钮从 896 px 回流到 822 px,其余说明区仍在折痕下方起始位置 946 px,避免整页突然缩成一团。
这段代码解决什么问题:计算按钮是否与折痕相交,并生成带安全间距的目标位置。
interface VerticalRange {
top: number
bottom: number
}
function overlaps(a: VerticalRange, b: VerticalRange): boolean {
return a.top < b.bottom && b.top < a.bottom
}
function resolveButtonCenterPx(
currentCenter: number,
buttonHeight: number,
creaseTop: number,
creaseHeight: number,
safeGapPx: number
): number {
const button: VerticalRange = {
top: currentCenter - buttonHeight / 2,
bottom: currentCenter + buttonHeight / 2
}
const crease: VerticalRange = {
top: creaseTop,
bottom: creaseTop + creaseHeight
}
if (!overlaps(button, crease)) return currentCenter
return creaseTop - safeGapPx - buttonHeight / 2
}
// 演示夹具:按钮高 64 px,安全间距 24 px
const targetCenter = resolveButtonCenterPx(896, 64, 878, 36, 24)
// targetCenter === 822
相交判断使用半开区间思路,边界刚好相切不算覆盖。回流公式不是把按钮顶部贴到折痕上边,而是同时扣除按钮半高和安全间距,所以目标中心为 878 - 24 - 32 = 822 px。这一结果与页面和图片保持一致。
若折痕矩形是横向分隔,以上算法有效;若未来设备返回竖向或多段折痕,则应升级为二维矩形避让,不能继续只比较 y 轴。算法也不应覆盖系统已有的布局能力。官方悬停适配资料中提到 FolderStack 可以按上下区域组织内容;页面结构符合其模型时,优先使用平台组件通常比自维护绝对坐标更稳。自定义计算更适合已有复杂布局或需要明确控制命中区的页面。

运行页把关键事实放在同一屏:HALF_FOLDED、折痕 878–914 px、按钮中心 822 px、任务 FOLD-CREASE-0056。红色细线只标出折痕禁入带和按钮移动方向,不把整张图变成标注海报。顶部状态栏时间为 04:08,电量 64%。
五、用诊断页证明状态闭环,而不是只证明 UI 好看
仅看最终页面无法判断回流是系统自然布局还是自定义算法生效。诊断页需要保留输入、决策和输出:输入是状态、窗口和折痕矩形;决策是相交检测与安全间距;输出是目标位置、事件合并数和命中测试结果。
演示夹具记录四个原始事件:折叠状态变化、显示模式变化、窗口高度变化和折痕刷新。合并器只提交一次,layoutEpoch 21 → 22。随后用七个落在原危险区附近的坐标进行离线命中回放:回流前有 7 次落在折痕带内,回流后为 0。这不是对某台真机触控准确率的宣称,而是对几何算法的确定性测试。
这段代码解决什么问题:把页面生命周期与监视器、合并器成对绑定,避免重复监听和离场后的延迟提交。
@Entry
@Component
struct CheckoutPage {
@State foldLabel: string = 'EXPANDED'
@State buttonCenterPx: number = 896
@State layoutEpoch: number = 21
private monitor = new CreaseMonitor((status, bands) => {
const first = bands[0]
const snapshot: CreaseLayoutSnapshot = {
status,
bands,
windowHeightVp: this.getUIContext().px2vp(1840)
}
this.committer.offer(snapshot)
if (!first) this.buttonCenterPx = 896
})
private committer = new LayoutCommitter((snapshot) => {
const band = snapshot.bands[0]
this.foldLabel = snapshot.status === display.FoldStatus.FOLD_STATUS_HALF_FOLDED
? 'HALF_FOLDED' : 'EXPANDED'
this.buttonCenterPx = band
? resolveButtonCenterPx(896, 64, band.sourceTopPx, band.sourceHeightPx, 24)
: 896
this.layoutEpoch += 1
})
aboutToAppear(): void {
this.monitor.start(this.getUIContext())
}
aboutToDisappear(): void {
this.committer.dispose()
this.monitor.stop()
}
}
为什么先 dispose() 再 stop()?因为停止监听前可能已有快照进入计时器队列,先取消延迟任务能避免离场后提交。顺序并非平台硬性规定,但要保持一致并写进组件契约。若 CreaseMonitor 被多个页面共享,则应使用引用计数或应用级生命周期,不能由任意页面直接关闭全局监听。

诊断图与运行图明显不同:它展示的是 4 events → 1 commit、layoutEpoch 21 → 22、命中失配 7 → 0,以及折痕原始矩形。红圈用于指出一次提交和零失配,而不是装饰。
六、边界条件决定这套方案能不能进生产
第一,非折叠设备必须是正常路径,而不是异常路径。display.isFoldable() 为 false 时返回空安全区,页面继续使用流式布局。不要为普通设备打印高等级错误,也不要展示“能力不可用”的打扰提示。
第二,折痕区域不能只在应用启动时读取。方向、显示模式和多折形态都可能改变矩形;事件后重读当前值,才符合 getCurrentFoldCreaseRegion() 的语义。与此同时要处理系统服务错误码,而不是假设同步调用永不失败。
第三,布局安全区与触控热区要使用同一份快照。若视觉位置已经更新,点击测试仍引用旧坐标,故障会从“按钮压折痕”变成“看得见但点不到”。动画期间尤其要明确命中区跟随策略:跟随每帧,或暂时禁用主操作,不能一半新一半旧。
第四,多折设备不要只认第一个矩形。本文用一个矩形讲清算法,但接口的数组结构必须贯穿模型。可以把每个交互组件与所有折痕矩形求交,再选择距离最小的安全方向。
第五,示例中的 1276 × 1840 px、878–914 px、80 ms 都是演示数据,不是通用常量。生产代码要以运行时查询和设计系统令牌为准。文中没有宣称在某型号真机上完成测试;进入发布流程前,仍需覆盖展开、半折、闭合、横竖屏、分屏及连续快速开合。
1. 测试矩阵要围绕形态转换,而不只是静态尺寸
普通响应式页面常按若干宽度档位截图验收,折叠态页面如果沿用这套方法,会漏掉最关键的过程问题。应把测试用例写成“起始形态—动作—结束形态—预期事务数”。例如从展开态缓慢折到半折态,预期进入一次稳定回流;在半折态旋转到横屏,预期重新读取折痕并计算新安全区;连续开合三次后停在展开态,预期最终快照属于展开态,计时器队列为空。
对每个用例,除了保存界面截图,还应记录原始折叠状态、显示模式、窗口宽高、折痕矩形数组、密度换算结果和布局代次。这样一旦出现“按钮多跳了一次”,可以判断是系统发送了额外事件,还是应用重复注册了监听。只有截图而没有事件轨迹,团队很容易在动画参数上反复试错,却没有触及状态重复提交的根因。
几何测试可以脱离设备运行。把 resolveButtonCenterPx() 当纯函数,为“完全在上方、完全在下方、与上边界相切、与下边界相切、完全覆盖折痕、多折痕逐个求交”建立表驱动用例。演示中的 896 → 822 只是其中一个夹具。纯函数测试不能替代真机,但能保证公式修改后不会把已知边界重新破坏。
2. 动画、焦点与无障碍需要同一份安全区
视觉回流若使用动画,焦点迁移不能滞后。主按钮从折痕中移出时,键盘焦点、无障碍焦点和触控命中区都应指向新的几何位置。最稳妥的做法是让语义节点与视觉组件保持同一组件身份,只改变布局参数;不要删除旧按钮再创建一个长得相同的新按钮,否则读屏用户可能听到重复节点,键盘用户也可能失去当前焦点。
如果回流会改变内容顺序,需要检查读屏顺序是否仍符合业务逻辑。视觉上把确认区移到折痕上方,并不意味着语义顺序也应跑到地址表单之前。对结算页这类高风险操作,主按钮在过渡期间还应避免重复点击:布局事务未提交时保持原业务状态,提交后再恢复交互,而不是在动画两端各保留一个可点击副本。
3. 观测指标要区分系统事件与业务提交
线上日志不要把每个折叠事件都当错误。更有价值的指标是一次形态切换收到多少原始事件、产生多少布局提交、提交用了多久、是否存在离场后提交,以及交互组件最终是否与任一折痕相交。前两项的比值可以发现事件合并失效,最后一项可以作为开发版断言或自动化巡检。
日志中的折痕坐标属于设备几何信息,不需要关联账号、订单或内容数据。诊断字段应围绕任务号、页面、窗口和布局代次,避免为了排查一个 UI 问题把不相关的业务隐私写入 HiLog。对于高频形态变化,可按会话采样或只在状态稳定时落一条汇总,防止日志本身制造性能抖动。
4. 交付前的最小检查清单
代码评审时可以用六个问题快速拦截回归:折痕坐标是否来自当前显示模式;px 是否只在系统边界与诊断层出现;所有监听和计时器是否成对释放;页面离开后是否还可能提交快照;视觉、命中和语义焦点是否共享同一布局代次;多折痕数组是否被完整遍历。任一问题答不上来,都说明实现仍依赖隐含假设。
验收人员则不需要理解内部类名,只需沿任务 FOLD-CREASE-0056 查看三组事实:折痕为 878–914 px,按钮最终中心为 822 px,布局代次只增加一次。若设备环境不同,应替换几何数值,但验证关系不变——按钮矩形不能与任何折痕矩形相交,连续事件最终只能留下最新稳定状态。
最后,把异常降级路径也纳入验收:读取折痕失败时页面仍可滚动并完成操作;回调注册失败时不会持续重试打满日志;普通设备不展示折叠诊断;快速退出页面后不再出现提交记录。成功路径证明功能可用,降级路径才证明它不会在设备差异中变成新的故障源。
七、结语:先把系统几何当事实,再谈页面美学
折叠屏适配容易被做成宽度断点问题,但半折叠态真正棘手的是物理结构进入交互区域。Display Kit 给出了状态和折痕矩形,ArkUI 负责把这些事实变成布局。两者之间还需要一层稳定的状态事务:转换单位、合并事件、计算相交、同步视觉与命中,并在生命周期结束时释放资源。
CreaseSafe 的判断标准很简单:任务 FOLD-CREASE-0056 中,按钮中心从 896 px 移到 822 px,折痕仍为 878–914 px;四个原始事件只产生一次提交,几何回放的失配从 7 降为 0。这些是演示夹具可复现的结果,不替代真机验收,却足以把一个“偶尔点不中”的模糊投诉,收敛为可验证、可维护的布局规则。
参考资料:
- 华为 HarmonyOS 多设备自适应应用资料:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
- OpenHarmony Display API 参考(
getCurrentFoldCreaseRegion、foldStatusChange):https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/js-apis-display.md
更多推荐



所有评论(0)