iOS resizability 设计思想:当 App 不再知道自己跑在什么设备上

五条设计原则,逐条落到代码:窗口缩放时代,App 只关心自己有多大空间。含场景配置、几何回调、两级分档、Info.plist 红线与测试矩阵。

iOS resizability 指的是:App 的窗口可以像桌面应用一样,被用户抓住边缘自由拖拽、改变大小。对这个变化,苹果给出的设计思想可以压缩成一句话——App 不再关心自己跑在什么设备、什么方向、哪块屏幕上,只关心自己此刻拥有多大的可用空间。 你负责定义「空间宽时怎么摆、空间窄时怎么摆」两套逻辑,系统负责把它们套到所有设备、所有窗口尺寸和所有折叠态上。

这不是一次普通的 API 更新,而是 iOS 诞生以来布局范式的一次换轨。这篇文章把 resizability 的设计思想拆成五条原则,再逐条落到 UIKit 与 SwiftUI 的代码上,最后给一份能直接照着执行的迁移路线图和测试矩阵。

文中签名对照 Xcode 27.1 的 iOS 27.1 SDK,行为依据来自 Apple 官方文档与 WWDC session,文末列出来源。本文是转述与工程化整理,不是官方页面的译文。

一、iOS 26 到 27:resizability 是怎么变成默认的

理解设计思想之前,先看这条时间线。它解释了为什么「以前那套」现在不够用了。

阶段 发生了什么 对开发者的意义
iPadOS 15 Split View / Slide Over / Stage Manager iPad 开始出现非全屏尺寸
iPadOS 26 iPad 进入自由窗口时代,任意拖拽缩放 「iPad = regular」的假设开始松动
iOS 26 SDK UIRequiresFullScreen 被弃用 无法再用全屏键退出缩放
iOS 26 SDK iPhone App 跑在 iPad 或 Mac 镜像里也可缩放 「跑在 iPhone 上」不再等于 compact
iOS 27 最新 SDK 构建的 App 强制采用 UIScene 生命周期 场景成为 resizability 的地基
iOS 27 Tab bar、工具栏等系统组件全面适配任意宽高比 系统组件成为自适应的主力

一句话概括这条线:窗口尺寸从「枚举值」变成了「连续值」。 过去你的 App 面对的是 375、393、414、768、1024 这几个离散档位;现在用户的手指捏住窗口边缘一拖,宽度可以是 320 到屏幕上限之间的任何一个数。为离散档位写死的布局,在连续值面前必坏。

二、设计思想一:从「设备思维」到「空间思维」

旧的布局知识几乎都建立在设备思维上:这是 iPhone,横屏了,所以这样摆;这是 iPad,所以分两栏。resizability 要求把这套思维整个换掉。

设备思维(旧) 空间思维(新)
判断依据 设备类型、方向、屏幕尺寸 当前容器可用空间
布局数量 每个设备一版 一套规则覆盖所有空间
新设备出现 可能需要改代码、发新版 自动套用,零改动
折叠屏形态 每种姿态长一套界面 同一套界面跟随宽度伸缩
代码里的气味 if idiom == .pad、isLandscape horizontalSizeClass、容器宽度

空间思维最直白的表述来自 WWDC:你不需要为每个平台、每个窗口尺寸单独设计,你只需定义一次内容「宽时怎么排、窄时怎么排」,系统把这份逻辑应用在所有宽高比上。旋转屏幕只是这份逻辑的一次触发事件,与拖拽窗口、分屏、折叠展开没有本质区别——它们最终都归结为同一件事:可用空间的尺寸变了。

三、设计思想二:size class 是新的世界语

设备思维退场后,什么上来接棒?size class。它是框架对空间做的定性判断:两根轴,每根轴两个字——compact 表示紧张,regular 表示宽裕。

  • horizontal 轴看左右:compact 就该单栏纵向堆叠,regular 才敢分栏、并排、上 sidebar。

  • vertical 轴看上下:compact 时上下 chrome(tab bar、工具栏)放不下,系统会把横排 tab bar 改成竖排;regular 时顶底栏可以同时常驻。

SwiftUI 里直接读取:

@Environment(\.horizontalSizeClass) private var hSize  // UserInterfaceSizeClass?
@Environment(\.verticalSizeClass) private var vSize

if hSize == .regular {
    // 左右宽裕:分栏 / 侧栏 / 双列
} else {
    // 左右紧张:单栏堆叠
}

