关于 axiom-swiftui-layout-ref
面向 SwiftUI 开发者的自适应布局 API 参考技能,涵盖系统布局容器、几何读取、安全区域和尺寸特征,并说明常见限制与用法。
主要功能
- 01ViewThatFits 变体选择
- 02AnyLayout 布局切换与动画
- 03自定义 Layout 算法
- 04几何信息读取
- 05安全区域间距与尺寸类
适用场景
- 01根据可用空间选择视图变体
- 02在布局间切换并保留视图身份
- 03按容器尺寸计算自适应内容
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-layout-ref安装后在 Claude Code 中输入/axiom-swiftui-layout-ref确认已加载。
详细介绍
SwiftUI 布局 API 参考
SwiftUI 自适应布局工具的完整 API 参考。有关决策指导和反模式,请参阅 axiom-swiftui-layout 技能。
概述
本参考涵盖用于构建自适应界面的所有 SwiftUI 布局 API:
- ViewThatFits — 自动选择变体(iOS 16+)
- AnyLayout — 类型擦除的动画布局切换(iOS 16+)
- Layout Protocol — 自定义布局算法(iOS 16+)
- onGeometryChange — 高效读取几何信息(向后移植至 iOS 16+)
- GeometryReader — 在布局阶段访问几何信息(iOS 13+)
- Safe Area Padding — .safeAreaPadding() 与 .padding() 的区别(iOS 17+)
- Size Classes — 基于特征的自适应
- iOS 26 Window APIs — 自由形式窗口、菜单栏、调整大小锚点
ViewThatFits
按顺序评估子视图,并显示第一个适合可用空间的视图。
基本用法
ViewThatFits {
// First choice
HStack {
icon
title
Spacer()
button
}
// Second choice
HStack {
icon
title
button
}
// Fallback
VStack {
HStack { icon; title }
button
}
}
添加轴约束
// Only consider horizontal fit
ViewThatFits(in: .horizontal) {
wideVersion
narrowVersion
}
// Only consider vertical fit
ViewThatFits(in: .vertical) {
tallVersion
shortVersion
}
工作原理
- 对每个子视图应用
fixedSize() - 根据可用空间测量理想尺寸
- 返回第一个适合的子视图
- 如果都不适合,则回退到最后一个子视图
局限性
- 不会公开所选的变体
- 无法在变体之间进行动画切换(请改用 AnyLayout)
- 会测量所有变体(复杂视图需要考虑性能)
AnyLayout
类型擦除的布局容器,可在布局之间进行动画过渡。
基本用法
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var layout: AnyLayout {
sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
}
var body: some View {
layout {
ForEach(items) { item in
ItemView(item: item)
}
}
.animation(.default, value: sizeClass)
}
}
可用布局类型
AnyLayout(HStackLayout(alignment: .top, spacing: 10))
AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
AnyLayout(ZStackLayout(alignment: .center))
AnyLayout(GridLayout(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10))
自定义条件
// Based on Dynamic Type
@Environment(\.dynamicTypeSize) var typeSize
var layout: AnyLayout {
typeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
// Based on geometry
@State private var isWide = true
var layout: AnyLayout {
isWide
? AnyLayout(HStackLayout())
: AnyLayout(VStackLayout())
}
为什么优于条件视图
// ❌ Loses view identity, no animation
if isCompact {
VStack { content }
} else {
HStack { content }
}
// ✅ Preserves identity, smooth animation
let layout = isCompact ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
layout { content }
Layout Protocol
创建可完全控制位置的自定义布局容器。
基本自定义布局
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return calculateSize(for: sizes, in: proposal.width ?? .infinity)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var point = bounds.origin
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if point.x + size.width > bounds.maxX {
point.x = bounds.origin.x
point.y += lineHeight + spacing
lineHeight = 0
}
subview.place(at: point, proposal: .unspecified)
point.x += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
}
}
// Usage
FlowLayout(spacing: 12) {
ForEach(tags) { tag in
TagView(tag: tag)
}
}
使用缓存
struct CachedLayout: Layout {
struct CacheData {
var sizes: [CGSize] = []
}
func makeCache(subviews: Subviews) -> CacheData {
CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
// Use cache.sizes instead of measuring again
}
}
布局值
// Define custom layout value
struct Rank: LayoutValueKey {
static let defaultValue: Int = 0
}
extension View {
func rank(_ value: Int) -> some View {
layoutValue(key: Rank.self, value: value)
}
}
// Read in layout
func placeSubviews(...) {
let sorted = subviews.sorted { $0[Rank.self] < $1[Rank.self] }
}
onGeometryChange
高效读取几何信息,不产生布局副作用。向后移植至 iOS 16+。
基本用法
@State private var size: CGSize = .zero
var body: some View {
content
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
size = newSize
}
}
读取特定值
// Width only
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { width in
columnCount = max(1, Int(width / 150))
}
// Frame in coordinate space
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { frame in
globalFrame = frame
}
// Aspect ratio
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height
} action: { isWide in
self.isWide = isWide
}
坐标空间
// Named coordinate space
ScrollView {
content
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.frame(in: .named("scroll")).minY
} action: { offset in
scrollOffset = offset
}
}
.coordinateSpace(name: "scroll")
与 GeometryReader 的比较
| 方面 | onGeometryChange | GeometryReader |
|---|---|---|
| 布局影响 | 无 | 贪婪(填满空间) |
| 评估时机 | 布局之后 | 布局期间 |
| 使用场景 | 副作用 | 布局计算 |
| iOS 版本 | 16+(向后移植) | 13+ |
GeometryReader
在布局阶段提供几何信息。由于其贪婪的尺寸行为,请谨慎使用。
基本用法(受约束)
// ✅ Always constrain GeometryReader
GeometryReader { proxy in
let width = proxy.size.width
HStack(spacing: 0) {
Rectangle().frame(width: width * 0.3)
Rectangle().frame(width: width * 0.7)
}
}
.frame(height: 100) // Required constraint
GeometryProxy 属性
GeometryReader { proxy in
// Container size
let size = proxy.size // CGSize
// Safe area insets
let insets = proxy.safeAreaInsets // EdgeInsets
// Frame in coordinate space
let globalFrame = proxy.frame(in: .global)
let localFrame = proxy.frame(in: .local)
let namedFrame = proxy.frame(in: .named("container"))
}
常见模式
// Proportional sizing
GeometryReader { geo in
VStack {
header.frame(height: geo.size.height * 0.2)
content.frame(height: geo.size.height * 0.8)
}
}
// Centering with offset
GeometryReader { geo in
content
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
避免常见错误
// ❌ Unconstrained in VStack
VStack {
GeometryReader { ... } // Takes ALL space
Button("Next") { } // Invisible
}
// ✅ Constrained
VStack {
GeometryReader { ... }
.frame(height: 200)
Button("Next") { }
}
// ❌ Causing layout loops
GeometryReader { geo in
content
.frame(width: geo.size.width) // Can cause infinite loop
}
Safe Area Padding
SwiftUI 提供两种处理内容周围间距的主要方式:.padding() 和 .safeAreaPadding()。了解何时使用哪一种,对在带有安全区域(刘海、Dynamic Island、主屏幕指示器)的设备上实现正确布局至关重要。
关键区别
// ❌ WRONG - Ignores safe areas, content hits notch/home indicator
ScrollView {
content
}
.padding(.horizontal, 20)
// ✅ CORRECT - Respects safe areas, adds padding beyond them
ScrollView {
content
}
.safeAreaPadding(.horizontal, 20)
关键要点:.padding() 从视图边缘开始添加固定间距。.safeAreaPadding() 则在安全区域内边距之外添加间距。
何时使用
在以下情况使用 .padding()
- 在容器内的同级视图之间添加间距
- 创建在任何地方都应保持一致的内部间距
- 处理已遵循安全区域的视图(例如 List、Form)
- 在 macOS 上添加装饰性间距(无需考虑安全区域)
VStack(spacing: 0) {
header
.padding(.horizontal, 16) // ✅ Internal spacing
Divider()
content
.padding(.horizontal, 16) // ✅ Internal spacing
}
在以下情况使用 .safeAreaPadding()(iOS 17+)
- 为延伸至屏幕边缘的全宽内容添加边距
- 实现带有适当内边距的边缘到边缘滚动
- 创建需要感知安全区域的自定义容器
- 使用 Liquid Glass 或全屏材质
// ✅ Edge-to-edge list with custom padding
List(items) { item in
ItemRow(item)
}
.listStyle(.plain)
.safeAreaPadding(.horizontal, 20) // Adds 20pt beyond safe areas
// ✅ Full-screen content with proper margins
ZStack {
Color.blue.ignoresSafeArea()
VStack {
content
}
.safeAreaPadding(.all, 16) // Respects notch, home indicator
}
平台支持情况
iOS 17+、iPadOS 17+、macOS 14+、axiom-visionOS 1.0+
对于更早的 iOS 版本,请手动处理安全区域:
// iOS 13-16 fallback
GeometryReader { geo in
content
.padding(.horizontal, 20 + geo.safeAreaInsets.leading)
}
或使用条件编译:
if #available(iOS 17, *) {
content.safeAreaPadding(.horizontal, 20)
} else {
content.padding(.horizontal, 20)
.padding(.leading, safeAreaInsets.leading)
}
针对特定边缘的用法
// Top only (below status bar/notch)
.safeAreaPadding(.top, 8)
// Bottom only (above home indicator)
.safeAreaPadding(.bottom, 16)
// Horizontal (left/right of safe areas)
.safeAreaPadding(.horizontal, 20)
// All edges
.safeAreaPadding(.all, 16)
// Individual edges
.safeAreaPadding(EdgeInsets(top: 8, leading: 20, bottom: 16, trailing: 20))
常见模式
边缘到边缘的 ScrollView
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item)
}
}
}
.safeAreaPadding(.horizontal, 16) // Content inset from edges + safe areas
.safeAreaPadding(.vertical, 8)
全屏背景与安全区域内的内容
ZStack {
// Background extends edge-to-edge
LinearGradient(...)
.ignoresSafeArea()
// Content respects safe areas + custom padding
VStack {
header
Spacer()
content
Spacer()
footer
}
.safeAreaPadding(.all, 20)
}
嵌套内边距(组合方式)
// Outer: Safe area padding for device insets
VStack(spacing: 0) {
content
}
.safeAreaPadding(.horizontal, 16) // Beyond safe areas
// Inner: Regular padding for internal spacing
VStack {
Text("Title")
.padding(.bottom, 8) // Internal spacing
Text("Subtitle")
}
决策树
Does your content extend to screen edges?
├─ YES → Use .safeAreaPadding()
│ ├─ Is it scrollable? → .safeAreaPadding(.horizontal/.vertical)
│ └─ Is it full-screen? → .safeAreaPadding(.all)
│
└─ NO (contained within a safe container like List/Form)
└─ Use .padding() for internal spacing
可视化调试
// Visualize safe area padding (iOS 17+)
content
.safeAreaPadding(.horizontal, 20)
.background(.red.opacity(0.2)) // Shows padding area
.border(.blue) // Shows content bounds
从手动处理安全区域迁移
// ❌ OLD: Manual calculation (iOS 13-16)
GeometryReader { geo in
content
.padding(.top, geo.safeAreaInsets.top + 16)
.padding(.bottom, geo.safeAreaInsets.bottom + 16)
.padding(.horizontal, 20)
}
// ✅ NEW: .safeAreaPadding() (iOS 17+)
content
.safeAreaPadding(.vertical, 16)
.safeAreaPadding(.horizontal, 20)
相关 API
.safeAreaInset(edge:) - 添加会缩小安全区域的持久内容:
ScrollView {
content
}
.safeAreaInset(edge: .bottom) {
// This REDUCES the safe area, content scrolls under it
toolbarButtons
.padding()
.background(.ultraThinMaterial)
}
.ignoresSafeArea() - 完全忽略安全区域:
Color.blue
.ignoresSafeArea() // Extends to absolute screen edges
为什么这很重要
iOS 17 之前:开发者必须使用 GeometryReader 手动计算安全区域内边距,这会导致:
- 代码冗长
- 性能开销(GeometryReader 会强制进行额外的布局遍历)
- 容易出错(忘记检查所有边缘)
iOS 17 及更高版本:.safeAreaPadding() 提供:
- 声明式 API(符合 SwiftUI 理念)
- 自动感知安全区域
- 更好的性能(无需额外的布局遍历)
- 类型安全的边缘指定
实际影响:在 iPhone 15 Pro 上使用 .padding() 而不是 .safeAreaPadding() 会导致内容:
- 撞上灵动岛(顶部)
- 与主屏幕指示条重叠(底部)
- 被屏幕圆角裁切(圆角边缘)
尺寸类别
用于指示水平和垂直尺寸特征的环境值。
读取尺寸类别
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
if horizontalSizeClass == .compact {
compactLayout
} else {
regularLayout
}
}
}
尺寸类别值
enum UserInterfaceSizeClass {
case compact // Constrained space
case regular // Ample space
}
平台行为
iPhone:
| 方向 | 水平 | 垂直 |
|---|---|---|
| 竖屏 | .compact | .regular |
| 横屏(小尺寸) | .compact | .compact |
| 横屏(Plus/Max) | .regular | .compact |
iPad:
| 配置 | 水平 | 垂直 |
|---|---|---|
| 任意全屏 | .regular | .regular |
| 70% 分屏视图 | .regular | .regular |
| 50% 分屏视图 | .regular | .regular |
| 33% 分屏视图 | .compact | .regular |
| 滑动悬浮 | .compact | .regular |
覆盖尺寸类别
content
.environment(\.horizontalSizeClass, .compact)
动态字体大小
用于表示用户首选文本大小的环境值。
读取动态字体大小
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
accessibleLayout
} else {
standardLayout
}
}
大小类别
enum DynamicTypeSize: Comparable {
case xSmall
case small
case medium
case large // Default
case xLarge
case xxLarge
case xxxLarge
case accessibility1 // isAccessibilitySize = true
case accessibility2
case accessibility3
case accessibility4
case accessibility5
}
缩放度量
@ScaledMetric var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .largeTitle) var headerSize: CGFloat = 44
Image(systemName: "star")
.frame(width: iconSize, height: iconSize)
iOS 26 窗口 API
窗口调整大小锚点
WindowGroup {
ContentView()
}
.windowResizeAnchor(.topLeading) // Resize originates from top-left
.windowResizeAnchor(.center) // Resize from center
菜单栏命令(iPad)
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("View") {
Button("Show Sidebar") {
showSidebar.toggle()
}
.keyboardShortcut("s", modifiers: [.command, .option])
Divider()
Button("Zoom In") { zoom += 0.1 }
.keyboardShortcut("+")
Button("Zoom Out") { zoom -= 0.1 }
.keyboardShortcut("-")
}
}
}
}
NavigationSplitView 列控制
// iOS 26: Automatic column visibility
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// Columns auto-hide/show based on available width
// Manual control (when needed)
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
} detail: {
DetailView()
}
场景阶段
@Environment(\.scenePhase) var scenePhase
var body: some View {
content
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
// Window is visible and interactive
case .inactive:
// Window is visible but not interactive
case .background:
// Window is not visible
}
}
}
坐标空间
内置坐标空间
// Global (screen coordinates)
proxy.frame(in: .global)
// Local (view's own bounds)
proxy.frame(in: .local)
// Named (custom)
proxy.frame(in: .named("mySpace"))
创建命名空间
ScrollView {
content
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.frame(in: .named("scroll")).minY
} action: { offset in
scrollOffset = offset
}
}
.coordinateSpace(name: "scroll")
// iOS 17+ typed coordinate space
extension CoordinateSpaceProtocol where Self == NamedCoordinateSpace {
static var scroll: Self { .named("scroll") }
}
ScrollView 几何信息(iOS 18 及更高版本)
onScrollGeometryChange
ScrollView {
content
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { offset in
scrollOffset = offset
}
ScrollGeometry 属性
.onScrollGeometryChange(for: ScrollGeometry.self) { $0 } action: { geo in
let offset = geo.contentOffset // Current scroll position
let size = geo.contentSize // Total content size
let visible = geo.visibleRect // Currently visible rect
let insets = geo.contentInsets // Content insets
}
惰性容器注意事项
回收行为
LazyVStack 和 LazyHStack 会按需创建视图,并在视图滚出屏幕时回收它们。这意味着:
- 视图标识很重要:如果单元格在快速滚动时闪烁或消失,说明视图标识不稳定。请为项目使用显式的
.id()。 - onAppear/onDisappear 会反复触发:视图会随着滚动而创建和销毁。不要将它们用于一次性设置。
- 回收时状态会重置:惰性项目中的
@State在回收时会重置。请将状态提升到模型层。
// ❌ Items flash during fast scroll — unstable identity
LazyVStack {
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemRow(item: item) // Identity changes when array mutates
}
}
// ✅ Stable identity prevents flash/disappear
LazyVStack {
ForEach(items) { item in // Uses item.id (Identifiable)
ItemRow(item: item)
}
}
不要使用惰性容器的情况
| 场景 | 改用 | 原因 |
|---|---|---|
| 少于 50 个项目 | VStack / HStack | 无回收开销,更简单 |
| 嵌套在另一个惰性容器中 | VStack(内部) | 嵌套惰性容器会导致布局问题 |
| 需要预先测量所有项目 | VStack | 惰性容器不知道总尺寸 |
资源
WWDC:2025-208、2024-10074、2022-10056
文档:/swiftui/layout、/swiftui/viewthatfits
技能:axiom-swiftui-layout、axiom-swiftui-debugging