Swift SwiftUI Basics Cheat Sheet
Covers SwiftUI View structs, layout stacks, state management with @State and @Binding, and common modifiers for building declarative UIs.
A Basic View
Every SwiftUI screen is a struct conforming to View.
import SwiftUIstruct ContentView: View { var body: some View { Text("Hello, SwiftUI!") .font(.title) .foregroundColor(.blue) .padding() }}#Preview { ContentView()}
Layout Stacks
VStack, HStack, and ZStack arrange child views.
struct ProfileView: View { var body: some View { VStack(spacing: 12) { // vertical stack HStack { // horizontal stack Image(systemName: "person.circle") Text("Ana") Spacer() // pushes content apart } ZStack { // overlapping stack Circle().fill(Color.gray) Text("A") } .frame(width: 50, height: 50) } .padding() }}
@State and @Binding
Local mutable state and two-way references passed to child views.
struct CounterView: View { @State private var count = 0 // local, mutable view state var body: some View { VStack { Text("Count: \(count)") Button("Increment") { count += 1 // mutating @State triggers a re-render } ChildToggle(isOn: .constant(count > 0)) } }}struct ChildToggle: View { @Binding var isOn: Bool // two-way reference to a parent's @State var body: some View { Toggle("Enabled", isOn: $isOn) }}
State Property Wrappers
SwiftUI's tools for owning and observing state.
- @State- Source of truth for simple, view-local mutable state; SwiftUI owns storage
- @Binding- A two-way reference to state owned by a parent view, passed with $
- @ObservedObject- Subscribes to an external reference type conforming to ObservableObject
- @StateObject- Like @ObservedObject, but the view owns and creates the instance's lifecycle
- @EnvironmentObject- Injects a shared ObservableObject from the view hierarchy's environment
- @Environment- Reads a value from SwiftUI's environment, e.g. \.colorScheme or \.dismiss
Lists & Modifiers
Displaying dynamic data and styling views with chained modifiers.
struct Item: Identifiable { let id = UUID() let name: String}struct ItemListView: View { let items = [Item(name: "Apple"), Item(name: "Banana")] var body: some View { NavigationStack { List(items) { item in Text(item.name) } .navigationTitle("Groceries") } }}// Common modifiers, chained left-to-right (order matters):Text("Styled") .padding() .background(Color.yellow) .cornerRadius(8) .shadow(radius: 2)
@ViewBuilder & Conditional Content
How the result-builder behind `body` assembles branching and optional views.
struct StatusView: View { let isPremium: Bool let badge: String? // @ViewBuilder lets a function return heterogeneous view trees @ViewBuilder var body: some View { if isPremium { Label("Premium", systemImage: "star.fill") } else { Text("Free tier") } // if-let inside a ViewBuilder omits the view entirely when nil if let badge { Text(badge) .font(.caption) } // switch is also supported directly in a builder context switch (isPremium, badge) { case (true, .some): Text("Premium + badge") default: EmptyView() } }}
Custom ViewModifier
Packaging a reusable chain of modifiers into a type instead of copy-pasting `.padding().background()...`.
struct CardStyle: ViewModifier { func body(content: Content) -> some View { content .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) .shadow(radius: 4) }}extension View { // Ergonomic call-site: `.cardStyle()` instead of `.modifier(CardStyle())` func cardStyle() -> some View { modifier(CardStyle()) }}struct SummaryCard: View { var body: some View { Text("Weekly Total: $412") .cardStyle() }}
PreferenceKey: Child-to-Parent Data Flow
The only sanctioned way for a descendant view to report a value (e.g. measured size) up to an ancestor.
struct HeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }}struct MeasuredRow: View { var body: some View { Text("Row content") .background( GeometryReader { proxy in Color.clear .preference(key: HeightKey.self, value: proxy.size.height) } ) }}struct ParentView: View { @State private var rowHeight: CGFloat = 0 var body: some View { MeasuredRow() .onPreferenceChange(HeightKey.self) { rowHeight = $0 } }}
Layout Protocol (iOS 16+)
Writing a fully custom container layout without GeometryReader's implicit-frame pitfalls.
struct RadialLayout: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let radius = min(bounds.width, bounds.height) / 2 let angleStep = subviews.isEmpty ? 0 : .pi * 2 / Double(subviews.count) for (index, subview) in subviews.enumerated() { let angle = angleStep * Double(index) - .pi / 2 let point = CGPoint( x: bounds.midX + radius * cos(angle), y: bounds.midY + radius * sin(angle) ) subview.place(at: point, anchor: .center, proposal: .unspecified) } }}
View Identity & Lifecycle Gotchas
Subtle rules that determine whether SwiftUI reuses or destroys a view's state.
- Structural identity- Two views at the same position in the view tree with the same type are treated as the same view across re-renders; @State survives
- Explicit .id(_:)- Forces SwiftUI to treat a view as brand new when the id changes, resetting all its @State
- AnyView erasure cost- Wrapping in AnyView hides the underlying type from the diffing algorithm, disabling most identity-based optimizations
- ForEach id stability- Using array index as id breaks state when items are inserted/removed/reordered; prefer a stable Identifiable id
- Equatable views- Conforming a view to Equatable and using .equatable() lets SwiftUI skip re-diffing when the value hasn't changed
- onChange vs onReceive- onChange(of:) fires synchronously on value equality change; onReceive subscribes to a Combine Publisher and can fire off the main run loop turn
- task(id:) modifier- Cancels and restarts an async task automatically whenever the given id value changes, replacing manual onAppear/onDisappear task juggling
Modifier order matters — `.padding().background(.yellow)` pads first then colors the padded area, while `.background(.yellow).padding()` colors only the original content and adds transparent padding around it. Always read modifier chains top-to-bottom as the actual render order.