iPhone Duo 折叠屏适配实践:从 displayFeatures 到自研 Duo Bar

苹果入局折叠屏后,iPhone Duo 带来了一种新形态:柔性内屏 + 右侧硬件条(摄像头 + 状态簇)。本文记录一次完整的 Flutter 适配实践——从 Flutter 多屏适配的底层原理讲起,弄清 iPhone Duo 在设计上与 Android 折叠屏的本质差异,剖析 foldable 插件的实现手法,最后落地到一个自研的 trailing bar 组件。文中所有结论均来自对 Flutter engine/framework 源码、iOS 27 SDK 头文件和 iPhone Duo 模拟器的实际验证。

一、Flutter 多屏适配的基本原理

1.1 传统适配:尺寸与安全区

Flutter 的响应式适配长期围绕 MediaQuery 展开:size 给屏幕尺寸,padding/viewPadding 给系统侵占区(刘海、Home 指示条、圆角)。绝大多数适配需求——手机/平板、横竖屏——用 LayoutBuilder + MediaQuery 就能解决。

但折叠屏带来了一个尺寸之外的新维度:屏幕上有一条”物理上特殊”的区域(铰链或折痕),以及设备可以半开。仅靠 size 和 padding 表达不了这两件事。

1.2 displayFeatures:表达“屏幕上的特殊区域”

Flutter 在 MediaQuery.displayFeatures(底层是 dart:ui.DisplayFeature)中提供了标准答案。每个 DisplayFeature 有三元组:

1
2
3
4
5
DisplayFeature {
Rect bounds; // 区域在屏幕上的矩形
DisplayFeatureType type; // hinge | fold | cutout
DisplayFeatureState state; // postureFlat | postureHalfOpened | postureOpened | unknown
}

三个 type 的语义:

  • hinge:物理铰链,屏幕被分成两块独立的显示区域(如 Surface Duo)。内容跨不过去。
  • fold:柔性屏的折痕,屏幕在物理上是一块(如三星 Fold)。内容可以跨,但跨折痕的体验未必好。
  • cutout:打孔/刘海。

type 和 state 是正交的两个维度:hinge/fold 说的是”这条缝是什么”,posture 说的是”设备折到什么程度”。半折叠不是一种 type,而是 fold(或 hinge)配上 state = postureHalfOpened。

1.3 Android 上这些值是谁填的

dart:ui 的注释明确写着 displayFeatures 只在 Android 填充。来源在 engine 的 FlutterView.java:

  • 注册 AndroidX WindowManager 的 WindowLayoutInfo 回调,把 FoldingFeature 映射过来:occlusionType == FULL → hinge,NONE → fold;FLAT → postureFlat,HALF_OPENED → postureHalfOpened。
  • 另外把 DisplayCutout 映射为 cutout(同时并入 MediaQuery.padding)。

所以 Android 折叠屏上,Flutter 应用不用写任何平台代码就能拿到完整的折叠信息。

1.4 谁会消费 displayFeatures

框架级的消费者是 DisplayFeatureSubScreen:showDialog/bottomSheet/popup 路由如果发现 displayFeatures 里有 hinge/fold,会把弹窗的边界钳制到折缝的一侧——两半屏各弹各的菜单。这个行为是发布 displayFeatures 的隐形成本,第三节会再次提到它。

二、iPhone Duo 设计上的不同

2.1 柔性单屏,不是铰链双屏

Android 折叠屏的两条路线里,iPhone Duo 走的是柔性屏:内屏物理上是一块 7.6 英寸连续屏幕,只有折痕、没有铰链分隔。对应 displayFeatures 的词汇,它只有 fold,没有 hinge。

但这只是开始。真正让 iPhone Duo 与众不同的,是苹果把”折叠”和”硬件布局”揉成了一套全新的屏幕契约,其中最有代表性的就是 reserved regions(保留区域)。

2.2 Reserved Regions:Occlusion 与 Division

iOS 27.1 在 UIView 上新增了 reservedRegionsOfKind:options:,返回两类区域,语义截然不同:

Division(分割区域) Occlusion(遮挡区域)
代表 内屏中央的折痕(实测宽 40pt) 内屏 FaceTime 摄像头
是否真挡内容 否,只是建议布局分界 是,硬件物理遮挡,必须避让
isActive 语义 设备处于半折叠时才为 true;平放时仍为 40pt 宽但标记失活 该摄像头正在使用时才为 true
失活时还有意义吗 有——知道折痕将出现在哪 没有——摄像头不在用就等于不存在

