axiom-swiftui-layout
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-layout
Agent 安装分布
Skill 文档
SwiftUI Adaptive Layout
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.
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”
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)
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
When to Use Each Tool
| 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 |
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 |
Pattern 1: ViewThatFits
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.
Pattern 2: AnyLayout for Animated Switching
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())
}
Pattern 3: onGeometryChange (Preferred for Geometry)
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
}
}
}
Pattern 4: GeometryReader (When Necessary)
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") { }
}
Size Class Truth Table (iPad)
| Configuration | Horizontal | Vertical |
|---|---|---|
| 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.
iOS 26 Free-Form Windows
What Changed
| 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 |
Apple’s Guideline
“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
// iOS 26: Columns automatically show/hide
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// No manual columnVisibility management needed
Migration Checklist
- Remove
UIRequiresFullScreenfrom 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
Anti-Patterns
â Device Orientation Observer
// â 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.
â Screen Bounds
// â 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.
â Device Model Checks
// â 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.
â Unconstrained GeometryReader
// â WRONG: GeometryReader is greedy
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // Crushed
}
Fix: Constrain with .frame() or use onGeometryChange.
â Size Class as Orientation Proxy
// â 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.
Pressure Scenarios
“Designer wants iPhone-specific layout”
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.”
“Just use GeometryReader, it’s fine”
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.”
“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.”
“We don’t support iPad multitasking”
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.”
Resources
WWDC: 2025-208, 2024-10074, 2022-10056
Skills: axiom-swiftui-layout-ref, axiom-swiftui-debugging, axiom-liquid-glass