前些日子忙活适配iPhoneDuo了。主力虽然是vibecoding,但是遇到的坑简直不要太多。主要是“洪绘敲木鱼”这个应用,因为本身软件是SwiftUI编写的,所以原生sdk适配起来也不难。

主要需要适配的就是iPhoneDuo的多种屏幕形态情况。

iPhoneDuo在内屏下分成了三个角度,在半折叠下分成了两个角度,在展开情况下分成了两个角度。

于是,你需要适配7个不同的情况(我的天呐)

先来看看我适配的情况吧。可以为大家设计与开发作为一个参考。

外屏设计情况

外屏涉及到了三个尺寸,非常的复杂,因为是异形屏,所以所有尺寸都需要单独适配。

三个尺寸

主要的难点就是在于将摄像头所在的纵向安全区进行信息填充,避免该区域浪费。

内屏设计情况

还好内屏是一个屏下摄像头,不然就得适配四种了。得益于这个屏下摄像头,我们可以只需要两种布局即可解决,类似iPad适配。但是与iPad不同的是,我们需要尽可能利用可折叠的能力,以及避免半折叠中间区域无法点击的情况。

这里推荐使用分栏设计,并且根据开合情况进行比例适配。

在半折叠横屏的时候,我做了分栏设计。左右各占一半。

分栏横屏

但是在纵向的时候,就需要类似笔记本的操作感觉了。底部是操作区域,并且尽可能避免用户点击处于直立的上半部分屏幕。另外,最好保证在折叠时和展开时,内屏的内容与外屏右侧一致,这样可以保证体验的一致性。所以我选择了左侧选择音效,右侧和外屏一样的操作样式。

对于洪绘敲木鱼来说,敲击是最基本的操作,所以为了保证体验,在底部增加了一个额外的敲击按钮。

纵向适配

在全部展开时,适配就可以参考iPad了。值得一提的是,如果你使用了分栏布局,在横向的时候,分栏左侧为了视觉效果更加紧凑,应该减小横向比例,可以减少到三分之一或者根据设计情况来。

全部展开

开发过程的坑

最开始全折叠情况vibecoding自然去识别摄像头方向或者屏幕方向了,这都错了。苹果提供了官方的sdk,用来识别屏幕情况,所以一定要用。

苹果在 iPhone Duo 的适配文档里写得很直接:不要根据设备方向、摄像头位置或者屏幕尺寸去猜当前姿态。方向描述的是画面怎么转,形态描述的是设备怎么折,这两件事在 Duo 上经常对不上。合上、展开、半折、立在桌上,横竖都能成立。

官方给的接口是 onHingeChange(SwiftUI)和 UIHingeInteraction(UIKit)。回调报三档状态:closed、partiallyOpen、fullyOpen,同时带一个连续角度。DeviceHinge 的文档原话是,角度是铰链当前的角度,状态由系统根据当前角度和设备方向算出来。也就是说三档不用你自己拿角度去切:closed 是合上,partiallyOpen 是中间那段,fullyOpen 是开到设备允许的最大角度。判断的第一步是看上下文在不在,普通 iPhone 上没有铰链,context.hinge 是 nil。官方特意提醒过,不要按系统版本或者机型去推断设备形态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
enum DuoPosture {
case flat // 展开 / 直板
case tabletop // 半折
case closed // 合盖,只用外屏
}

@available(iOS 27.1, *)
struct DuoRootView: View {
@State private var posture: DuoPosture = .flat

var body: some View {
content
.onHingeChange { _, context in
guard let hinge = context.hinge else {
posture = .flat // 普通 iPhone,没有铰链
return
}

switch hinge.status {
case .closed:
posture = .closed
case .partiallyOpen:
posture = .tabletop
case .fullyOpen:
posture = .flat
@unknown default:
posture = .flat
}
}
}
}

还有一条容易被忽略的规定:铰链数据只用来驱动交互,不要拿它算布局。布局交给尺寸类、reservedRegions 和 arrangement。角度告诉你设备正在怎么动,不告诉你内容该放哪。半折时两块屏各占多少,直接读 reservedRegions(kind: .division),不要自己拿角度反推屏幕尺寸。

1
2
3
4
5
6
7
8
9
10
11
@available(iOS 27.1, *)
private func foldCenterRatio(in proxy: GeometryProxy) -> CGFloat {
let regions = proxy.reservedRegions(kind: .division, options: [.includeInactive])
guard let fold = regions.first(where: \.isActive) else { return 0.5 }

if proxy.size.width > proxy.size.height {
return fold.frame.midX / proxy.size.width
} else {
return fold.frame.midY / proxy.size.height
}
}

横屏时折痕是竖的,读 midX;竖屏时折痕是横的,读 midY。拿到的比例直接交给分屏容器,两块屏的大小就有了,不需要再碰角度。