这个区别直接决定了消费方式:布局上是否采用分栏,由 posture(折叠姿态)决定,而不是由 division 的 isActive 决定——因为它滞后(详见第三节);而 occlusion 只要 active 就必须避开,查询时也只查 active 的,失活的 occlusion 不携带任何信息。

2.3 右侧硬件条:safe area 的新形态

iPhone Duo 有一条约 84pt 宽的硬件竖条,集成摄像头和状态簇——但它并非时刻存在。实测它只出现在两种场景:内屏横屏(两个旋转方向)和合盖后的外屏;内屏竖屏展开时状态区仍在顶部,没有侧条。系统把竖条声明进 safe area,实测竖条姿态下 viewPadding = EdgeInsets(0, 0, 84, 34)——注意顶部 inset 归零,状态栏内容移进了竖条。

这条竖条天然适合做 iPadOS 风格的侧边栏,但它对存量 Flutter 代码有个立竿见影的”坑”:Scaffold 布局 FAB 时(floating_action_button_location.dart 的 _rightOffsetX)会减去 MediaQuery.padding.right,于是 FAB 距右缘变成 16 + 84 = 100pt,还横着一条被 safe area 白白吃掉的 84pt 内容区。这就是适配的核心动机。

2.4 系统用 size class 推理,还分 SDK 版本解锁能力

苹果自己的布局逻辑建立在 size class 上:合盖外屏是 compact 宽,展开内屏是 regular 宽——这比”宽度断点”更好的地方在于它同样适用于 Split View(设备没折叠,size class 也会变)。

另一个容易忽略的事实是能力随编译 SDK 分级解锁:

应用编译所用 SDK 在 iPhone Duo 上的表现
iOS 27 之前 正常跑,但保持熟悉的尺寸和纵横比
iOS 27 SDK 显示区域延伸到状态栏左侧
iOS 27.1 SDK 全屏到边、竖条、reserved regions 全部可用

reserved regions 只有 27.1 SDK 才存在——用旧 SDK 编译的应用即使在真机上拿到的 region 列表也是空的。铰链角度和姿态不受影响。

最后,苹果的官方指引值得记住:“不要为每种 pose 设计一套自定义布局”——布局由 posture 和 size class 驱动,原始角度只驱动动效和交互。

2.5 Flutter 看到的:什么都没有

把 2.1–2.4 所有这些 iOS 27 新能力摆在一起,再看 Flutter:dart:ui 只在 Android 填充 displayFeatures,同一套 Flutter 代码跑到 iPhone Duo 上,MediaQuery.displayFeatures 恒为空——折痕、摄像头区域、铰链角度,一条都拿不到。

这就是 foldable 插件存在的理由:用 platform channel 把 iOS 27 的这套屏幕契约翻译成 Flutter 的 FoldableState(angle / foldPosture / regions / sizeClass),让 Dart 侧重新看得见这台设备。

三、foldable 的实现细节与巧妙做法

foldable 插件的体量不大(Swift 侧约 1200 行),但几乎每一层都有值得学习的取舍。按数据流向拆解:

3.1 ObjC runtime 桥:一份源码编译所有 Xcode

铰链 API 只存在于 iOS 27.1 SDK。如果 Swift 源码里直接写 UIHingeInteraction,所有依赖者的 Xcode 都被迫升到 27.1。插件的默认路径完全绕开编译期类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Swift 源码里不出现任何 iOS 27 类型名
let cls = NSClassFromString("UIHingeInteraction") // 类动态查找
// initWithUpdateHandler: 是 designated initializer(init/new 均不可用),
// 转成 IMP 调用,block 作为参数
let created = ObjCRuntimeSupport.makeObject(
className: "UIHingeInteraction",
initSelector: "initWithUpdateHandler:",
argument: handler)
// addInteraction 对不遵守 UIInteraction 协议的对象会直接崩溃,
// 而这里无法在编译期检查——先查 conformance
guard ObjCRuntimeSupport.conforms(type(of: created),
toProtocolNamed: "UIInteraction")
// 属性一律 KVC 读取:hinge.status(rawValue 1=closed 2=partiallyOpen 3=fullyOpen)
// hinge.angle(单位是弧度,0=合拢,π=平)

收益是实打实的:旧 SDK 上 NSClassFromString 返回 nil,特性整体关闭,deployment target 可以停留在 iOS 15.0,插件”无条件依赖也安全”。源码里还有一条对照的 typed 路径(FOLDABLE_NATIVE_API flag,对着真头文件编译),两条路径在 Duo 模拟器上读数完全一致,capabilities.strategy 告诉你当前活的是哪条。

