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 红线
迁移清单里有两条来自工程配置的红线,漏掉任何一条都会在会上线前才爆炸:
UIRequiresFullScreen必须处理。 已弃用,且持它的 App 无法参与缩放。要么删掉;如果确实有历史兼容性诉求,用UIRequiresFullScreenIgnoredStartingWithVersion显式声明从哪个版本起忽略该键。游戏类 App 会因此进入 discrete resizing:每次拖完切换到匹配尺寸的新屏幕配置,保 full-quality 渲染。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 下载这些笔记分享个人健康工具的产品思考,不构成医疗建议。