axiom-swiftui-layout
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSwiftUI Adaptive Layout
SwiftUI 自适应布局
Overview
概述
Discipline-enforcing skill for building layouts that respond to available space rather than device assumptions. Covers tool selection, size class limitations, iOS 26 free-form windows, and common anti-patterns.
Core principle: Your layout should work correctly if Apple ships a new device tomorrow, or if iPadOS adds a new multitasking mode next year. Respond to your container, not your assumptions about the device.
这是一种用于构建响应可用空间而非依赖设备假设的布局的规范技能。涵盖工具选择、尺寸类限制、iOS 26自由形态窗口以及常见反模式。
核心原则: 即使苹果明天发布新设备,或者明年iPadOS新增多任务模式,你的布局也应能正常工作。响应容器空间,而非你对设备的假设。
When to Use This Skill
何时使用此技能
- "How do I make this layout work on iPad and iPhone?"
- "Should I use GeometryReader or ViewThatFits?"
- "My layout breaks in Split View / Stage Manager"
- "Size classes aren't giving me what I need"
- "Designer wants different layout for portrait vs landscape"
- "Preparing app for iOS 26 window resizing"
- "如何让这个布局在iPad和iPhone上都能正常工作?"
- "我应该使用GeometryReader还是ViewThatFits?"
- "我的布局在分屏视图/台前调度中出现问题"
- "尺寸类无法满足我的需求"
- "设计师希望竖屏和横屏使用不同布局"
- "为iOS 26窗口调整功能准备应用"
Decision Tree
决策树
"I need my layout to adapt..."
│
├─ TO AVAILABLE SPACE (container-driven)
│ │
│ ├─ "Pick best-fitting variant"
│ │ → ViewThatFits
│ │
│ ├─ "Animated switch between H↔V"
│ │ → AnyLayout + condition
│ │
│ ├─ "Read size for calculations"
│ │ → onGeometryChange (iOS 16+)
│ │
│ └─ "Custom layout algorithm"
│ → Layout protocol
│
├─ TO PLATFORM TRAITS
│ │
│ ├─ "Compact vs Regular width"
│ │ → horizontalSizeClass (⚠️ iPad limitations)
│ │
│ ├─ "Accessibility text size"
│ │ → dynamicTypeSize.isAccessibilitySize
│ │
│ └─ "Platform differences"
│ → #if os() / Environment
│
└─ TO WINDOW SHAPE (aspect ratio)
│
├─ "Portrait vs Landscape semantics"
│ → Geometry + custom threshold
│
├─ "Auto show/hide columns"
│ → NavigationSplitView (automatic in iOS 26)
│
└─ "Window lifecycle"
→ @Environment(\.scenePhase)"我需要我的布局适配..."
│
├─ 可用空间(容器驱动)
│ │
│ ├─ "选择最适合的变体"
│ │ → ViewThatFits
│ │
│ ├─ "在水平↔垂直布局间动画切换"
│ │ → AnyLayout + 条件
│ │
│ ├─ "读取尺寸用于计算"
│ │ → onGeometryChange (iOS 16+)
│ │
│ └─ "自定义布局算法"
│ → Layout 协议
│
├─ 平台特性
│ │
│ ├─ "紧凑 vs 常规宽度"
│ │ → horizontalSizeClass (⚠️ iPad 限制)
│ │
│ ├─ "辅助功能文本尺寸"
│ │ → dynamicTypeSize.isAccessibilitySize
│ │
│ └─ "平台差异"
│ → #if os() / Environment
│
└─ 窗口形状(宽高比)
│
├─ "竖屏 vs 横屏语义"
│ → Geometry + 自定义阈值
│
├─ "自动显示/隐藏列"
│ → NavigationSplitView (iOS 26 中自动适配)
│
└─ "窗口生命周期"
→ @Environment(\.scenePhase)Tool Selection
工具选择
Quick Decision
快速决策
Do you need a calculated value (width, height)?
├─ YES → onGeometryChange
└─ NO → Do you need animated transitions?
├─ YES → AnyLayout + condition
└─ NO → ViewThatFits你需要计算值(宽度、高度)吗?
├─ 是 → onGeometryChange
└─ 否 → 你需要动画过渡吗?
├─ 是 → AnyLayout + 条件
└─ 否 → ViewThatFitsWhen to Use Each Tool
各工具的适用场景
| I need to... | Use this | Not this |
|---|---|---|
| Pick between 2-3 layout variants | | |
| Switch H↔V with animation | | Conditional HStack/VStack |
| Read container size | | |
| Adapt to accessibility text | | Fixed breakpoints |
| Detect compact width | | |
| Detect narrow window on iPad | Geometry + threshold | Size class alone |
| Hide/show sidebar | | Manual column logic |
| Custom layout algorithm | | Nested GeometryReaders |
| 我需要... | 使用这个 | 不要使用这个 |
|---|---|---|
| 在2-3种布局变体中选择 | | |
| 在水平/垂直布局间带动画切换 | | 条件式HStack/VStack |
| 读取容器尺寸 | | |
| 适配辅助功能文本 | | 固定断点 |
| 检测紧凑宽度 | | |
| 检测iPad上的窄窗口 | Geometry + 阈值 | 仅使用尺寸类 |
| 显示/隐藏侧边栏 | | 手动列逻辑 |
| 自定义布局算法 | | 嵌套GeometryReader |
Pattern 1: ViewThatFits
模式1:ViewThatFits
Use when: You have 2-3 layout variants and want SwiftUI to pick the first that fits.
swift
ViewThatFits {
// First choice: horizontal
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
// Fallback: vertical
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}Limitation: ViewThatFits doesn't expose which variant was chosen. If you need that state for other views, use AnyLayout instead.
适用场景: 你有2-3种布局变体,希望SwiftUI选择第一个适配的变体。
swift
ViewThatFits {
// 首选:水平布局
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
// 备选:垂直布局
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}限制: ViewThatFits不会暴露当前选择的变体。如果你需要将该状态用于其他视图,请改用AnyLayout。
Pattern 2: AnyLayout for Animated Switching
模式2:使用AnyLayout实现动画切换
Use when: You need animated transitions between layouts, or need to know current layout state.
swift
struct AdaptiveStack<Content: View>: View {
@Environment(\.horizontalSizeClass) var sizeClass
let content: Content
var layout: AnyLayout {
sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
}
var body: some View {
layout {
content
}
.animation(.default, value: sizeClass)
}
}For Dynamic Type:
swift
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}适用场景: 你需要布局间的动画过渡,或者需要了解当前布局状态。
swift
struct AdaptiveStack<Content: View>: View {
@Environment(\.horizontalSizeClass) var sizeClass
let content: Content
var layout: AnyLayout {
sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
}
var body: some View {
layout {
content
}
.animation(.default, value: sizeClass)
}
}针对动态字体:
swift
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}Pattern 3: onGeometryChange (Preferred for Geometry)
模式3:onGeometryChange(几何读取首选方案)
Use when: You need actual dimensions for calculations. Preferred over GeometryReader.
swift
struct ResponsiveGrid: View {
@State private var columnCount = 2
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
ForEach(items) { item in
ItemView(item: item)
}
}
.onGeometryChange(for: Int.self) { proxy in
max(1, Int(proxy.size.width / 150))
} action: { newCount in
columnCount = newCount
}
}
}For aspect ratio detection (iPad "orientation"):
swift
struct WindowShapeReader: View {
@State private var isWide = true
var body: some View {
content
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height * 1.2
} action: { newValue in
isWide = newValue
}
}
}适用场景: 你需要实际尺寸用于计算。优先于GeometryReader。
swift
struct ResponsiveGrid: View {
@State private var columnCount = 2
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
ForEach(items) { item in
ItemView(item: item)
}
}
.onGeometryChange(for: Int.self) { proxy in
max(1, Int(proxy.size.width / 150))
} action: { newCount in
columnCount = newCount
}
}
}用于宽高比检测(iPad"方向"):
swift
struct WindowShapeReader: View {
@State private var isWide = true
var body: some View {
content
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height * 1.2
} action: { newValue in
isWide = newValue
}
}
}Pattern 4: GeometryReader (When Necessary)
模式4:GeometryReader(必要时使用)
Use when: You need geometry AND are on iOS 15 or earlier, OR need geometry during layout phase (not just as side effect).
swift
// ✅ CORRECT: Constrained GeometryReader
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
.frame(height: 44) // MUST constrain!
Button("Next") { }
}
// ❌ WRONG: Unconstrained (greedy)
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
// Takes all available space, crushes siblings
Button("Next") { }
}适用场景: 你需要几何信息且运行在iOS 15或更早版本,或者需要在布局阶段获取几何信息(而非仅作为副作用)。
swift
// ✅ 正确:约束GeometryReader
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
.frame(height: 44) // 必须添加约束!
Button("Next") { }
}
// ❌ 错误:无约束(贪婪占用空间)
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
// 占用所有可用空间,挤压兄弟视图
Button("Next") { }
}Size Class Truth Table (iPad)
iPad尺寸类真值表
| Configuration | Horizontal | Vertical |
|---|---|---|
| Full screen portrait | | |
| Full screen landscape | | |
| 70% Split View | | |
| 50% Split View | | |
| 33% Split View | | |
| Slide Over | | |
| With keyboard | (unchanged) | (unchanged) |
Key insight: Size class only goes on iPad at ~33% width or Slide Over. For finer control, use geometry.
.compact| 配置 | 水平 | 垂直 |
|---|---|---|
| 全屏竖屏 | | |
| 全屏横屏 | | |
| 70%分屏视图 | | |
| 50%分屏视图 | | |
| 33%分屏视图 | | |
| 侧滑视图 | | |
| 搭配键盘 | (无变化) | (无变化) |
关键结论: iPad仅在宽度约33%或侧滑视图时,尺寸类才会变为。如需更精细的控制,请使用几何信息。
.compactiOS 26 Free-Form Windows
iOS 26自由形态窗口
What Changed
变化内容
| Before iOS 26 | iOS 26+ |
|---|---|
| Fixed Split View sizes | Free-form drag-to-resize |
| Deprecated |
| No menu bar on iPad | Menu bar via |
| Manual column visibility | |
| iOS 26之前 | iOS 26及以后 |
|---|---|
| 固定分屏视图尺寸 | 自由拖拽调整大小 |
允许 | 已废弃 |
| iPad无菜单栏 | 通过 |
| 手动控制列可见性 | |
Apple's Guideline
Apple指南
"Resizing an app should not permanently alter its layout. Be opportunistic about reverting back to the starting state whenever possible."
Translation: Don't save layout state based on window size. When window returns to original size, layout should too.
"调整应用窗口大小不应永久改变其布局。只要有可能,应尽可能恢复到初始状态。"
解读: 不要根据窗口大小保存布局状态。当窗口恢复到原始尺寸时,布局也应恢复。
NavigationSplitView Auto-Adaptation
NavigationSplitView自动适配
swift
// iOS 26: Columns automatically show/hide
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// No manual columnVisibility management neededswift
// iOS 26:列会自动显示/隐藏
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// 无需手动管理columnVisibilityMigration Checklist
迁移检查清单
- Remove from Info.plist
UIRequiresFullScreen - Test at arbitrary window sizes (not just 33/50/66%)
- Verify layout doesn't "stick" after resize
- Add menu bar commands for common actions
- Test Window Controls don't overlap toolbar items
- 从Info.plist中移除
UIRequiresFullScreen - 在任意窗口尺寸下测试(不只是33/50/66%)
- 验证调整大小后布局不会"固定"在某一状态
- 为常用操作添加菜单栏命令
- 测试窗口控件不会与工具栏项重叠
Anti-Patterns
反模式
❌ Device Orientation Observer
❌ 设备方向观察者
swift
// ❌ WRONG: Reports device, not window
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification, ...
)
let orientation = UIDevice.current.orientation
if orientation.isLandscape { ... }Why it fails: Reports physical device orientation, not window shape. Wrong in Split View, Stage Manager, iOS 26.
Fix: Use to read actual window dimensions.
onGeometryChangeswift
// ❌ 错误:报告设备状态,而非窗口形状
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification, ...
)
let orientation = UIDevice.current.orientation
if orientation.isLandscape { ... }问题所在: 报告的是物理设备方向,而非窗口形状。在分屏视图、台前调度、iOS 26中均会出错。
修复方案: 使用读取实际窗口尺寸。
onGeometryChange❌ Screen Bounds
❌ 屏幕边界
swift
// ❌ WRONG: Returns full screen, not your window
let width = UIScreen.main.bounds.width
if width > 700 { useWideLayout() }Why it fails: In multitasking, your app may only have 40% of the screen.
Fix: Read your view's actual container size.
swift
// ❌ 错误:返回全屏尺寸,而非你的窗口尺寸
let width = UIScreen.main.bounds.width
if width > 700 { useWideLayout() }问题所在: 在多任务模式下,你的应用可能仅占用屏幕的40%。
修复方案: 读取视图的实际容器尺寸。
❌ Device Model Checks
❌ 设备型号检查
swift
// ❌ WRONG: Breaks on new devices, wrong in multitasking
if UIDevice.current.userInterfaceIdiom == .pad {
useWideLayout()
}Why it fails: iPad in 1/3 Split View is narrower than iPhone 14 Pro Max landscape.
Fix: Respond to available space, not device identity.
swift
// ❌ 错误:在新设备上会失效,在多任务模式下也不正确
if UIDevice.current.userInterfaceIdiom == .pad {
useWideLayout()
}问题所在: iPad在1/3分屏视图下的宽度比横屏的iPhone 14 Pro Max更窄。
修复方案: 响应可用空间,而非设备类型。
❌ Unconstrained GeometryReader
❌ 无约束的GeometryReader
swift
// ❌ WRONG: GeometryReader is greedy
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // Crushed
}Fix: Constrain with or use .
.frame()onGeometryChangeswift
// ❌ 错误:GeometryReader会贪婪占用空间
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // 被挤压
}修复方案: 使用添加约束,或改用。
.frame()onGeometryChange❌ Size Class as Orientation Proxy
❌ 将尺寸类作为方向代理
swift
// ❌ WRONG: iPad is .regular in both orientations
var isLandscape: Bool {
horizontalSizeClass == .regular // Always true on iPad!
}Fix: Calculate from actual geometry if you need aspect ratio.
swift
// ❌ 错误:iPad在横竖屏时均为`.regular`
var isLandscape: Bool {
horizontalSizeClass == .regular // 在iPad上始终为true!
}修复方案: 如果需要宽高比,从实际几何信息计算。
Pressure Scenarios
压力场景
"Designer wants iPhone-specific layout"
"设计师想要iPhone专属布局"
Temptation:
if UIDevice.current.userInterfaceIdiom == .phoneResponse: "I'll implement these as 'compact' and 'regular' layouts that switch based on available space. The iPhone layout will appear on iPad when the window is narrow. This future-proofs us for Stage Manager and iOS 26."
诱惑:
if UIDevice.current.userInterfaceIdiom == .phone应对: "我会将这些实现为基于可用空间切换的'紧凑'和'常规'布局。当iPad窗口变窄时,也会显示iPhone布局。这能让我们的应用适配台前调度和iOS 26,具备未来兼容性。"
"Just use GeometryReader, it's fine"
"直接用GeometryReader就行,没问题"
Temptation: Wrap everything in GeometryReader.
Response: "GeometryReader has known layout side effects — it expands greedily. reads the same data without affecting layout. It's backported to iOS 16."
onGeometryChange诱惑: 用GeometryReader包裹所有内容。
应对: "GeometryReader存在已知的布局副作用——它会贪婪地扩展空间。可以读取相同的数据,且不会影响布局。它已向后兼容到iOS 16。"
onGeometryChange"Size classes worked before"
"之前尺寸类一直能用"
Temptation: Force everything through size class.
Response: "Size classes are coarse. iPad is in both orientations. I'll use size class for broad categories and geometry for precise thresholds."
.regular诱惑: 强制所有逻辑通过尺寸类实现。
应对: "尺寸类的粒度较粗。iPad在横竖屏时均为。我会用尺寸类处理宽泛的分类,用几何信息处理精确阈值。"
.regular"We don't support iPad multitasking"
"我们不支持iPad多任务"
Temptation:
UIRequiresFullScreen = trueResponse: "Apple deprecated full-screen-only in iOS 26. Even without active Split View support, the app can't break when resized. Space-based layout costs the same."
诱惑: 设置
UIRequiresFullScreen = true应对: "苹果在iOS 26中废弃了仅全屏模式。即使不主动支持分屏,应用在调整大小时也不能崩溃。基于空间的布局实现成本相同。"
Resources
参考资源
WWDC: 2025-208, 2024-10074, 2022-10056
Skills: axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass
WWDC:2025-208, 2024-10074, 2022-10056
相关技能:axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass