SwiftUI Guides
12 guides for writing modern, production-quality SwiftUI code.
SwiftUI Animations Reference
Comprehensive guide to SwiftUI animations: basics, transitions, keyframes, phase animators, and custom Animatable conformance.
Core Concepts
State changes trigger view updates. SwiftUI provides mechanisms to animate these changes.
Animation Process:
- State change triggers view tree re-evaluation
- SwiftUI compares new tree to current render tree
- 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()
}
}
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
// 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
| Curve | Use Case |
|---|---|
.spring |
Interactive elements, most UI |
.easeInOut |
Appearance changes |
.bouncy |
Playful feedback (iOS 17+) |
.linear |
Progress indicators only |
.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)
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
// BAD - layout changes are expensive
Rectangle()
.frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive
Narrow Animation Scope
// GOOD - animation scoped to specific subview
VStack {
HeaderView() // Not affected
ExpandableContent(isExpanded: isExpanded)
.animation(.spring, value: isExpanded) // Only this
FooterView() // Not affected
}
Avoid Animation in Hot Paths
// GOOD - gate by threshold
.onPreferenceChange(ScrollOffsetKey.self) { offset in
let shouldShow = offset.y < -50
if shouldShow != showTitle {
withAnimation(.easeOut(duration: 0.2)) {
showTitle = shouldShow
}
}
}
Disabling Animations
// GOOD - disable with transaction
Text("Count: \(count)")
.transaction { $0.animation = nil }
// GOOD - disable from parent context
DataView()
.transaction { $0.disablesAnimations = true }
Transitions
Transitions animate views being inserted or removed from the render tree.
Critical: Transitions Require Animation Context
// GOOD - animation outside conditional
VStack {
Button("Toggle") { showDetail.toggle() }
if showDetail {
DetailView()
.transition(.slide)
}
}
.animation(.spring, value: showDetail)
// BAD - animation inside conditional (removed with view!)
if showDetail {
DetailView()
.transition(.slide)
.animation(.spring, value: showDetail) // Won't work on removal!
}
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
.transition(.slide.combined(with: .opacity))
Asymmetric Transitions
// GOOD - different animations for insert/remove
if showCard {
CardView()
.transition(
.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
)
)
}
Custom Transitions (iOS 17+)
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)
}
}
The Animatable Protocol
Enables custom property interpolation during animations.
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)
}
}
Multiple Properties with AnimatablePair
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))
}
}
Transactions
The underlying mechanism for all animations in SwiftUI.
// withAnimation is shorthand for withTransaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }
Implicit animations override explicit animations (later in view tree wins).
Phase Animations (iOS 17+)
Cycle through discrete phases automatically.
// 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)
}
Enum Phases (Recommended)
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)
}
Keyframe Animations (iOS 17+)
Precise timing control with exact values at specific times.
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 Type | Behavior |
|---|---|
CubicKeyframe |
Smooth interpolation |
LinearKeyframe |
Straight-line interpolation |
SpringKeyframe |
Spring physics |
MoveKeyframe |
Instant jump (no interpolation) |
Animation Completion (iOS 17+)
Button("Animate") {
withAnimation(.spring) {
isExpanded.toggle()
} completion: {
showNextStep = true
}
}
SwiftUI Forms & Input Reference
Form Basics
struct SettingsView: View {
@State private var username = ""
@State private var notificationsEnabled = true
@State private var selectedColor = Color.blue
var body: some View {
Form {
Section("Profile") {
TextField("Username", text: $username)
ColorPicker("Accent Color", selection: $selectedColor)
}
Section("Preferences") {
Toggle("Notifications", isOn: $notificationsEnabled)
}
}
}
}
TextField Patterns
Styled TextField
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password)
.textContentType(.password)
TextField with Validation
@State private var email = ""
TextField("Email", text: $email)
.onChange(of: email) { _, newValue in
isEmailValid = newValue.contains("@")
}
.overlay(alignment: .trailing) {
if !email.isEmpty {
Image(systemName: isEmailValid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(isEmailValid ? .green : .red)
}
}
Picker Patterns
Segmented Picker
@State private var selectedTab = 0
Picker("View", selection: $selectedTab) {
Text("List").tag(0)
Text("Grid").tag(1)
}
.pickerStyle(.segmented)
Menu Picker
Picker("Sort By", selection: $sortOrder) {
Text("Name").tag(SortOrder.name)
Text("Date").tag(SortOrder.date)
Text("Size").tag(SortOrder.size)
}
DatePicker
DatePicker("Due Date", selection: $dueDate, displayedComponents: [.date])
.datePickerStyle(.compact)
Stepper and Slider
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)
Slider(value: $volume, in: 0...100) {
Text("Volume")
} minimumValueLabel: {
Image(systemName: "speaker")
} maximumValueLabel: {
Image(systemName: "speaker.wave.3")
}
Form Submission
struct CreateItemView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var isSubmitting = false
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
}
.navigationTitle("New Item")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Task { await submit() }
}
.disabled(name.isEmpty || isSubmitting)
}
}
}
}
private func submit() async {
isSubmitting = true
// Save logic
dismiss()
}
}
SwiftUI Layout & View Structure Reference
Comprehensive guide to stack layouts, view composition, subview extraction, and layout best practices.
Relative Layout Over Constants
// 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()
}
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
Image(user.avatar)
.frame(width: UIScreen.main.bounds.width) // Wrong!
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()
}
}
}
View Structure Principles
SwiftUI's diffing algorithm compares view hierarchies to determine what needs updating.
Prefer Modifiers Over Conditional Views
// Good - same view, different states
SomeView()
.opacity(isVisible ? 1 : 0)
// Avoid - creates/destroys view identity
if isVisible {
SomeView()
}
Use conditionals when you truly have different views:
// Correct - fundamentally different views
if isLoggedIn {
DashboardView()
} else {
LoginView()
}
Extract Subviews, Not Computed Properties
The Problem with @ViewBuilder Functions
// BAD - re-executes complexSection() on every tap
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Tap: \(count)") { count += 1 }
complexSection() // Re-executes every tap!
}
}
@ViewBuilder
func complexSection() -> some View {
ForEach(0..<100) { i in
HStack {
Image(systemName: "star")
Text("Item \(i)")
}
}
}
}
The Solution: Separate Structs
// GOOD - ComplexSection body SKIPPED when its inputs don't change
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Tap: \(count)") { count += 1 }
ComplexSection() // Body skipped during re-evaluation
}
}
}
struct ComplexSection: View {
var body: some View {
ForEach(0..<100) { i in
HStack {
Image(systemName: "star")
Text("Item \(i)")
}
}
}
}
Container View Pattern
// BAD - closure prevents SwiftUI from skipping updates
struct MyContainer<Content: View>: View {
let content: () -> Content
var body: some View {
VStack { Text("Header"); content() }
}
}
// GOOD - view can be compared
struct MyContainer<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack { Text("Header"); content }
}
}
ZStack vs overlay/background
Use ZStack to compose multiple peer views that should be layered together.
Prefer overlay / background when decorating a primary view.
// GOOD - decoration in overlay
Button("Continue") { }
.overlay(alignment: .trailing) {
Image(systemName: "lock.fill")
.padding(.trailing, 8)
}
// GOOD - background shape takes parent size
HStack(spacing: 12) {
Image(systemName: "tray")
Text("Inbox")
}
.background {
Capsule()
.strokeBorder(.blue, lineWidth: 2)
}
Layout Performance
Avoid Layout Thrash
// Bad - deep nesting, excessive layout passes
VStack { HStack { VStack { HStack { Text("Deep") } } } }
// Good - flatter hierarchy
VStack { Text("Shallow"); Text("Structure") }
Minimize GeometryReader (use iOS 17+ alternatives)
// Good - single geometry reader or containerRelativeFrame
containerRelativeFrame(.horizontal) { width, _ in
width * 0.8
}
Gate Frequent Geometry Updates
// Good - gate by threshold
.onPreferenceChange(ViewSizeKey.self) { size in
let difference = abs(size.width - currentSize.width)
if difference > 10 { currentSize = size }
}
View Logic and Testability
// Good - logic in testable model (iOS 17+)
@Observable
@MainActor
final class LoginViewModel {
var email = ""
var password = ""
var isValid: Bool {
!email.isEmpty && password.count >= 8
}
func login() async throws { }
}
struct LoginView: View {
@State private var viewModel = LoginViewModel()
var body: some View {
Form {
TextField("Email", text: $viewModel.email)
SecureField("Password", text: $viewModel.password)
Button("Login") {
Task { try? await viewModel.login() }
}
.disabled(!viewModel.isValid)
}
}
}
Action Handlers
// Good - action references method
struct PublishView: View {
@State private var viewModel = PublishViewModel()
var body: some View {
Button("Publish Project", action: viewModel.handlePublish)
}
}
SwiftUI Liquid Glass Reference (iOS 26+)
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.
Availability
All Liquid Glass APIs require iOS 26 or later. Always provide fallbacks:
if #available(iOS 26, *) {
// Liquid Glass implementation
} else {
// Fallback using materials
}
Core APIs
glassEffect Modifier
The primary modifier for applying glass effects to views:
.glassEffect(_ style: GlassEffectStyle = .regular, in shape: some Shape = .rect)
Basic Usage
Text("Hello")
.padding()
.glassEffect() // Default regular style, rect shape
With Shape
Text("Rounded Glass")
.padding()
.glassEffect(in: .rect(cornerRadius: 16))
Image(systemName: "star")
.padding()
.glassEffect(in: .circle)
Text("Capsule")
.padding(.horizontal, 20)
.padding(.vertical, 10)
.glassEffect(in: .capsule)
GlassEffectStyle
Prominence Levels
.glassEffect(.regular) // Standard glass appearance
.glassEffect(.prominent) // More visible, higher contrast
Tinting
Add color tint to the glass:
.glassEffect(.regular.tint(.blue))
.glassEffect(.prominent.tint(.red.opacity(0.3)))
Interactivity
Make glass respond to touch/pointer hover:
// Interactive glass - responds to user interaction
.glassEffect(.regular.interactive())
// Combined with tint
.glassEffect(.regular.tint(.blue).interactive())
Important: Only use .interactive() on elements that actually respond to user input (buttons, tappable views, focusable elements).
GlassEffectContainer
Wraps multiple glass elements for proper visual grouping and spacing:
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.
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
- Both views must have the same
glassEffectID - Use the same
@Namespace - Wrap in
GlassEffectContainer - Apply animation to the container or parent
Modifier Order
Critical: Apply glassEffect after layout and visual modifiers:
// CORRECT order
Text("Label")
.font(.headline) // 1. Typography
.foregroundStyle(.primary) // 2. Color
.padding() // 3. Layout
.glassEffect() // 4. Glass effect LAST
// WRONG order - glass applied too early
Text("Label")
.glassEffect() // Wrong position
.padding()
.font(.headline)
Complete Examples
Toolbar with Glass Buttons
struct GlassToolbar: View {
var body: some View {
if #available(iOS 26, *) {
GlassEffectContainer(spacing: 16) {
HStack(spacing: 16) {
ToolbarButton(icon: "pencil", action: { })
ToolbarButton(icon: "eraser", action: { })
ToolbarButton(icon: "scissors", action: { })
Spacer()
ToolbarButton(icon: "square.and.arrow.up", action: { })
}
.padding(.horizontal)
}
} else {
// Fallback toolbar
HStack(spacing: 16) {
// ... fallback implementation
}
}
}
}
struct ToolbarButton: View {
let icon: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.title2)
.frame(width: 44, height: 44)
}
.glassEffect(.regular.interactive(), in: .circle)
}
}
Card with Glass Effect
struct GlassCard: View {
let title: String
let subtitle: String
var body: some View {
if #available(iOS 26, *) {
cardContent
.glassEffect(.regular, in: .rect(cornerRadius: 20))
} else {
cardContent
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
private var cardContent: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Segmented Control
struct GlassSegmentedControl: View {
@Binding var selection: Int
let options: [String]
@Namespace private var animation
var body: some View {
if #available(iOS 26, *) {
GlassEffectContainer(spacing: 4) {
HStack(spacing: 4) {
ForEach(options.indices, id: \.self) { index in
Button(options[index]) {
withAnimation(.smooth) {
selection = index
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.glassEffect(
selection == index ? .prominent.interactive() : .regular.interactive(),
in: .capsule
)
.glassEffectID(selection == index ? "selected" : "option\(index)", in: animation)
}
}
.padding(4)
}
} else {
Picker("Options", selection: $selection) {
ForEach(options.indices, id: \.self) { index in
Text(options[index]).tag(index)
}
}
.pickerStyle(.segmented)
}
}
}
Fallback Strategies
Using Materials
if #available(iOS 26, *) {
content.glassEffect()
} else {
content.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
Available Materials for Fallback
.ultraThinMaterial- Closest to glass appearance.thinMaterial- Slightly more opaque.regularMaterial- Standard blur.thickMaterial- More opaque.ultraThickMaterial- Most opaque
Conditional Modifier Extension
extension View {
@ViewBuilder
func glassEffectWithFallback(
_ style: GlassEffectStyle = .regular,
in shape: some Shape = .rect,
fallbackMaterial: Material = .ultraThinMaterial
) -> some View {
if #available(iOS 26, *) {
self.glassEffect(style, in: shape)
} else {
self.background(fallbackMaterial, in: shape)
}
}
}
Best Practices
Do
- Use
GlassEffectContainerfor grouped glass elements - 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
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
Checklist
-
#available(iOS 26, *)with fallback -
GlassEffectContainerwraps grouped elements -
.glassEffect()applied after layout modifiers -
.interactive()only on user-interactable elements -
glassEffectIDwith@Namespacefor morphing - Consistent shapes and spacing across feature
- Container spacing matches layout spacing
- Appropriate prominence levels used
SwiftUI List Patterns Reference
ForEach Identity and Stability
Always provide stable identity for ForEach. Never use .indices for dynamic content.
// Good - stable identity via Identifiable
extension User: Identifiable {
var id: String { userId }
}
ForEach(users) { user in
UserRow(user: user)
}
// Good - stable identity via keypath
ForEach(users, id: \.userId) { user in
UserRow(user: user)
}
// Wrong - indices create static content
ForEach(users.indices, id: \.self) { index in
UserRow(user: users[index]) // Can crash on removal!
}
// Wrong - unstable identity
ForEach(users, id: \.self) { user in
UserRow(user: user) // Only works if User is Hashable and stable
}
Critical: Ensure constant number of views per element in ForEach:
// Good - consistent view count
ForEach(items) { item in
ItemRow(item: item)
}
// Bad - variable view count breaks identity
ForEach(items) { item in
if item.isSpecial {
SpecialRow(item: item)
DetailRow(item: item)
} else {
RegularRow(item: item)
}
}
Avoid inline filtering:
// Bad - unstable identity, changes on every update
ForEach(items.filter { $0.isEnabled }) { item in
ItemRow(item: item)
}
// Good - prefilter and cache
@State private var enabledItems: [Item] = []
var body: some View {
ForEach(enabledItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { _, newItems in
enabledItems = newItems.filter { $0.isEnabled }
}
}
Avoid AnyView in list rows:
// Bad - hides identity, increases cost
ForEach(items) { item in
AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))
}
// Good - Create a unified row view
ForEach(items) { item in
ItemRow(item: item)
}
struct ItemRow: View {
let item: Item
var body: some View {
if item.isSpecial {
SpecialRow(item: item)
} else {
RegularRow(item: item)
}
}
}
Why: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.
Enumerated Sequences
Always convert enumerated sequences to arrays. To be able to use them in a ForEach.
let items = ["A", "B", "C"]
// Correct
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text("\(index): \(item)")
}
// Wrong - Doesn't compile, enumerated() isn't an array
ForEach(items.enumerated(), id: \.offset) { index, item in
Text("\(index): \(item)")
}
List with Custom Styling
// Remove default background and separators
List(items) { item in
ItemRow(item: item)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.customBackground)
.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights
List with Pull-to-Refresh
List(items) { item in
ItemRow(item: item)
}
.refreshable {
await loadItems()
}
Summary Checklist
- ForEach uses stable identity (never
.indicesfor dynamic content) - Constant number of views per ForEach element
- No inline filtering in ForEach (prefilter and cache instead)
- No
AnyViewin list rows - Don't convert enumerated sequences to arrays
- Use
.refreshablefor pull-to-refresh - Custom list styling uses appropriate modifiers
SwiftUI Media Reference
PhotosPicker
import PhotosUI
struct PhotoPickerView: View {
@State private var selectedItem: PhotosPickerItem?
@State private var selectedImage: Image?
var body: some View {
VStack {
if let selectedImage {
selectedImage
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 300)
}
PhotosPicker("Select Photo", selection: $selectedItem, matching: .images)
}
.onChange(of: selectedItem) { _, newItem in
Task {
if let data = try? await newItem?.loadTransferable(type: Data.self),
let uiImage = UIImage(data: data) {
selectedImage = Image(uiImage: uiImage)
}
}
}
}
}
Multiple Photo Selection
@State private var selectedItems: [PhotosPickerItem] = []
PhotosPicker("Select Photos", selection: $selectedItems, maxSelectionCount: 5, matching: .images)
MapKit Integration
import MapKit
struct MapView: View {
@State private var position: MapCameraPosition = .automatic
let annotations: [Location]
var body: some View {
Map(position: $position) {
ForEach(annotations) { location in
Marker(location.name, coordinate: location.coordinate)
}
}
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
}
}
Map with Custom Annotations
Map(position: $position) {
ForEach(places) { place in
Annotation(place.name, coordinate: place.coordinate) {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red)
.font(.title)
}
}
}
Location Services
import CoreLocation
@Observable
@MainActor
final class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var location: CLLocation?
var authorizationStatus: CLAuthorizationStatus = .notDetermined
override init() {
super.init()
manager.delegate = self
}
func requestPermission() {
manager.requestWhenInUseAuthorization()
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
location = locations.last
}
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
authorizationStatus = manager.authorizationStatus
}
}
}
Camera Access
struct CameraView: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraView
init(_ parent: CameraView) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
parent.image = info[.originalImage] as? UIImage
parent.dismiss()
}
}
}
Modern SwiftUI APIs Reference
Overview
This reference covers modern SwiftUI API usage patterns and deprecated API replacements. Always use the latest APIs to ensure forward compatibility and access to new features.
Styling and Appearance
foregroundStyle() vs foregroundColor()
Always use foregroundStyle() instead of foregroundColor().
// Modern (Correct)
Text("Hello")
.foregroundStyle(.primary)
Image(systemName: "star")
.foregroundStyle(.blue)
// Legacy (Avoid)
Text("Hello")
.foregroundColor(.primary)
Why: foregroundStyle() supports hierarchical styles, gradients, and materials, making it more flexible and future-proof.
clipShape() vs cornerRadius()
Always use clipShape(.rect(cornerRadius:)) instead of cornerRadius().
// Modern (Correct)
Image("photo")
.clipShape(.rect(cornerRadius: 12))
VStack {
// content
}
.clipShape(.rect(cornerRadius: 16))
// Legacy (Avoid)
Image("photo")
.cornerRadius(12)
Why: cornerRadius() is deprecated. clipShape() is more explicit and supports all shape types.
fontWeight() vs bold()
Don't apply fontWeight() unless there's a good reason. Always use bold() for bold text.
// Correct
Text("Important")
.bold()
// Avoid (unless you need a specific weight)
Text("Important")
.fontWeight(.bold)
// Acceptable (specific weight needed)
Text("Semibold")
.fontWeight(.semibold)
Navigation
NavigationStack vs NavigationView
Always use NavigationStack instead of NavigationView.
// Modern (Correct)
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
// Legacy (Avoid)
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
navigationDestination(for:)
Use navigationDestination(for:) for type-safe navigation.
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Profile", value: Route.profile)
NavigationLink("Settings", value: Route.settings)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .profile:
ProfileView()
case .settings:
SettingsView()
}
}
}
}
}
enum Route: Hashable {
case profile
case settings
}
Tabs
Tab API vs tabItem()
For iOS 18 and later, prefer the Tab API over tabItem() to access modern tab features, and use availability checks or tabItem() for earlier OS versions.
// Modern (Correct) - iOS 18+
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass") {
SearchView()
}
Tab("Profile", systemImage: "person") {
ProfileView()
}
}
// Legacy (Avoid)
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
}
Important: When using Tab(role:) with roles, you must use the new Tab { } label: { } syntax for all tabs. Mixing with .tabItem() causes compilation errors.
// Correct - all tabs use Tab syntax
TabView {
Tab(role: .search) {
SearchView()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
Tab {
HomeView()
} label: {
Label("Home", systemImage: "house")
}
}
// Wrong - mixing Tab and tabItem causes errors
TabView {
Tab(role: .search) {
SearchView()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
HomeView() // Error: can't mix with Tab(role:)
.tabItem {
Label("Home", systemImage: "house")
}
}
Interactions
Button vs onTapGesture()
Never use onTapGesture() unless you specifically need tap location or tap count. Always use Button otherwise.
// Correct - standard tap action
Button("Tap me") {
performAction()
}
// Correct - need tap location
Text("Tap anywhere")
.onTapGesture { location in
handleTap(at: location)
}
// Correct - need tap count
Image("photo")
.onTapGesture(count: 2) {
handleDoubleTap()
}
// Wrong - use Button instead
Text("Tap me")
.onTapGesture {
performAction()
}
Why: Button provides proper accessibility, visual feedback, and semantic meaning. Use onTapGesture() only when you need its specific features.
Button with Images
Always specify text alongside images in buttons for accessibility.
// Correct - includes text label
Button("Add Item", systemImage: "plus") {
addItem()
}
// Also correct - custom label
Button {
addItem()
} label: {
Label("Add Item", systemImage: "plus")
}
// Wrong - image only, no text
Button {
addItem()
} label: {
Image(systemName: "plus")
}
Layout and Sizing
Avoid UIScreen.main.bounds
Never use UIScreen.main.bounds to read available space.
// Wrong - uses UIKit, doesn't respect safe areas
let screenWidth = UIScreen.main.bounds.width
// Correct - use GeometryReader
GeometryReader { geometry in
Text("Width: \(geometry.size.width)")
}
// Better - use containerRelativeFrame (iOS 17+)
Text("Full wid
…(truncated)