Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
939
.claude/skills/axiom-swiftui-layout-ref/SKILL.md
Normal file
939
.claude/skills/axiom-swiftui-layout-ref/SKILL.md
Normal file
@@ -0,0 +1,939 @@
|
||||
---
|
||||
name: axiom-swiftui-layout-ref
|
||||
description: Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryChange, GeometryReader, size classes, and iOS 26 window APIs
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# SwiftUI Layout API Reference
|
||||
|
||||
Comprehensive API reference for SwiftUI adaptive layout tools. For decision guidance and anti-patterns, see the `axiom-swiftui-layout` skill.
|
||||
|
||||
## Overview
|
||||
|
||||
This reference covers all SwiftUI layout APIs for building adaptive interfaces:
|
||||
|
||||
- **ViewThatFits** — Automatic variant selection (iOS 16+)
|
||||
- **AnyLayout** — Type-erased animated layout switching (iOS 16+)
|
||||
- **Layout Protocol** — Custom layout algorithms (iOS 16+)
|
||||
- **onGeometryChange** — Efficient geometry reading (iOS 16+ backported)
|
||||
- **GeometryReader** — Layout-phase geometry access (iOS 13+)
|
||||
- **Safe Area Padding** — .safeAreaPadding() vs .padding() (iOS 17+)
|
||||
- **Size Classes** — Trait-based adaptation
|
||||
- **iOS 26 Window APIs** — Free-form windows, menu bar, resize anchors
|
||||
|
||||
---
|
||||
|
||||
## ViewThatFits
|
||||
|
||||
Evaluates child views in order and displays the first one that fits in the available space.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
ViewThatFits {
|
||||
// First choice
|
||||
HStack {
|
||||
icon
|
||||
title
|
||||
Spacer()
|
||||
button
|
||||
}
|
||||
|
||||
// Second choice
|
||||
HStack {
|
||||
icon
|
||||
title
|
||||
button
|
||||
}
|
||||
|
||||
// Fallback
|
||||
VStack {
|
||||
HStack { icon; title }
|
||||
button
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Axis Constraint
|
||||
|
||||
```swift
|
||||
// Only consider horizontal fit
|
||||
ViewThatFits(in: .horizontal) {
|
||||
wideVersion
|
||||
narrowVersion
|
||||
}
|
||||
|
||||
// Only consider vertical fit
|
||||
ViewThatFits(in: .vertical) {
|
||||
tallVersion
|
||||
shortVersion
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Applies `fixedSize()` to each child
|
||||
2. Measures ideal size against available space
|
||||
3. Returns first child that fits
|
||||
4. Falls back to last child if none fit
|
||||
|
||||
### Limitations
|
||||
|
||||
- Does not expose which variant was selected
|
||||
- Cannot animate between variants (use AnyLayout instead)
|
||||
- Measures all variants (performance consideration for complex views)
|
||||
|
||||
---
|
||||
|
||||
## AnyLayout
|
||||
|
||||
Type-erased layout container enabling animated transitions between layouts.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available Layout Types
|
||||
|
||||
```swift
|
||||
AnyLayout(HStackLayout(alignment: .top, spacing: 10))
|
||||
AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
|
||||
AnyLayout(ZStackLayout(alignment: .center))
|
||||
AnyLayout(GridLayout(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10))
|
||||
```
|
||||
|
||||
### Custom Conditions
|
||||
|
||||
```swift
|
||||
// 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())
|
||||
}
|
||||
```
|
||||
|
||||
### Why Use Over Conditional Views
|
||||
|
||||
```swift
|
||||
// ❌ 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
|
||||
|
||||
Create custom layout containers with full control over positioning.
|
||||
|
||||
### Basic Custom Layout
|
||||
|
||||
```swift
|
||||
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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Cache
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Values
|
||||
|
||||
```swift
|
||||
// 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
|
||||
|
||||
Efficient geometry reading without layout side effects. Backported to iOS 16+.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
@State private var size: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onGeometryChange(for: CGSize.self) { proxy in
|
||||
proxy.size
|
||||
} action: { newSize in
|
||||
size = newSize
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reading Specific Values
|
||||
|
||||
```swift
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinate Spaces
|
||||
|
||||
```swift
|
||||
// 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")
|
||||
```
|
||||
|
||||
### Comparison with GeometryReader
|
||||
|
||||
| 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+ |
|
||||
|
||||
---
|
||||
|
||||
## GeometryReader
|
||||
|
||||
Provides geometry information during layout phase. Use sparingly due to greedy sizing.
|
||||
|
||||
### Basic Usage (Constrained)
|
||||
|
||||
```swift
|
||||
// ✅ 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 Properties
|
||||
|
||||
```swift
|
||||
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"))
|
||||
}
|
||||
```
|
||||
|
||||
### Common Patterns
|
||||
|
||||
```swift
|
||||
// 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)
|
||||
}
|
||||
```
|
||||
|
||||
### Avoiding Common Mistakes
|
||||
|
||||
```swift
|
||||
// ❌ 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 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).
|
||||
|
||||
### The Critical Difference
|
||||
|
||||
```swift
|
||||
// ❌ 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.
|
||||
|
||||
### When to Use Each
|
||||
|
||||
#### Use `.padding()` when
|
||||
|
||||
- Adding 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)
|
||||
|
||||
```swift
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 16) // ✅ Internal spacing
|
||||
|
||||
Divider()
|
||||
|
||||
content
|
||||
.padding(.horizontal, 16) // ✅ Internal spacing
|
||||
}
|
||||
```
|
||||
|
||||
#### Use `.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
|
||||
|
||||
```swift
|
||||
// ✅ 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
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Availability
|
||||
|
||||
**iOS 17+, iPadOS 17+, macOS 14+, axiom-visionOS 1.0+**
|
||||
|
||||
For earlier iOS versions, use manual safe area handling:
|
||||
|
||||
```swift
|
||||
// iOS 13-16 fallback
|
||||
GeometryReader { geo in
|
||||
content
|
||||
.padding(.horizontal, 20 + geo.safeAreaInsets.leading)
|
||||
}
|
||||
```
|
||||
|
||||
Or conditional compilation:
|
||||
|
||||
```swift
|
||||
if #available(iOS 17, *) {
|
||||
content.safeAreaPadding(.horizontal, 20)
|
||||
} else {
|
||||
content.padding(.horizontal, 20)
|
||||
.padding(.leading, safeAreaInsets.leading)
|
||||
}
|
||||
```
|
||||
|
||||
### Edge-Specific Usage
|
||||
|
||||
```swift
|
||||
// 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))
|
||||
```
|
||||
|
||||
### Common Patterns
|
||||
|
||||
#### Edge-to-Edge ScrollView
|
||||
|
||||
```swift
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(items) { item in
|
||||
ItemCard(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(.horizontal, 16) // Content inset from edges + safe areas
|
||||
.safeAreaPadding(.vertical, 8)
|
||||
```
|
||||
|
||||
#### Full-Screen Background with Safe Content
|
||||
|
||||
```swift
|
||||
ZStack {
|
||||
// Background extends edge-to-edge
|
||||
LinearGradient(...)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Content respects safe areas + custom padding
|
||||
VStack {
|
||||
header
|
||||
Spacer()
|
||||
content
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
.safeAreaPadding(.all, 20)
|
||||
}
|
||||
```
|
||||
|
||||
#### Nested Padding (Combined Approach)
|
||||
|
||||
```swift
|
||||
// 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")
|
||||
}
|
||||
```
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Visual Debugging
|
||||
|
||||
```swift
|
||||
// Visualize safe area padding (iOS 17+)
|
||||
content
|
||||
.safeAreaPadding(.horizontal, 20)
|
||||
.background(.red.opacity(0.2)) // Shows padding area
|
||||
.border(.blue) // Shows content bounds
|
||||
```
|
||||
|
||||
### Migration from Manual Safe Area Handling
|
||||
|
||||
```swift
|
||||
// ❌ 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)
|
||||
```
|
||||
|
||||
### Related APIs
|
||||
|
||||
**`.safeAreaInset(edge:)`** - Adds persistent content that shrinks the safe area:
|
||||
```swift
|
||||
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:
|
||||
```swift
|
||||
Color.blue
|
||||
.ignoresSafeArea() // Extends to absolute screen edges
|
||||
```
|
||||
|
||||
### Why It Matters
|
||||
|
||||
**Before iOS 17**: Developers had to manually calculate safe area insets with GeometryReader, leading to:
|
||||
- Verbose code
|
||||
- Performance overhead (GeometryReader forces extra layout pass)
|
||||
- Easy mistakes (forgetting to check all edges)
|
||||
|
||||
**iOS 17+**: `.safeAreaPadding()` provides:
|
||||
- Declarative API (matches SwiftUI philosophy)
|
||||
- Automatic safe area awareness
|
||||
- Better performance (no extra layout passes)
|
||||
- Type-safe edge specification
|
||||
|
||||
**Real-world impact**: Using `.padding()` instead of `.safeAreaPadding()` on iPhone 15 Pro causes content to:
|
||||
- Hit the Dynamic Island (top)
|
||||
- Overlap the home indicator (bottom)
|
||||
- Get cut off by screen corners (rounded edges)
|
||||
|
||||
---
|
||||
|
||||
## Size Classes
|
||||
|
||||
Environment values indicating horizontal and vertical size characteristics.
|
||||
|
||||
### Reading Size Classes
|
||||
|
||||
```swift
|
||||
struct AdaptiveView: View {
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||
|
||||
var body: some View {
|
||||
if horizontalSizeClass == .compact {
|
||||
compactLayout
|
||||
} else {
|
||||
regularLayout
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Size Class Values
|
||||
|
||||
```swift
|
||||
enum UserInterfaceSizeClass {
|
||||
case compact // Constrained space
|
||||
case regular // Ample space
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Behavior
|
||||
|
||||
**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` | `.regular` |
|
||||
| Slide Over | `.compact` | `.regular` |
|
||||
|
||||
### Overriding Size Classes
|
||||
|
||||
```swift
|
||||
content
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Type Size
|
||||
|
||||
Environment value for user's preferred text size.
|
||||
|
||||
### Reading Dynamic Type
|
||||
|
||||
```swift
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
var body: some View {
|
||||
if dynamicTypeSize.isAccessibilitySize {
|
||||
accessibleLayout
|
||||
} else {
|
||||
standardLayout
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Size Categories
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### Scaled Metric
|
||||
|
||||
```swift
|
||||
@ScaledMetric var iconSize: CGFloat = 24
|
||||
@ScaledMetric(relativeTo: .largeTitle) var headerSize: CGFloat = 44
|
||||
|
||||
Image(systemName: "star")
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## iOS 26 Window APIs
|
||||
|
||||
### Window Resize Anchor
|
||||
|
||||
```swift
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.windowResizeAnchor(.topLeading) // Resize originates from top-left
|
||||
.windowResizeAnchor(.center) // Resize from center
|
||||
```
|
||||
|
||||
### Menu Bar Commands (iPad)
|
||||
|
||||
```swift
|
||||
@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 Column Control
|
||||
|
||||
```swift
|
||||
// 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()
|
||||
}
|
||||
```
|
||||
|
||||
### Scene Phase
|
||||
|
||||
```swift
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coordinate Spaces
|
||||
|
||||
### Built-in Coordinate Spaces
|
||||
|
||||
```swift
|
||||
// Global (screen coordinates)
|
||||
proxy.frame(in: .global)
|
||||
|
||||
// Local (view's own bounds)
|
||||
proxy.frame(in: .local)
|
||||
|
||||
// Named (custom)
|
||||
proxy.frame(in: .named("mySpace"))
|
||||
```
|
||||
|
||||
### Creating Named Spaces
|
||||
|
||||
```swift
|
||||
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 Geometry (iOS 18+)
|
||||
|
||||
### onScrollGeometryChange
|
||||
|
||||
```swift
|
||||
ScrollView {
|
||||
content
|
||||
}
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.y
|
||||
} action: { offset in
|
||||
scrollOffset = offset
|
||||
}
|
||||
```
|
||||
|
||||
### ScrollGeometry Properties
|
||||
|
||||
```swift
|
||||
.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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lazy Container Gotchas
|
||||
|
||||
### Recycling Behavior
|
||||
|
||||
`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.
|
||||
|
||||
```swift
|
||||
// ❌ 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When NOT to Use Lazy Containers
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**WWDC**: 2025-208, 2024-10074, 2022-10056
|
||||
|
||||
**Docs**: /swiftui/layout, /swiftui/viewthatfits
|
||||
|
||||
**Skills**: axiom-swiftui-layout, axiom-swiftui-debugging
|
||||
Reference in New Issue
Block a user