
Swiftui Expert Skill
- 27.6k installs
- 3.3k repo stars
- Updated July 24, 2026
- avdlee/swiftui-agent-skill
SwiftUI Expert Skill is a comprehensive reference providing expert guidance on SwiftUI state management, view composition, performance, and modern iOS APIs.
About
SwiftUI Expert Skill provides expert guidance for SwiftUI development including state management, view composition, performance optimization, and iOS 26+ Liquid Glass adoption. Use when developers need SwiftUI best practices, code review guidance, or performance diagnosis with instrument trace analysis.
- Comprehensive SwiftUI guidance - state management, view composition, performance, Liquid Glass (iOS 26+)
- 16.6k weekly installs; includes xctrace recording and analysis Python toolchain for instrument traces
- Covers lists, navigation, animations, Swift Charts, macOS, and accessibility patterns
Swiftui Expert Skill by the numbers
- 27,560 all-time installs (skills.sh)
- +762 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #4 of 1,048 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
swiftui-expert-skill capabilities & compatibility
- Use cases
- code review · debugging
- Runs
- Runs locally
- Pricing
- Free
npx skills add https://github.com/avdlee/swiftui-agent-skill --skill swiftui-expert-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27.6k |
|---|---|
| repo stars | ★ 3.3k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | avdlee/swiftui-agent-skill ↗ |
What it does
Get expert SwiftUI guidance for state management, performance, modern APIs, and iOS development workflows.
Who is it for?
iOS developers adopting modern SwiftUI APIs, teams refactoring SwiftUI code, and developers optimizing performance.
Skip if: UIKit-only codebases, Android or cross-platform Flutter work, or teams not targeting Apple platforms.
When should I use this skill?
User asks for SwiftUI best practices, code review, performance optimization, or instrument trace analysis.
What you get
Refactored SwiftUI views, state patterns, performance fixes, and Instruments trace analysis guidance.
- Refactored SwiftUI code
- Performance recommendations
- API modernization notes
By the numbers
- 16.6k weekly installs
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
Record a new Instruments trace
Trigger when the user asks to "record a trace", "profile the app", "capture a session", etc. Full reference: references/trace-recording.md.
1. Confirm target — attach to a running app, launch an app, or record all processes? If the user didn't say, ask. List connected devices when useful:
python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices2. Pick a template based on target kind — the SwiftUI template populates the SwiftUI lane on any real device: a physical iOS/iPadOS device or the host Mac. The only exception is the iOS Simulator, where the SwiftUI lane comes back empty — switch to --template "Time Profiler" in that case (still gives Time Profiler + Hangs + Animation Hitches). Always check --list-devices: simulators kind → Time Profiler; devices kind (real devices and the host Mac) → default SwiftUI. Full decision table in references/trace-recording.md. 3. Start the recording. For agent-driven sessions where the user says "I'll tell you when I'm done", start in the background and use a stop-file:
python3 "${SKILL_DIR}/scripts/record_trace.py" \
--device "<name|udid>" --attach "<AppName>" \
--stop-file /tmp/stop-trace --output ~/Desktop/session.traceFor interactive sessions, just tell the user to press Ctrl+C when done. 4. Signal stop — when the user says they've finished exercising the app, touch /tmp/stop-trace. The script cleanly SIGINTs xctrace and waits up to 60s for finalisation. 5. Analyse the resulting trace (flow into the "Trace-driven improvement" workflow below).
Trace-driven improvement (Instruments .trace provided)
Trigger whenever the user's request references a .trace file. A target SwiftUI source file is optional — if given, cite specific lines; if not, recommend where to look based on view names and symbols the trace already reveals.
Full reference: references/trace-analysis.md. Summary of the composition pattern:
1. Scope the analysis. Ask yourself: does the user want the whole trace, or a slice?
- "focus on X / after X / between X and Y / during X" → resolve to a window first (see step 2).
- No scoping cue → analyse the whole trace.
2. Resolve a window (only if the user scoped). The parser exposes two discovery modes:
# Find a log that marks the start/end of the region of interest:
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
--list-logs --log-message-contains "loaded feed" --log-limit 5
# Or list os_signpost intervals (paired begin/end), filterable by name:
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
--list-signposts --signpost-name-contains "ImageDecode"Both modes accept --window START_MS:END_MS to scope discovery. Pick the time_ms (for logs) or start_ms/end_ms (for signposts) that match the user's description. Build a window like --window 10400:11700. 3. Run the main analysis (with or without --window):
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
--json-only --top 10 [--window START_MS:END_MS]4. Interpret with `references/trace-analysis.md` — key diagnostics:
main_running_coverage_pctinside each correlation (<25% = blocked; ≥75% = CPU-bound).swiftui-causes.top_sourcesreveals why updates keep happening — high-edge-count sources likeUserDefaultObserver.send()or wideEnvironmentWriterentries are structural invalidation bugs. Fixing one often collapses many downstream hot views.
5. When a specific view shows as expensive, ask who's invalidating it. Use --fanin-for "<view name>" to get the ranked list of source nodes driving the updates. 6. Optionally ground in source. If the user pointed at a file, read it and match view names / user-code symbols against identifiers there. If not, recommend which files to open based on the view names SwiftUI reported. 7. Return a prioritised plan. Cite evidence (coverage %, hot symbol, overlapping view, log timestamp, cause-graph edges) and route each recommendation to a Topic Router reference. 8. Only edit code if the user asked for edits.
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 |
| Focus management | references/focus-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 |
| Text patterns | references/text-patterns.md |
| Localization | references/localization.md |
| Deprecated API lookup | references/latest-apis.md |
| Handling soft-deprecated APIs | references/soft-deprecation.md |
| Previews | references/previews.md |
| Instruments trace analysis | references/trace-analysis.md |
| Instruments trace recording | references/trace-recording.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.indices/\.offset; id outlives the view and isn't derived from mutable content) - [ ] Constant number of views per
ForEachelement;Listrows are unary - [ ] No closures stored in custom
@Environment/@FocusedValuekeys - [ ] Custom
@Entrydefault values are stable (noModel()/Date()/UUID()expressions) - [ ]
.animation(_:value:)always includes thevalueparameter - [ ]
@FocusStateproperties areprivate - [ ] No redundant
@FocusStatewrites inside tap gesture handlers on.focusable()views - [ ] iOS 26+ APIs gated with
#availableand fallback provided - [ ]
import Chartspresent in files using chart types - [ ] Previews use self-contained mock data; no dependency on live services or network
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/focus-patterns.md-- Focus state, focusable views, focused values, default focus, common pitfallsreferences/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 interopreferences/previews.md--#Previewmacro,@Previewable(iOS 18+), preview traits, mock data patterns for self-contained previewsreferences/text-patterns.md-- Text initializer selection, verbatim vs localizedreferences/localization.md-- String Catalogs,#bundlefor packages,LocalizedStringResource, locale-aware formatting, RTL layout, translator commentsreferences/soft-deprecation.md-- How to behave with soft-deprecated APIs (when to migrate, scoping rule, don't migrate during unrelated edits)references/trace-analysis.md-- Parse Instruments.tracefiles viascripts/analyze_trace.py; interpret main-thread coverage, high-severity SwiftUI updates, hitch narratives, and map findings back to source filesreferences/trace-recording.md-- Record a new trace viascripts/record_trace.py: attach to a running app, launch one fresh, or capture a manually-stopped session; supports stop-file for agent-driven flows
SwiftUI Accessibility Patterns Reference
Table of Contents
- Core Principle
- Dynamic Type and @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 and @ScaledMetric
System text styles scale with Dynamic Type automatically. Prefer built-in styles like .largeTitle, .title, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption, and .caption2 when they fit your UI:
VStack(alignment: .leading) {
Text("Inbox")
.font(.title2)
Text("3 unread messages")
.font(.body)
Text("Updated just now")
.font(.caption)
}For custom fonts, use a Dynamic Type-aware font initializer so the text still follows the user's preferred content size:
VStack(alignment: .leading) {
Text("Article")
.font(.custom("SourceSerif4-Semibold", size: 28, relativeTo: .title2))
Text("Body copy")
.font(.custom("SourceSerif4-Regular", size: 17))
}Font.custom(_:size:relativeTo:) lets you match a specific text style. Font.custom(_:size:) scales relative to the body style. Avoid fixed-size custom fonts for primary content that should respond to Dynamic Type.
For non-text numeric values like padding, spacing, and image sizes, 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 track a specific Dynamic Type style, including for images or icons that should stay proportional to nearby text:
struct StatusRow: View {
@ScaledMetric(relativeTo: .body) private var iconSize = 18.0
var body: some View {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: iconSize))
Text("Synced")
.font(.custom("AvenirNext-Regular", size: 17, relativeTo: .body))
}
}
}Accessibility 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 built-in text styles or Dynamic Type-aware custom fonts for text
- [ ] 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)
When to Implement animatableData Manually
Reach for an explicit animatableData (instead of the macro) when the interpolated value needs custom logic that doesn't map 1:1 to a stored property — normalization, clamping, or driving a derived value. For a deployment target of iOS 26+, use AnimatableValues; for earlier targets, use AnimatablePair.
// iOS 26+: keep phase in 0..<2π and clamp amplitude during interpolation
struct WaveShape: Shape {
var amplitude: CGFloat
var phase: CGFloat
var maxAmplitude: CGFloat
var animatableData: AnimatableValues<CGFloat, CGFloat> {
get { AnimatableValues(amplitude, phase) }
set {
amplitude = min(max(newValue.value.0, 0), maxAmplitude)
phase = newValue.value.1.truncatingRemainder(dividingBy: 2 * .pi)
}
}
func path(in rect: CGRect) -> Path { /* ... */ }
}On earlier deployment targets, the same logic uses AnimatablePair with newValue.first / newValue.second.
---
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 Focus Patterns Reference
Table of Contents
- @FocusState
- Making Views Focusable
- Focused Values for Commands and Menus
- Default Focus
- Focus Scope and Sections
- Focus Effects
- Search Focus
- Common Pitfalls
@FocusState
Always mark @FocusState as private. Use Bool for a single field, an optional Hashable enum for multiple fields.
Single field
@FocusState private var isFocused: Bool
TextField("Email", text: $email)
.focused($isFocused)Multiple fields
enum Field: Hashable { case name, email, password }
@FocusState private var focusedField: Field?
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Email", text: $email)
.focused($focusedField, equals: .email)Set focusedField = .email to move focus programmatically; set nil to dismiss the keyboard.
focused(_:) vs focused(_:equals:) with nested views
.focused($bool) reports true when the modified view or any focusable descendant has focus. .focused($enum, equals:) reports its value only when that specific view receives focus.
enum Focus: Hashable { case container, field }
@FocusState private var focus: Focus?
VStack {
TextField("Name", text: $name)
.focused($focus, equals: .field)
}
.focusable()
.focused($focus, equals: .container)With focused(_:equals:) and a single @FocusState, SwiftUI distinguishes the container receiving focus from the container merely containing focus.
isFocused environment value
Read-only environment value that returns true when the nearest focusable ancestor has focus. Useful for styling non-focusable child views.
struct HighlightWrapper: View {
@Environment(\.isFocused) private var isFocused
var body: some View {
content
.background(isFocused ? Color.accentColor.opacity(0.1) : .clear)
}
}Making Views Focusable
.focusable(_:)
Makes a non-text-input view participate in the focus system. Focused views can respond to keyboard events via onKeyPress and menu commands like Edit > Delete via onDeleteCommand.
struct SelectableCard: View {
@FocusState private var isFocused: Bool
var body: some View {
CardContent()
.focusable()
.focused($isFocused)
.border(isFocused ? Color.accentColor : .clear)
.onDeleteCommand { deleteCard() }
}
}.focusable(_:interactions:) (iOS 17+)
Controls which focus-driven interactions the view supports via FocusInteractions:
.activate-- Button-like: only focusable when system-wide keyboard navigation is on (macOS/iOS).edit-- Captures keyboard/Digital Crown input.automatic-- Platform default (both activate and edit)
MyTapGestureView(...)
.focusable(interactions: .activate)Use .activate for custom button-like views that should match system keyboard-navigation behavior.
Focused Values for Commands and Menus
Focused values let parent views (App, Scene, Commands) read state from whichever view currently has focus. Use for enabling/disabling menu commands based on the focused document or selection.
Declare with @Entry
extension FocusedValues {
@Entry var selectedDocument: Binding<Document>?
}Focused values are typically optional (default is nil when no view publishes them), but you can also use non-optional entries when you have a sensible default value.
Publish from views
// View-scoped: available when this view (or descendant) has focus
.focusedValue(\.selectedDocument, $document)
// Scene-scoped: available when this scene has focus
.focusedSceneValue(\.selectedDocument, $document)Consume in commands
@FocusedValue reads the value; @FocusedBinding unwraps a Binding automatically.
@main
struct MyApp: App {
@FocusedBinding(\.selectedDocument) var document
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(after: .pasteboard) {
Button("Duplicate") { document?.duplicate() }
.disabled(document == nil)
}
}
}
}@FocusedObject (iOS 16+)
For ObservableObject types. The view invalidates when the focused object changes.
// Publish
.focusedObject(myObservableModel)
// Consume
@FocusedObject var model: MyModel?Scene-scoped variant: .focusedSceneObject(_:).
Default Focus
.defaultFocus(_:_:priority:) (iOS 17+, macOS 13+, tvOS 16+)
Prefer .defaultFocus over setting @FocusState in onAppear for initial focus placement.
@FocusState private var focusedField: Field?
VStack {
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
}
.defaultFocus($focusedField, .email)Priority: .automatic (default) applies on window appearance and programmatic focus changes. .userInitiated also applies during user-driven focus navigation.
prefersDefaultFocus(_:in:) (macOS/tvOS/watchOS)
Used with .focusScope(_:) to mark a preferred default target within a scoped region.
resetFocus environment action (macOS/tvOS/watchOS)
Re-evaluates default focus within a namespace.
@Namespace var scopeID
@Environment(\.resetFocus) private var resetFocus
Button("Reset") { resetFocus(in: scopeID) }Focus Scope and Sections
.focusScope(_:) (macOS/tvOS/watchOS)
Limits default focus preferences to a namespace. Use with prefersDefaultFocus and resetFocus.
.focusSection() (macOS 13+, tvOS 15+)
Guides directional and sequential focus movement through a group of focusable descendants. Useful when focusable views are spatially separated and directional navigation would otherwise skip them.
HStack {
VStack { Button("1") {}; Button("2") {}; Spacer() }
Spacer()
VStack { Spacer(); Button("A") {}; Button("B") {} }
.focusSection()
}Without .focusSection(), swiping right from buttons 1/2 finds nothing. With it, the VStack receives directional focus and delivers it to its first focusable child.
Focus Effects
.focusEffectDisabled(_:)
Suppresses the system focus ring (macOS) or hover effect. Use when providing custom focus visuals.
MyCustomCard()
.focusable()
.focusEffectDisabled()
.overlay { customFocusRing }isFocusEffectEnabled environment value reads the current state.
Search Focus
.searchFocused(_:) / .searchFocused(_:equals:)
Bind focus state to the search field associated with the nearest .searchable modifier. Works like .focused but targets the search bar.
@FocusState private var isSearchFocused: Bool
NavigationStack {
ContentView()
.searchable(text: $query)
.searchFocused($isSearchFocused)
}
// Programmatically focus the search bar
Button("Search") { isSearchFocused = true }Common Pitfalls
Redundant @FocusState writes revoke focus
.focusable() + .focused() handles focus-on-click natively. Adding a tap gesture that also writes to @FocusState triggers a redundant state write, causing a second body evaluation that revokes focus. The result: focus briefly appears then disappears, and key commands like onDeleteCommand stop working.
// WRONG -- tap gesture redundantly sets focus, causing double evaluation
CardView()
.focusable()
.focused($isFocused)
.onTapGesture { isFocused = true } // Remove this line
// CORRECT -- let .focusable() + .focused() handle it
CardView()
.focusable()
.focused($isFocused)Ambiguous focus bindings
Binding the same enum case to multiple views is ambiguous. SwiftUI picks the first candidate and emits a runtime warning.
// WRONG -- .name bound to two views
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Full Name", text: $fullName)
.focused($focusedField, equals: .name) // ambiguousAlways use distinct enum cases for each focusable view.
.onAppear focus timing
Setting @FocusState in .onAppear may fail if the view tree hasn't settled. Prefer .defaultFocus (iOS 17+) for reliable initial focus. If you must use .onAppear, wrap in DispatchQueue.main.async as a last resort.
Missing .focusable() for non-text views
TextField and SecureField are implicitly focusable. Custom views (stacks, shapes, images) are not. Forgetting .focusable() means .focused() bindings have no effect and key event handlers never fire.
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.
This file lists what the modern replacements are. For how to behave when you find a soft-deprecated API — when to migrate, when to leave it alone, and the scoping rule for unrelated edits — seereferences/soft-deprecation.md. To refresh this list after a new SDK release, run the maintenance skill at.agents/skills/update-swiftui-apis/SKILL.md.
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+)
Lists and Forms
Use trailing-closure `Section` initializers instead of the positional header/footer View initializers.
The single-title form is still current and should not be treated as deprecated:
// Current - single-title LocalizedStringKey initializer
Section("Settings") {
Toggle("Notifications", isOn: .constant(true))
}
// Replacement - content/header/footer trailing-closure initializer
Section {
Toggle("Notifications", isOn: .constant(true))
} header: {
Text("Settings")
} footer: {
Text("Changes apply immediately.")
}
// Deprecated/renamed - positional header/footer View arguments
Section(header: Text("Settings"), footer: Text("Changes apply immediately.")) {
Toggle("Notifications", isOn: .constant(true))
}
Section(header: Text("Settings")) {
Toggle("Notifications", isOn: .constant(true))
}
Section(footer: Text("Changes apply immediately.")) {
Toggle("Notifications", isOn: .constant(true))
}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
Sensory Feedback
Prefer `sensoryFeedback(_:trigger:)` and related overloads instead of `UIImpactFeedbackGenerator`, `UISelectionFeedbackGenerator`, and `UINotificationFeedbackGenerator` in SwiftUI views.
Attach haptics declaratively to the view that owns the state change, rather than imperatively firing UIKit generators inside button actions.
@State private var isFavorite = false
Button("Favorite", systemImage: isFavorite ? "heart.fill" : "heart") {
isFavorite.toggle()
}
.sensoryFeedback(.selection, trigger: isFavorite)Use the conditional overload when feedback should fire only for specific transitions:
.sensoryFeedback(.selection, trigger: phase) { old, new in
old == .inactive || new == .expanded
}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+) |
Section(header:content:) | Section(content:header:) | Future-deprecated |
Section(footer:content:) | Section(content:footer:) | Future-deprecated |
Section(header:footer:content:) | Section(content:header:footer:) | Future-deprecated |
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+ |
UIImpactFeedbackGenerator / UISelectionFeedbackGenerator / UINotificationFeedbackGenerator | sensoryFeedback(_:trigger:) | 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(_ glass: Glass = .regular, in shape: some Shape = .rect, isEnabled: Bool = true)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)Glass
Available Styles
The Glass type exposes three static values — there is no .prominent:
.glassEffect(.regular) // Standard glass appearance (most common)
.glassEffect(.clear) // Nearly invisible glass surface
.glassEffect(.identity) // No-op / pass-through glassTo make a surface appear more prominent, increase the tint opacity instead of reaching for a non-existent .prominent property.
Tinting
Add color tint to the glass:
.glassEffect(.regular.tint(.blue))
.glassEffect(.regular.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
? .regular.tint(.accentColor.opacity(0.4)).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(
_ glass: Glass = .regular,
in shape: some Shape = .rect,
fallbackMaterial: Material = .ultraThinMaterial
) -> some View {
if #available(iOS 26, *) {
self.glassEffect(glass, 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
- [ ] Tint opacity used instead of non-existent
.prominentfor emphasis
SwiftUI Localization Reference
Guidance for user-facing text: Text, Button, Label, navigation/toolbar titles, alerts, and types that carry localizable strings. For the narrower "verbatim vs localized" decision on a single Text, see references/text-patterns.md.
Table of Contents
- SwiftUI Localizes String Literals Automatically
- String Catalogs
- Bundle for Swift Packages and Frameworks
- Localizing Variables and Custom Types
- LocalizedStringResource for Non-View Types
- Interpolation vs Concatenation
- Casing
- Formatting Dates, Numbers, and Currencies
- Layout for Localization
- Reading the Current Locale
- String(localized:) Outside SwiftUI Views
- Comments for Translators
SwiftUI Localizes String Literals Automatically
Initializers that accept LocalizedStringKey (Text, Button, Label, .navigationTitle, alert titles, and so on) treat string literals as localization keys automatically. Do not wrap literals in NSLocalizedString, String(localized:), or LocalizedStringResource — that resolves the string eagerly and ignores \.locale overrides.
// AVOID: double work, and resolves eagerly
Text(String(localized: "start_workout"))
// PREFER: pass the literal directly
Text("start_workout")Both opaque keys ("start_workout") and natural-language strings ("Start Workout") work as keys — pick whichever convention the project already uses. Use Text(verbatim:) only to opt a literal out of localization (e.g. a debug label interpolating a runtime value). When the argument is already a String variable, Text(value) calls the StringProtocol overload and skips localization on its own.
String Catalogs
Most projects localize through String Catalogs (.xcstrings). Each build syncs new keys from code into the catalog, but the catalog file must already exist — Xcode doesn't create one automatically. If a project already uses .strings / .stringsdict, add new strings there rather than migrating. Route groups of strings to a specific catalog with tableName:.
Text("Explore", tableName: "Navigation",
comment: "Tab bar item title for the Explore screen.")Bundle for Swift Packages and Frameworks
Apps, app extensions, and XPC services are their own main bundle, so bundle can be omitted. Frameworks and Swift packages need an explicit bundle: — without one, SwiftUI looks up strings in Bundle.main, the lookup fails silently, and the string appears unlocalized at runtime.
// AVOID (inside a framework/package): searches the app's catalog
Text("Save to Favorites")
// PREFER: #bundle resolves to the current target's bundle
Text("Save to Favorites", bundle: #bundle,
comment: "Button to bookmark a recipe.")#bundle is the preferred form; Bundle.module and Bundle(for:) still work but are older patterns.
Localizing Variables and Custom Types
A String variable passed to Text runs the StringProtocol overload and is not localized. Wrapping it in LocalizedStringKey(_:) doesn't help — Xcode can't extract a literal from a runtime value, so nothing lands in the catalog. To localize a value chosen from a known set, model it with a type that exposes LocalizedStringResource:
enum Category {
case appetizers, mains, desserts
var name: LocalizedStringResource {
switch self {
case .appetizers: "Appetizers"
case .mains: "Mains"
case .desserts: "Desserts"
}
}
}
Text(category.name)When a view or model exposes user-facing text, type the property as LocalizedStringKey or LocalizedStringResource rather than String. Every SwiftUI view that takes localized text accepts both, so deferring resolution costs nothing at the display site and preserves locale/bundle context.
LocalizedStringResource for Non-View Types
When a non-view type carries user-facing text — a model object, a tip, a queued notification — use LocalizedStringResource instead of String. It defers resolution to display time, so it honors the locale active when the value actually renders, not when it was created.
// AVOID: resolved at creation time, can't re-render in another locale
struct Tip { let headline: String }
let tip = Tip(headline: String(localized: "Tip of the Day"))
// PREFER: resolution deferred to display time
struct Tip { let headline: LocalizedStringResource }
let tip = Tip(headline: "Tip of the Day")Apply this when designing new types or changing user-facing text — don't sweep through existing String properties as part of unrelated edits.
Interpolation vs Concatenation
String interpolation preserves LocalizedStringKey and produces a format string in the catalog (e.g. "Welcome, %@"). Concatenation with + produces a plain String and is not localized. Never glue separately localized fragments into a sentence — word order varies across languages.
// AVOID: + produces String; sentence assembly breaks word order
Text("Error: " + statusMessage)
Text(String(localized: "Created by")) + Text(" ") + Text(authorName)
// PREFER: one interpolated string translators can rearrange
Text("Error: \(statusMessage)")
Text("Created by \(authorName)")Casing
Bake the desired case into the string rather than transforming at runtime via .textCase(_:), .localizedUppercase, or .localizedCapitalized. A runtime transform forces the same casing on every translation, leaving translators no room to adjust per language.
// AVOID
Text("Section Header").textCase(.uppercase)
// PREFER
Text("SECTION HEADER")This applies to localized strings; display user-entered text as-is. If a transform is unavoidable, prefer .localizedUppercase / .localizedCapitalized, which honor the user's locale.
Formatting Dates, Numbers, and Currencies
Use Text's format: parameter or .formatted() instead of DateFormatter / NumberFormatter with hardcoded format strings. Format styles adapt to the user's locale; hardcoded format strings don't.
// AVOID
let f = DateFormatter(); f.dateFormat = "MM/dd/yyyy"
Text(f.string(from: workout.date))
Text("$\(product.price, specifier: "%.2f")")
// PREFER
Text(workout.date, format: .dateTime.month().day().year())
Text(product.price, format: .currency(code: store.currencyCode))Field components (.month(), .day()) choose which fields appear; the locale decides the order. For lists, Array.formatted() inserts locale-correct separators and conjunctions instead of joined(separator:). When DateFormatter is genuinely unavoidable, use setLocalizedDateFormatFromTemplate(_:) rather than assigning dateFormat.
Layout for Localization
- Use
.leading/.trailinginstead of.left/.right— they flip for right-to-left locales. - Don't hardcode frame widths/heights for text; translations vary in length and scripts vary in height. Use
ViewThatFitswhen a layout might not fit longer translations. - Use text styles (
.body,.headline) rather than fixed point sizes, so line height adapts per script.
// PREFER
Text(recipe.title)
.frame(maxWidth: .infinity, alignment: .leading)
ViewThatFits {
HStack { actionButtons }
VStack { actionButtons }
}Reading the Current Locale
Use @Environment(\.locale) for locale-dependent logic in views, not Locale.current — the environment respects preview overrides and per-view injection.
String(localized:) Outside SwiftUI Views
When you need a localized String outside a view, use String(localized:), not NSLocalizedString. Don't interpolate inside NSLocalizedString — Xcode extracts keys from literals at build time and can't extract interpolated values. String(localized:) supports interpolation (it extracts the format string and treats values as runtime arguments) and is preferred over String(format:), which always renders digits as 0–9 regardless of locale.
// PREFER
let title = String(localized: "activity_summary", comment: "Dashboard header")Comments for Translators
Add a comment: describing the UI element and its purpose, especially for ambiguous strings. For interpolated strings, describe each placeholder by position — translators don't see Swift variable names. Comments can live at the call site or in the String Catalog's per-string Comment field; keep one source of truth per string.
// AVOID: "Edit" could be a noun or a verb
Text("Edit")
// PREFER
Text("Edit", comment: "Toolbar button that enters editing mode for the list.")
Text("Completed \(count) of \(total)",
comment: "Progress label — first variable is finished items, second is the total.")Summary Checklist
- [ ] String literals passed directly to
Text/Button/Label(not wrapped inNSLocalizedString/String(localized:)) - [ ]
bundle: #bundleon user-facing strings inside frameworks and Swift packages - [ ] User-facing text on models/non-view types typed as
LocalizedStringResource, notString - [ ] Interpolation (not
+) for dynamic strings; no sentence assembly from fragments - [ ] Case baked into the string, not applied via
.textCase - [ ] Dates/numbers/currencies use
format:/.formatted()with locale-aware styles - [ ]
.leading/.trailing(not.left/.right); no hardcoded text frame sizes - [ ]
@Environment(\.locale)for locale logic in views - [ ]
comment:provided for ambiguous strings and interpolated placeholders
"""Parsers for Xcode Instruments .trace files via xctrace export."""
Related skills
FAQ
Does this cover the latest iOS 26+ APIs?
Yes - includes guidance on Liquid Glass, latest deprecation migrations from iOS 15 through iOS 26+.
Can I get instrument trace analysis?
Yes - the skill includes Python toolchain scripts to record and analyze xctrace files for hangs, hitches, and SwiftUI update issues.
Is Swiftui Expert Skill safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.