【iOS】关于传统竖屏 UIKit 项目如何简单适配 iPhone Duo 的第一版

简单适配的 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

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

参考

Get ready for iPhone Duo

Preparing your app for iPhone Duo

Prepare - iPhone Duo

Designing for iPhone Duo

相关推荐
iOS开发上架哦7 小时前
抓包怎么修改响应?响应头、响应体、脚本注入实操教程
后端·ios
开心就好20257 小时前
在 Xcode 中运行和调试单元测试的完整指南
后端·ios
庚鑫7 小时前
Xcode 26.3 调试 iOS 27:从设备元数据报错到 GUI Run
ios·objective-c·xcode
Autumn_ing9 小时前
AI生成应用能做到哪一步?在线问诊APP从挂号到支付的完整案例
小程序·app·健康医疗·ai应用·问诊app·ai生成应用·墨刀ai
CocoaKier10 小时前
如何安装旧版的MacOS系统?
macos·ios·mac
2501_9151063210 小时前
iOS开发之Xcode常用调试技巧总结
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
sakiko_11 小时前
使用SPM(Xcode27), 并解决Package.swift不存在问题
swift
美狐美颜SDK开放平台12 小时前
直播APP平台搭建需要哪些技术?视频美颜SDK只是其中一环
android·ios·音视频·直播美颜sdk·视频美颜sdk
小π军13 小时前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos