axiom-swiftui-layout

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftUI 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 + 条件
         └─ 否 → ViewThatFits

When to Use Each Tool

各工具的适用场景

I need to...Use thisNot this
Pick between 2-3 layout variants
ViewThatFits
if size > X
Switch H↔V with animation
AnyLayout
Conditional HStack/VStack
Read container size
onGeometryChange
GeometryReader
Adapt to accessibility text
dynamicTypeSize
Fixed breakpoints
Detect compact width
horizontalSizeClass
UIDevice.idiom
Detect narrow window on iPadGeometry + thresholdSize class alone
Hide/show sidebar
NavigationSplitView
Manual column logic
Custom layout algorithm
Layout
protocol
Nested GeometryReaders

我需要...使用这个不要使用这个
在2-3种布局变体中选择
ViewThatFits
if size > X
在水平/垂直布局间带动画切换
AnyLayout
条件式HStack/VStack
读取容器尺寸
onGeometryChange
GeometryReader
适配辅助功能文本
dynamicTypeSize
固定断点
检测紧凑宽度
horizontalSizeClass
UIDevice.idiom
检测iPad上的窄窗口Geometry + 阈值仅使用尺寸类
显示/隐藏侧边栏
NavigationSplitView
手动列逻辑
自定义布局算法
Layout
协议
嵌套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尺寸类真值表

ConfigurationHorizontalVertical
Full screen portrait
.regular
.regular
Full screen landscape
.regular
.regular
70% Split View
.regular
.regular
50% Split View
.regular
.regular
33% Split View
.compact
.regular
Slide Over
.compact
.regular
With keyboard(unchanged)(unchanged)
Key insight: Size class only goes
.compact
on iPad at ~33% width or Slide Over. For finer control, use geometry.

配置水平垂直
全屏竖屏
.regular
.regular
全屏横屏
.regular
.regular
70%分屏视图
.regular
.regular
50%分屏视图
.regular
.regular
33%分屏视图
.compact
.regular
侧滑视图
.compact
.regular
搭配键盘(无变化)(无变化)
关键结论: iPad仅在宽度约33%或侧滑视图时,尺寸类才会变为
.compact
。如需更精细的控制,请使用几何信息。

iOS 26 Free-Form Windows

iOS 26自由形态窗口

What Changed

变化内容

Before iOS 26iOS 26+
Fixed Split View sizesFree-form drag-to-resize
UIRequiresFullScreen
allowed
Deprecated
No menu bar on iPadMenu bar via
.commands
Manual column visibility
NavigationSplitView
auto-adapts
iOS 26之前iOS 26及以后
固定分屏视图尺寸自由拖拽调整大小
允许
UIRequiresFullScreen
已废弃
iPad无菜单栏通过
.commands
添加菜单栏
手动控制列可见性
NavigationSplitView
自动适配

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 needed
swift
// iOS 26:列会自动显示/隐藏
NavigationSplitView {
    Sidebar()
} content: {
    ContentList()
} detail: {
    DetailView()
}
// 无需手动管理columnVisibility

Migration Checklist

迁移检查清单

  • Remove
    UIRequiresFullScreen
    from Info.plist
  • 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
onGeometryChange
to read actual window dimensions.
swift
// ❌ 错误:报告设备状态,而非窗口形状
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
.frame()
or use
onGeometryChange
.
swift
// ❌ 错误: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 == .phone
Response: "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.
onGeometryChange
reads the same data without affecting layout. It's backported to iOS 16."
诱惑: 用GeometryReader包裹所有内容。
应对: "GeometryReader存在已知的布局副作用——它会贪婪地扩展空间。
onGeometryChange
可以读取相同的数据,且不会影响布局。它已向后兼容到iOS 16。"

"Size classes worked before"

"之前尺寸类一直能用"

Temptation: Force everything through size class.
Response: "Size classes are coarse. iPad is
.regular
in both orientations. I'll use size class for broad categories and geometry for precise thresholds."
诱惑: 强制所有逻辑通过尺寸类实现。
应对: "尺寸类的粒度较粗。iPad在横竖屏时均为
.regular
。我会用尺寸类处理宽泛的分类,用几何信息处理精确阈值。"

"We don't support iPad multitasking"

"我们不支持iPad多任务"

Temptation:
UIRequiresFullScreen = true
Response: "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