Building Proficiency with SwiftUI

I spent years developing iOS apps with UIKit. I knew exactly when viewDidLoad fired, how Auto Layout's constraint solver worked, and which UITableViewDelegate method I needed for each situation. It was verbose, sometimes tedious, but ultimately mine to control.

Then I wanted to build a personal project in SwiftUI. I'd read enough about it to feel prepared — declarative syntax, less boilerplate, the compiler catches your mistakes. I expected a learning curve, not a wall. Instead, I found myself staring at the simulator in disbelief more times than I'd like to admit.

These are the things that actually surprised me — not the tutorial-level stuff, but the sharp edges you hit once you start building something real.

Layout isn't what you tell it; it's what you negotiate

My first instinct in SwiftUI was to reach for something like a frame. In UIKit, you either set a frame or you wire up constraints. You're the boss.

SwiftUI doesn't work that way. Layout is a conversation:

  1. The parent proposes a size to the child.
  2. The child decides its own size (and can ignore the proposal).
  3. The parent places the child wherever it wants.

That sounds fine in theory. In practice, it means a Color or a Spacer will greedily fill every pixel of available space, while a Text will hug its content. Drop them both in a VStack incorrectly and you'll spend twenty minutes wondering why your design looks completely wrong. In UIKit a UILabel doesn't suddenly eat the screen just because you put it in a stack view — in SwiftUI, "greedy" vs. "neutral" is a concept you have to internalize fast.

var body: some View {
    VStack {
        // Text is "content-hugging" — it takes only what it needs
        Text("Hello")
 
        // Color is greedy — it expands to fill all proposed space.
        // This will push Text up and consume the rest of the screen.
        Color.blue
 
        // Fix: constrain the greedy view explicitly
        Color.blue
            .frame(height: 44)
    }
}

There's another layout trap that catches UIKit developers: modifier order matters. In UIKit, setting .backgroundColor and setting a frame are independent operations. In SwiftUI, modifiers are applied in sequence — each one wraps the view before it. .padding().background(.red) gives you red behind the padding. .background(.red).padding() gives you red only behind the text, with transparent padding outside. This is not a bug; it's the model. But it will absolutely confuse you the first time your background color doesn't cover what you expected.

// Red fills the padded area (background is applied after padding)
Text("Hello")
    .padding()
    .background(.red)
 
// Red fills only behind the text (background is applied before padding)
Text("Hello")
    .background(.red)
    .padding()

View identity bit me harder than anything else

In UIKit, a view is its pointer. I hold a reference to myButton, and that button is that button until I release it. Simple.

In SwiftUI, views are value-type structs. They get recreated constantly. SwiftUI decides whether two renders represent the "same" view using two mechanisms: structural identity (where the view sits in the hierarchy) or explicit identity (the .id() modifier). This WWDC 2021 talk helped explain it further for me.

The structural identity part is where I kept tripping up. The moment I wrapped a view in an if statement, I was implicitly changing its identity. When that condition flipped, SwiftUI didn't update the view — it destroyed the old one and created a brand new one. Any @State I had tucked inside? Gone. Any animation that was mid-flight? Dead.

// Subtle trap: It looks like you're just adding a border.
// In reality, this creates two different branches in the view tree.
// Flipping 'hasError' will destroy and recreate ProfileView, resetting all @State inside.
if hasError {
    ProfileView()
        .border(.red)
} else {
    ProfileView()
}
 
// Stable identity: The hierarchy never changes, @State is preserved.
ProfileView()
    .border(hasError ? .red : .clear)

In UIKit terms, this isn't isHidden = true. It's closer to removing the subview and allocating a new one. The fix is usually to pull the condition out and use .opacity() when you just need to hide something, or to restructure so the view stays in the hierarchy. But I learned this the hard way after an animation failed silently for the third time and I finally went looking.

The same trap hides inside ForEach. If you use index-based identification, SwiftUI can't tell which row is which when the underlying array changes — it treats them all as structurally different views, resetting state and producing broken animations. Always give your models stable, unique IDs.

struct Post: Identifiable {
    let id: UUID  // stable, unique
    var title: String
}
 
// Bad: index-based identity. Reordering the array destroys and recreates every row.
ForEach(0..<posts.count, id: \.self) { index in
    PostRow(post: posts[index])
}
 
// Good: model-based identity. SwiftUI can track each row across updates.
ForEach(posts) { post in
    PostRow(post: post)
}

body runs whenever SwiftUI wants to, not whenever you expect

My UIKit mental model was: viewDidLoad runs once, layoutSubviews runs when the bounds change, and I know roughly when my code executes. There's a lifecycle, and I control where I hook into it.

SwiftUI's body property doesn't work that way. It can be invoked constantly — every frame during an animation, or because a parent view invalidated even though the data my view cares about didn't change at all.

