Flutter - iPhone Duo 折叠屏适配实践
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 | DisplayFeature { |
三个 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 | // Swift 源码里不出现任何 iOS 27 类型名 |
收益是实打实的:旧 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。无论哪档,有五条不变量兜底,保证这个包绝不会”意外地劈开你的布局”:
- 零宽区域永不发布(配合
postureHalfOpened会满足 avoidBounds,以一条看不见的折痕劈开屏幕); - 失活区域永不发布;
- fold 只在铰链读到
partiallyOpen时发布——region 的isActive滞后且摊平后不再纠正,轻信它会让摊平的设备上每条 dialog 都被 40pt 折痕劈开; - cutout 永远携带
DisplayFeatureState.unknown(dart:ui有断言); - 合盖期间什么都不发布(此时视图在外屏,没有折痕)。
将来 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 | DuoPose? resolveDuoPose(EdgeInsets viewPadding) { |
判定不出就回退普通 Scaffold——折叠组件的基本自觉:在非折叠设备上零存在感。
4.2 第二步:DuoBarScaffold——布局与”防二次避让”
1 | // 骨架 |
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 适配的本质是三件事:
- 承认平台差异:Flutter 的跨端统一(displayFeatures)在 iOS 上是断的,必须通过 platform channel 补一层;
foldable的 ObjC runtime 桥示范了”新 SDK API 旧工具链编译”的标准解法。 - 读懂系统契约:occlusion 与 division 的语义差异、
isActive的滞后、safe area 的 84pt——布局决策全部建立在系统声明之上,而不是”查设备型号”,新形态出现时组件自然跟上。 - 优雅降级:所有折叠组件在非折叠设备上退化为零,回退路径与折叠路径同等重要;桥接有副作用时,默认关闭、显式 opt-in 是对用户负责的做法。