UIKit 里则由 traitCollection.horizontalSizeClass 承担同样职责,并且能用 registerForTraitChanges 精确订阅变化。

接下来是全文最重要的一张表——size class 和设备、方向都不是一对一关系:

场景 horizontal vertical 说明
iPhone 竖屏 compact regular 全 App 的默认形态
iPhone 横屏 compact compact 高度只剩约 320pt,tab bar 竖排
iPhone 窗口拖大(iOS 26+) regular regular 运行时可变,不是启动时定死
iPad 全屏 regular regular 宽度约 1024pt
iPad 分屏一半 regular regular 宽度只有约 507pt,仍是 regular
iPad Slide Over compact compact iPad 上的 compact 形态
Duo 外屏 compact compact 外屏约 466pt 宽
Duo 展开内屏 regular compact 折叠态切换,运行时可变

把这张表读三遍,两个反直觉的结论就浮出来了:「跑在 iPhone 上」不再等于 compact(窗口拖大就是 regular),「跑在 iPad 上」也不再等于 regular(Slide Over 就是 compact)。设备类型和屏幕方向都回答不了「我现在该用什么布局」,只有运行时去问 size class。

这张表还矫正一个老观念:旋屏不是「设备变了」。旋屏只是 (H, V) 组合从 (compact, regular) 切换到 (compact, compact)、外加一次 bounds 变化。布局代码的结构不用动,动的只是输入。把「方向」当成布局概念的日子里,它一直是一个代理变量;现在代理没了,只剩空间本身。

size class 的断崖:为什么还需要第二级

size class 是二值化的,粗到会「说谎」。看这个断崖:iPad 全屏 1024pt 和分屏一半 507pt,都是 (regular, regular)——但前者放得下 sidebar,后者放不下。两个字回答不了「几列、多大、限不限宽」这类程度问题。

所以成熟的做法是两级接力:size class 回答方向性问题(单栏还是分栏),工程策略回答程度问题(几列、Hero 多大、是否限宽)。 第二级通常按实测容器宽度分档,比如宽屏限宽让正文不超过舒服的行宽,或者按内容最小尺寸决定网格列数。iOS 17 起 SwiftUI 可以直接用 onGeometryChange 实测容器尺寸参与判断,不必再依赖 GeometryReader。

四、设计思想三:功能不随尺寸改变,只改变「可见量」

这是 Human Interface Guidelines 里一条容易被忽略但极其重要的原则:size class 变化时,功能集合保持不变,变的只是「一次能看到多少」。

宽屏上 tab bar 可以展开成 sidebar,露出二级目标;窄屏里低优先级的工具项收进 overflow 菜单,一次点击仍可到达。收纳是一种重排,不是删除。 如果一个操作在宽屏布局里可用、在窄屏里凭空消失,这不是自适应,这是功能残缺。同理,横竖屏之间不应该出现「这个页面只有一个方向能完整操作」的设计——能在竖屏做的事,横屏要有头有尾。

对产品设计者的隐含要求是:做信息架构时就给每个元素标注优先级。分享、主操作这类高优先级项在所有尺寸下常驻;保存、次级设置这类低优先级项宽时常驻、窄时进 overflow。系统组件的 Visibility Priority 机制干的就是这件事。

五、设计思想四:min / max 尺寸是防重叠,不是拒绝缩放

UIKit 允许场景声明最小、最大尺寸,但语义要说清楚:这两个值的存在是为了防止元素在小尺寸下互相遮挡、在大尺寸下松散失控,不是给用户设置「不许再拖小」的门槛。 当一个窗口被允许拖到任何尺寸,你的责任是保证任何尺寸下内容都至少可用、可读、不重叠;如果某个尺寸下必然破碎,那说明你的最小尺寸声明得太贪心,或者布局本身还没有真正自适应。

场景连接时声明:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options: UIScene.ConnectionOptions) {
    guard let windowScene = scene as? UIWindowScene else { return }

    // 下限:再小下去元素就开始重叠;上限:再大布局开始松散
    windowScene.sizeRestrictions?.minimumSize = CGSize(width: 320, height: 480)
    windowScene.sizeRestrictions?.maximumSize = CGSize(width: 1200, height: 1600)
}

配套的一条旧账:UIRequiresFullScreen 已弃用,持它的 App 无法退出缩放,等于自动放弃新的显示尺寸。从 iOS 26 SDK 起,App 不再需要为新屏幕重写代码或重新提交,系统会自动适配——前提是你不锁死自己。

