
Swiftui Expert Skill
- 16 installs
- Updated May 26, 2026
- expo/agent-templates
Helps with ai & agent building tasks.
About
swiftui-expert-skill is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- swiftui-expert-skill
- AI & Agent Building
- AI-coding skill
Swiftui Expert Skill by the numbers
- 16 all-time installs (skills.sh)
- Ranked #11,068 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/expo/agent-templates --skill swiftui-expert-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| Last updated | May 26, 2026 |
| Repository | expo/agent-templates ↗ |
What it does
Helps with ai & agent building tasks.
Files
SwiftUI Expert Skill
Operating Rules
- Consult
references/latest-apis.mdat the start of every task to avoid deprecated APIs - Prefer native SwiftUI APIs over UIKit/AppKit bridging unless bridging is necessary
- Focus on correctness and performance; do not enforce specific architectures (MVVM, VIPER, etc.)
- Encourage separating business logic from views for testability without mandating how
- Follow Apple's Human Interface Guidelines and API design patterns
- Only adopt Liquid Glass when explicitly requested by the user (see
references/liquid-glass.md) - Present performance optimizations as suggestions, not requirements
- Use
#availablegating with sensible fallbacks for version-specific APIs
Task Workflow
Review existing SwiftUI code
- Read the code under review and identify which topics apply
- Flag deprecated APIs (compare against
references/latest-apis.md) - Run the Topic Router below for each relevant topic
- Validate
#availablegating and fallback paths for iOS 26+ features
Improve existing SwiftUI code
- Audit current implementation against the Topic Router topics
- Replace deprecated APIs with modern equivalents from
references/latest-apis.md - Refactor hot paths to reduce unnecessary state updates
- Extract complex view bodies into separate subviews
- Suggest image downsampling when
UIImage(data:)is encountered (optional optimization, seereferences/image-optimization.md)
Implement new SwiftUI feature
- Design data flow first: identify owned vs injected state
- Structure views for optimal diffing (extract subviews early)
- Apply correct animation patterns (implicit vs explicit, transitions)
- Use
Buttonfor all tappable elements; add accessibility grouping and labels - Gate version-specific APIs with
#availableand provide fallbacks
Topic Router
Consult the reference file for each topic relevant to the current task:
| Topic | Reference |
|---|---|
| State management | references/state-management.md |
| View composition | references/view-structure.md |
| Performance | references/performance-patterns.md |
| Lists and ForEach | references/list-patterns.md |
| Layout | references/layout-best-practices.md |
| Sheets and navigation | references/sheet-navigation-patterns.md |
| ScrollView | references/scroll-patterns.md |
| Animations (basics) | references/animation-basics.md |
| Animations (transitions) | references/animation-transitions.md |
| Animations (advanced) | references/animation-advanced.md |
| Accessibility | references/accessibility-patterns.md |
| Swift Charts | references/charts.md |
| Charts accessibility | references/charts-accessibility.md |
| Image optimization | references/image-optimization.md |
| Liquid Glass (iOS 26+) | references/liquid-glass.md |
| macOS scenes | references/macos-scenes.md |
| macOS window styling | references/macos-window-styling.md |
| macOS views | references/macos-views.md |
| Deprecated API lookup | references/latest-apis.md |
Correctness Checklist
These are hard rules -- violations are always bugs:
- [ ]
@Stateproperties areprivate - [ ]
@Bindingonly where a child modifies parent state - [ ] Passed values never declared as
@Stateor@StateObject(they ignore updates) - [ ]
@StateObjectfor view-owned objects;@ObservedObjectfor injected - [ ] iOS 17+:
@Statewith@Observable;@Bindablefor injected observables needing bindings - [ ]
ForEachuses stable identity (never.indicesfor dynamic content) - [ ] Constant number of views per
ForEachelement - [ ]
.animation(_:value:)always includes thevalueparameter - [ ] iOS 26+ APIs gated with
#availableand fallback provided - [ ]
import Chartspresent in files using chart types
References
references/latest-apis.md-- Read first for every task. Deprecated-to-modern API transitions (iOS 15+ through iOS 26+)references/state-management.md-- Property wrappers, data flow,@Observablemigrationreferences/view-structure.md-- View extraction, container patterns,@ViewBuilderreferences/performance-patterns.md-- Hot-path optimization, update control,_logChanges()references/list-patterns.md-- ForEach identity, Table (iOS 16+), inline filtering pitfallsreferences/layout-best-practices.md-- Layout patterns, GeometryReader alternativesreferences/accessibility-patterns.md-- VoiceOver, Dynamic Type, grouping, traitsreferences/animation-basics.md-- Implicit/explicit animations, timing, performancereferences/animation-transitions.md-- View transitions,matchedGeometryEffect,Animatablereferences/animation-advanced.md-- Phase/keyframe animations (iOS 17+),@Animatablemacro (iOS 26+)references/charts.md-- Swift Charts marks, axes, selection, styling, Chart3D (iOS 26+)references/charts-accessibility.md-- Charts VoiceOver, Audio Graph, fallback strategiesreferences/sheet-navigation-patterns.md-- Sheets, NavigationSplitView, Inspectorreferences/scroll-patterns.md-- ScrollViewReader, programmatic scrollingreferences/image-optimization.md-- AsyncImage, downsampling, cachingreferences/liquid-glass.md-- iOS 26+ Liquid Glass effects and fallback patternsreferences/macos-scenes.md-- Settings, MenuBarExtra, WindowGroup, multi-windowreferences/macos-window-styling.md-- Toolbar styles, window sizing, Commandsreferences/macos-views.md-- HSplitView, Table, PasteButton, AppKit interop
SwiftUI Accessibility Patterns Reference
Table of Contents
- Core Principle
- Dynamic Type with @ScaledMetric
- Accessibility Traits
- Decorative Images
- Element Grouping
- Custom Controls
- Summary Checklist
Core Principle
Prefer Button over onTapGesture for tappable elements. Button provides VoiceOver support, focus handling, and proper traits for free.
Dynamic Type with @ScaledMetric
System Text scales with Dynamic Type automatically. For custom numeric values (padding, image sizes, spacing), use @ScaledMetric:
struct ProfileHeader: View {
@ScaledMetric private var avatarSize = 60.0
@ScaledMetric private var spacing = 12.0
var body: some View {
HStack(spacing: spacing) {
Image("avatar")
.resizable()
.frame(width: avatarSize, height: avatarSize)
Text("Username")
}
}
}Specify a relativeTo text style when the value should scale relative to a specific font size:
@ScaledMetric(relativeTo: .caption) private var iconSize = 16.0Accessibility Traits
Use accessibilityAddTraits and accessibilityRemoveTraits for state-driven traits:
Text(item.title)
.accessibilityAddTraits(item.isSelected ? [.isSelected, .isButton] : .isButton)Use .disabled(true) to make VoiceOver announce "Dimmed" for non-interactive elements.
Decorative Images
Use Image(decorative:bundle:) when an asset image is purely visual and should not appear in the accessibility tree.
Image(decorative: "confetti")This is appropriate for backgrounds, flourishes, and icons that do not add meaning beyond nearby text.
If the image conveys information, keep it accessible and provide a clear label:
Image("receipt")
.accessibilityLabel("Receipt")For non-asset images, such as SF Symbols, hide decorative content with accessibilityHidden(true) instead:
Image(systemName: "sparkles")
.accessibilityHidden(true)Element Grouping
.combine -- Auto-join child labels
HStack {
Image(systemName: "star.fill")
Text("Favorites")
Text("(\(count))")
}
.accessibilityElement(children: .combine)VoiceOver reads all child labels as one element, separated by commas.
.ignore -- Manual label for container
HStack {
Text(item.name)
Spacer()
Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)").contain -- Semantic grouping
HStack {
ForEach(tabs) { tab in
TabButton(tab: tab)
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Tab bar")VoiceOver announces the container name when focus enters/exits.
Custom Controls
Adjustable controls (increment/decrement)
PageControl(selectedIndex: $selectedIndex, pageCount: pageCount)
.accessibilityElement()
.accessibilityValue("Page \(selectedIndex + 1) of \(pageCount)")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
guard selectedIndex < pageCount - 1 else { break }
selectedIndex += 1
case .decrement:
guard selectedIndex > 0 else { break }
selectedIndex -= 1
@unknown default:
break
}
}Representing custom views as native controls
When a custom view should behave like a native control for accessibility:
HStack {
Text(label)
Toggle("", isOn: $isOn)
}
.accessibilityRepresentation {
Toggle(label, isOn: $isOn)
}Label-content pairing
@Namespace private var ns
HStack {
Text("Volume")
.accessibilityLabeledPair(role: .label, id: "volume", in: ns)
Slider(value: $volume)
.accessibilityLabeledPair(role: .content, id: "volume", in: ns)
}Summary Checklist
- [ ] Use
Buttoninstead ofonTapGesturefor tappable elements - [ ] Use
@ScaledMetricfor custom values that should scale with Dynamic Type - [ ] Mark purely decorative images as decorative or hidden from accessibility
- [ ] Group related elements with
accessibilityElement(children:) - [ ] Provide
accessibilityLabelwhen default labels are unclear - [ ] Use
accessibilityRepresentationfor custom controls - [ ] Use
accessibilityAdjustableActionfor increment/decrement controls - [ ] Ensure navigation flow is logical when using VoiceOver grouping
SwiftUI Advanced Animations
Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and @Animatable macro (iOS 26+).
Table of Contents
- Transactions
- Phase Animations (iOS 17+)
- Keyframe Animations (iOS 17+)
- Animation Completion Handlers (iOS 17+)
- @Animatable Macro (iOS 26+)
---
Transactions
The underlying mechanism for all animations in SwiftUI.
Basic Usage
// withAnimation is shorthand for withTransaction
withAnimation(.default) { flag.toggle() }
// Equivalent explicit transaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }The .transaction Modifier
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.transaction { t in
t.animation = .default
}Note: This behaves like the deprecated .animation(_:) without value parameter - it animates on every state change.
Animation Precedence
Implicit animations override explicit animations (later in view tree wins).
Button("Tap") {
withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag) // .bouncy wins!Disabling Animations
// Prevent implicit animations from overriding
.transaction { t in
t.disablesAnimations = true
}
// Remove animation entirely
.transaction { $0.animation = nil }Custom Transaction Keys (iOS 17+)
Pass metadata through transactions.
struct ChangeSourceKey: TransactionKey {
static let defaultValue: String = "unknown"
}
extension Transaction {
var changeSource: String {
get { self[ChangeSourceKey.self] }
set { self[ChangeSourceKey.self] = newValue }
}
}
// Set source
var transaction = Transaction(animation: .default)
transaction.changeSource = "server"
withTransaction(transaction) { flag.toggle() }
// Read in view tree
.transaction { t in
if t.changeSource == "server" {
t.animation = .smooth
} else {
t.animation = .bouncy
}
}---
Phase Animations (iOS 17+)
Cycle through discrete phases automatically. Each phase change is a separate animation.
Basic Usage
// GOOD - triggered phase animation
Button("Shake") { trigger += 1 }
.phaseAnimator(
[0.0, -10.0, 10.0, -5.0, 5.0, 0.0],
trigger: trigger
) { content, offset in
content.offset(x: offset)
}
// Infinite loop (no trigger)
Circle()
.phaseAnimator([1.0, 1.2, 1.0]) { content, scale in
content.scaleEffect(scale)
}Enum Phases (Recommended for Clarity)
// GOOD - enum phases are self-documenting
enum BouncePhase: CaseIterable {
case initial, up, down, settle
var scale: CGFloat {
switch self {
case .initial: 1.0
case .up: 1.2
case .down: 0.9
case .settle: 1.0
}
}
}
Circle()
.phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in
content.scaleEffect(phase.scale)
}Custom Timing Per Phase
.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in
content.offset(x: offset)
} animation: { phase in
switch phase {
case -20: .bouncy
case 20: .linear
default: .smooth
}
}Good vs Bad
// GOOD - use phaseAnimator for multi-step sequences
.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in
content.offset(x: offset)
}
// BAD - manual DispatchQueue sequencing
Button("Animate") {
withAnimation(.easeOut(duration: 0.1)) { offset = -10 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation { offset = 10 }
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation { offset = 0 }
}
}---
Keyframe Animations (iOS 17+)
Precise timing control with exact values at specific times.
Basic Usage
Button("Bounce") { trigger += 1 }
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.verticalOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.15)
SpringKeyframe(0.9, duration: 0.1)
SpringKeyframe(1.0, duration: 0.15)
}
KeyframeTrack(\.verticalOffset) {
LinearKeyframe(-20, duration: 0.15)
LinearKeyframe(0, duration: 0.25)
}
}
struct AnimationValues {
var scale: CGFloat = 1.0
var verticalOffset: CGFloat = 0
}Keyframe Types
| Type | Behavior |
|---|---|
CubicKeyframe | Smooth interpolation |
LinearKeyframe | Straight-line interpolation |
SpringKeyframe | Spring physics |
MoveKeyframe | Instant jump (no interpolation) |
Multiple Synchronized Tracks
Tracks run in parallel, each animating one property.
// GOOD - bell shake with synchronized rotation and scale
struct BellAnimation {
var rotation: Double = 0
var scale: CGFloat = 1.0
}
Image(systemName: "bell.fill")
.keyframeAnimator(
initialValue: BellAnimation(),
trigger: trigger
) { content, value in
content
.rotationEffect(.degrees(value.rotation))
.scaleEffect(value.scale)
} keyframes: { _ in
KeyframeTrack(\.rotation) {
CubicKeyframe(15, duration: 0.1)
CubicKeyframe(-15, duration: 0.1)
CubicKeyframe(10, duration: 0.1)
CubicKeyframe(-10, duration: 0.1)
CubicKeyframe(0, duration: 0.1)
}
KeyframeTrack(\.scale) {
CubicKeyframe(1.1, duration: 0.25)
CubicKeyframe(1.0, duration: 0.25)
}
}
// BAD - manual timer-based animation
Image(systemName: "bell.fill")
.onTapGesture {
withAnimation(.easeOut(duration: 0.1)) { rotation = 15 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation { rotation = -15 }
}
// ... more manual timing - error prone
}KeyframeTimeline (iOS 17+)
Query animation values directly for testing or non-SwiftUI use.
let timeline = KeyframeTimeline(initialValue: AnimationValues()) {
KeyframeTrack(\.scale) {
CubicKeyframe(1.2, duration: 0.25)
CubicKeyframe(1.0, duration: 0.25)
}
}
let midpoint = timeline.value(time: 0.25)
print(midpoint.scale) // Value at 0.25 seconds---
Animation Completion Handlers (iOS 17+)
Execute code when animations finish.
With withAnimation
// GOOD - completion with withAnimation
Button("Animate") {
withAnimation(.spring) {
isExpanded.toggle()
} completion: {
showNextStep = true
}
}With Transaction (For Reexecution)
// GOOD - completion fires on every trigger change
Circle()
.scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
.transaction(value: bounceCount) { transaction in
transaction.animation = .spring
transaction.addAnimationCompletion {
message = "Bounce \(bounceCount) complete"
}
}
// BAD - completion only fires ONCE (no value parameter)
Circle()
.scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
.animation(.spring, value: bounceCount)
.transaction { transaction in // No value!
transaction.addAnimationCompletion {
completionCount += 1 // Only fires once, ever
}
}---
@Animatable Macro (iOS 26+)
The @Animatable macro auto-synthesizes animatableData from all animatable stored properties, eliminating verbose manual conformance. Use @AnimatableIgnored to exclude properties that should not animate.
Before (Manual)
struct Wedge: Shape {
var startAngle: Angle
var endAngle: Angle
var drawClockwise: Bool
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(startAngle.radians, endAngle.radians) }
set {
startAngle = .radians(newValue.first)
endAngle = .radians(newValue.second)
}
}
func path(in rect: CGRect) -> Path { /* ... */ }
}After (@Animatable)
@Animatable
struct Wedge: Shape {
var startAngle: Angle
var endAngle: Angle
@AnimatableIgnored var drawClockwise: Bool
func path(in rect: CGRect) -> Path { /* ... */ }
}When to Use
- Prefer `@Animatable` for any custom
Shape,AnimatableModifier, or type conforming toAnimatablewith multiple properties - Use `@AnimatableIgnored` for properties that control behavior but should not interpolate (e.g., directions, flags, identifiers)
- The macro works with any type conforming to
Animatable, not justShape
Source: "What's new in SwiftUI" (WWDC25, session 256)
---
Quick Reference
Transactions (All iOS versions)
withTransactionis the explicit form ofwithAnimation- Implicit animations override explicit (later in view tree wins)
- Use
disablesAnimationsto prevent override - Use
.transaction { $0.animation = nil }to remove animation
Custom Transaction Keys (iOS 17+)
- Pass metadata through animation system via
TransactionKey
Phase Animations (iOS 17+)
- Use for multi-step sequences returning to start
- Prefer enum phases for clarity
- Each phase change is a separate animation
- Use
triggerparameter for one-shot animations
Keyframe Animations (iOS 17+)
- Use for precise timing control
- Tracks run in parallel
- Use
KeyframeTimelinefor testing/advanced use - Prefer over manual DispatchQueue timing
Completion Handlers (iOS 17+)
- Use
withAnimation(.animation) { } completion: { }for one-shot completion handlers - Use
.transaction(value:)for handlers that should refire on every value change - Without
value:parameter, completion only fires once
@Animatable Macro (iOS 26+)
- Use
@Animatableto auto-synthesizeanimatableDatafrom stored properties - Use
@AnimatableIgnoredto exclude non-animatable properties - Replaces verbose manual
animatableDatagetters/setters
SwiftUI Animation Basics
Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns.
Table of Contents
- Core Concepts
- Implicit Animations
- Explicit Animations
- Animation Placement
- Selective Animation
- Timing Curves
- Animation Performance
- Disabling Animations
- Debugging
---
Core Concepts
State changes trigger view updates. SwiftUI provides mechanisms to animate these changes.
Animation Process: 1. State change triggers view tree re-evaluation 2. SwiftUI compares new tree to current render tree 3. Animatable properties are identified and interpolated (~60 fps)
Key Characteristics:
- Animations are additive and cancelable
- Always start from current render tree state
- Blend smoothly when interrupted
---
Implicit Animations
Use .animation(_:value:) to animate when a specific value changes.
// GOOD - uses value parameter
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
.onTapGesture { isExpanded.toggle() }
// BAD - deprecated, animates all changes unexpectedly
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring) // Deprecated!---
Explicit Animations
Use withAnimation for event-driven state changes.
// GOOD - explicit animation
Button("Toggle") {
withAnimation(.spring) {
isExpanded.toggle()
}
}
// BAD - no animation context
Button("Toggle") {
isExpanded.toggle() // Abrupt change
}When to use which:
- Implicit: Animations tied to specific value changes, precise view tree scope
- Explicit: Event-driven animations (button taps, gestures)
---
Animation Placement
Place animation modifiers after the properties they should animate.
// GOOD - animation after properties
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.foregroundStyle(isExpanded ? .blue : .red)
.animation(.default, value: isExpanded) // Animates both
// BAD - animation before properties
Rectangle()
.animation(.default, value: isExpanded) // Too early!
.frame(width: isExpanded ? 200 : 100, height: 50)---
Selective Animation
Animate only specific properties using multiple animation modifiers or scoped animations.
// GOOD - selective animation
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded) // Animate size
.foregroundStyle(isExpanded ? .blue : .red)
.animation(nil, value: isExpanded) // Don't animate color
// iOS 17+ scoped animation
Rectangle()
.foregroundStyle(isExpanded ? .blue : .red) // Not animated
.animation(.spring) {
$0.frame(width: isExpanded ? 200 : 100, height: 50) // Animated
}---
Timing Curves
Built-in Curves
| Curve | Use Case |
|---|---|
.spring | Interactive elements, most UI |
.easeInOut | Appearance changes |
.bouncy | Playful feedback (iOS 17+) |
.linear | Progress indicators only |
Modifiers
.animation(.default.speed(2.0), value: flag) // 2x faster
.animation(.default.delay(0.5), value: flag) // Delayed start
.animation(.default.repeatCount(3, autoreverses: true), value: flag)Good vs Bad Timing
// GOOD - appropriate timing for interaction type
Button("Tap") {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isActive.toggle()
}
}
.scaleEffect(isActive ? 0.95 : 1.0)
// BAD - too slow for button feedback
Button("Tap") {
withAnimation(.easeInOut(duration: 1.0)) { // Way too slow!
isActive.toggle()
}
}
// BAD - linear feels robotic
Rectangle()
.animation(.linear(duration: 0.5), value: isActive) // Mechanical---
Animation Performance
Prefer Transforms Over Layout
// GOOD - GPU accelerated transforms
Rectangle()
.frame(width: 100, height: 100)
.scaleEffect(isActive ? 1.5 : 1.0) // Fast
.offset(x: isActive ? 50 : 0) // Fast
.rotationEffect(.degrees(isActive ? 45 : 0)) // Fast
.animation(.spring, value: isActive)
// BAD - layout changes are expensive
Rectangle()
.frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive
.padding(isActive ? 50 : 0) // ExpensiveNarrow Animation Scope
// GOOD - animation scoped to specific subview
VStack {
HeaderView() // Not affected
ExpandableContent(isExpanded: isExpanded)
.animation(.spring, value: isExpanded) // Only this
FooterView() // Not affected
}
// BAD - animation at root
VStack {
HeaderView()
ExpandableContent(isExpanded: isExpanded)
FooterView()
}
.animation(.spring, value: isExpanded) // Animates everythingAvoid Animation in Hot Paths
// GOOD - gate by threshold
.onPreferenceChange(ScrollOffsetKey.self) { offset in
let shouldShow = offset.y < -50
if shouldShow != showTitle { // Only when crossing threshold
withAnimation(.easeOut(duration: 0.2)) {
showTitle = shouldShow
}
}
}
// BAD - animating every scroll change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
withAnimation { // Fires constantly!
self.offset = offset.y
}
}---
Disabling Animations
// GOOD - disable with transaction
Text("Count: \(count)")
.transaction { $0.animation = nil }
// GOOD - disable from parent context
DataView()
.transaction { $0.disablesAnimations = true }
// BAD - hacky zero duration
Text("Count: \(count)")
.animation(.linear(duration: 0), value: count) // Hacky---
Debugging
// Slow down for inspection
#if DEBUG
.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded)
#else
.animation(.spring, value: isExpanded)
#endif
// Debug modifier to log values
struct AnimationDebugModifier: ViewModifier, Animatable {
var value: Double
var animatableData: Double {
get { value }
set {
value = newValue
print("Animation: \(newValue)")
}
}
func body(content: Content) -> some View {
content.opacity(value)
}
}---
Quick Reference
Do
- Use
.animation(_:value:)with value parameter - Use
withAnimationfor event-driven animations - Prefer transforms over layout changes
- Scope animations narrowly
- Choose appropriate timing curves
Don't
- Use deprecated
.animation(_:)without value - Animate layout properties in hot paths
- Apply broad animations at root level
- Use linear timing for UI (feels robotic)
- Animate on every frame in scroll handlers
SwiftUI Transitions
Transitions for view insertion/removal, custom transitions, and the Animatable protocol.
Table of Contents
- Property Animations vs Transitions
- Basic Transitions
- Asymmetric Transitions
- Custom Transitions
- Identity and Transitions
- The Animatable Protocol
---
Property Animations vs Transitions
Property animations: Interpolate values on views that exist before AND after state change.
Transitions: Animate views being inserted or removed from the render tree.
// Property animation - same view, different properties
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
// Transition - view inserted/removed
if showDetail {
DetailView()
.transition(.scale)
}---
Basic Transitions
Critical: Transitions Require Animation Context
// GOOD - animation outside conditional
VStack {
Button("Toggle") { showDetail.toggle() }
if showDetail {
DetailView()
.transition(.slide)
}
}
.animation(.spring, value: showDetail)
// GOOD - explicit animation
Button("Toggle") {
withAnimation(.spring) {
showDetail.toggle()
}
}
if showDetail {
DetailView()
.transition(.scale.combined(with: .opacity))
}
// BAD - animation inside conditional (removed with view!)
if showDetail {
DetailView()
.transition(.slide)
.animation(.spring, value: showDetail) // Won't work on removal!
}
// BAD - no animation context
Button("Toggle") {
showDetail.toggle() // No animation
}
if showDetail {
DetailView()
.transition(.slide) // Ignored - just appears/disappears
}Built-in Transitions
| Transition | Effect |
|---|---|
.opacity | Fade in/out (default) |
.scale | Scale up/down |
.slide | Slide from leading edge |
.move(edge:) | Move from specific edge |
.offset(x:y:) | Move by offset amount |
Combining Transitions
// Parallel - both simultaneously
.transition(.slide.combined(with: .opacity))
// Chained
.transition(.scale.combined(with: .opacity).combined(with: .offset(y: 20)))---
Asymmetric Transitions
Different animations for insertion vs removal.
// GOOD - different animations for insert/remove
if showCard {
CardView()
.transition(
.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
)
)
}
// BAD - same transition when different behaviors needed
if showCard {
CardView()
.transition(.slide) // Same both ways - may feel awkward
}---
Custom Transitions
Pre-iOS 17
struct BlurModifier: ViewModifier {
var radius: CGFloat
func body(content: Content) -> some View {
content.blur(radius: radius)
}
}
extension AnyTransition {
static func blur(radius: CGFloat) -> AnyTransition {
.modifier(
active: BlurModifier(radius: radius),
identity: BlurModifier(radius: 0)
)
}
}
// Usage
.transition(.blur(radius: 10))iOS 17+ (Transition Protocol)
struct BlurTransition: Transition {
var radius: CGFloat
func body(content: Content, phase: TransitionPhase) -> some View {
content
.blur(radius: phase.isIdentity ? 0 : radius)
.opacity(phase.isIdentity ? 1 : 0)
}
}
// Usage
.transition(BlurTransition(radius: 10))Good vs Bad Custom Transitions
// GOOD - reusable transition
if showContent {
ContentView()
.transition(BlurTransition(radius: 10))
}
// BAD - inline logic (won't animate on removal!)
if showContent {
ContentView()
.blur(radius: showContent ? 0 : 10) // Not a transition
.opacity(showContent ? 1 : 0)
}---
Identity and Transitions
View identity changes trigger transitions, not property animations.
// Triggers transition - different branches have different identities
if isExpanded {
Rectangle().frame(width: 200, height: 50)
} else {
Rectangle().frame(width: 100, height: 50)
}
// Triggers transition - .id() changes identity
Rectangle()
.id(flag) // Different identity when flag changes
.transition(.scale)
// Property animation - same view, same identity
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)---
The Animatable Protocol
Enables custom property interpolation during animations.
Protocol Definition
protocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}Basic Implementation
// GOOD - explicit animatableData
struct ShakeModifier: ViewModifier, Animatable {
var shakeCount: Double
var animatableData: Double {
get { shakeCount }
set { shakeCount = newValue }
}
func body(content: Content) -> some View {
content.offset(x: sin(shakeCount * .pi * 2) * 10)
}
}
extension View {
func shake(count: Int) -> some View {
modifier(ShakeModifier(shakeCount: Double(count)))
}
}
// Usage
Button("Shake") { shakeCount += 3 }
.shake(count: shakeCount)
.animation(.default, value: shakeCount)
// BAD - missing animatableData (silent failure!)
struct BadShakeModifier: ViewModifier {
var shakeCount: Double
// Missing animatableData! Uses EmptyAnimatableData
func body(content: Content) -> some View {
content.offset(x: sin(shakeCount * .pi * 2) * 10)
}
}
// Animation jumps to final value instead of interpolatingMultiple Properties with AnimatablePair
// GOOD - AnimatablePair for two properties
struct ComplexModifier: ViewModifier, Animatable {
var scale: CGFloat
var rotation: Double
var animatableData: AnimatablePair<CGFloat, Double> {
get { AnimatablePair(scale, rotation) }
set {
scale = newValue.first
rotation = newValue.second
}
}
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.rotationEffect(.degrees(rotation))
}
}
// GOOD - nested AnimatablePair for 3+ properties
struct ThreePropertyModifier: ViewModifier, Animatable {
var x: CGFloat
var y: CGFloat
var rotation: Double
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>, Double> {
get { AnimatablePair(AnimatablePair(x, y), rotation) }
set {
x = newValue.first.first
y = newValue.first.second
rotation = newValue.second
}
}
func body(content: Content) -> some View {
content
.offset(x: x, y: y)
.rotationEffect(.degrees(rotation))
}
}---
Quick Reference
Do
- Place transitions outside conditional structures
- Use
withAnimationor.animationoutside theif - Implement
animatableDataexplicitly for custom Animatable - Use
AnimatablePairfor multiple animated properties - Use asymmetric transitions when insert/remove need different effects
Don't
- Put animation modifiers inside conditionals for transitions
- Forget
animatableDataimplementation (silent failure) - Use inline blur/opacity instead of proper transitions
- Expect property animation when view identity changes
Swift Charts Accessibility, Fallback, and Resources
Table of Contents
- Accessibility
- Meaningful Labels
- Custom Audio Graphs
- Composite Example
- Fallback Strategies
- Version Breakdown
- WWDC Sessions
- Summary Checklist
---
Accessibility
Swift Charts provides built-in accessibility support. VoiceOver users get three rotor actions automatically:
- Describe Chart — overview of axes and data series
- Audio Graph — sonification where pitch represents data values
- Chart Detail — interactive mode for exploring individual data points
Meaningful Labels
Always use clear, descriptive strings in .value(_, _) calls. These labels are read by VoiceOver and used in the Audio Graph.
// Good — descriptive labels
LineMark(
x: .value("Date", entry.date),
y: .value("Daily Steps", entry.count)
)
// Bad — generic labels
LineMark(
x: .value("X", entry.date),
y: .value("Y", entry.count)
)Custom Audio Graphs
For advanced accessibility, conform your chart view to AXChartDescriptorRepresentable and implement makeChartDescriptor(). Attach it with .accessibilityChartDescriptor(self).
struct StepsChart: View, AXChartDescriptorRepresentable {
let steps: [DailySteps]
var body: some View {
Chart(steps) { day in
LineMark(x: .value("Date", day.date), y: .value("Steps", day.count))
}
.accessibilityChartDescriptor(self)
}
func makeChartDescriptor() -> AXChartDescriptor {
guard let first = steps.first, let last = steps.last else {
return AXChartDescriptor(title: "Daily Step Count", summary: nil,
xAxis: AXNumericDataAxisDescriptor(title: "Date", range: 0...1, gridlinePositions: []) { "\($0)" },
yAxis: AXNumericDataAxisDescriptor(title: "Steps", range: 0...1, gridlinePositions: []) { "\($0)" },
additionalAxes: [], series: [])
}
let xAxis = AXDateDataAxisDescriptor(
title: "Date", range: first.date...last.date, gridlinePositions: [])
let yAxis = AXNumericDataAxisDescriptor(
title: "Steps", range: 0...Double(steps.map(\.count).max() ?? 0),
gridlinePositions: []) { "\(Int($0)) steps" }
let series = AXDataSeriesDescriptor(
name: "Daily Steps", isContinuous: true,
dataPoints: steps.map { .init(x: $0.date, y: Double($0.count)) })
return AXChartDescriptor(title: "Daily Step Count", summary: nil,
xAxis: xAxis, yAxis: yAxis, additionalAxes: [], series: [series])
}
}Composite Example
A scrollable bar chart with range selection combining multiple iOS 17+ APIs:
@State private var selectedRange: ClosedRange<Int>?
Chart(weeklyRevenue) { week in
BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue))
.foregroundStyle(by: .value("Region", week.region))
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 8)
.chartXSelection(range: $selectedRange)
.chartXAxis {
AxisMarks(values: .stride(by: 1)) {
AxisGridLine()
AxisValueLabel { Text("W\($0.as(Int.self) ?? 0)") }
}
}Fallback Strategies
Gate advanced APIs with #available and provide a fallback chart without the gated features. Because chart modifiers like .chartXSelection change the return type, you must duplicate the entire Chart — you cannot conditionally apply the modifier:
Version Breakdown
- iOS 16+:
Chart, custom axes, scales,BarMark,LineMark,AreaMark,PointMark,RectangleMark,RuleMark,ChartProxy,chartOverlay,chartBackground - iOS 17+:
SectorMark,chartXSelection,chartYSelection,chartAngleSelection,chartScrollableAxes, visible-domain scrolling APIs,chartGesture - iOS 18+:
AreaPlot,BarPlot,LinePlot,PointPlot,RectanglePlot,RulePlot,SectorPlot, function plotting - iOS 26+:
Chart3D,SurfacePlot, Z-axis marks, 3D camera and pose APIs
WWDC Sessions
- Hello Swift Charts (WWDC 2022) — introduction to the framework
- Swift Charts: Raise the bar (WWDC 2022) — marks, composition, customization
- Design an effective chart (WWDC 2022) — chart design principles
- Design app experiences with charts (WWDC 2022) — integrating charts into app UX
- Explore pie charts and interactivity in Swift Charts (WWDC 2023) — SectorMark, selection, scrolling
- Swift Charts: Vectorized and function plots (WWDC 2024) — LinePlot, AreaPlot, function plotting
- Bring Swift Charts to the third dimension (WWDC 2025) — Chart3D, SurfacePlot, 3D marks
Summary Checklist
- [ ]
import Chartsis present in files using chart types - [ ] Deployment target matches the APIs used (
Charton iOS 16+, selection andSectorMarkon iOS 17+, plot types on iOS 18+,Chart3Don iOS 26+) - [ ] Chart data models use
Identifiable(orChart(data, id:)is provided) - [ ] All chart families are represented with the correct mark type
- [ ] Axes use
AxisMarkswhen default ticks are too dense or unclear - [ ]
chartXScaleorchartYScaleis set when fixed domains matter - [ ] Chart-wide modifiers are applied to
Chart, not individual marks - [ ]
foregroundStyle(by:)used for categorical series (not manual per-mark colors) - [ ] Single-value selection uses
chartXSelection(value:)orchartYSelection(value:) - [ ] Range selection uses
chartXSelection(range:)orchartYSelection(range:) - [ ]
SectorMarkselection useschartAngleSelection(value:) - [ ] iOS 17+, iOS 18+, and iOS 26+ APIs are guarded with
#available - [ ]
.value()labels are descriptive for VoiceOver and Audio Graph accessibility
SwiftUI Charts Reference
Table of Contents
- Overview
- Availability
- Core APIs
- Chart Types
- Axis Tweaks
- Selection APIs
- Annotations
- ChartProxy and Custom Touch Handling
- Modifier Scope
- Styling and Visual Channels
- Composing Multiple Marks
- Animating Chart Data
- Best Practices
Overview
Swift Charts is Apple's native charting framework for SwiftUI. Use Chart with one or more marks to build bar, line, area, point, rule, rectangle, and sector charts. This reference covers the standard 2D chart APIs, axis customization, built-in selection APIs, annotations, and custom touch handling.
Availability
Base Chart, custom axes, scales, and most marks require iOS 16 or later.
BarMark,LineMark,AreaMark,PointMark,RectangleMark, andRuleMarkare available on iOS 16+SectorMark, built-in selection, and scrollable chart axes require iOS 17+- Data-driven plot types such as
BarPlotandLinePlotrequire iOS 18+ - Chart3D and Z-axis APIs exist on iOS 26+; this reference is primarily about 2D
Chart, with a dedicated Chart3D section below
if #available(iOS 17, *) {
// Selection, SectorMark, scrollable axes
} else {
// Base Chart, axes, scales, and core marks
}Core APIs
Import the Framework
Always check that the file imports Charts before using Chart, Chart3D, BarMark, SectorMark, or ChartProxy.
import SwiftUI
import ChartsIf chart types are unresolved, the first thing to verify is that Charts is imported in that file.
Chart Container
Chart is the root view. Add one or more marks inside it.
Chart(sales) { item in
BarMark(
x: .value("Month", item.month),
y: .value("Revenue", item.revenue)
)
}Data Models Should Be Identifiable
Prefer Identifiable models for chart data so identity stays stable as data changes.
struct SalesPoint: Identifiable {
let id: UUID
let month: String
let revenue: Double
}If your model cannot conform to Identifiable, provide an explicit id key path:
Chart(sales, id: \.month) { item in
BarMark(
x: .value("Month", item.month),
y: .value("Revenue", item.revenue)
)
}Plottable Values
Use .value(_, _) to describe what each axis value means. Those labels are reused by axes, legends, and accessibility.
LineMark(
x: .value("Day", entry.date),
y: .value("Steps", entry.count)
)Chart Types
BarMark
BarMark(
x: .value("Product", product.name),
y: .value("Units", product.units)
)Stacking via MarkStackingMethod: .standard, .normalized, .center, .unstacked.
LineMark
LineMark(
x: .value("Day", day.date),
y: .value("Steps", day.count)
)
.interpolationMethod(.monotone)Interpolation methods: .linear, .monotone, .cardinal, .catmullRom, .stepStart, .stepCenter, .stepEnd. Cardinal and Catmull-Rom accept optional tension/alpha parameters.
AreaMark
AreaMark(
x: .value("Hour", sample.hour),
y: .value("Temperature", sample.value),
stacking: .unstacked
)Ranged areas use yStart/yEnd for bands like min/max or confidence intervals:
AreaMark(
x: .value("Day", sample.day),
yStart: .value("Low", sample.low),
yEnd: .value("High", sample.high)
)PointMark
PointMark(
x: .value("Time", measurement.time),
y: .value("Value", measurement.value)
)RectangleMark
RectangleMark(
xStart: .value("Start Day", cell.startDay),
xEnd: .value("End Day", cell.endDay),
yStart: .value("Low", cell.low),
yEnd: .value("High", cell.high)
)RuleMark
RuleMark(y: .value("Goal", 10_000))
.foregroundStyle(.red)SectorMark
Use SectorMark for pie and donut-style charts. SectorMark requires iOS 17 or later.
Chart(expenses) { expense in
SectorMark(
angle: .value("Amount", expense.amount),
innerRadius: .ratio(0.6),
angularInset: 2
)
.foregroundStyle(by: .value("Category", expense.category))
}Use innerRadius to turn a pie chart into a donut chart, and angularInset to separate slices visually.
Plot Types (iOS 18+)
iOS 18 adds data-driven plot wrappers: AreaPlot, BarPlot, LinePlot, PointPlot, RectanglePlot, RulePlot, and SectorPlot.
LinePlot and AreaPlot also accept function closures for plotting mathematical functions without discrete data:
if #available(iOS 18, *) {
Chart {
LinePlot(x: "x", y: "sin(x)") { x in
sin(x)
}
}
.chartXScale(domain: -Double.pi ... Double.pi)
.chartYScale(domain: -1.5 ... 1.5)
}Use plot types when you want a data-first API surface or need function plotting. The underlying chart families stay the same.
Chart3D (iOS 26+)
Chart3D is a separate API for 3D chart content. It supports 3D PointMark, RectangleMark, RuleMark, and SurfacePlot.
if #available(iOS 26, *) {
Chart3D(points) { point in
PointMark(
x: .value("X", point.x),
y: .value("Y", point.y),
z: .value("Z", point.z)
)
}
.chart3DPose(.front)
.chart3DCameraProjection(.perspective)
}SurfacePlot visualizes mathematical surfaces by evaluating a two-variable function:
if #available(iOS 26, *) {
Chart3D {
SurfacePlot(x: "x", y: "height", z: "z") { x, z in
sin(x) * cos(z)
}
}
.chartXScale(domain: -Double.pi ... Double.pi)
.chartZScale(domain: -Double.pi ... Double.pi)
}Camera and pose configuration:
- Projection:
.chart3DCameraProjection(.orthographic)(default, precise measurements) or.perspective(depth effect) - Pose presets:
.chart3DPose(.default),.front,.back,.left,.right - Custom pose:
.chart3DPose(azimuth: .degrees(45), inclination: .degrees(30)) - On visionOS, Chart3D supports natural 3D interaction gestures for rotation and exploration
Always gate Chart3D with #available(iOS 26, *) — it is not available on earlier OS versions.
Axis Tweaks
Axis Visibility and Labels
Use chartXAxis, chartYAxis, chartXAxisLabel, and chartYAxisLabel on the Chart container. Axis visibility supports .automatic, .visible, and .hidden.
Chart(data) { item in
BarMark(
x: .value("Month", item.month),
y: .value("Revenue", item.revenue)
)
}
.chartXAxis(.visible)
.chartYAxis(.hidden)
.chartXAxisLabel("Month")
.chartYAxisLabel("Revenue")Custom Axis Marks
Use AxisMarks to control tick placement, labels, and grid lines.
Chart(steps) { day in
LineMark(
x: .value("Day", day.date),
y: .value("Steps", day.count)
)
}
.chartXAxis {
AxisMarks(
preset: .aligned,
position: .bottom,
values: .stride(by: .day)
) {
AxisGridLine()
AxisTick(length: .label)
AxisValueLabel(format: .dateTime.weekday(.abbreviated))
}
}Useful AxisMarks inputs:
preset:.automatic,.extended,.aligned,.insetposition:.automatic,.leading,.trailing,.top,.bottomvalues:.automatic,.automatic(desiredCount:),.stride(by:),.stride(by:count:), or an explicit array
Axis Components
Within AxisMarks, combine the built-in axis components as needed:
AxisGridLine()
AxisTick()
AxisValueLabel()AxisValueLabel can be tuned for dense axes:
AxisValueLabel(
collisionResolution: .greedy(minimumSpacing: 8),
orientation: .vertical
)Label orientations: .automatic, .horizontal, .vertical, .verticalReversed.
Collision strategies: .automatic, .greedy, .greedy(priority:minimumSpacing:), .truncate, .disabled.
Axis Domains and Plot Area Tweaks
Use scales when you need explicit axis domains or plot area control.
Chart(data) { item in
LineMark(
x: .value("Index", item.index),
y: .value("Score", item.score)
)
}
.chartXScale(domain: 0...30)
.chartYScale(domain: 0...100)
.chartPlotStyle { plotArea in
plotArea
.background(.gray.opacity(0.08))
}You can set one axis domain without forcing the other:
.chartXScale(domain: startDate...endDate)Scrollable Axes (iOS 17+)
For larger datasets, make the plot area scroll and control the visible domain.
@State private var scrollX = 7
Chart(data) { item in
BarMark(
x: .value("Day", item.day),
y: .value("Value", item.value)
)
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 7)
.chartScrollPosition(x: $scrollX)Selection APIs
Single-Value Selection
Use chartXSelection(value:) or chartYSelection(value:) for one selected value.
@State private var selectedDate: Date?
Chart(steps) { day in
LineMark(x: .value("Day", day.date), y: .value("Steps", day.count))
if let selectedDate {
RuleMark(x: .value("Selected Day", selectedDate))
.foregroundStyle(.secondary)
}
}
.chartXSelection(value: $selectedDate)Range Selection
Use chartXSelection(range:) or chartYSelection(range:) for a dragged range. Bind to a ClosedRange whose bound type matches the plotted axis value.
@State private var selectedWeeks: ClosedRange<Int>?
Chart(weeks) { week in
BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue))
}
.chartXSelection(range: $selectedWeeks)Choosing Single vs Range
- Use
value:bindings when only one point or axis value should be selected. - Use
range:bindings when users should brush a span (for zoom windows, comparisons, or grouped summaries).
Angle Selection
Use chartAngleSelection(value:) with SectorMark charts. No built-in range overload for angle selection.
@State private var selectedAmount: Double?
Chart(expenses) { expense in
SectorMark(angle: .value("Amount", expense.amount))
.foregroundStyle(by: .value("Category", expense.category))
}
.chartAngleSelection(value: $selectedAmount)Important: Selection bindings return the plottable axis value, not the full data element. Map back to your model if you need the selected record.
Annotations
Use annotation(position:) on a mark when you need labels, callouts, or highlighted values attached to the plotted content.
BarMark(
x: .value("Month", item.month),
y: .value("Revenue", item.revenue)
)
.annotation(position: .top) {
Text(item.revenue.formatted())
}This is useful for selected values, thresholds, summaries, and direct labeling. Common positions include .overlay, .top, .bottom, .leading, and .trailing.
ChartProxy and Custom Touch Handling
Use chartOverlay/chartBackground (iOS 16+) or chartGesture (iOS 17+) with ChartProxy when built-in selection modifiers are not enough.
.chartOverlay { proxy in
GeometryReader { geometry in
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let plotFrame = proxy.plotFrame else { return } // iOS 16: use proxy.plotAreaFrame
let frame = geometry[plotFrame]
let x = value.location.x - frame.origin.x
guard x >= 0, x <= frame.size.width else { return }
selectedDate = proxy.value(atX: x, as: Date.self)
}
.onEnded { _ in selectedDate = nil }
)
}
}Use proxy.plotFrame (iOS 17+) or proxy.plotAreaFrame (iOS 16) to get the plot area anchor.
ChartProxy gives you lower-level access to:
value(atX:as:),value(atY:as:), andvalue(at:as:)for converting gesture coordinates into chart valuesposition(forX:),position(forY:), andposition(for:)for placing custom overlays or indicatorsselectXValue(at:),selectYValue(at:),selectXRange(from:to:), andselectYRange(from:to:)for driving built-in selection from custom gesturesplotFrame(iOS 17+) orplotAreaFrame(iOS 16) withplotSizefor converting between gesture coordinates and the plot area
select* ChartProxy selection methods and chartGesture are available on iOS 17+.
Modifier Scope
Apply chart-wide modifiers to the Chart container and mark-specific modifiers to the individual mark.
Chart(data) { item in
LineMark(
x: .value("Day", item.date),
y: .value("Value", item.value)
)
.interpolationMethod(.monotone) // Mark-level modifier
}
.chartXAxis { AxisMarks() } // Chart-level modifier
.chartYScale(domain: 0...100) // Chart-level modifier
.chartPlotStyle { $0.background(.thinMaterial) }Styling and Visual Channels
Categorical Coloring
Use foregroundStyle(by: .value(...)) to color marks by a data property. Swift Charts generates a legend automatically.
Chart(sales) { item in
BarMark(
x: .value("Month", item.month),
y: .value("Revenue", item.revenue)
)
.foregroundStyle(by: .value("Region", item.region))
}Avoid applying .foregroundStyle(.red) per mark for categorical data — this suppresses the automatic legend and breaks accessibility.
Custom Color Scales
Use chartForegroundStyleScale to control the mapping from data values to colors.
.chartForegroundStyleScale([
"North": .blue,
"South": .orange,
"East": .green
])For dynamic data where not all series appear at every point, use the mapping overload:
.chartForegroundStyleScale(domain: regions, mapping: { region in
colorForRegion(region)
})Symbol and Size Channels
Use symbol(by:) and symbolSize(by:) to encode additional data dimensions on PointMark and LineMark.
Chart(measurements) { item in
PointMark(
x: .value("Time", item.time),
y: .value("Value", item.value)
)
.foregroundStyle(by: .value("Category", item.category))
.symbol(by: .value("Category", item.category))
.symbolSize(by: .value("Weight", item.weight))
}Legend Control
.chartLegend(.visible)
.chartLegend(.hidden)
.chartLegend(position: .bottom, alignment: .center)Composing Multiple Marks
Combine different mark types inside the same Chart closure:
// Line with points
LineMark(x: .value("Day", day.date), y: .value("Steps", day.count))
.interpolationMethod(.monotone)
PointMark(x: .value("Day", day.date), y: .value("Steps", day.count))
// Bars with threshold line
BarMark(x: .value("Month", item.month), y: .value("Revenue", item.revenue))
RuleMark(y: .value("Target", 10_000))
.foregroundStyle(.red)
.lineStyle(StrokeStyle(dash: [5, 3]))Animating Chart Data
Chart marks animate automatically when data identity is stable and changes are wrapped in an animation.
withAnimation(.easeInOut) {
chartData = updatedData
}Always use Identifiable models (or explicit id:) so Swift Charts can match old and new data points and animate transitions between them.
Best Practices
Do
- Use semantic
.value(_, _)labels so axes and accessibility read clearly - Prefer
Identifiablemodels (or explicitid:) for stable chart data identity - Use
foregroundStyle(by:)for categorical series to get automatic legends and accessibility - Use
RuleMarkfor goals, thresholds, and selected-value indicators - Use explicit
AxisMarks(values:)when automatic tick generation gets crowded - Use
chartXScaleandchartYScalewhen you need stable visual comparisons - Use
chartXSelection(range:)orchartYSelection(range:)for brushed selection - Gate iOS 17+ APIs such as
SectorMarkand selection with#available
Don't
- Put chart-wide modifiers such as
chartXAxisorchartXSelectionon individual marks - Apply manual
.foregroundStyle(.color)per mark for categorical data — useforegroundStyle(by:)instead - Rely on unstable identities when chart data can be inserted, removed, or reordered
- Use string values for naturally numeric or date-based axes unless you want categorical behavior
- Stack unrelated series by default just because
BarMarkandAreaMarkallow it - Force every tick label to display when collision handling or stride values would be clearer
- Assume selection returns a model object; it only returns the plottable axis value
- Forget that range selection is available only for X and Y axes, not angle selection
For chart accessibility (VoiceOver, Audio Graph, AXChartDescriptorRepresentable), fallback strategies, WWDC sessions, and a full summary checklist, see charts-accessibility.md.
SwiftUI Image Optimization Reference
Table of Contents
- AsyncImage Best Practices
- Image Decoding and Downsampling (Optional Optimization)
- UIImage Loading and Memory
- SF Symbols
- Summary Checklist
AsyncImage Best Practices
Basic AsyncImage with Phase Handling
// Good - handles loading and error states
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
Image(systemName: "photo")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 200, height: 200)For custom placeholders, replace ProgressView() in the .empty case with your placeholder view. Add .transition(.opacity) to the success case and .animation(.easeInOut, value: imageURL) to the container for fade-in transitions.
Image Decoding and Downsampling (Optional Optimization)
When you encounter `UIImage(data:)` usage, consider suggesting image downsampling as a potential performance improvement, especially for large images in lists or grids.
Current Pattern That Could Be Optimized
// Current pattern - decodes full image on main thread
// Unsafe - force unwrap can crash if imageData is invalid
Image(uiImage: UIImage(data: imageData)!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)Suggested Optimization Pattern
// Suggested optimization - decode and downsample off main thread
struct OptimizedImageView: View {
let imageData: Data
let targetSize: CGSize
@State private var processedImage: UIImage?
var body: some View {
Group {
if let processedImage {
Image(uiImage: processedImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
ProgressView()
}
}
.task {
processedImage = await decodeAndDownsample(imageData, targetSize: targetSize)
}
}
private func decodeAndDownsample(_ data: Data, targetSize: CGSize) async -> UIImage? {
await Task.detached {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height),
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}.value
}
}
// Usage
OptimizedImageView(
imageData: imageData,
targetSize: CGSize(width: 200, height: 200)
)Reusable Downsampling Actor
For production use, wrap the logic in an actor with scale-aware sizing and cache-disabled source options:
actor ImageProcessor {
func downsample(data: Data, targetSize: CGSize) -> UIImage? {
let scale = await UIScreen.main.scale
let maxPixel = max(targetSize.width, targetSize.height) * scale
let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else { return nil }
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { return nil }
return UIImage(cgImage: cgImage)
}
}Key details: kCGImageSourceShouldCache: false on the source prevents the full-resolution image from being cached in memory. Multiplying targetSize by UIScreen.main.scale ensures the thumbnail is sharp on Retina displays. kCGImageSourceShouldCacheImmediately: true on the thumbnail forces decoding at creation time rather than at first render.
When to Suggest This Optimization
Mention this optimization when you see UIImage(data:) usage, particularly in:
- Scrollable content (List, ScrollView with LazyVStack/LazyHStack)
- Grid layouts with many images
- Image galleries or carousels
- Any scenario where large images are displayed at smaller sizes
Don't automatically apply it—present it as an optional improvement for performance-sensitive scenarios.
UIImage Loading and Memory
UIImage(named:) Caches in System Cache
UIImage(named:) adds images to the system cache, which can cause memory spikes when loading many images (e.g., in a slider or gallery). For single-use or frequently-rotated images, use UIImage(contentsOfFile:) to bypass the cache:
// Caches in system cache -- memory builds up
let image = UIImage(named: "Wallpapers/image_001.jpg")
// No system caching -- memory stays flat
guard let path = Bundle.main.path(forResource: "Wallpapers/image_001.jpg", ofType: nil) else { return nil }
let image = UIImage(contentsOfFile: path)NSCache for Controlled Image Caching
When image processing (resizing, filtering) is needed, use NSCache with a countLimit to bound memory instead of relying on system caching:
struct ImageCache {
private let cache = NSCache<NSString, UIImage>()
init(countLimit: Int = 50) {
cache.countLimit = countLimit
}
subscript(key: String) -> UIImage? {
get { cache.object(forKey: key as NSString) }
nonmutating set {
if let newValue {
cache.setObject(newValue, forKey: key as NSString)
} else {
cache.removeObject(forKey: key as NSString)
}
}
}
}SF Symbols
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.symbolRenderingMode(.multicolor) // or .hierarchical, .palette, .monochrome
// Animated symbols (iOS 17+)
Image(systemName: "antenna.radiowaves.left.and.right")
.symbolEffect(.variableColor)Variants are available via naming convention: star.circle.fill, star.square.fill, folder.badge.plus.
Summary Checklist
- [ ] Use
AsyncImagewith proper phase handling - [ ] Handle empty, success, and failure states
- [ ] Consider downsampling for
UIImage(data:)in performance-sensitive scenarios - [ ] Decode and downsample images off the main thread
- [ ] Use appropriate target sizes for downsampling
- [ ] Consider image caching for frequently accessed images
- [ ] Use SF Symbols with appropriate rendering modes
Performance Note: Image downsampling is an optional optimization. Only suggest it when you encounter UIImage(data:) usage in performance-sensitive contexts like scrollable lists or grids.
Latest SwiftUI APIs Reference
Based on a comparison of Apple's documentation using the Sosumi MCP, we found the latest recommended APIs to use.
Table of Contents
- Always Use (iOS 15+)
- When Targeting iOS 16+
- When Targeting iOS 17+
- When Targeting iOS 18+
- When Targeting iOS 26+
---
Always Use (iOS 15+)
These APIs have been deprecated long enough that there is no reason to use the old variants.
Compact Replacements
These replacements have minimal API shape changes. Most are near-direct swaps; a few require an additional parameter or structural adjustment:
- `navigationTitle(_:)` instead of
navigationBarTitle(_:) - `toolbar { ToolbarItem(...) }` instead of
navigationBarItems(...)(structural change) - `toolbarVisibility(.hidden, for: .navigationBar)` instead of
navigationBarHidden(_:) - `statusBarHidden(_:)` instead of
statusBar(hidden:) - `ignoresSafeArea(_:edges:)` instead of
edgesIgnoringSafeArea(_:) - `preferredColorScheme(_:)` instead of
colorScheme(_:) - `foregroundStyle(_:)` instead of
foregroundColor(_:)(e.g.,.foregroundStyle(.primary)) - `clipShape(.rect(cornerRadius:))` instead of
cornerRadius() - `textInputAutocapitalization(_:)` instead of
autocapitalization(_:)(note:.neverreplaces.none) - `animation(_:value:)` instead of
animation(_:)(adds requiredvalue:parameter; back-deploys to iOS 13+)
Presentation
- Always use `.confirmationDialog(_:isPresented:actions:message:)` instead of
actionSheet(...). - Always use `.alert(_:isPresented:actions:message:)` instead of
alert(isPresented:content:).
Both take a title String, isPresented: Binding<Bool>, an actions builder with Button items (supporting role: .destructive / .cancel), and an optional message builder:
.alert("Delete Item?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}Text Input
Always use `onSubmit(of:_:)` and `focused(_:equals:)` instead of `TextField` `onEditingChanged`/`onCommit` callbacks.
@FocusState private var isFocused: Bool
TextField("Search", text: $query)
.focused($isFocused)
.onSubmit { performSearch() }Accessibility
Always use dedicated accessibility modifiers instead of the generic `accessibility(...)` variants. Use .accessibilityLabel(), .accessibilityValue(), .accessibilityHint(), .accessibilityAddTraits(), .accessibilityHidden() instead of .accessibility(label:), .accessibility(value:), etc.
Custom Environment / Container Values
Always use the `@Entry` macro instead of manual `EnvironmentKey` conformance. The @Entry macro was introduced in Xcode 16 and back-deploys to all OS versions.
// Modern — one line replaces ~10 lines of EnvironmentKey boilerplate
extension EnvironmentValues {
@Entry var myCustomValue: String = "Default value"
}Styling
Always use `Button` instead of `onTapGesture()` unless you need tap location or count.
Button("Tap me") { performAction() }
// Use onTapGesture only when you need location or count
Image("photo")
.onTapGesture(count: 2) { handleDoubleTap() }---
When Targeting iOS 16+
Navigation
Use `NavigationStack` (or `NavigationSplitView`) instead of `NavigationView`. Value-based NavigationLink(value:) with .navigationDestination(for:) replaces destination-based links.
NavigationStack {
List(items) { item in
NavigationLink(value: item) { Text(item.name) }
}
.navigationDestination(for: Item.self) { DetailView(item: $0) }
}Simple Renames
- `tint(_:)` instead of
accentColor(_:) - `autocorrectionDisabled(_:)` instead of
disableAutocorrection(_:)
Clipboard
Prefer `PasteButton` for user-initiated paste UI to avoid paste prompts. It handles permissions automatically. Use UIPasteboard only when you need programmatic or non-Transferable clipboard access (triggers the paste permission prompt).
PasteButton(payloadType: String.self) { strings in
pastedText = strings.first ?? ""
}---
When Targeting iOS 17+
State Management
- Prefer `@Observable` over `ObservableObject` for new code. Use
@Stateinstead of@StateObject; use@Bindableinstead of@ObservedObject. Seestate-management.mdfor full@Observablemigration patterns.
Events
Use `onChange(of:initial:_:)` or `onChange(of:) { }` instead of `onChange(of:perform:)`.
The deprecated variant passes only the new value. The modern variants provide either both old and new values, or a no-parameter closure.
- No-parameter (most common):
.onChange(of: value) { doSomething() } - Old and new values:
.onChange(of: value) { old, new in ... } - With initial trigger:
.onChange(of: value, initial: true) { ... } - Deprecated:
.onChange(of: value) { newValue in ... }— single-parameter closure
Gestures
- `MagnifyGesture` instead of
MagnificationGesture(access magnitude viavalue.magnification) - `RotateGesture` instead of
RotationGesture(access angle viavalue.rotation)
Layout
Consider `containerRelativeFrame()` or `visualEffect()` as alternatives to `GeometryReader` for sizing and position-based effects. GeometryReader is not deprecated and remains necessary for many measurement-based layouts.
Image("hero")
.resizable()
.containerRelativeFrame(.horizontal) { length, axis in length * 0.8 }- `visualEffect { content, geometry in ... }` — position-based effects (parallax, offsets) without a
GeometryReaderwrapper. - `onGeometryChange(for:of:action:)` — react to geometry changes of a specific view; useful for driving state/effects.
GeometryReaderis still better when layout itself depends on geometry. Note the two-closure shape:
.onGeometryChange(for: CGFloat.self) { proxy in proxy.size.height } action: { newHeight in height = newHeight }- `.coordinateSpace(.named("scroll"))` instead of
.coordinateSpace(name: "scroll").
---
When Targeting iOS 18+
Tabs
Use the `Tab` API instead of `tabItem(_:)`.
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Search", systemImage: "magnifyingglass") { SearchView() }
Tab("Profile", systemImage: "person") { ProfileView() }
}When using Tab(role:), all tabs must use the Tab syntax. Mixing Tab(role:) with .tabItem() causes compilation errors.
Previews
Use `@Previewable` for dynamic properties in previews.
// Modern (iOS 18+)
#Preview {
@Previewable @State var isOn = false
Toggle("Setting", isOn: $isOn)
}---
When Targeting iOS 26+
For Liquid Glass APIs (glassEffect, GlassEffectContainer, glass button styles), see liquid-glass.md.
Scroll Edge Effects
Use `scrollEdgeEffectStyle(_:for:)` to configure scroll edge behavior.
ScrollView {
// content
}
.scrollEdgeEffectStyle(.soft, for: .top)Background Extension
Use `backgroundExtensionEffect()` for edge-extending blurred backgrounds.
Views behind a Liquid Glass sidebar can appear clipped. This modifier mirrors and blurs content outside the safe area so artwork remains visible.
Image("hero")
.backgroundExtensionEffect()Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Tab Bar
Use `tabBarMinimizeBehavior(_:)` to control tab bar minimization on scroll.
TabView {
// tabs
}
.tabBarMinimizeBehavior(.onScrollDown)Use `tabViewBottomAccessory` for persistent controls above the tab bar. Read tabViewBottomAccessoryPlacement from the environment to adapt content when the accessory collapses into the tab bar area.
TabView {
// tabs
}
.tabViewBottomAccessory {
NowPlayingBar()
}Use `Tab(role: .search)` for a dedicated search tab. The tab separates from the rest and morphs into a search field when selected.
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Profile", systemImage: "person") { ProfileView() }
Tab(role: .search) { SearchResultsView() }
}Source: "What's new in SwiftUI" (WWDC25, session 256) and "Build a SwiftUI app with the new design" (WWDC25, session 323)
Toolbars
Use `ToolbarSpacer` to control grouping of toolbar items. Fixed spacers visually separate related groups; flexible spacers push items apart.
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Up", systemImage: "chevron.up") { }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Down", systemImage: "chevron.down") { }
}
ToolbarSpacer(.fixed)
ToolbarItem(placement: .topBarTrailing) {
Button("Settings", systemImage: "gear") { }
}
}Use `sharedBackgroundVisibility(.hidden)` to remove the glass group background from an individual toolbar item.
ToolbarItem(placement: .topBarTrailing) {
Image(systemName: "person.circle.fill")
.sharedBackgroundVisibility(.hidden)
}Use `badge(_:)` on toolbar item content to display an indicator.
ToolbarItem(placement: .topBarTrailing) {
Button("Notifications", systemImage: "bell") { }
.badge(unreadCount)
}Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Search
Use `searchToolbarBehavior(.minimizable)` to opt into a minimized search button. The system may automatically minimize search into a toolbar button depending on available space. Use this modifier to explicitly opt in.
NavigationStack {
ContentView()
.searchable(text: $query)
.searchToolbarBehavior(.minimizable)
}Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Animations
Use `@Animatable` macro instead of manual `animatableData` declarations. The macro auto-synthesizes animatableData from all animatable properties. Use @AnimatableIgnored to exclude specific properties.
@Animatable
struct Wedge: Shape {
var startAngle: Angle
var endAngle: Angle
@AnimatableIgnored var drawClockwise: Bool
func path(in rect: CGRect) -> Path { /* ... */ }
}Source: "What's new in SwiftUI" (WWDC25, session 256)
Presentations
Use `navigationZoomTransition` to morph sheets out of their source view. Toolbar items and buttons can serve as the transition source.
.toolbar {
ToolbarItem {
Button("Add", systemImage: "plus") { showSheet = true }
.navigationTransitionSource(id: "addSheet", namespace: namespace)
}
}
.sheet(isPresented: $showSheet) {
AddItemView()
.navigationTransitionDestination(id: "addSheet", namespace: namespace)
}Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Controls
Use `controlSize(.extraLarge)` for extra-large prominent action buttons.
Button("Get Started") { }
.buttonStyle(.borderedProminent)
.controlSize(.extraLarge)Use `concentric` corner style for buttons that match their container's corners.
Button("Confirm") { }
.clipShape(.rect(cornerRadius: 12, style: .concentric))Sliders now support tick marks and a neutral value.
Slider(value: $speed, in: 0.5...2.0, step: 0.25) {
Text("Speed")
} ticks: {
SliderTick(value: 0.6)
SliderTick(value: 0.9)
}
.sliderNeutralValue(1.0)Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Rich Text
Use `TextEditor` with an `AttributedString` binding for rich text editing. Supports bold, italic, underline, strikethrough, custom fonts, foreground/background colors, paragraph styles, and Genmoji.
@State private var text: AttributedString = "Hello, world!"
var body: some View {
TextEditor(text: $text)
}Source: "Cook up a rich text experience in SwiftUI with AttributedString" (WWDC25, session 280)
Web Content
Use `WebView` to display web content. For richer interaction, create a WebPage observable model.
// Simple URL display
WebView(url: URL(string: "https://example.com")!)
// With observable model
@State private var page = WebPage()
WebView(page)
.onAppear { page.load(URLRequest(url: myURL)) }
.navigationTitle(page.title ?? "")Source: "Meet WebKit for SwiftUI" (WWDC25, session 231)
Drag and Drop
Use `dragContainer` for multi-item drag operations. Combine with DragConfiguration for custom drag behavior and onDragSessionUpdated to observe events.
PhotoGrid(photos: photos)
.dragContainer(for: Photo.self) { selection in
return selection.map { $0.transferable }
}
.onDragSessionUpdated { session in
if session.phase == .endedWithDelete {
deleteSelectedPhotos()
}
}Source: "What's new in SwiftUI" (WWDC25, session 256)
Scene Bridging
UIKit and AppKit lifecycle apps can now request SwiftUI scenes. This enables using SwiftUI-only scene types like MenuBarExtra and ImmersiveSpace from imperative lifecycle apps via UIApplication.shared.activateSceneSession(for:errorHandler:).
Source: "What's new in SwiftUI" (WWDC25, session 256)
---
Quick Lookup Table
| Deprecated | Recommended | Since |
|---|---|---|
navigationBarTitle(_:) | navigationTitle(_:) | iOS 15+ |
navigationBarItems(...) | toolbar { ToolbarItem(...) } | iOS 15+ |
navigationBarHidden(_:) | toolbarVisibility(.hidden, for: .navigationBar) | iOS 15+ |
statusBar(hidden:) | statusBarHidden(_:) | iOS 15+ |
edgesIgnoringSafeArea(_:) | ignoresSafeArea(_:edges:) | iOS 15+ |
colorScheme(_:) | preferredColorScheme(_:) | iOS 15+ |
foregroundColor(_:) | foregroundStyle(_:) | iOS 15+ |
cornerRadius(_:) | clipShape(.rect(cornerRadius:)) | iOS 15+ |
actionSheet(...) | confirmationDialog(...) | iOS 15+ |
alert(isPresented:content:) | alert(_:isPresented:actions:message:) | iOS 15+ |
autocapitalization(_:) | textInputAutocapitalization(_:) | iOS 15+ |
accessibility(label:) etc. | accessibilityLabel() etc. | iOS 15+ |
TextField onCommit/onEditingChanged | onSubmit + focused | iOS 15+ |
animation(_:) (no value) | animation(_:value:) | Back-deploys (iOS 13+) |
Manual EnvironmentKey | @Entry macro | Back-deploys (Xcode 16+) |
NavigationView | NavigationStack / NavigationSplitView | iOS 16+ |
accentColor(_:) | tint(_:) | iOS 16+ |
disableAutocorrection(_:) | autocorrectionDisabled(_:) | iOS 16+ |
UIPasteboard.general | PasteButton | iOS 16+ |
onChange(of:perform:) | onChange(of:) { } or onChange(of:) { old, new in } | iOS 17+ |
MagnificationGesture | MagnifyGesture | iOS 17+ |
RotationGesture | RotateGesture | iOS 17+ |
coordinateSpace(name:) | coordinateSpace(.named(...)) | iOS 17+ |
ObservableObject | @Observable | iOS 17+ |
tabItem(_:) | Tab API | iOS 18+ |
Manual animatableData | @Animatable macro | iOS 26+ |
presentationBackground(_:) on sheets | Default Liquid Glass sheet material | iOS 26+ |
| Custom toolbar background hacks | scrollEdgeEffectStyle(_:for:) | iOS 26+ |
SwiftUI Layout Best Practices Reference
Table of Contents
- Relative Layout Over Constants
- Context-Agnostic Views
- Own Your Container
- Layout Performance
- View Logic and Testability
- Full-Width Views
- Action Handlers
- Summary Checklist
Relative Layout Over Constants
Use dynamic layout calculations instead of hard-coded values.
// Good - relative to actual layout
GeometryReader { geometry in
VStack {
HeaderView()
.frame(height: geometry.size.height * 0.2)
ContentView()
}
}
// Avoid - magic numbers that don't adapt
VStack {
HeaderView()
.frame(height: 150) // Doesn't adapt to different screens
ContentView()
}Why: Hard-coded values don't account for different screen sizes, orientations, or dynamic content (like status bars during phone calls).
Context-Agnostic Views
Views should work in any context. Never assume presentation style or screen size.
// Good - adapts to given space
struct ProfileCard: View {
let user: User
var body: some View {
VStack {
Image(user.avatar)
.resizable()
.aspectRatio(contentMode: .fit)
Text(user.name)
Spacer()
}
.padding()
}
}
// Avoid - assumes full screen
struct ProfileCard: View {
let user: User
var body: some View {
VStack {
Image(user.avatar)
.frame(width: UIScreen.main.bounds.width) // Wrong!
Text(user.name)
}
}
}Why: Views should work as full screens, modals, sheets, popovers, or embedded content.
Own Your Container
Custom views should own static containers but not lazy/repeatable ones.
// Good - owns static container
struct HeaderView: View {
var body: some View {
HStack {
Image(systemName: "star")
Text("Title")
Spacer()
}
}
}
// Avoid - missing container
struct HeaderView: View {
var body: some View {
Image(systemName: "star")
Text("Title")
// Caller must wrap in HStack
}
}
// Good - caller owns lazy container
struct FeedView: View {
let items: [Item]
var body: some View {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
}Layout Performance
Avoid Layout Thrash
Minimize deep view hierarchies and excessive layout dependencies.
// Bad - deep nesting, excessive layout passes
VStack {
HStack {
VStack {
HStack {
VStack {
Text("Deep")
}
}
}
}
}
// Good - flatter hierarchy
VStack {
Text("Shallow")
Text("Structure")
}Avoid excessive `GeometryReader` and preference chains:
// Bad - multiple geometry readers cause layout thrash
GeometryReader { outerGeometry in
VStack {
GeometryReader { innerGeometry in
// Layout recalculates multiple times
}
}
}
// Good - single geometry reader or use alternatives (iOS 17+)
containerRelativeFrame(.horizontal) { width, _ in
width * 0.8
}Gate frequent geometry updates:
// Bad - updates on every pixel change
.onPreferenceChange(ViewSizeKey.self) { size in
currentSize = size
}
// Good - gate by threshold
.onPreferenceChange(ViewSizeKey.self) { size in
let difference = abs(size.width - currentSize.width)
if difference > 10 { // Only update if significant change
currentSize = size
}
}View Logic and Testability
Keep Business Logic in Services and Models
Business logic belongs in services and models, not in views. Views should stay simple and declarative — orchestrating UI state, not implementing business rules. This makes logic independently testable without requiring view instantiation.
iOS 17+: Use@Observablewith@State.
@Observable
final class AuthService {
var email = ""
var password = ""
var isValid: Bool {
!email.isEmpty && password.count >= 8
}
func login() async throws {
// Business logic here — testable without the view
}
}
struct LoginView: View {
@State private var authService = AuthService()
var body: some View {
Form {
TextField("Email", text: $authService.email)
SecureField("Password", text: $authService.password)
Button("Login") {
Task {
try? await authService.login()
}
}
.disabled(!authService.isValid)
}
}
}For iOS 16 and earlier, use ObservableObject with @StateObject -- see state-management.md for the legacy pattern.
Avoid embedding business logic directly in view closures (e.g., validation checks inside a Button action). This makes logic untestable without view instantiation.
Note: This is about making business logic testable, not about enforcing a specific architecture. The key is that logic lives outside views where it can be tested independently.
Full-Width Views
When a single view needs to fill the available width, use `.frame(maxWidth: .infinity, alignment:)` instead of wrapping it in a stack with a `Spacer`.
// Good - frame modifier
Text("Hello")
.frame(maxWidth: .infinity, alignment: .leading)
// Avoid - unnecessary stack and spacer
HStack {
Text("Hello")
Spacer()
}Why: .frame(maxWidth:alignment:) is a single modifier that clearly communicates intent. Wrapping in an HStack with a Spacer adds an extra container to the view hierarchy for no benefit.
Action Handlers
Separate layout from logic. View body should reference action methods, not contain inline logic.
// Good - action references method
Button("Publish Project", action: publishService.handlePublish)
// Avoid - multi-line logic in closure
Button("Publish Project") {
isLoading = true
apiService.publish(project) { result in /* ... */ }
}Summary Checklist
- [ ] Use relative layout over hard-coded constants
- [ ] Views work in any context (don't assume screen size)
- [ ] Custom views own static containers
- [ ] Avoid deep view hierarchies (layout thrash)
- [ ] Gate frequent geometry updates by thresholds
- [ ] Business logic kept in services and models (not in views)
- [ ] Action handlers reference methods, not inline logic
- [ ] Use
.frame(maxWidth: .infinity, alignment:)for full-width views (notHStack+Spacer) - [ ] Avoid excessive
GeometryReaderusage - [ ] Use
containerRelativeFrame()when appropriate
SwiftUI Liquid Glass Reference (iOS 26+)
Table of Contents
- Overview
- Availability
- Core APIs
- GlassEffectContainer
- Glass Button Styles
- Morphing Transitions
- Modifier Order
- Complete Examples
- Fallback Strategies
- Design System Notes
- Best Practices
- Checklist
Overview
Liquid Glass is Apple's new design language introduced in iOS 26. It provides translucent, dynamic surfaces that respond to content and user interaction. This reference covers the native SwiftUI APIs for implementing Liquid Glass effects.
Only adopt Liquid Glass when explicitly requested by the user. Do not proactively convert existing UI to glass effects.
Availability
All Liquid Glass APIs require iOS 26 or later. Always provide fallbacks:
if #available(iOS 26, *) {
// Liquid Glass implementation
} else {
// Fallback using materials
}Core APIs
glassEffect Modifier
The primary modifier for applying glass effects to views:
.glassEffect(_ style: GlassEffectStyle = .regular, in shape: some Shape = .rect)Basic Usage
Text("Hello")
.padding()
.glassEffect() // Default regular style, rect shapeWith Shape
Text("Rounded Glass")
.padding()
.glassEffect(in: .rect(cornerRadius: 16))
Image(systemName: "star")
.padding()
.glassEffect(in: .circle)
Text("Capsule")
.padding(.horizontal, 20)
.padding(.vertical, 10)
.glassEffect(in: .capsule)GlassEffectStyle
Prominence Levels
.glassEffect(.regular) // Standard glass appearance
.glassEffect(.prominent) // More visible, higher contrastTinting
Add color tint to the glass:
.glassEffect(.regular.tint(.blue))
.glassEffect(.prominent.tint(.red.opacity(0.3)))Interactivity
Make glass respond to touch/pointer hover:
// Interactive glass - responds to user interaction
.glassEffect(.regular.interactive())
// Combined with tint
.glassEffect(.regular.tint(.blue).interactive())Important: Only use .interactive() on elements that actually respond to user input (buttons, tappable views, focusable elements).
GlassEffectContainer
Wraps multiple glass elements for proper visual grouping and spacing.
Glass cannot sample other glass. The glass material reflects and refracts light by sampling content from an area larger than itself. Nearby glass elements in different containers will produce inconsistent visual results because they cannot sample each other. GlassEffectContainer gives grouped elements a shared sampling region, ensuring a consistent appearance.
GlassEffectContainer {
HStack {
Button("One") { }
.glassEffect()
Button("Two") { }
.glassEffect()
}
}With Spacing
Control the visual spacing between glass elements:
GlassEffectContainer(spacing: 24) {
HStack(spacing: 24) {
GlassChip(icon: "pencil")
GlassChip(icon: "eraser")
GlassChip(icon: "trash")
}
}Note: The container's spacing parameter should match the actual spacing in your layout for proper glass effect rendering.
Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Glass Button Styles
Built-in button styles for glass appearance:
// Standard glass button
Button("Action") { }
.buttonStyle(.glass)
// Prominent glass button (higher visibility)
Button("Primary Action") { }
.buttonStyle(.glassProminent)Custom Glass Buttons
For more control, apply glass effect manually:
Button(action: { }) {
Label("Settings", systemImage: "gear")
.padding()
}
.glassEffect(.regular.interactive(), in: .capsule)Morphing Transitions
Create smooth transitions between glass elements using glassEffectID and @Namespace:
struct MorphingExample: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
GlassEffectContainer {
if isExpanded {
ExpandedCard()
.glassEffect()
.glassEffectID("card", in: animation)
} else {
CompactCard()
.glassEffect()
.glassEffectID("card", in: animation)
}
}
.animation(.smooth, value: isExpanded)
}
}Requirements for Morphing
1. Both views must have the same glassEffectID 2. Use the same @Namespace 3. Wrap in GlassEffectContainer 4. Apply animation to the container or parent
Modifier Order
Critical: Apply glassEffect after layout and visual modifiers:
// CORRECT order
Text("Label")
.font(.headline) // 1. Typography
.foregroundStyle(.primary) // 2. Color
.padding() // 3. Layout
.glassEffect() // 4. Glass effect LAST
// WRONG order - glass applied too early
Text("Label")
.glassEffect() // Wrong position
.padding()
.font(.headline)Complete Examples
Toolbar with Glass Buttons
struct GlassToolbar: View {
var body: some View {
if #available(iOS 26, *) {
GlassEffectContainer(spacing: 16) {
HStack(spacing: 16) {
ToolbarButton(icon: "pencil", action: { })
ToolbarButton(icon: "eraser", action: { })
ToolbarButton(icon: "scissors", action: { })
Spacer()
ToolbarButton(icon: "square.and.arrow.up", action: { })
}
.padding(.horizontal)
}
} else {
// Fallback toolbar
HStack(spacing: 16) {
// ... fallback implementation
}
}
}
}
struct ToolbarButton: View {
let icon: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.title2)
.frame(width: 44, height: 44)
}
.glassEffect(.regular.interactive(), in: .circle)
}
}Card with Glass Effect
struct GlassCard: View {
let title: String
let subtitle: String
var body: some View {
if #available(iOS 26, *) {
cardContent
.glassEffect(.regular, in: .rect(cornerRadius: 20))
} else {
cardContent
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
private var cardContent: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}Segmented Control
struct GlassSegmentedControl: View {
@Binding var selection: Int
let options: [String]
@Namespace private var animation
var body: some View {
if #available(iOS 26, *) {
GlassEffectContainer(spacing: 4) {
HStack(spacing: 4) {
ForEach(options.indices, id: \.self) { index in
Button(options[index]) {
withAnimation(.smooth) {
selection = index
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.glassEffect(
selection == index ? .prominent.interactive() : .regular.interactive(),
in: .capsule
)
.glassEffectID(selection == index ? "selected" : "option\(index)", in: animation)
}
}
.padding(4)
}
} else {
Picker("Options", selection: $selection) {
ForEach(options.indices, id: \.self) { index in
Text(options[index]).tag(index)
}
}
.pickerStyle(.segmented)
}
}
}Fallback Strategies
Using Materials
if #available(iOS 26, *) {
content.glassEffect()
} else {
content.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}Available Materials for Fallback
.ultraThinMaterial- Closest to glass appearance.thinMaterial- Slightly more opaque.regularMaterial- Standard blur.thickMaterial- More opaque.ultraThickMaterial- Most opaque
Conditional Modifier Extension
extension View {
@ViewBuilder
func glassEffectWithFallback(
_ style: GlassEffectStyle = .regular,
in shape: some Shape = .rect,
fallbackMaterial: Material = .ultraThinMaterial
) -> some View {
if #available(iOS 26, *) {
self.glassEffect(style, in: shape)
} else {
self.background(fallbackMaterial, in: shape)
}
}
}Design System Notes
Toolbar Icons
In the new design, toolbar icons use monochrome rendering by default. The monochrome palette reduces visual noise and maintains legibility. Use tint(_:) only to convey meaning (e.g., a call to action), not for visual effect.
Sheet Presentations
Partial-height sheets use a Liquid Glass background by default. If you previously used presentationBackground(_:) with a custom background, consider removing it to let the new material shine. Sheets can morph out of the glass controls that present them using navigationZoomTransition.
Scroll Edge Effects
An automatic scroll edge effect blurs and fades content under system toolbars to keep controls legible. Remove any custom background-darkening effects behind bar items, as they will interfere.
Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)
Best Practices
Do
- Use
GlassEffectContainerfor grouped glass elements (glass cannot sample other glass) - Apply glass after layout modifiers
- Use
.interactive()only on tappable elements - Match container spacing with layout spacing
- Provide material-based fallbacks for older iOS
- Keep glass shapes consistent within a feature
- Remove custom
presentationBackground(_:)on sheets to use the default glass material
Don't
- Apply glass to every element (use sparingly)
- Use
.interactive()on static content - Mix different corner radii arbitrarily
- Forget iOS version checks
- Apply glass before padding/frame modifiers
- Nest
GlassEffectContainerunnecessarily - Add custom darkening backgrounds behind toolbars (conflicts with scroll edge effect)
Checklist
- [ ]
#available(iOS 26, *)with fallback - [ ]
GlassEffectContainerwraps grouped elements - [ ]
.glassEffect()applied after layout modifiers - [ ]
.interactive()only on user-interactable elements - [ ]
glassEffectIDwith@Namespacefor morphing - [ ] Consistent shapes and spacing across feature
- [ ] Container spacing matches layout spacing
- [ ] Appropriate prominence levels used
SwiftUI List Patterns Reference
Table of Contents
- ForEach Identity and Stability
- Enumerated Sequences
- List with Custom Styling
- List with Pull-to-Refresh
- Empty States with ContentUnavailableView (iOS 17+)
- Custom List Backgrounds
- Table
- Summary Checklist
ForEach Identity and Stability
Always provide stable identity for `ForEach`. Never use .indices for dynamic content.
// Good - stable identity via Identifiable
extension User: Identifiable {
var id: String { userId }
}
ForEach(users) { user in
UserRow(user: user)
}
// Good - stable identity via keypath
ForEach(users, id: \.userId) { user in
UserRow(user: user)
}
// Wrong - indices create static content
ForEach(users.indices, id: \.self) { index in
UserRow(user: users[index]) // Can crash on removal!
}
// Wrong - unstable identity
ForEach(users, id: \.self) { user in
UserRow(user: user) // Only works if User is Hashable and stable
}Critical: Ensure constant number of views per element in ForEach:
// Good - consistent view count
ForEach(items) { item in
ItemRow(item: item)
}
// Bad - variable view count breaks identity
ForEach(items) { item in
if item.isSpecial {
SpecialRow(item: item)
DetailRow(item: item)
} else {
RegularRow(item: item)
}
}Avoid inline filtering:
// Bad - unstable identity, changes on every update
ForEach(items.filter { $0.isEnabled }) { item in
ItemRow(item: item)
}
// Good - prefilter and cache
@State private var enabledItems: [Item] = []
var body: some View {
ForEach(enabledItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { _, newItems in
enabledItems = newItems.filter { $0.isEnabled }
}
}Avoid `AnyView` in list rows:
// Bad - hides identity, increases cost
ForEach(items) { item in
AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))
}
// Good - Create a unified row view
ForEach(items) { item in
ItemRow(item: item)
}
struct ItemRow: View {
let item: Item
var body: some View {
if item.isSpecial {
SpecialRow(item: item)
} else {
RegularRow(item: item)
}
}
}Why: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.
Identifiable ID Must Be Truly Unique
Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:
// Bug -- two articles with the same URL show identical content
struct Article: Identifiable {
let title: String
let url: URL
var id: String { url.absoluteString } // Not unique if URLs repeat!
}
// Fix -- use a genuinely unique identifier
struct Article: Identifiable {
let id: UUID
let title: String
let url: URL
}Classes get a default `ObjectIdentifier`-based `id` when conforming to Identifiable without providing one. This is only unique for the object's lifetime and can be recycled after deallocation.
Enumerated Sequences
Always convert enumerated sequences to arrays. To be able to use them in a ForEach.
let items = ["A", "B", "C"]
// Correct
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text("\(index): \(item)")
}
// Wrong - Doesn't compile, enumerated() isn't an array
ForEach(items.enumerated(), id: \.offset) { index, item in
Text("\(index): \(item)")
}List with Custom Styling
// Remove default background and separators
List(items) { item in
ItemRow(item: item)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.customBackground)
.environment(\.defaultMinListRowHeight, 1) // Allows custom row heightsList with Pull-to-Refresh
List(items) { item in
ItemRow(item: item)
}
.refreshable {
await loadItems()
}Empty States with ContentUnavailableView (iOS 17+)
Use ContentUnavailableView for empty list/search states. The built-in .search variant is auto-localized:
List {
ForEach(searchResults) { item in
ItemRow(item: item)
}
}
.overlay {
if searchResults.isEmpty, !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}For non-search empty states, use a custom instance:
ContentUnavailableView(
"No Articles",
systemImage: "doc.richtext.fill",
description: Text("Articles you save will appear here.")
)Custom List Backgrounds
Use .scrollContentBackground(.hidden) to replace the default list background:
List(items) { item in
ItemRow(item: item)
}
.scrollContentBackground(.hidden)
.background(Color.customBackground)Without .scrollContentBackground(.hidden), a custom .background() has no visible effect on List.
Table
Availability: iOS 16.0+, iPadOS 16.0+, visionOS 1.0+
A multi-column data container that presents rows of Identifiable data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden.
Basic Table
struct Person: Identifiable {
let givenName: String
let familyName: String
let emailAddress: String
let id = UUID()
var fullName: String { givenName + " " + familyName }
}
struct PeopleTable: View {
@State private var people: [Person] = [ /* ... */ ]
var body: some View {
Table(people) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
}
}Table with Selection
Bind to a single ID for single-selection, or a Set<ID> for multi-selection:
struct SelectableTable: View {
@State private var people: [Person] = [ /* ... */ ]
@State private var selectedPeople = Set<Person.ID>()
var body: some View {
Table(people, selection: $selectedPeople) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
Text("\(selectedPeople.count) people selected")
}
}Sortable Table
Provide a binding to [KeyPathComparator] and re-sort the data in .onChange(of:):
struct SortableTable: View {
@State private var people: [Person] = [ /* ... */ ]
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View {
Table(people, sortOrder: $sortOrder) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
.onChange(of: sortOrder) { _, newOrder in
people.sort(using: newOrder)
}
}
}Important: The table does not sort data itself — you must re-sort the collection when sortOrder changes.
Adaptive Table for Compact Size Classes
On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:
struct AdaptiveTable: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompact: Bool { horizontalSizeClass == .compact }
@State private var people: [Person] = [ /* ... */ ]
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View {
Table(people, sortOrder: $sortOrder) {
TableColumn("Given Name", value: \.givenName) { person in
VStack(alignment: .leading) {
Text(isCompact ? person.fullName : person.givenName)
if isCompact {
Text(person.emailAddress)
.foregroundStyle(.secondary)
}
}
}
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
.onChange(of: sortOrder) { _, newOrder in
people.sort(using: newOrder)
}
}
}Table with Static Rows
Use init(of:columns:rows:) when rows are known at compile time:
struct Purchase: Identifiable {
let price: Decimal
let id = UUID()
}
struct TipTable: View {
let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")
var body: some View {
Table(of: Purchase.self) {
TableColumn("Base price") { purchase in
Text(purchase.price, format: currencyStyle)
}
TableColumn("With 15% tip") { purchase in
Text(purchase.price * 1.15, format: currencyStyle)
}
TableColumn("With 20% tip") { purchase in
Text(purchase.price * 1.2, format: currencyStyle)
}
} rows: {
TableRow(Purchase(price: 20))
TableRow(Purchase(price: 50))
TableRow(Purchase(price: 75))
}
}
}Table Styles
// Inset (no borders)
Table(people) { /* columns */ }
.tableStyle(.inset)
// Hide column headers
Table(people) { /* columns */ }
.tableColumnHeaders(.hidden)Platform Behavior
| Platform | Behavior |
|---|---|
| iPadOS (regular) | Full multi-column layout; headers and all columns visible |
| iPadOS (compact) | Only the first column shown; headers hidden |
| iPhone (all sizes) | Only the first column shown; headers hidden; list-like appearance |
Best Practice: Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes (e.g., entering/exiting Slide Over on iPad).
Summary Checklist
- [ ] ForEach uses stable identity (never
.indicesfor dynamic content) - [ ] Identifiable IDs are truly unique across all items
- [ ] Constant number of views per ForEach element
- [ ] No inline filtering in ForEach (prefilter and cache instead)
- [ ] No
AnyViewin list rows - [ ] Don't convert enumerated sequences to arrays
- [ ] Use
.refreshablefor pull-to-refresh - [ ] Use
ContentUnavailableViewfor empty states (iOS 17+) - [ ] Use
.scrollContentBackground(.hidden)for custom list backgrounds - [ ]
Tableadapts for compact size classes (first column shows combined info) - [ ]
Tablesorting re-sorts data in.onChange(of: sortOrder)(table doesn't sort itself) - [ ]
Tabledata conforms toIdentifiable
macOS Scenes Reference
SwiftUI scene types for macOS apps —Settings,MenuBarExtra,WindowGroup,Window,UtilityWindow, andDocumentGroup. Covers macOS-only scenes and cross-platform scenes with macOS-specific behavior.
Table of Contents
- Quick Lookup Table
- Settings (macOS-only)
- MenuBarExtra (macOS-only)
- WindowGroup (macOS behavior)
- Window
- UtilityWindow (macOS-only)
- DocumentGroup
- Platform Conditionals
- Best Practices
---
Quick Lookup Table
| API | Availability | macOS-Only? | macOS-Specific Behavior |
|---|---|---|---|
WindowGroup | macOS 11.0+ | No | Multiple window instances, tabbed interface, automatic Window menu commands |
Window | macOS 13.0+ | No | App quits when sole window closes; adds itself to Windows menu |
UtilityWindow | macOS 15.0+ | Yes | Floating tool palette; receives FocusedValues from active main window |
Settings | macOS 11.0+ | Yes | Presents preferences window (Cmd+,) |
MenuBarExtra | macOS 13.0+ | Yes | Persistent icon/menu in the system menu bar |
DocumentGroup | macOS 11.0+ | No | Document-based menu bar commands (File > New/Open/Save); multiple document windows |
---
Settings (macOS-only)
Presents the app's preferences window, accessible via Cmd+, or the app menu. SwiftUI automatically enables the Settings menu item and manages the window lifecycle.
Settings {
TabView {
Tab("General", systemImage: "gear") { GeneralSettingsView() }
Tab("Advanced", systemImage: "star") { AdvancedSettingsView() }
}
.scenePadding()
.frame(maxWidth: 350, minHeight: 100)
}Use TabView with Tab items for multi-pane preferences. Each tab's content is typically a Form with @AppStorage-backed controls.
SettingsLink (macOS 14.0+)
A button that opens the Settings scene. Use for in-app navigation to preferences.
struct SidebarFooter: View {
var body: some View {
SettingsLink {
Label("Preferences", systemImage: "gear")
}
}
}openSettings environment action (macOS 14.0+)
Programmatically open (or bring to front) the Settings window.
struct OpenSettingsButton: View {
@Environment(\.openSettings) private var openSettings
var body: some View {
Button("Open Settings") {
openSettings()
}
}
}---
MenuBarExtra (macOS-only)
Renders a persistent control in the system menu bar. Two styles available:
- `.menu` (default) — standard dropdown menu
- `.window` — popover panel with custom SwiftUI views
Menu-style (dropdown)
MenuBarExtra("My Utility", systemImage: "hammer") {
Button("Action One") { /* ... */ }
Button("Action Two") { /* ... */ }
Divider()
Button("Quit") { NSApplication.shared.terminate(nil) }
}Window-style (popover panel)
MenuBarExtra("Status", systemImage: "chart.bar") {
DashboardView()
.frame(width: 240)
}
.menuBarExtraStyle(.window)Variations:
- Toggleable — pass
isInserted:with an@AppStoragebinding to let users show/hide the extra:MenuBarExtra("Status", systemImage: "chart.bar", isInserted: $showMenuBarExtra) - Menu-bar-only app — use
MenuBarExtraas the sole scene + setLSUIElement = truein Info.plist to hide the Dock icon. The app auto-terminates if the user removes the extra from the menu bar.
---
WindowGroup (macOS behavior)
On macOS, WindowGroup supports:
- Multiple window instances — users can open many windows from File > New Window
- Tabbed interface — users can merge windows into tabs
- Automatic Window menu — commands for window management appear automatically
@main
struct Mail: App {
var body: some Scene {
// Basic multi-window support
WindowGroup {
MailViewer()
}
// Data-presenting window opened programmatically
WindowGroup("Message", for: Message.ID.self) { $messageID in
MessageDetail(messageID: messageID)
}
}
}
// Open a specific window programmatically
struct NewMessageButton: View {
var message: Message
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Open Message") {
openWindow(value: message.id)
}
}
}Key difference from `Window`:WindowGroupkeeps the app running even after all windows are closed.Window(as sole scene) quits the app when closed.
---
Window
A single, unique window scene. The system ensures only one instance exists.
@main
struct Mail: App {
var body: some Scene {
WindowGroup {
MailViewer()
}
// Supplementary singleton window
Window("Connection Doctor", id: "connection-doctor") {
ConnectionDoctor()
}
}
}
// Open programmatically — brings to front if already open
struct OpenDoctorButton: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Connection Doctor") {
openWindow(id: "connection-doctor")
}
}
}Window as sole scene
If Window is the only scene, the app quits when the window closes:
@main
struct VideoCall: App {
var body: some Scene {
Window("VideoCall", id: "main") {
CameraView()
}
}
}Recommendation: In most cases, preferWindowGroupfor the primary scene. UseWindowfor supplementary singleton windows.
---
UtilityWindow (macOS-only)
A specialized floating window for tool palettes and inspector panels. Available since macOS 15.0.
Key behaviors:
- Receives
FocusedValuesfrom the focused main scene (like menu bar commands) - Floats above main windows (default level:
.floating) - Hides when the app is no longer active
- Only becomes focused when explicitly needed (e.g., clicking the title bar)
- Dismissible with the Escape key
- Not minimizable by default
- Automatically adds a show/hide item to the View menu
@main
struct PhotoBrowser: App {
var body: some Scene {
WindowGroup {
PhotoGallery()
}
UtilityWindow("Photo Info", id: "photo-info") {
PhotoInfoViewer()
}
}
}
struct PhotoInfoViewer: View {
// Automatically updates based on whichever main window is focused
@FocusedValue(PhotoSelection.self) private var selectedPhotos
var body: some View {
if let photos = selectedPhotos {
Text("\(photos.count) photos selected")
} else {
Text("No selection")
.foregroundStyle(.secondary)
}
}
}Tip: Remove the automatic View menu item with.commandsRemoved()and place aWindowVisibilityToggleelsewhere in your commands.
---
DocumentGroup
Document-based apps with automatic file management. On macOS, provides:
- Document-based menu bar commands (File > New, Open, Save, Revert)
- Multiple document windows simultaneously
- On iOS, shows a document browser instead
DocumentGroup(newDocument: TextFile()) { config in
ContentView(document: config.$document)
}The document type must conform to FileDocument (value type) or ReferenceFileDocument (reference type). Key requirements:
struct TextFile: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String = ""
init() {}
init(configuration: ReadConfiguration) throws {
text = String(data: configuration.file.regularFileContents ?? Data(), encoding: .utf8) ?? ""
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: Data(text.utf8))
}
}For multiple document types, add additional DocumentGroup scenes — use DocumentGroup(viewing:) for read-only formats.
---
Platform Conditionals
Always wrap macOS-only scenes in #if os(macOS):
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
MenuBarExtra("Status", systemImage: "bolt") {
StatusMenu()
}
#endif
}
}---
Best Practices
- Use `Settings` for preferences — prefer this over a custom preferences window
- Use `MenuBarExtra` for menu bar items — prefer this over managing AppKit's
NSStatusItemdirectly - Use `WindowGroup` as the primary scene — reserve
Windowfor supplementary singletons - Use `UtilityWindow` for inspectors/palettes — it handles floating, focus, and visibility automatically
- Use `DocumentGroup` for document-based apps — it provides the full File menu and document lifecycle
- Gate macOS-only scenes with
#if os(macOS)for multiplatform projects - Use `openWindow(id:)` to open windows programmatically — it brings existing windows to front
macOS Window & Toolbar Styling Reference
Window configuration, toolbar styles, sizing, positioning, and navigation patterns specific to macOS SwiftUI apps.
Table of Contents
- Quick Lookup Table
- Toolbar Styles
- Window Style
- Window Sizing
- MenuBarExtra Style (macOS-only)
- Navigation Layout (macOS behavior)
- Commands & Keyboard
- Best Practices
---
Quick Lookup Table
| API | Availability | macOS-Only? | Usage |
|---|---|---|---|
windowToolbarStyle(_:) | macOS 11.0+ | Yes | Sets toolbar style: .unified, .unifiedCompact, .expanded |
windowStyle(_:) | macOS 11.0+ | No | Supports .hiddenTitleBar for chromeless windows |
windowResizability(_:) | macOS 13.0+ | No | Controls resize handle and green zoom button behavior |
defaultSize(width:height:) | macOS 13.0+ | No | Initial frame size when user creates a new window |
defaultPosition(_:) | macOS 13.0+ | No | Initial window position on screen |
windowIdealPlacement(_:) | macOS 15.0+ | No | Closure with display geometry for precise window positioning |
menuBarExtraStyle(_:) | macOS 13.0+ | Yes | Sets MenuBarExtra to .menu or .window style |
NavigationSplitView | macOS 13.0+ | No | Columns always visible side-by-side on macOS; translucent sidebar |
Inspector | macOS 14.0+ | No | Trailing-edge sidebar panel; resizable by dragging |
---
Toolbar Styles
windowToolbarStyle (macOS-only)
Controls how the toolbar and title bar are displayed. Applied to a scene.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// Title bar and toolbar in a single row
.windowToolbarStyle(.unified)
}
}Available styles:
| Style | Description |
|---|---|
.automatic | System default |
.unified | Title bar and toolbar in a single combined row |
.unifiedCompact | Same as unified but with reduced vertical height |
.expanded | Title bar displayed above the toolbar (more toolbar space) |
// Unified compact — minimal chrome
.windowToolbarStyle(.unifiedCompact)
// Expanded — title bar above toolbar
.windowToolbarStyle(.expanded)
// Unified with title hidden
.windowToolbarStyle(.unified(showsTitle: false))Toolbar content
struct ContentView: View {
@State private var searchText = ""
var body: some View {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.toolbar {
ToolbarItem(placement: .automatic) {
Button(action: addItem) {
Label("Add", systemImage: "plus")
}
}
}
.searchable(text: $searchText, placement: .sidebar)
}
}---
Window Style
windowStyle
Set the visual style of a window. Use .hiddenTitleBar for chromeless, immersive windows.
// Standard title bar (default)
WindowGroup {
ContentView()
}
.windowStyle(.titleBar)
// Hidden title bar — chromeless window
WindowGroup {
ContentView()
}
.windowStyle(.hiddenTitleBar)Use case: .hiddenTitleBar is useful for media players, custom-chrome apps, or immersive experiences where the standard title bar is unwanted.---
Window Sizing
windowResizability, defaultSize, defaultPosition
These modifiers work together to configure window sizing and placement:
WindowGroup {
ContentView()
.frame(minWidth: 600, minHeight: 400)
}
.defaultSize(width: 900, height: 600)
.defaultPosition(.center)
.windowResizability(.contentMinSize)`windowResizability` options:
| Value | Behavior |
|---|---|
.automatic | System decides resize behavior |
.contentSize | Fixed to content size; no user resize; zoom button disabled |
.contentMinSize | Resizable with minimum based on content's minWidth/minHeight |
`defaultPosition` options: .center, .topLeading, .top, .topTrailing, .leading, .trailing, .bottomLeading, .bottom, .bottomTrailing
Guidelines:
- Set
minWidth/minHeightvia.frame()on content, enforce with.contentMinSize - Use
.defaultSize()for initial dimensions (larger than minimums) defaultSizealso acceptsCGSize
windowIdealPlacement (macOS 15.0+)
For precise programmatic positioning, use a closure with display geometry:
.windowIdealPlacement { context in
let screen = context.defaultDisplay.visibleArea
return WindowPlacement(x: screen.midX, y: screen.midY,
width: screen.width / 2, height: screen.height)
}---
MenuBarExtra Style (macOS-only)
Choose between dropdown menu and popover panel for MenuBarExtra.
// Dropdown menu (default)
MenuBarExtra("Status", systemImage: "chart.bar") {
Button("Action") { /* ... */ }
}
.menuBarExtraStyle(.menu)
// Popover panel with custom SwiftUI content
MenuBarExtra("Status", systemImage: "chart.bar") {
DashboardView()
}
.menuBarExtraStyle(.window)---
Navigation Layout (macOS behavior)
NavigationSplitView
On macOS, NavigationSplitView displays columns side-by-side (never overlaid). The sidebar gets a translucent material background. Columns support variable-width resizing by the user.
NavigationSplitView {
List(items, selection: $selectedId) { item in
Text(item.name)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
} detail: {
DetailView(id: selectedId)
}
.navigationSplitViewStyle(.balanced)Use the three-column variant (sidebar / content / detail) for master-detail-detail layouts. Customize column widths with .navigationSplitViewColumnWidth(min:ideal:max:).
Inspector (macOS 14.0+)
A trailing-edge panel for supplementary information. On macOS, it appears as a sidebar-style panel that can be resized by dragging its edge.
struct ContentView: View {
@State private var showInspector = false
var body: some View {
MainContent()
.inspector(isPresented: $showInspector) {
InspectorView()
.inspectorColumnWidth(min: 200, ideal: 250, max: 400)
}
.toolbar {
ToolbarItem {
Button {
showInspector.toggle()
} label: {
Label("Inspector", systemImage: "info.circle")
}
}
}
}
}---
Commands & Keyboard
Commands, CommandGroup, CommandMenu
Define menu bar commands. On macOS, these populate the menu bar directly. On iOS, they create key commands.
.commands {
CommandMenu("Tools") {
Button("Run Analysis") { /* ... */ }
.keyboardShortcut("r", modifiers: [.command, .shift])
}
CommandGroup(after: .newItem) {
Button("New From Template...") { /* ... */ }
}
}`CommandGroup` placement options: .replacing(_:) replaces a system group, .before(_:) / .after(_:) inserts adjacent to it. Common placements: .newItem, .saveItem, .help, .toolbar, .sidebar.
KeyboardShortcut
On macOS, shortcuts are displayed alongside menu items and in button tooltips on hover.
Button("Save") {
save()
}
.keyboardShortcut("s", modifiers: .command)
Button("Delete") {
delete()
}
.keyboardShortcut(.delete, modifiers: .command)openWindow
Programmatically open a window. If the target window is already open, brings it to the front.
struct ToolbarActions: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Connection Doctor") {
openWindow(id: "connection-doctor")
}
Button("Show Message") {
openWindow(value: message.id) // Type-matched to WindowGroup
}
}
}---
Best Practices
- Use `.unified` or `.unifiedCompact` for most apps —
.expandedonly when you need many toolbar items - Set min frame sizes on content and use
.windowResizability(.contentMinSize)to enforce them - Always provide `defaultSize` so new windows start at a reasonable size
- Use `NavigationSplitView` for sidebar navigation — not
HSplitView - Use `Inspector` for supplementary panels — it integrates with the toolbar automatically
- Define `Commands` for all repeatable actions — users expect keyboard shortcuts on macOS
- Use `#if os(macOS)` to wrap macOS-only window configuration in multiplatform projects