I made the mistake early on of doing some light data processing inside body. Nothing crazy — just a computed sort on an array. It was fine at first, but as the app grew, I noticed jank in places I didn't expect. The body property needs to be a pure, cheap function of state. Side effects and expensive work belong in a view model or a task, not in the view description itself.

// Performance trap: sorting 1,000 items inside body.
// This runs on every render, including animations and unrelated parent invalidations.
var body: some View {
    List(items.sorted(by: { $0.timestamp > $1.timestamp })) { item in
        ItemRow(item: item)
    }
}
 
// Optimal: sort once, when the data actually changes.
// The view just reads a pre-computed property.
@Observable
final class FeedViewModel {
    private(set) var sortedItems: [Item] = []
 
    func load(items: [Item]) {
        sortedItems = items.sorted(by: { $0.timestamp > $1.timestamp })
    }
}
 
var body: some View {
    List(viewModel.sortedItems) { item in
        ItemRow(item: item)
    }
}

Bindings are a two-way street, but I treated them like one-way

The UIKit pattern I'd internalized: read a value, display it, fire a delegate or closure when something changes, update the model somewhere upstream. Data flows one way; you manage the connections explicitly.

@Binding broke that model for me. A child view that receives a @Binding can write back to the parent's state directly — there's no delegate, no callback, it just writes through. That's genuinely useful once you trust it, but my initial reaction was to not trust it. So I duplicated state instead: @State in the parent and @State in the child, then tried to sync them with onChange. That's fighting the framework.

The correct pattern is to own state in one place and pass bindings down. I knew this abstractly but kept hedging against it.

// Bad: duplicated state.
// The parent passes a plain String value (a copy),
// so the child's edits never propagate back up.
struct ParentView: View {
    @State private var text = ""
 
    var body: some View {
        ChildView(text: text)       // passing a value, not a binding
        Text("Parent sees: \(text)") // will never update
    }
}
 
struct ChildView: View {
    @State private var localText: String  // owns its own copy — disconnected
 
    init(text: String) { _localText = State(initialValue: text) }
 
    var body: some View {
        TextField("Type something", text: $localText)
    }
}
 
// Good: @Binding creates a shared reference to the parent's @State.
// Edits in the child are immediately visible to the parent.
struct ParentView: View {
    @State private var text = ""
 
    var body: some View {
        ChildView(text: $text)       // passing a Binding<String>
        Text("Parent sees: \(text)")  // updates live as the user types
    }
}
 
struct ChildView: View {
    @Binding var text: String  // writes go directly back to the parent's @State
 
    var body: some View {
        TextField("Type something", text: $text)
    }
}

The .animation() modifier is less scalpel than you think

I thought I understood SwiftUI animations after reading the docs. Then I added .animation(.easeInOut) to a view and watched unrelated parts of my layout start sliding around with it.

The issue is that .animation(_:) without a value: parameter was deprecated in iOS 15, and for good reason: it animates every animatable change that happens to that view during any render pass — not just the state change you had in mind. It also propagates down into the view's subtree, so any descendant that has animatable properties gets swept up too.

The fix depends on what you're doing. If the animation is triggered by a user action, use withAnimation to scope it to a specific mutation. If you want a view to animate whenever a particular piece of state changes, use the value: parameter.

// Bad: .animation() without value: animates on ANY state change.
// Toggling otherValue will also animate the Text counter.
struct CounterView: View {
    @State private var count = 0
    @State private var otherValue = false
 
    var body: some View {
        VStack {
            Text("\(count)")
                .animation(.easeInOut) // fires for count AND otherValue changes
            Toggle("Toggle", isOn: $otherValue)
        }
    }
}
 
// Good option 1: withAnimation — scoped to a specific mutation.
Button("Increment") {
    withAnimation(.easeInOut) {
        count += 1  // only this change is animated
    }
}
 
// Good option 2: value: parameter — the modifier only triggers when 'count' changes.
Text("\(count)")
    .animation(.easeInOut, value: count)

withAnimation is what I reach for when an action drives the change. The value: form is better when the change is data-driven and you want the view itself to react.

A single observable object change can re-render your entire app

This one took me a while to notice because in small apps it doesn't matter. But once I had a meaningful amount of state in an @EnvironmentObject and I started profiling, I saw views re-rendering that had no business re-rendering.

The root cause is that ObservableObject with @Published properties has coarse-grained invalidation: any @Published change fires objectWillChange, and SwiftUI re-renders every view that holds a reference to that object — regardless of which property changed or whether that view reads it.

If you can't target iOS 17 yet, the mitigation is to split large objects by concern — a UserStore, a NotificationsStore, and so on — so that views opt into only the slice of state they actually need. But if you can target iOS 17+, just switch to the @Observable macro (introduced alongside Swift 5.9). It tracks dependencies at the property level, so a view that only reads user.name won't re-render when user.unreadCount changes.