合盖横屏时,外屏前摄是在左上角还是右上角,同样不用猜。苹果把外屏前摄作为 occlusion 保留区一直报出来,读它的 frame 就行。ReservedRegion.frame 用的是当前 view 的坐标,(0, 0) 是左上角,而且 frame 已经包含 margins,不用自己再加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@available(iOS 27.1, *)
private func outerCameraCorner(in proxy: GeometryProxy) -> (HorizontalEdge, VerticalEdge)? {
let regions = proxy.reservedRegions(kind: .occlusion)

// 外屏前摄是一块接近方形的窄区域;整条竖栏不算
guard let camera = regions.first(where: {
$0.frame.width < proxy.size.width * 0.5 &&
$0.frame.height < proxy.size.height * 0.5
}) else { return nil }

let horizontal: HorizontalEdge = camera.frame.midX < proxy.size.width / 2 ? .leading : .trailing
let vertical: VerticalEdge = camera.frame.midY < proxy.size.height / 2 ? .top : .bottom
return (horizontal, vertical)
}

注意,系统有时会把状态栏、工具栏、灵动岛一起报进 occlusion,整条竖栏的 midY 会落在中间,不能直接当摄像头位置。木鱼里这块没有只读一次就完事,DuoLayout.swift 的 DuoClosedMetrics.resolve 先取所有 occlusion frame,再按“窄且贴边”筛出摄像头那一列:

1
2
3
4
5
6
7
let column = proxy.reservedRegions(kind: .occlusion)
.map(\.frame)
.first { frame in
frame.width < proxy.size.width * 0.5
&& (frame.midX < proxy.size.width * 0.35
|| frame.midX > proxy.size.width * 0.65)
}

左右判断就是这一列的中线:

1
2
3
4
5
6
7
8
9
10
let edge: HorizontalEdge
if let column {
edge = column.midX > proxy.size.width / 2 ? .trailing : .leading
} else if stripTrailing > stripLeading + 1 {
edge = .trailing
} else if stripLeading > stripTrailing + 1 {
edge = .leading
} else {
edge = systemStripEdge
}

读不到保留区时,先看左右安全区哪边更宽,再退到 traitCollection.verticalBarEdge,最后兜底右侧。

上下要麻烦一点。系统可能把整条竖栏报上来,这时 midY 是屏幕中心,不能当摄像头位置。所以只有高度小于屏幕一半的区域,才按摄像头本体读:

1
2
3
4
5
let camera = column.flatMap {
$0.height < proxy.size.height * 0.5 ? $0 : nil
}
let cameraAtTop = camera.map { $0.midY < proxy.size.height / 2 }
?? (edge == .trailing ? proxy.size.height >= proxy.size.width : true)

兜底规则是:摄像头列在右侧,竖屏在上、横屏在下;列在左侧,一律按上处理。合盖横屏时摄像头到底在左上还是右上,还是保留区说了算,代码不猜方向。

还有一道跟摄像头无关但必须加的门:usesDuoSideLayout 要求铰链是 .closed,并且 windowLayout.needsSideDock 为真。折叠到 55° 左右铰链就会先报 closed,这时界面还在内屏上;只看铰链会把切换栏贴到内屏最右边。这个坑是实测出来的。

另外,UIScreen.main 在双屏设备上没有明确含义,官方也说这个用法以后会废弃。尺寸从窗口场景或者 GeometryProxy 拿,不要缓存一块屏的宽度去推另一块屏。

苹果没有公布三档状态各自对应的角度区间,也不保证角度更新的频率和精度。所以七种姿态只能建在这三档之上:先认 status,再按 angle.degrees 细分,阈值是你自己定的产品规则,不是官方保证。判断入口仍然只能有一个,别在 view 里到处写 if。

设计屏幕截图

在适配完成后需要上传到App Store。我们可以看到iPhone Duo的尺寸推荐为:

App 预览支持的尺寸包括 1920 × 886px 和 886 × 1920px。App 截屏支持的尺寸包括 2034 × 1398px、1398 × 2034px、2853 × 2007px 和 2007 × 2853px。

屏幕截图

我这里建议用2853 × 2007px 和 2007 × 2853px。最开始我用了最前面的值,才发现是外屏小尺寸。。。

我认为体验最好的预览尺寸是内屏,所以我建议用 2853 × 2007px 横屏,当然也可以用竖屏形式,具体由你自己去把握。

我就直接续用之前的风格和素材了,调整尺寸和位置做了一版。

屏幕截图

总结

总的来说适配起来主要是需要适配的屏幕情况太多了,在早期应用适配的时候,我们只需要考虑纵向的高度以及旋转,在iPad时期我们可能需要稍微考虑下横屏的分栏和信息密度。但是在iPhoneduo足有7种屏幕形态需要单独适配,这非常考验设计团队和开发的经验和能力。

iPhoneDuo适配难度并不算简单,特别如果是大厂应用有大量界面、活动营销页面需要设计和适配,所以iPhoneDuo用户也不用着急,适配肯定会慢慢跟上的。