六、设计思想五:让系统组件替你干活

resizability 时代最省力的策略,是把重排的活儿交给系统组件。它们对任意宽高比的处理远比手写布局稳健:

  • Tab bar / 工具栏:窗口变宽时 tab 图标与标签同行排列保持紧凑;窗口变窄时低优先级工具项自动移入 overflow 菜单。新闻类 App 里「分享」可以钉在所有尺寸下常驻,「收藏」窄时才进 overflow——通过 Visibility Priority 声明,不用写一行布局代码。

  • Sidebar:iPadOS 26 起 iPhone App 在空间允许时也能主动选侧栏布局,用 sidebar.isAvailable 判断当前空间展示得了再请求。

  • NavigationSplitView:iOS 26 起列会根据空间自动显隐,不再需要手工管理列可见性。

  • Liquid Glass 系统材质:在 iOS 27 上获得改进的漫射与边缘分离度,不重编译也能受益。

你自己的 UI 要做的只有一件事:别和这些组件打架。自绘顶栏要避开窗口左上角的关闭/最小化控件(用带水平角适应的布局参考线),别假设 tab bar 永远在底部、工具栏永远在顶部——它们的形态本身就会随空间改变。

七、UIKit 落地:场景配置与几何响应

场景是 resizability 的地基:每个场景独立管理自己的 UI 实例和状态,并提供「这块 UI 正显示在哪、有多大」的上下文。iOS 27 起,用最新 SDK 构建的 App 必须采用 UIScene 生命周期,这不是可选项。

一份可以直接抄的场景委托骨架:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }

        // 1) 尺寸能力声明:防重叠,不拒绝缩放
        windowScene.sizeRestrictions?.minimumSize = CGSize(width: 320, height: 480)

        // 2) 窗口挂在场景上,禁止 UIWindow(frame: UIScreen.main.bounds)
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = AdaptiveRootController()
        self.window = window
        window.makeKeyAndVisible()
    }

    // 3) 几何变化回调:拖拽、分屏、折叠展开、换外接屏都走这里
    func windowScene(_ windowScene: UIWindowScene,
                     didUpdateEffectiveGeometry previous: UIWindowScene.Geometry) {
        let geometry = windowScene.effectiveGeometry
        let size = geometry.coordinateSpace.bounds.size

        // isInteractivelyResizing 为 true 说明用户正捏着边缘拖
        // 重活(重建资源、重算图表)等松手后一次做完,别逐帧渲染
        if !geometry.isInteractivelyResizing,
           size != previous.coordinateSpace.bounds.size {
            AssetStore.shared.rebuild(for: size)
        }
    }

    // 4) iPadOS 26+ 窗口按钮(关闭/最小化)样式偏好
    func preferredWindowingControlStyle(for scene: UIWindowScene)
        -> UIWindowScene.WindowingControlStyle {
        .unified
    }
}

三个要点值得展开:

isInteractivelyResizing 是拖拽性能的生命线。 用户拖窗口的过程中系统会连续上报几何变化,游戏和应用如果对每个中间尺寸都重建渲染资源,帧率会直接崩。标准姿势是拖拽中只做轻量更新(改约束、改字号),松手后一次性做重活。

取屏幕、取尺寸永远不要经过 UIScreen.main。 iPhone 镜像和外接屏场景下 main screen 是错的。正确来源是 view.window?.windowScene?.screen、场景的 effectiveGeometry,或视图本地的 view.bounds。

窗口控件避让。 iPadOS 26 起场景左上角有关闭/最小化控件,自绘顶栏要约束到 containerView.layoutGuide(for: .margins(cornerAdaptation: .horizontal)),否则用户点不到你的第一个按钮。

八、视图层:删掉三个旧模式

自适应的 UIKit 视图代码,本质是一次「三个替换」:

删掉 换成 原因
UIScreen.main.bounds view.bounds / effectiveGeometry / windowScene?.screen 分屏与镜像下 main screen 不是你的窗口
userInterfaceIdiom == .pad 分支 traitCollection.horizontalSizeClass 设备类型不含布局语义
interfaceOrientation 相关分支 size class + 本地 bounds 方向是布局的结果,不是布局的原因

替换之后,trait 变化驱动布局:

