简单适配的 Demo:WaterfallLayoutDemo

今年苹果推出了首款折叠屏手机 iPhone Duo,据说所有 App 都得强制适配,这对于传统竖屏 UIKit 项目来说简直就是飞来横祸。
打开老项目看到这,简直就是惨不忍睹😱

事先声明,本文主要针对传统竖屏 UIKit 项目,也就是没有适配 iPad 版、大量使用 frame 布局、或 AutoLayout 用的绝对数值的 App,在这里分享一下我这几天适配的方案,在不改动大部分 UI 结构的情况下先快速适配第一版。
swift
我的方案是类似以前横竖屏切换的方式,统一一处监控手机方向的变动,然后在指定界面做UI布局更新。
当然,现在有 AI,可以配合 app-resizability 技能,直接叫 Agent 去更新:让 UIKit 应用适配 iPhone Duo、分屏和动态窗口尺寸。
- 如果是有着大量历史界面的老 App,AI 改造后的大概率几乎都重构了,这可能会引发新的 Bug。
- 很多界面需要设计师去重新设计,所以短期内无法完成适配,改造后还得去测试,估计最快也要1、2个月才能真正适配好。
综合考虑,还是先按我的想法先适配第一版吧,所谓第一版,不是一上来就重构成类似 iPad 多栏应用,我打算先参考微博,先做界面拉伸,这总比空出一大块好,尽可能先让界面能兼容 Duo 的所有场景,后续再去逐步替换一些需要重构的 UI 场景。
目前 iPhone Duo 包含的场景有:外屏横/竖屏、内屏横/竖半开屏、内屏横/竖全开屏、内屏左/右分屏:

