折叠屏 iPhone Duo 带来的挑战,不只是让现有界面在更宽的屏幕上显示。应用可能在外屏、展开后的内屏以及部分折叠状态之间切换,窗口尺寸、宽高比、安全区域和交互方式都可能动态变化。真正需要调整的,是代码中那些默认设备形态固定不变的假设。
在公开 SDK 提供专门的折叠姿态 API 之前,开发者不应猜测设备型号或铰链参数。更可靠的策略是:根据当前可用空间决定布局,让导航、工具栏、相机预览和底部控件能够在窗口变化时重新组织。
不要识别设备,要响应容器
很多旧代码会读取 UIScreen.main.bounds,然后根据某几个已知分辨率选择布局。这种方案在多窗口、旋转、外接显示器和折叠设备上都很脆弱,因为屏幕尺寸不等于当前视图真正获得的空间。
更稳妥的判断依据包括:
- SwiftUI 的容器尺寸和
horizontalSizeClass - UIKit 的
traitCollection与view.safeAreaInsets - 内容本身需要的最小宽度
- 动态字体、语言和辅助功能设置
不要把“展开态”简单等同于某个设备型号。可以把它定义为“当前宽度足以同时展示列表和详情”,把“外屏态”定义为“当前空间只适合单列导航”。这样即使最终硬件尺寸与预期不同,界面仍然能够工作。
| 场景 | 推荐布局 | 需要验证的重点 |
|---|---|---|
| 较窄外屏 | 单列、逐级进入详情 | 标题截断、返回路径、底部按钮 |
| 展开内屏 | 列表与详情并列 | 最大内容宽度、空白区域、工具栏归属 |
| 部分折叠或特殊宽高比 | 按实时容器重新排版 | 遮挡、滚动位置、状态保留 |
| 状态切换中 | 尽量保留选择和输入 | 不重复请求、不重建业务状态 |
一个可直接改造的 SwiftUI 自适应导航示例
下面的示例不检测设备名称,也不假设存在某个折叠角度 API。它只根据当前容器宽度和 size class,在单列导航与双栏导航之间切换。
可以新建一个 SwiftUI App 项目,将 App 文件替换为以下内容。示例要求 iOS 16 或更高版本:
import SwiftUI
@main
struct DuoReadyApp: App {
var body: some Scene {
WindowGroup {
AdaptiveCatalogView()
}
}
}
struct CatalogItem: Identifiable, Hashable {
let id = UUID()
let title: String
let detail: String
}
struct AdaptiveCatalogView: View {
private static let items = [
CatalogItem(title: "相机", detail: "检查预览裁剪、旋转和权限状态。"),
CatalogItem(title: "地图", detail: "验证列表与地图在宽屏下的协作。"),
CatalogItem(title: "编辑器", detail: "确保键盘出现后主要操作仍然可见。")
]
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selection: CatalogItem.ID?
var body: some View {
GeometryReader { proxy in
let showTwoColumns = horizontalSizeClass == .regular && proxy.size.width >= 700
Group {
if showTwoColumns {
NavigationSplitView {
List(Self.items, selection: $selection) { item in
Text(item.title)
.tag(item.id)
}
.navigationTitle("功能")
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView(
"请选择一个功能",
systemImage: "rectangle.split.2x1"
)
}
}
} else {
NavigationStack {
List(Self.items) { item in
NavigationLink(value: item) {
Text(item.title)
}
}
.navigationTitle("功能")
.navigationDestination(for: CatalogItem.self) { item in
DetailView(item: item)
}
}
}
}
.safeAreaInset(edge: .bottom) {
BottomActionBar()
}
.animation(.snappy, value: showTwoColumns)
}
}
private var selectedItem: CatalogItem? {
Self.items.first { $0.id == selection }
}
}
struct DetailView: View {
let item: CatalogItem
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(item.title)
.font(.largeTitle.bold())
Text(item.detail)
.font(.body)
Text("改变窗口宽度,观察导航结构如何切换。")
.foregroundStyle(.secondary)
}
.frame(maxWidth: 720, alignment: .leading)
.padding()
}
.navigationTitle(item.title)
.navigationBarTitleDisplayMode(.inline)
}
}
struct BottomActionBar: View {
var body: some View {
Button("创建新项目", systemImage: "plus") {
print("Create tapped")
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding()
.background(.bar)
}
}
这个例子体现了几个重要原则:
- 选择状态存放在布局分支之外,切换单栏和双栏时不会丢失。
- 双栏是否出现由可用宽度决定,而不是由设备名称决定。
- 详情内容设置了最大宽度,避免在大内屏上被无限拉长。
- 底部操作使用
safeAreaInset占据安全区域,而不是写死底部间距。
实际项目中,不应因为布局切换而重新创建网络请求、编辑草稿或相机会话。将这些状态放进 ObservableObject、@Observable 模型或更高层的业务容器中,视图只负责选择布局。
导航和工具栏需要重新分配,而不只是放大
从窄外屏切换到大内屏后,原本位于导航栏右侧的按钮可能离主要内容过远;反过来,把桌面式三栏界面压进外屏也会造成标题和按钮互相争抢空间。
可以按以下规则重新审视导航:
- 外屏保留一个明确的主路径,次要操作放入菜单。
- 内屏优先使用
NavigationSplitView展示主从关系,但不要强制始终显示多栏。 - 工具栏操作应属于当前内容,而不是机械地固定在整个窗口最外侧。
- 布局切换后保留当前选择、滚动位置和未提交输入。
- 不要依赖左上角或右下角永远可触达;展开后的设备可能改变单手操作范围。
如果应用同时支持 iPad,多数工作可以先在 iPad 分屏和可调整窗口中完成。折叠设备暴露的是同一类根本问题:窗口不是常量。
相机预览不能假设固定方向和固定裁剪
相机类应用还要处理镜头位置、设备方向、预览比例和折叠状态变化。即使没有新的专用 API,也应确保预览层始终跟随宿主视图,而不是只在初始化时设置一次 frame。
下面是一个可嵌入 UIKit 相机实现的预览视图。它不包含权限申请和采集会话配置,只负责让 AVCaptureVideoPreviewLayer 跟随容器变化:
import AVFoundation
import UIKit
final class CameraPreviewView: UIView {
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
var previewLayer: AVCaptureVideoPreviewLayer {
guard let layer = layer as? AVCaptureVideoPreviewLayer else {
fatalError("Unexpected backing layer")
}
return layer
}
func attach(session: AVCaptureSession) {
previewLayer.session = session
previewLayer.videoGravity = .resizeAspectFill
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer.frame = bounds
}
}
使用 .resizeAspectFill 时,画面边缘可能被裁剪,因此拍照应用不能假设预览中所有区域都会出现在最终照片里。二维码扫描、文档识别等功能还需要把识别框坐标转换到预览层坐标,并在每次尺寸变化后更新覆盖层。
相机测试至少应覆盖:
- 窄屏与展开宽屏之间切换时,采集会话是否被重复启动。
- 方向或宽高比变化后,预览是否拉伸、旋转错误或留下黑边。
- 快门、变焦和关闭按钮是否位于安全区域内。
- 应用进入后台或窗口失去活动状态时,是否正确暂停资源占用。
- 权限提示返回后,布局和会话状态是否仍然一致。
把保留区域视为动态输入
新形态设备可能引入系统手势区、摄像头区域、圆角或其他不可交互区域。无论最终形态如何,应用都不应该使用固定的顶部、底部或侧边补偿值。
普通内容应默认尊重 safe area。只有背景、视频或装饰元素适合延伸到安全区域之外,按钮、文本输入框和关键状态信息仍应放在可交互区域内。尤其要谨慎使用:
.ignoresSafeArea()
它适合全屏背景,却不应直接套在整个交互层上。需要自定义悬浮控件时,应读取当前容器的 safe area,并在尺寸变化时重新布局。
可以先在代码库中查找常见的固定设备假设。安装 ripgrep 后,在项目根目录运行:
rg 'UIScreen\.main|statusBarFrame|keyWindow|ignoresSafeArea|UIDevice\.current' \
--glob '*.swift' .
这些匹配不一定都是错误,但值得逐项确认:代码究竟需要的是物理屏幕信息,还是当前窗口和视图的信息?
上线前的改造清单
不必等待专用模拟器或正式硬件才开始准备。可以先完成以下工作:
- 删除基于设备型号和固定分辨率的布局分支。
- 在窄、宽、矮以及特殊宽高比窗口中测试核心流程。
- 确保导航结构变化不会清空业务状态。
- 让工具栏和主要操作跟随内容,而不是跟随屏幕边缘。
- 审核所有
ignoresSafeArea、绝对坐标和固定底部间距。 - 验证相机、地图、视频和画布在运行时尺寸变化后的行为。
- 为未来可能出现的折叠姿态 API 建立独立能力层,不把平台细节散落到业务视图中。
最值得提前投入的并不是猜测 iPhone Duo 的具体像素或铰链位置,而是让应用真正接受“窗口可以随时变化”这一事实。做到这一点,应用不仅更容易适配折叠屏,也会同时改善 iPad、多窗口、横屏和辅助功能体验。