class AdaptiveRootController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        // size class 变化时重排;自动追踪覆盖不到的缓存失效同样用注册
        registerForTraitChanges([UITraitHorizontalSizeClass.self,
                                 UITraitVerticalSizeClass.self]) { (vc: Self, _) in
            vc.applyLayout()
        }
    }

    private func applyLayout() {
        switch (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass) {
        case (.regular, _):   useSidebarLayout()
        case (.compact, _):   useStackedLayout()
        default:              useStackedLayout()
        }
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // 需要比 size class 更细的判断时,用本地可用尺寸
        fineTune(for: view.bounds.size)
    }
}

注意 viewDidLayoutSubviews / layoutSubviews 是持续生效的:size class 触发的是「结构重排」,bounds 变化触发的是「细粒度微调」。一次性的 onAppear / viewDidLoad 里读一次尺寸存起来,是开始缩放后最先过时的代码。

九、SwiftUI 对照:多数自动,关键处兜底

用 SwiftUI 写的 App 在 resizability 上占便宜:用 Xcode 27 重新编译,窗口缩放支持自动生效,没有新 API 要采纳,Liquid Glass 新材质也自动继承。但「自动」不等于「免检」,以下机制构成 SwiftUI 侧的兜底工具箱:

  • @Environment(\.horizontalSizeClass) / verticalSizeClass:对应 UIKit trait,回答方向性问题。

  • onGeometryChange(for:of:action:):实测容器尺寸,回答程度性问题。它只读尺寸、不招惹 GeometryReader 的贪婪占位问题,且只有派生值真正变化时才回调,天然过滤拖拽过程中的中间态。

  • ViewThatFits:「哪个装得下用哪个」的声明式写法,适合内容在不同宽度下换排列方式的场景。

  • containerRelativeFrame():按容器比例而非绝对点数定尺寸,写背景延展区域时尤其省心。

  • NavigationSplitView:iOS 26 起列宽与列可见性自适应,不要在它外面再包一层手工列管理。

  • .windowResizability(.contentMinSize):SwiftUI 场景声明最小尺寸的入口,等价于 UIKit 的 sizeRestrictions?.minimumSize。

一个典型的两级接力写法:

struct DashboardView: View {
    @Environment(\.horizontalSizeClass) private var hSize
    @State private var columnCount = 1

    var body: some View {
        ScrollView {
            MetricGrid(columns: columnCount)
        }
        // 第一级:size class 决定单栏还是分栏的世界观
        .onChange(of: hSize) { _, new in
            usesSidebar = (new == .regular)
        }
        // 第二级:实测宽度决定网格几列,覆盖同为 regular 的 507 与 1024
        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { _, width in
            columnCount = AdaptiveWindowLayoutPolicy.tier(for: width).columns
        }
    }
}

十、Info.plist 与 Launch Screen 红线

迁移清单里有两条来自工程配置的红线,漏掉任何一条都会在会上线前才爆炸:

  1. UIRequiresFullScreen 必须处理。 已弃用,且持它的 App 无法参与缩放。要么删掉;如果确实有历史兼容性诉求,用 UIRequiresFullScreenIgnoredStartingWithVersion 显式声明从哪个版本起忽略该键。游戏类 App 会因此进入 discrete resizing:每次拖完切换到匹配尺寸的新屏幕配置,保 full-quality 渲染。

  2. Launch Screen 成为硬性要求。 iOS 27 SDK 起,没有配置 Launch Screen 的 App 无法通过 App Store 提交(TN3208)。SwiftUI 工程里这通常意味着补一个最小的启动屏配置,而不是继续裸奔。

十一、反模式速查表

改代码前先全文搜一遍这些模式,搜到的每一处都是待还的债:

反模式 为什么错 换成
UIScreen.main.bounds 分屏/镜像下不是你的窗口 本地 view.bounds 或场景几何
UIDevice.current.userInterfaceIdiom 分支 设备类型无布局语义 horizontalSizeClass
interfaceOrientation / 方向通知 报设备方向,不报窗口形状 size class + 本地 bounds
size.width > 600 自造断点 绕过 trait 语义,漏掉分屏中间态 工程分档策略 + onGeometryChange
onAppear 里读一次尺寸 缩放是持续的,缓存立刻过期 trait / 几何回调
只为 iPad 写宽布局 Slide Over 的 iPad 行为像手机 宽窄两套逻辑都用空间触发
写死 .frame(height: 480) 装地图/头图 矮窗口下正文被顶出首屏 高度按容器比例或自适应

十二、迁移路线图

按风险和收益排序的四步走:

