axiom-swiftui-layout-ref by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-layout-ref适用于 SwiftUI 自适应布局工具的完整 API 参考。如需决策指导和反模式,请参阅 axiom-swiftui-layout 技能。
本参考涵盖了用于构建自适应界面的所有 SwiftUI 布局 API:
按顺序评估子视图,并显示第一个适合可用空间的子视图。
ViewThatFits {
// 首选方案
HStack {
icon
title
Spacer()
button
}
// 备选方案
HStack {
icon
title
button
}
// 后备方案
VStack {
HStack { icon; title }
button
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// 仅考虑水平方向适配
ViewThatFits(in: .horizontal) {
wideVersion
narrowVersion
}
// 仅考虑垂直方向适配
ViewThatFits(in: .vertical) {
tallVersion
shortVersion
}
fixedSize()类型擦除的布局容器,支持在布局之间进行动画过渡。
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))
// 基于动态类型
@Environment(\.dynamicTypeSize) var typeSize
var layout: AnyLayout {
typeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
// 基于几何信息
@State private var isWide = true
var layout: AnyLayout {
isWide
? AnyLayout(HStackLayout())
: AnyLayout(VStackLayout())
}
// ❌ 丢失视图标识,无动画
if isCompact {
VStack { content }
} else {
HStack { content }
}
// ✅ 保持标识,平滑动画
let layout = isCompact ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
layout { content }
创建自定义布局容器,完全控制定位。
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)
}
}
}
// 用法
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 {
// 使用 cache.sizes 而不是重新测量
}
}
// 定义自定义布局值
struct Rank: LayoutValueKey {
static let defaultValue: Int = 0
}
extension View {
func rank(_ value: Int) -> some View {
layoutValue(key: Rank.self, value: value)
}
}
// 在布局中读取
func placeSubviews(...) {
let sorted = subviews.sorted { $0[Rank.self] < $1[Rank.self] }
}
高效的几何信息读取,无布局副作用。向后移植至 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
}
}
// 仅宽度
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { width in
columnCount = max(1, Int(width / 150))
}
// 坐标系中的框架
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { frame in
globalFrame = frame
}
// 宽高比
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height
} action: { isWide in
self.isWide = isWide
}
// 命名坐标系
ScrollView {
content
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.frame(in: .named("scroll")).minY
} action: { offset in
scrollOffset = offset
}
}
.coordinateSpace(name: "scroll")
| 方面 | onGeometryChange | GeometryReader |
|---|---|---|
| 布局影响 | 无 | 贪婪(填充空间) |
| 何时评估 | 布局之后 | 布局期间 |
| 使用场景 | 副作用 | 布局计算 |
| iOS 版本 | 16+ (向后移植) | 13+ |
在布局阶段提供几何信息。由于其贪婪的尺寸计算,请谨慎使用。
// ✅ 始终约束 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) // 必需的约束
GeometryReader { proxy in
// 容器尺寸
let size = proxy.size // CGSize
// 安全区域插入量
let insets = proxy.safeAreaInsets // EdgeInsets
// 坐标系中的框架
let globalFrame = proxy.frame(in: .global)
let localFrame = proxy.frame(in: .local)
let namedFrame = proxy.frame(in: .named("container"))
}
// 按比例调整尺寸
GeometryReader { geo in
VStack {
header.frame(height: geo.size.height * 0.2)
content.frame(height: geo.size.height * 0.8)
}
}
// 带偏移的居中
GeometryReader { geo in
content
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
// ❌ 在 VStack 中未受约束
VStack {
GeometryReader { ... } // 占用所有空间
Button("Next") { } // 不可见
}
// ✅ 受约束
VStack {
GeometryReader { ... }
.frame(height: 200)
Button("Next") { }
}
// ❌ 导致布局循环
GeometryReader { geo in
content
.frame(width: geo.size.width) // 可能导致无限循环
}
SwiftUI 提供了两种主要方法来处理内容周围的间距:.padding() 和 .safeAreaPadding()。了解何时使用每种方法对于在具有安全区域(刘海屏、灵动岛、主屏幕指示器)的设备上进行正确布局至关重要。
// ❌ 错误 - 忽略安全区域,内容会触及刘海屏/主屏幕指示器
ScrollView {
content
}
.padding(.horizontal, 20)
// ✅ 正确 - 尊重安全区域,在其之外添加内边距
ScrollView {
content
}
.safeAreaPadding(.horizontal, 20)
关键见解:.padding() 从视图边缘添加固定间距。.safeAreaPadding() 在安全区域插入量之外添加间距。
.padding()在容器内的兄弟视图之间添加间距
创建应始终保持一致的内部间距
处理已尊重安全区域的视图(如 List、Form)
在 macOS 上添加装饰性间距(无安全区域问题)
VStack(spacing: 0) { header .padding(.horizontal, 16) // ✅ 内部间距
Divider()
content
.padding(.horizontal, 16) // ✅ 内部间距
}
.safeAreaPadding() (iOS 17+)为延伸到屏幕边缘的全宽内容添加边距
实现具有适当插入量的边缘到边缘滚动
创建需要感知安全区域的自定义容器
处理 Liquid Glass 或全屏材质
// ✅ 带自定义内边距的边缘到边缘列表 List(items) { item in ItemRow(item) } .listStyle(.plain) .safeAreaPadding(.horizontal, 20) // 在安全区域之外添加 20pt
// ✅ 具有适当边距的全屏内容 ZStack { Color.blue.ignoresSafeArea()
VStack {
content
}
.safeAreaPadding(.all, 16) // 尊重刘海屏、主屏幕指示器
}
iOS 17+, iPadOS 17+, macOS 14+, axiom-visionOS 1.0+
对于更早的 iOS 版本,使用手动安全区域处理:
// iOS 13-16 回退方案
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)
}
// 仅顶部(状态栏/刘海屏下方)
.safeAreaPadding(.top, 8)
// 仅底部(主屏幕指示器上方)
.safeAreaPadding(.bottom, 16)
// 水平方向(安全区域的左/右侧)
.safeAreaPadding(.horizontal, 20)
// 所有边缘
.safeAreaPadding(.all, 16)
// 单独边缘
.safeAreaPadding(EdgeInsets(top: 8, leading: 20, bottom: 16, trailing: 20))
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item)
}
}
}
.safeAreaPadding(.horizontal, 16) // 内容从边缘 + 安全区域缩进
.safeAreaPadding(.vertical, 8)
ZStack {
// 背景延伸到边缘到边缘
LinearGradient(...)
.ignoresSafeArea()
// 内容尊重安全区域 + 自定义内边距
VStack {
header
Spacer()
content
Spacer()
footer
}
.safeAreaPadding(.all, 20)
}
// 外层:用于设备插入量的安全区域内边距
VStack(spacing: 0) {
content
}
.safeAreaPadding(.horizontal, 16) // 超出安全区域
// 内层:用于内部间距的常规内边距
VStack {
Text("Title")
.padding(.bottom, 8) // 内部间距
Text("Subtitle")
}
您的内容是否延伸到屏幕边缘?
├─ 是 → 使用 .safeAreaPadding()
│ ├─ 是否可滚动? → .safeAreaPadding(.horizontal/.vertical)
│ └─ 是否全屏? → .safeAreaPadding(.all)
│
└─ 否(包含在安全的容器内,如 List/Form)
└─ 使用 .padding() 进行内部间距
// 可视化安全区域内边距 (iOS 17+)
content
.safeAreaPadding(.horizontal, 20)
.background(.red.opacity(0.2)) // 显示内边距区域
.border(.blue) // 显示内容边界
// ❌ 旧方法:手动计算 (iOS 13-16)
GeometryReader { geo in
content
.padding(.top, geo.safeAreaInsets.top + 16)
.padding(.bottom, geo.safeAreaInsets.bottom + 16)
.padding(.horizontal, 20)
}
// ✅ 新方法:.safeAreaPadding() (iOS 17+)
content
.safeAreaPadding(.vertical, 16)
.safeAreaPadding(.horizontal, 20)
.safeAreaInset(edge:) - 添加会缩小安全区域的持久内容:
ScrollView {
content
}
.safeAreaInset(edge: .bottom) {
// 这会减少安全区域,内容在其下方滚动
toolbarButtons
.padding()
.background(.ultraThinMaterial)
}
.ignoresSafeArea() - 完全退出安全区域:
Color.blue
.ignoresSafeArea() // 延伸到绝对屏幕边缘
iOS 17 之前:开发者必须使用 GeometryReader 手动计算安全区域插入量,导致:
iOS 17+:.safeAreaPadding() 提供:
实际影响:在 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 // 受限空间
case regular // 充足空间
}
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 // 默认
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)
WindowGroup {
ContentView()
}
.windowResizeAnchor(.topLeading) // 从左上角开始调整大小
.windowResizeAnchor(.center) // 从中心开始调整大小
@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("-")
}
}
}
}
// iOS 26: 自动列可见性
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// 列根据可用宽度自动隐藏/显示
// 手动控制(需要时)
@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:
// 窗口可见且可交互
case .inactive:
// 窗口可见但不可交互
case .background:
// 窗口不可见
}
}
}
// 全局(屏幕坐标)
proxy.frame(in: .global)
// 局部(视图自身的边界)
proxy.frame(in: .local)
// 命名(自定义)
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+ 类型化坐标系
extension CoordinateSpaceProtocol where Self == NamedCoordinateSpace {
static var scroll: Self { .named("scroll") }
}
ScrollView {
content
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { offset in
scrollOffset = offset
}
.onScrollGeometryChange(for: ScrollGeometry.self) { $0 } action: { geo in
let offset = geo.contentOffset // 当前滚动位置
let size = geo.contentSize // 总内容大小
let visible = geo.visibleRect // 当前可见矩形
let insets = geo.contentInsets // 内容插入量
}
LazyVStack 和 LazyHStack 按需创建视图,并在其离开屏幕时回收它们。这意味着:
视图标识很重要:如果在快速滚动期间单元格闪烁/消失,则视图标识不稳定。在项目上使用显式的 .id()。
onAppear/onDisappear 会重复触发:视图在滚动时被创建和销毁。不要将这些用于一次性设置。
回收时状态重置:惰性项目中的 @State 在回收时重置。将状态提升到模型层。
// ❌ 项目在快速滚动时闪烁 — 不稳定的标识 LazyVStack { ForEach(Array(items.enumerated()), id: .offset) { index, item in ItemRow(item: item) // 数组变化时标识改变 } }
// ✅ 稳定的标识防止闪烁/消失 LazyVStack { ForEach(items) { item in // 使用 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
每周安装量
112
代码仓库
GitHub 星标数
610
首次出现
2026年1月21日
安全审计
安装于
opencode97
codex91
claude-code89
gemini-cli88
cursor87
github-copilot85
Comprehensive API reference for SwiftUI adaptive layout tools. For decision guidance and anti-patterns, see the axiom-swiftui-layout skill.
This reference covers all SwiftUI layout APIs for building adaptive interfaces:
Evaluates child views in order and displays the first one that fits in the available space.
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() to each childType-erased layout container enabling animated transitions between layouts.
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 }
Create custom layout containers with full control over positioning.
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] }
}
Efficient geometry reading without layout side effects. Backported to 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")
| Aspect | onGeometryChange | GeometryReader |
|---|---|---|
| Layout impact | None | Greedy (fills space) |
| When evaluated | After layout | During layout |
| Use case | Side effects | Layout calculations |
| iOS version | 16+ (backported) | 13+ |
Provides geometry information during layout phase. Use sparingly due to greedy sizing.
// ✅ 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
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
}
SwiftUI provides two primary approaches for handling spacing around content: .padding() and .safeAreaPadding(). Understanding when to use each is critical for proper layout on devices with safe areas (notch, Dynamic Island, home indicator).
// ❌ 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)
Key insight : .padding() adds fixed spacing from the view's edges. .safeAreaPadding() adds spacing beyond the safe area insets.
.padding() whenAdding spacing between sibling views within a container
Creating internal spacing that should be consistent everywhere
Working with views that already respect safe areas (like List, Form)
Adding decorative spacing on macOS (no safe area concerns)
VStack(spacing: 0) { header .padding(.horizontal, 16) // ✅ Internal spacing
Divider()
content
.padding(.horizontal, 16) // ✅ Internal spacing
}
.safeAreaPadding() when (iOS 17+)Adding margin to full-width content that extends to screen edges
Implementing edge-to-edge scrolling with proper insets
Creating custom containers that need safe area awareness
Working with Liquid Glass or full-screen materials
// ✅ 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+
For earlier iOS versions, use manual safe area handling:
// iOS 13-16 fallback
GeometryReader { geo in
content
.padding(.horizontal, 20 + geo.safeAreaInsets.leading)
}
Or conditional compilation:
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 {
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)
.safeAreaInset(edge:) - Adds persistent content that shrinks the safe area:
ScrollView {
content
}
.safeAreaInset(edge: .bottom) {
// This REDUCES the safe area, content scrolls under it
toolbarButtons
.padding()
.background(.ultraThinMaterial)
}
.ignoresSafeArea() - Opts out of safe area completely:
Color.blue
.ignoresSafeArea() // Extends to absolute screen edges
Before iOS 17 : Developers had to manually calculate safe area insets with GeometryReader, leading to:
iOS 17+ : .safeAreaPadding() provides:
Real-world impact : Using .padding() instead of .safeAreaPadding() on iPhone 15 Pro causes content to:
Environment values indicating horizontal and vertical size characteristics.
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:
| Orientation | Horizontal | Vertical |
|---|---|---|
| Portrait | .compact | .regular |
| Landscape (small) | .compact | .compact |
| Landscape (Plus/Max) | .regular | .compact |
iPad:
| Configuration | Horizontal | Vertical |
|---|---|---|
| Any full screen | .regular | .regular |
| 70% Split View | .regular | .regular |
| 50% Split View | .regular | .regular |
| 33% Split View | .compact |
content
.environment(\.horizontalSizeClass, .compact)
Environment value for user's preferred text size.
@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)
WindowGroup {
ContentView()
}
.windowResizeAnchor(.topLeading) // Resize originates from top-left
.windowResizeAnchor(.center) // Resize from center
@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("-")
}
}
}
}
// 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 {
content
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { offset in
scrollOffset = offset
}
.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 and LazyHStack create views on demand and recycle them when off-screen. This means:
View identity matters : If cells flash/disappear during fast scrolling, the view identity is unstable. Use explicit .id() on items.
onAppear/onDisappear fire repeatedly : Views are created and destroyed as you scroll. Don't use these for one-time setup.
State resets on recycle : @State in lazy items resets when recycled. Lift state to the model layer.
// ❌ 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) } }
| Scenario | Use Instead | Why |
|---|---|---|
| < 50 items | VStack / HStack | No recycling overhead, simpler |
| Nested in another lazy container | VStack (inner) | Nested lazy causes layout issues |
| Need all items measured upfront | VStack | Lazy containers don't know total size |
WWDC : 2025-208, 2024-10074, 2022-10056
Docs : /swiftui/layout, /swiftui/viewthatfits
Skills : axiom-swiftui-layout, axiom-swiftui-debugging
Weekly Installs
112
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode97
codex91
claude-code89
gemini-cli88
cursor87
github-copilot85
Clerk Swift iOS 原生集成指南:SwiftUI/UIKit 认证实现与快速开始
876 周安装
UltraThink Orchestrator - 已弃用的AI工作流编排工具,推荐使用dev-orchestrator替代
85 周安装
数据库管理员技能:PostgreSQL/MySQL/MongoDB高可用架构、性能调优与灾难恢复
86 周安装
PDF编程技能:使用PDFKit、PDF.js、Puppeteer生成、解析、合并PDF文档
86 周安装
Spring Boot 3 工程师技能指南:微服务、云原生与响应式编程最佳实践
86 周安装
第一性原理思维教练:苏格拉底式提问,拆解难题,从零重建创新解决方案
86 周安装
临床试验方案生成工具:基于检查点的模块化AI技能,支持医疗器械与药物研究
86 周安装
.regular |
| Slide Over | .compact | .regular |