3.2 “有没有铰链”只有回调知道:流的生与死

iOS 不提供一个属性让你提前查询”这台设备有没有铰链”——唯一的信号是装上 UIHingeInteraction 之后,回调里 update.hinge 为 nil。所以插件在注册时就尽早把 interaction 挂上(此时 window 可能还没建好,于是 applicationDidBecomeActive 和 0.25s 重试兜底),而不是等 Dart 侧来问。

EventChannel 的收尾同样讲究:如果设备没有铰链,不能用 error 关流——Dart 端 EventChannel 的激活失败走的是 FlutterError.reportError,监听器拿不到数据、拿不到错误也等不到结束,StreamBuilder 会永远停在 waiting。正确做法是发 FlutterEndOfEventStream,让流干净地 onDone,这样非折叠设备上一个 StreamBuilder 能自然落到 ConnectionState.done。已经定型为”无铰链”的设备不会再有事件,流直接关闭,不浪费任何资源。

3.3 RegionLayoutObserver:逼 UIKit 替你发通知

UIKit 不提供 reserved regions 变化的通知,而且 regions 滞后于铰链:update 回调里 division 的 isActive 还是旧值——实测摊平后 3~14ms 才清、折叠后要等铰链停稳约 1 秒才置位;变化时 view 的 bounds 又不变,没有 layout 可监听。

插件发现的破局点是一条 UIKit 隐藏契约:在 layout 过程中读 region,该 region 变化时会再触发一轮 layout。于是挂一个什么都不画的空 UIView(RegionLayoutObserver),在它的 layoutSubviews 里读 regions、生成 kind:isActive:frame 签名、变了就重新 emit。模拟器实测:division 的每次变化后都有这样一轮 pass,而在 layout 之外读则一次都没有。签名去重避免了无谓广播;已定型无铰链的设备同样直接跳过。

查询参数的不对称也在这里体现:division 带 includeInactive(失活的折痕仍保留 40pt 帧,预知位置有用),occlusion 只查 active 的。

3.4 DuoMediaQuery:切面订阅,而不是一个粗粒度 InheritedWidget

Dart 侧的树内入口是 FoldableProvider + DuoMediaQuery(InheritedModel<FoldableAspect>)。widget 只订阅自己关心的切面:statusOf(context) 的读者不会因为角度扫过一度而重建,反之亦然;角度切面还带 0.5° 阈值,过滤掉物理抖动。这与 2.4 的苹果指引一一对应——姿态/尺寸类驱动布局(走 InheritedModel),逐帧动效走 Foldable.hingeAngleStream(不进 element 树)。

3.5 为什么 MediaQuery 桥是 opt-in,还有五条不变量

插件可以把 fold/cutout 发布进 MediaQuery.displayFeatures,但默认关。原因在 1.4:DisplayFeatureSubScreen 会把 dialog/sheet/popup 钳到折缝一侧,而 Material 体系里十一个文件继承了这个行为。模拟器实测(内屏 951pt 宽、40pt 折痕):

弹层 不桥接 full 模式
AlertDialog 951pt ~455pt
模态 bottom sheet 951pt ~455pt
DatePickerDialog 951pt ~455pt

Duo 的内屏是一块连续屏幕,把弹窗拦腰截断是错的。所以桥接分三档:none(默认)/cutoutsOnly(只避让摄像头)/full。无论哪档,有五条不变量兜底,保证这个包绝不会”意外地劈开你的布局”:

  1. 零宽区域永不发布(配合 postureHalfOpened 会满足 avoidBounds,以一条看不见的折痕劈开屏幕);
  2. 失活区域永不发布;
  3. fold 只在铰链读到 partiallyOpen 时发布——region 的 isActive 滞后且摊平后不再纠正,轻信它会让摊平的设备上每条 dialog 都被 40pt 折痕劈开;
  4. cutout 永远携带 DisplayFeatureState.unknown(dart:ui 有断言);
  5. 合盖期间什么都不发布(此时视图在外屏,没有折痕)。

