axiom-swiftui-layout by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-layout用于构建响应可用空间而非设备假设的布局的纪律性技能。涵盖工具选择、尺寸类别限制、iOS 26 自由形态窗口以及常见的反模式。
核心原则: 如果苹果明天发布新设备,或者 iPadOS 明年增加新的多任务模式,你的布局应该仍然能正常工作。响应你的容器,而不是你对设备的假设。
"我需要我的布局适应..."
│
├─ 适应可用空间(容器驱动)
│ │
│ ├─ "选择最佳拟合的变体"
│ │ → ViewThatFits
│ │
│ ├─ "H↔V 之间的动画切换"
│ │ → AnyLayout + 条件
│ │
│ ├─ "读取尺寸进行计算"
│ │ → onGeometryChange (iOS 16+)
│ │
│ └─ "自定义布局算法"
│ → Layout 协议
│
├─ 适应平台特性
│ │
│ ├─ "紧凑与常规宽度"
│ │ → horizontalSizeClass (⚠️ iPad 限制)
│ │
│ ├─ "辅助功能文本大小"
│ │ → dynamicTypeSize.isAccessibilitySize
│ │
│ └─ "平台差异"
│ → #if os() / Environment
│
└─ 适应窗口形状(宽高比)
│
├─ "竖屏与横屏语义"
│ → Geometry + 自定义阈值
│
├─ "自动显示/隐藏列"
│ → NavigationSplitView (iOS 26 中自动)
│
└─ "窗口生命周期"
→ @Environment(\.scenePhase)
你需要一个计算值(宽度、高度)吗?
├─ 是 → onGeometryChange
└─ 否 → 你需要动画过渡吗?
├─ 是 → AnyLayout + 条件
└─ 否 → ViewThatFits
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 我需要... | 使用这个 | 不要用这个 |
|---|---|---|
| 在 2-3 个布局变体中选择 | ViewThatFits | if size > X |
| H↔V 切换带动画 | AnyLayout | 条件式 HStack/VStack |
| 读取容器尺寸 | onGeometryChange | GeometryReader |
| 适应辅助功能文本 | dynamicTypeSize | 固定断点 |
| 检测紧凑宽度 | horizontalSizeClass | UIDevice.idiom |
| 检测 iPad 上的窄窗口 | Geometry + 阈值 | 仅使用尺寸类别 |
| 隐藏/显示侧边栏 | NavigationSplitView | 手动列逻辑 |
| 自定义布局算法 | Layout 协议 | 嵌套的 GeometryReaders |
使用时机: 你有 2-3 个布局变体,并且希望 SwiftUI 选择第一个能放下的。
ViewThatFits {
// 首选:水平布局
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
// 备选:垂直布局
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}
限制: ViewThatFits 不会暴露选择了哪个变体。如果你需要该状态用于其他视图,请改用 AnyLayout。
使用时机: 你需要布局之间的动画过渡,或者需要知道当前的布局状态。
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)
}
}
对于动态类型:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
使用时机: 你需要实际尺寸进行计算。优先于 GeometryReader。
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 "方向"):
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
}
}
}
使用时机: 你需要几何尺寸并且处于 iOS 15 或更早版本,或者需要在布局阶段获取几何尺寸(不仅仅是副作用)。
// ✅ 正确:受约束的 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") { }
}
| 配置 | 水平 | 垂直 |
|---|---|---|
| 全屏竖屏 | .regular | .regular |
| 全屏横屏 | .regular | .regular |
| 70% 分屏视图 | .regular | .regular |
| 50% 分屏视图 | .regular | .regular |
| 33% 分屏视图 | .compact | .regular |
| 侧拉 | .compact | .regular |
| 带键盘 | (不变) | (不变) |
关键洞察: 尺寸类别仅在 iPad 上宽度约 33% 或侧拉时变为 .compact。要进行更精细的控制,请使用几何尺寸。
| iOS 26 之前 | iOS 26+ |
|---|---|
| 固定的分屏视图尺寸 | 自由形态拖拽调整大小 |
允许 UIRequiresFullScreen | 已弃用 |
| iPad 上无菜单栏 | 通过 .commands 添加菜单栏 |
| 手动控制列可见性 | NavigationSplitView 自动适应 |
"调整应用大小不应永久改变其布局。应尽可能抓住机会恢复到起始状态。"
翻译: 不要基于窗口大小保存布局状态。当窗口恢复到原始大小时,布局也应如此。
// iOS 26:列自动显示/隐藏
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// 无需手动管理 columnVisibility
UIRequiresFullScreen// ❌ 错误:报告的是设备,而不是窗口
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification, ...
)
let orientation = UIDevice.current.orientation
if orientation.isLandscape { ... }
为何失败: 报告的是物理设备方向,而不是窗口形状。在分屏视图、台前调度、iOS 26 中错误。
修复: 使用 onGeometryChange 读取实际窗口尺寸。
// ❌ 错误:返回整个屏幕,而不是你的窗口
let width = UIScreen.main.bounds.width
if width > 700 { useWideLayout() }
为何失败: 在多任务处理中,你的应用可能只占用屏幕的 40%。
修复: 读取你的视图的实际容器大小。
// ❌ 错误:在新设备上失效,多任务处理时错误
if UIDevice.current.userInterfaceIdiom == .pad {
useWideLayout()
}
为何失败: 处于 1/3 分屏视图的 iPad 比 iPhone 14 Pro Max 横屏时更窄。
修复: 响应可用空间,而不是设备身份。
// ❌ 错误:GeometryReader 是贪婪的
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // 被挤压
}
修复: 使用 .frame() 进行约束,或使用 onGeometryChange。
// ❌ 错误:iPad 在两个方向上都是 .regular
var isLandscape: Bool {
horizontalSizeClass == .regular // 在 iPad 上总是 true!
}
修复: 如果需要宽高比,请根据实际几何尺寸计算。
诱惑: if UIDevice.current.userInterfaceIdiom == .phone
回应: "我将把这些实现为基于可用空间切换的'紧凑'和'常规'布局。当窗口变窄时,iPhone 布局也会出现在 iPad 上。这为我们的应用适应台前调度和 iOS 26 做好了准备。"
诱惑: 将所有内容都包装在 GeometryReader 中。
回应: "GeometryReader 有已知的布局副作用——它会贪婪地扩展。onGeometryChange 读取相同的数据而不影响布局。它向后兼容到 iOS 16。"
诱惑: 强制所有内容都通过尺寸类别处理。
回应: "尺寸类别是粗略的。iPad 在两个方向上都是 .regular。我将使用尺寸类别进行宽泛分类,使用几何尺寸进行精确阈值判断。"
诱惑: UIRequiresFullScreen = true
回应: "苹果在 iOS 26 中弃用了仅全屏模式。即使没有主动支持分屏视图,应用在调整大小时也不能崩溃。基于空间的布局成本相同。"
WWDC : 2025-208, 2024-10074, 2022-10056
技能 : axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass
每周安装数
139
仓库
GitHub 星标数
610
首次出现
2026年1月21日
安全审计
安装于
opencode119
codex114
claude-code107
gemini-cli106
github-copilot101
cursor98
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.
"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)
Do you need a calculated value (width, height)?
├─ YES → onGeometryChange
└─ NO → Do you need animated transitions?
├─ YES → AnyLayout + condition
└─ NO → ViewThatFits
| I need to... | Use this | Not 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 |
Use when: You have 2-3 layout variants and want SwiftUI to pick the first that fits.
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.
Use when: You need animated transitions between layouts, or need to know current layout state.
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:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
Use when: You need actual dimensions for calculations. Preferred over GeometryReader.
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"):
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
}
}
}
Use when: You need geometry AND are on iOS 15 or earlier, OR need geometry during layout phase (not just as side effect).
// ✅ 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") { }
}
| Configuration | Horizontal | Vertical |
|---|---|---|
| Full screen portrait | .regular | .regular |
| Full screen landscape | .regular | .regular |
| 70% Split View | .regular | .regular |
| 50% Split View | .regular |
Key insight: Size class only goes .compact on iPad at ~33% width or Slide Over. For finer control, use geometry.
| Before iOS 26 | iOS 26+ |
|---|---|
| Fixed Split View sizes | Free-form drag-to-resize |
UIRequiresFullScreen allowed | Deprecated |
| No menu bar on iPad | Menu bar via .commands |
| Manual column visibility | NavigationSplitView auto-adapts |
"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.
// iOS 26: Columns automatically show/hide
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// No manual columnVisibility management needed
UIRequiresFullScreen from Info.plist// ❌ 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.
// ❌ 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.
// ❌ 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.
// ❌ WRONG: GeometryReader is greedy
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // Crushed
}
Fix: Constrain with .frame() or use onGeometryChange.
// ❌ 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.
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."
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."
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."
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."
WWDC : 2025-208, 2024-10074, 2022-10056
Skills : axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass
Weekly Installs
139
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode119
codex114
claude-code107
gemini-cli106
github-copilot101
cursor98
agentation - AI智能体可视化UI反馈工具,连接人眼与代码的桥梁
5,400 周安装
| Fixed breakpoints |
| Detect compact width | horizontalSizeClass | UIDevice.idiom |
| Detect narrow window on iPad | Geometry + threshold | Size class alone |
| Hide/show sidebar | NavigationSplitView | Manual column logic |
| Custom layout algorithm | Layout protocol | Nested GeometryReaders |
.regular |
| 33% Split View | .compact | .regular |
| Slide Over | .compact | .regular |
| With keyboard | (unchanged) | (unchanged) |