也就是说现在不能只做竖屏界面了,界面尺寸和安全区域不再固定,这意味着 UIScreen 可以直接抛弃。
动手前
首先要把 AppDelegate 升级成 SceneDelegate 启动
这个很久之前苹果就要求了,现在 iOS 27 就强制性了,这个网上已经有很多文章介绍,这里不过多赘述了,或者直接叫 AI 帮你改就行,这个改动不大。
开始动手
其实对于多数界面来说,无非就是适配不同大小的窗口尺寸和不同的安全区域,首当其冲就是要把 UIScreen.main.bounds 换成 view.bounds / window.bounds,另外安全区域 safeAreaInsets 现在也不是固定了,上下左右都会有。
对于 UIViewController,可以去重写 func willTransition(to newCollection:, with coordinator:),这里能提前得知即将变换的尺寸,但不包括最终的安全区域(此时的view.safeAreaInsets还是旧值),
安全区域更新需要从 viewSafeAreaInsetsDidChange 获取。其实最合适在 viewDidLayoutSubviews 方法去更新 UI,这里能同时得知尺寸和安全区域的变动,如果用 AI 改造,它也会在这里去适配,毕竟官方推荐也是这里。
相对于 UIView 就是 safeAreaInsetsDidChange 和 layoutSubviews 方法,也是建议在后者去适配。
- PS: 这里不推荐在
func willTransition(to newCollection:, with coordinator:)做适配操作,因为当 VC 没有显示时(例如 push 了新 VC),此时 Duo 开始切换场景,viewDidLayoutSubviews是不会触发的,而willTransition还是会触发的,如果该 VC 的界面十分复杂,在没有渲染的情况下还是做了 UI 刷新操作(例如大量计算和解码操作),有可能会造成卡顿。
拿到最新的布局信息(view.bounds 和 view.safeAreaInsets),就可以去更新布局了,把 UIScreen 相关的使用剔除干净。
可是,最关键的问题来了,viewDidLayoutSubviews 和 layoutSubviews 是会频繁触发的,若想过滤重复操作,需要把尺寸和安全区域保存起来,下一次触发时先对比,不一样才重新刷新(如果都是 AutoLayout 做布局,感觉可以省一半力气,这都是历史债务啊)。
确实可以这样做,但这意味着每个 ViewController / View 都得存值,更麻烦的是 Duo 场景切换时,安全区域是是逐步变化的:
例如外屏打开内屏时,安全区域的变化过程是:bottom 34 -> 0 -> 34,top 82 -> 0 & right 0 -> 84,再加上子视图布局变动也会触发布局刷新,这导致外屏打开内屏这一过程,
viewDidLayoutSubviews可能会触发多次,对于元素复杂的首页,甚至会触发十几次。
那怎么只获取一次场景变化并且能直接拿到最终值呢?
有的,那就是 SceneDelegate 的 func windowScene(_ windowScene: , didUpdateEffectiveGeometry previousEffectiveGeometry: ) 方法,当 Duo 切换场景时就会触发该方法,可以在这里拿到最新的窗口信息,这非常符合我的要求:只做一次的判断处理。
要更新布局,最值得关注的是 window 的 bounds 和 safeAreaInsets,其实只要知道这两个值,加上屏幕尺寸,就能推断目前在 Duo 什么场景。
于是我自定义了一个枚举:
objc
typedef NS_ENUM(NSInteger, EnvDisplayMode) {
// 外屏竖屏
EnvDisplayMode_OuterPortrait = 0,
// 内屏横屏铺满
EnvDisplayMode_InnerLandscapeFull = 1,
// 内屏竖屏铺满
EnvDisplayMode_InnerPortraitFull = 2,
// 内屏分屏左边
EnvDisplayMode_InnerSplitLeft = 3,
// 内屏分屏右边
EnvDisplayMode_InnerSplitRight = 4,
};
- 项目目前是Swift/OC混编,为了兼容OC,枚举都使用OC的。
- 实际外屏还有左右横屏两种,鉴于目前项目是竖屏App,这里就不过多赘述了。
对应 window 的 bounds、safeAreaInsets 还有铰链位置的具体数值如下:
swift
switch displayMode {
// 内屏横屏铺满
case .innerLandscapeFull:
boudns = (0.0, 0.0, 951.0, 669.0)
safeAreaInsets = (0.0, 0.0, 34.0, 84.0)
hingeFrame = (455.5, 0.0, 40.0, 669.0)
// 内屏竖屏铺满
case .innerPortraitFull:
boudns = (0.0, 0.0, 669.0, 951.0)
safeAreaInsets = (82.0, 0.0, 34.0, 0.0)
hingeFrame = (0.0, 455.5, 669.0, 40.0)
// 内屏分屏左边
case .innerSplitLeft:
boudns = (0.0, 0.0, 469.0, 669.0)
safeAreaInsets = (0.0, 0.0, 34.0, 84.0)
hingeFrame = (0.0, 0.0, 13.5, 669.0)
// 内屏分屏右边
case .innerSplitRight:
boudns = (0.0, 0.0, 469.0, 669.0)
safeAreaInsets = (0.0, 0.0, 34.0, 0.0)
hingeFrame = (455.5, 0.0, 13.5, 669.0)
// 外屏竖屏
default:
boudns = (0.0, 0.0, 466.0, 678.0)
safeAreaInsets = (0.0, 0.0, 34.0, 84.0)
hingeFrame = nil
}
- 以上数值仅供参考,实际开发还是不要用写死的数值。
接着我创建一个名叫 Env 的工具类,专门存放这些主要窗口信息:
swift
enum Env {
// 屏幕尺寸
private(set) static var screenBounds: CGRect = .zero
// 窗口尺寸
private(set) static var sceneBounds: CGRect = .zero
// 窗口安全区域
private(set) static var safeAreaInsets: UIEdgeInsets = .zero
// 所在场景
private(set) static var displayMode: EnvDisplayMode = .outerPortrait
}
先去初始化:
swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
// 初始化
Env.setup(window)
window.rootViewController = UIHomeViewController()
window.makeKeyAndVisible()
}
}
监控是否发现变化:
swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@available(iOS 26.0, *)
func windowScene(
_ windowScene: UIWindowScene,
didUpdateEffectiveGeometry previousEffectiveGeometry: UIWindowScene.Geometry
) {
// previousEffectiveGeometry:旧场地
// windowScene.effectiveGeometry:新场地
Env.resetIfNeeded(windowScene)
}
}
内部判断是否发生变化:
swift
@available(iOS 26.0, *)
extension Env {
private static var _delayReset: DispatchWorkItem? = nil
static func resetIfNeeded(_ windowScene: UIWindowScene) {
_delayReset?.cancel()
_delayReset = nil
// 拖拽中尺寸会不断变化,故暂不刷新
if windowScene.effectiveGeometry.isInteractivelyResizing {
return
}
// 此时的window还没真正的变化,需要下一轮Runloop才会同步最新的尺寸
let resetItem = DispatchWorkItem {
_delayReset = nil
_resetIfNeeded(windowScene)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08, execute: resetItem)
_delayReset = resetItem
}
static private func _resetIfNeeded(_ windowScene: UIWindowScene) {
guard let window = windowScene.windows.first else { return }
window.layoutIfNeeded() // 📌 核心代码:调用这句才能立马同步最新的安全区域
let trait = windowScene.traitCollection
let isHorRegular = trait.horizontalSizeClass == .regular
let isVerRegular = trait.verticalSizeClass == .regular
let screenSpace = windowScene.screen.coordinateSpace
let screenBounds = screenSpace.bounds
let sceneSpace = windowScene.effectiveGeometry.coordinateSpace
let sceneBounds = sceneSpace.bounds
let safeAreaInsets = window.safeAreaInsets
// ---------- 更新变量 ----------
var isRotated: Bool = false
// 判断屏幕最大边是否相等,否则就是内外屏切换
if max(self.screenBounds.width, self.screenBounds.height) == max(screenBounds.width, screenBounds.height) {
let originIsLandscape = self.isLandscapeScreen
let currentIsLandscape = screenBounds.width > screenBounds.height
isRotated = originIsLandscape != currentIsLandscape
}
self.screenBounds = screenBounds
// 判断屏幕和App最大边是否相等,否则就是内屏正在分屏展示中(两个App并排同时运行)
if max(screenBounds.width, screenBounds.height) == max(sceneBounds.width, sceneBounds.height) {
if isHorRegular && isVerRegular { // 内屏铺满 Regular × Regular
let currentIsLandscape = sceneBounds.width > sceneBounds.height
displayMode = currentIsLandscape ? .innerLandscapeFull : .innerPortraitFull
} else { // 外屏竖屏
displayMode = .outerPortrait
}
} else { // 内屏分屏
// 临时处理:在左边时不会有右侧栏,所以right是0。
if safeAreaInsets.right > 0 { // 右边
displayMode = .innerSplitRight
} else { // 左边
displayMode = .innerSplitLeft
}
}
// ---------- 检查变化+更新变量 ----------
var options: EnvUpdateOptions = []
if self.sceneBounds != sceneBounds {
self.sceneBounds = sceneBounds
options.insert(.bounds)
}
if self.safeAreaInsets != safeAreaInsets {
self.safeAreaInsets = safeAreaInsets
options.insert(.safeArea)
}
// ---------- 若变化则广播 ----------
if options.isEmpty {
return
}
// 建议的转场动画时长,0代表无需动画(内外屏切换没有动画,屏幕旋转才有动画)
let transitionDuration: TimeInterval = (isRotated && UIApplication.fa_appState == .active) ? 0.3 : 0
NotificationCenter.default.post(name: .init("EnvSceneDidChange"), object: nil, userInfo: [
"options": options.rawValue,
"transitionDuration": transitionDuration,
])
}
}
- 这里有一个坑,就是触发
func windowScene(_ windowScene: , didUpdateEffectiveGeometry previousEffectiveGeometry: )的时候,window 还是旧的布局(bounds、safeAreaInsets 此时是旧值),需要到下一轮 Runloop 才会同步最新的尺寸,此时 bounds 是更新了,但 safeAreaInsets 可能还是旧的,最好接着调用layoutIfNeeded()立即布局才能拿到最新值。 - 但还是会有小概率出现不是最新的,最稳妥的做法就是延时一下下,给时间系统去布局。
- 延时大法虽好,但不建议,可能是模拟器的 Bug,日后需要真机验证。
- 为了兼容OC,选择使用通知进行广播,如果是纯 Swift 项目,更推荐 Combine。
最后就是在对应的 ViewController 和 View 去监听通知,收到刷新即可,放心的是,能收到该通知就是肯定是发生了变化的,没变化是不会广播的。
但通知也有弊端,例如 push 新 VC 后,前一个 VC 还是会收到通知,所以建议在显示后才监听,然后消失就不监听:
swift
class HomeViewController: UIViewController {
private var displayMode = Env.displayMode
private var isObservingSceneChanges: Bool = false {
didSet {
guard isObservingSceneChanges != oldValue else { return }
if isObservingSceneChanges {
startObservingSceneChanges()
} else {
stopObservingSceneChanges()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
...
updateLayout(duration: 0)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateLayoutIfNeeded()
isObservingSceneChanges = true
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
isObservingSceneChanges = false
}
}
// MARK: - Duo适配_监听场景、铰链状态变化的通知
private extension HomeViewController {
func startObservingSceneChanges() {
NotificationCenter.default.addObserver(
self,
selector: #selector(didReceiveEnvSceneDidChange(notification:)),
name: .init("EnvSceneDidChange"),
object: nil
)
}
func stopObservingSceneChanges() {
NotificationCenter.default.removeObserver(
self,
name: .init("EnvSceneDidChange"),
object: nil
)
}
/// 场景发生变化的通知(内外屏切换、内屏旋转、内屏全屏分屏切换、内屏左右分屏切换)
@objc func didReceiveEnvSceneDidChange(notification: Notification) {
displayMode = Env.displayMode
let duration = notification.userInfo?["transitionDuration"] as? TimeInterval ?? 0
updateLayout(duration: duration)
}
}
// MARK: - Duo适配_更新界面布局(如果有变化)
private extension HomeViewController {
func updateLayoutIfNeeded() {
guard displayMode != Env.displayMode else { return }
displayMode = Env.displayMode
updateLayout(duration: 0)
}
func updateLayout(duration: TimeInterval) {
...
guard duration > 0 else {
view.layoutIfNeeded()
return
}
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.view.layoutIfNeeded()
}
}
}
有了场景变化的通知,就可以专心在 updateLayout(duration:) 中集中去重新计算布局信息,然后更新布局。
最后
这就是我临时应付第一版的方案了。之后再慢慢升级 App 吧,最好都改成用 AutoLayout,另外导航栏和 TabBar 在 Duo 中建议用系统的。
值得关注的新控件:UIArrangementViewController,用于打开内屏时的多栏VC展示,估计以后会有很多地方用到。
关于如何识别是 iPhone Duo,我目前的做法是直接判断屏幕尺寸是不是 Duo 的尺寸(想当年刚开始适配 iPhone X 时也是这么干的):
swift
// 外屏尺寸:466 x 678 | 内屏尺寸:951 x 669
isDuo = (min(screenBounds.width, screenBounds.height) == 466.0 && max(screenBounds.width, screenBounds.height) == 678.0)
|| (max(screenBounds.width, screenBounds.height) == 951.0 && min(screenBounds.width, screenBounds.height) == 669.0)
- 目前就这么一款折叠屏,先这么应付吧
Demo
除了场景变化,我还补充了铰链变化的通知,具体可以看 Demo:
Swift Demo:WaterfallLayoutDemo

OC Demo:JPImageresizerView

- 这个只处理了裁剪页,其他还没适配。