将来 Flutter 自己开始在 iOS 上报 displayFeatures(#192515),桥会自动让位,fold 不会被发布两遍。

3.6 刻意不做的事

插件里没有 TwoPane widget。苹果自己在 iOS 27.1 提供了 Arrangements API(SwiftUI 的 ArrangementView),但 Flutter 侧一个忠实的移植只会重复 Row + Flexible + size class 判断已经能做的事。边界划得很干净:插件只负责”让 Dart 看见设备”,布局决策留给应用。

四、基于 foldable 的适配实践

有了 foldable 暴露的 regions,剩下的就是布局工程。目标很具体:竖条姿态下把 AppBar 的 actions 和 FAB 收进右侧竖条,页面内容避开条区与摄像头。

4.1 第一步:pose 判定——“现在是不是竖条布局”

系统没有”竖条在不在”的直接查询,但 viewPadding 的形状稳定可辨:横排姿态顶部有 inset(状态栏在顶上),竖条姿态顶部归零、状态簇移进右侧条。实测两种姿态:

姿态 viewPadding (l, t, r, b) 含义
内屏竖屏展开 (0, 82, 0, 34) 顶部 82pt 状态区——传统横排布局,无竖条
竖条姿态(内屏横屏 / 合盖外屏) (0, 0, 84, 34) 顶部归零,右侧 84pt 硬件条
1
2
3
4
5
6
7
8
9
10
11
DuoPose? resolveDuoPose(EdgeInsets viewPadding) {
// 横排姿态顶部有 inset;竖条姿态顶部为 0,状态簇移进侧条。
if (viewPadding.top != 0) return null;
if (viewPadding.right > 0 && viewPadding.left == 0) {
return DuoPose(side: .right, stripWidth: viewPadding.right);
}
if (viewPadding.left > 0 && viewPadding.right == 0) {
return DuoPose(side: .left, stripWidth: viewPadding.left);
}
return null; // 横屏等无竖条布局
}

判定不出就回退普通 Scaffold——折叠组件的基本自觉:在非折叠设备上零存在感。

4.2 第二步:DuoBarScaffold——布局与”防二次避让”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 骨架
Stack(
children: [
// body 向条侧让出 stripWidth
Padding(
padding: 条侧 EdgeInsets.only(...stripWidth),
child: MediaQuery.copyWith( // 把 strip 从 padding/viewPadding 中扣掉
child: body, // 防止内部 SafeArea 再避一次这 84pt
),
),
Positioned(
top: 0, bottom: 0, right: 0,
width: pose.bandWidth, // strip + 边距
child: DuoBar(...),
),
],
)

MediaQuery.copyWith 扣除 strip 是关键细节:body 内任何 SafeArea/Scaffold 若再次看到 84pt 的 right padding 会再缩进一次,内容区变成”条 + 空白 + 内容”。扣除后,body 内部的布局语义和普通手机完全一致。

4.3 第三步:regions 避让——别盖住摄像头

竖条本身 84pt 宽,条内还有 occluded 的摄像头。用 foldable 的 occlusion regions 计算条内上下留白:

  • 上半屏的 region → 条容器 top = region.bottom;
  • 下半屏的 region → bottom = 屏高 - region.top + 间隙;
  • 插件未上报(如编译 SDK 低于 27.1)时 fallback 到实测常量 170pt。

查询时只消费 kind == occlusion && isActive 的 region——与第三节”失活 occlusion 不携带信息”的原则一致。

4.4 第四步:胶囊化

actions 和 FAB 统一成竖条里的胶囊:

  • 普通胶囊:56pt 圆形毛玻璃(BackdropFilter + 半透明填充),对齐 Liquid Glass 视觉;
  • 强调胶囊:主色填充 + onPrimary 图标——把原本躺在 AppBar 里的主操作挪进条后,视觉上仍需一眼可辨;
  • FAB 挂在条底部,脱离 Scaffold 的 _rightOffsetX 逻辑,84pt 之谜就此消解。

4.5 验证:转着圈测

实现期在 DuoBarScaffold.build 里留一行 debugPrint(viewPadding),拿模拟器把各姿态转一圈收集 insets:内屏竖屏展开、竖条姿态(横屏两向 + 合盖外屏),每个方向核对 pose 判定和条内避让,全部对上后删掉打印、把实测矩阵固化成注释。折叠适配没法在办公桌前想明白,必须转着圈测——这是整个实践里最朴素也最重要的经验。

结语

回头看,iPhone Duo 适配的本质是三件事:

  1. 承认平台差异:Flutter 的跨端统一(displayFeatures)在 iOS 上是断的,必须通过 platform channel 补一层;foldable 的 ObjC runtime 桥示范了”新 SDK API 旧工具链编译”的标准解法。
  2. 读懂系统契约:occlusion 与 division 的语义差异、isActive 的滞后、safe area 的 84pt——布局决策全部建立在系统声明之上,而不是”查设备型号”,新形态出现时组件自然跟上。
  3. 优雅降级:所有折叠组件在非折叠设备上退化为零,回退路径与折叠路径同等重要;桥接有副作用时,默认关闭、显式 opt-in 是对用户负责的做法。