// ObservableObject: any @Published change invalidates all observers.
// A view that only shows userName will re-render when unreadCount changes.
class AppStore: ObservableObject {
    @Published var userName: String = ""
    @Published var unreadCount: Int = 0
}
 
struct HeaderView: View {
    @EnvironmentObject var store: AppStore
 
    var body: some View {
        // Re-renders whenever unreadCount changes, even though it's not used here.
        Text(store.userName)
    }
}
 
// @Observable: property-level tracking.
// HeaderView only re-renders when userName changes.
@Observable
final class AppStore {
    var userName: String = ""
    var unreadCount: Int = 0
}
 
struct HeaderView: View {
    @Environment(AppStore.self) var store
 
    var body: some View {
        // Only invalidated when userName changes. unreadCount changes are ignored.
        Text(store.userName)
    }
}

NavigationStack replaced NavigationView, and the API is completely different

NavigationView was deprecated in iOS 16, and almost every SwiftUI tutorial predating that still uses it. If you're learning from older resources, you'll eventually copy something that compiles fine but locks you out of the modern navigation model.

NavigationStack (iOS 16+) gives you two things NavigationView never had cleanly: a programmatic navigation path you can read and write, and type-safe destination declarations via .navigationDestination(for:). Coming from UIKit, the path-based model maps fairly well to how UINavigationController manages a stack of view controllers — you push and pop typed values instead of view controllers, and you can set the entire stack at once for deep links.

// Old: NavigationView. Programmatic navigation was awkward and fragile.
struct OldContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink("Go to detail", destination: DetailView(id: 42))
        }
    }
}
 
// New: NavigationStack with a typed path.
// The stack is a plain array — push by appending, pop by removing, reset by replacing.
struct ContentView: View {
    @State private var path: [Int] = []
 
    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 16) {
                Button("Go to Post 42") { path.append(42) }
                Button("Deep link to Post 7") { path = [7] }  // replaces entire stack
                Button("Go home") { path = [] }               // equivalent to popToRoot
            }
            // .navigationDestination belongs on the root content view, not a leaf element.
            .navigationDestination(for: Int.self) { postId in
                PostDetailView(postId: postId)
            }
        }
    }
}

For apps with complex navigation — tabs with independent stacks, deep links from push notifications — being able to set the path directly is genuinely better than anything UINavigationController offered through SwiftUI. But the declaration style is different enough from NavigationView that it's worth treating it as a new API rather than an upgrade.

Use .task {} instead of onAppear for async work

onAppear seems like the obvious place to kick off a network request — it's the rough equivalent of viewDidAppear. And it works, right up until you realize it fires every time the view appears, not just the first time. Navigate away and back, and your request fires again.

In UIKit you'd guard against this with a flag or by checking whether data was already loaded. In SwiftUI the answer is .task {}. It ties async work to the view's lifetime: starts when the view appears, and cancels when the view disappears. If the user navigates away mid-request, the task is cancelled automatically — no dangling completions writing to state that no longer exists.

// Fragile: onAppear fires every time the view appears.
// Navigating away and back fires a second network request.
struct PostListView: View {
    @State private var posts: [Post] = []
 
    var body: some View {
        List(posts) { post in PostRow(post: post) }
            .onAppear {
                // No built-in cancellation. No structured concurrency.
                Task { posts = await fetchPosts() }
            }
    }
}
 
// Better: .task fires on appear and cancels on disappear.
// If the user navigates away mid-flight, the request is cancelled cleanly.
struct PostListView: View {
    @State private var posts: [Post] = []
 
    var body: some View {
        List(posts) { post in PostRow(post: post) }
            .task {
                // Cancelled automatically if this view leaves the hierarchy.
                posts = await fetchPosts()
            }
    }
}
 
// .task also accepts a value: parameter, like .animation.
// Use it to re-run the task when a dependency changes (e.g., a search query).
struct SearchView: View {
    @State private var query = ""
    @State private var results: [Post] = []
 
    var body: some View {
        VStack {
            TextField("Search", text: $query)
            List(results) { post in PostRow(post: post) }
        }
        .task(id: query) {
            // Cancelled and restarted every time query changes.
            results = await search(query: query)
        }
    }
}

onAppear still has its place for synchronous work like triggering an animation or logging a screen view. But for anything async, .task is the right tool.


I'm still finding new edges in SwiftUI. What I've noticed is that most of my early confusion had the same root cause: I was looking for the UIKit equivalent of everything, and sometimes there isn't one. The layout negotiation model, the identity rules, the observable granularity — these aren't worse versions of UIKit concepts. They're different concepts, and they work together in ways that only become obvious once you stop fighting them.

The mental shift that actually helped: stop thinking about when things run and start thinking about what state drives what view. Once that clicked, most of the gotchas above started making sense as a coherent system.

The water really is fine. It's just deeper than it looks.