Phase 1 · 拆雷(先行,阻塞上线):删 UIRequiresFullScreen、补 Launch Screen、确认 UIScene 生命周期在用。这三项是硬性门槛,与设计质量无关,漏了直接卡审核或卡构建。

Phase 2 · 清反模式:按第十一节的表全文替换 UIScreen.main、idiom / 方向分支、自造宽度断点。替换的目标不是「能跑」,而是把布局的输入源统一到空间语义上。

Phase 3 · 补两级分档:给内容型页面建立「size class 定方向 + 实测宽度定程度」的策略层,收敛所有裸阈值到同一处。宽屏注意正文限宽,避免一行拉到 85 字符以上。

Phase 4 · 系统化测试:按第十三节的矩阵逐尺寸验证,把结果固化为回归项。

十三、测试矩阵

resizability 的测试不按设备清单做,按空间形态做。每种形态都要回答:功能是否完整、内容是否被裁、chrome 是否可点。

空间形态 重点验证
最窄可用宽度(接近 minimumSize) 元素是否重叠、主要按钮是否可点
标准手机宽度 默认布局基线
手机横屏 vertical compact 下 tab bar 竖排、首屏内容完整
手机窗口拖大到 regular 分栏是否接管、状态是否保持
分屏一半 / Slide Over 同为 iPad 的两种空间行为
又宽又扁 / 又高又窄的极端比例 背景延展是否露馅、内容是否被拉爆
Duo 外屏与展开内屏 折叠切换时布局与状态是否连续
iPhone 镜像 / 外接屏 取屏与取尺寸是否正确

工具侧:Xcode 27 的 Device Hub 与 Previews 提供窗口 resize 模式,拖边缘即可连续改尺寸;IDE 层面做真机验证补镜像与外接屏。

十四、常见问题

为什么我的 iPhone App 突然能被拖大窗口了?

这不是 bug,是 iOS 26 SDK 起的新行为:iPhone App 在 iPad 上和 Mac 的 iPhone 镜像里运行时,窗口可自由缩放。对应的布局信号是 horizontal size class 从 compact 变成 regular。之前的 if idiom == .phone 代码在这类窗口里会给出错误布局,改问 size class。

size class 够用吗,为什么还要实测宽度?

不够,且断崖真实存在。iPad 全屏和分屏一半都是 (regular, regular),宽度却相差一倍,一个放得下 sidebar 一个放不下。size class 定方向(单栏/分栏),实测宽度定程度(几列/多大)。两者是接力,不是二选一。

声明了 minimumSize,用户还能把窗口拖更小吗?

minimumSize 设定的是「保证你的内容不乱叠」的下限,用户拖到这个下限就拖不动了——这是防重叠机制,不是拒绝缩放的功能门禁。真正该保证的是:从 minimumSize 到 maximumSize 之间任何尺寸,App 都可用。做不到就说明自适应没写完,而不是调大最小值。

UIRequiresFullScreen 删了会怎样?

删了之后 App 参与窗口缩放,这是 iOS 26+ 的默认预期。担心旧版兼容可以用 UIRequiresFullScreenIgnoredStartingWithVersion 指定忽略版本;担心游戏渲染质量则会进入 discrete resizing,切到匹配尺寸的屏幕配置,反而更保画质。

SwiftUI 应用需要做什么?

重新编译即获得缩放支持,没有新 API 要采纳。要自查的是:有没有依赖 UIScreen 类旧假设的代码、尺寸判断是否只用 size class 而没兜底实测、固定 frame 的组件在极端宽高比下是否破裂。框架管宏观,破裂的组件通常是你自己写死的那个。

参考与延伸阅读

官方来源:WWDC25 session 282「Make your UIKit app more flexible」、WWDC26 session 278「Modernize your UIKit app」、Human Interface Guidelines 的 Layout 与 Designing for iPhone Duo、UISceneSizeRestrictions 与 UIWindowScene.Geometry 文档、技术说明 TN3192(从弃用的 UIRequiresFullScreen 键迁移) 与 TN3208(Launch Screen 要求)。

站内延伸:想看到这套思想在一台具体设备上的完整落地,可以读为 iPhone Duo 做界面:从布局到代码;想理解「数据也要随空间收缩」的一面,看个人基线指南;更多产品与适配问题见常见问题。

继续阅读

在 Stress HRV 里体验这些想法

App Store 下载

这些笔记分享个人健康工具的产品思考,不构成医疗建议。