Nov 18, 2020

State and View Composition in SwiftUI

Views are the atomic building blocks of SwiftUI. You combine views to create different components which then add up to screens of your application.

Since you most likely want to display dynamic data, your views will have different requirements in terms of data they receive, and the logic they expose. Data flow and state management are a huge part in how views are built and structured in SwiftUI, especially when it comes to previewing and testing.

With data being passed around, the question of ownership also has to be answered: Which views should be able to manipulate state, and which views will own it?

Most commonly, you'll either deal with read-only data, defined as properties, state that should be part of a view, or data that should be modifiable but is owned by an external source of truth.

🤔 Different Types of Data Flow

👀 Read-Only Properties

Imagine a detail view that will display more in-depth information about a certain entity. Since we're dealing with a purely presentational component, no mutation needs to take place, meaning we can simply pass in all data we need as classic properties, and that's it.

SwiftUI will update our view if the properties change, but won't keep track of anything else.

import SwiftUI

struct DetailView: View {
		let description: String

    var body: some View {
			Text(description)
		}
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
          DetailView(description: "Some detailed text content")
    }
}

This is the easiest case, as we don't have to use any property wrappers, and it's clear who owns the data.

🌁 View-Internal State

import SwiftUI

struct TestView: View {
    @State var toggled: Bool = false

    var body: some View {
        Button(action: {
            toggled.toggle()
        }, label: {
            Text("Tap me!")
        }).sheet(isPresented: $toggled, content: {
            Text("Hey there!")
        })
    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}

In this second example, we're adding more interactivity by keeping track of a toggled state in our view which will conditionally display a sheet once the button is tapped.

By adding the @State property wrapper SwiftUI now knows that toggled is a source of truth it should keep track of, so whenever this value changes, our view will re-render.

✍️ Bindings for two-way data flow

import SwiftUI

struct TestView: View {
    @State var toggled: Bool = false
    var body: some View {
        VStack(spacing: 4) {
            Button(action: {
                toggled.toggle()
            }, label: {
                Text("Tap me!")
            }).sheet(isPresented: $toggled, content: {
                Text("Hey there!")
            })
            ChildView(toggled: $toggled)
        }
    }
}

struct ChildView: View {
    @Binding var toggled: Bool
    var body: some View {
        Button(action: {
            toggled.toggle()
        }, label: {
            Text("Another button")
        })
    }
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}

Now let's add another view, a child view. It will also render a button, but this time, we won't modify its own state, but the parent state, so our initial toggled value. Whenever we want to share write-access to a source of truth in SwiftUI, we'll utilize bindings.

I didn't cover it back then, but we already used a binding when we passed $toggledto the isPresented property of our sheet. Using the $ prefix operator on a state variable will retrieve a binding.

Since we're sharing write-access to our single source of truth, it doesn't matter which button we tap. Both taps will update the state value to true, which triggers the sheet to show up. Once you dismiss the sheet, it will use the binding we passed to isPresented to toggle the value once more, so we're back to false.

😴 StateObject, ObservableObject, EnvironmentObject, etc.

For more complex state or when integrating existing or external data dependencies, SwiftUI offers to use ObservableObjects, which allow SwiftUI to react to changes using publishers and the @Published property wrapper. These observable objects can be instantiated using StateObject and added to views which should consume them using ObservedObject to tell SwiftUI to start tracking the property as a dependency for your view.

To mutate data in your observable object, you can use the dollar prefix operator once again to retrieve bindings for value types.

Then you can use EnvironmentObject to pass down a piece of state through multiple layers without manually declaring properties for each stage, similar to React's concept of Context.

📚 Additional Resources

🖼 Building Testable Views

Now that we know about the different kinds of data flow, we're ready to build our views. With a growing application, especially growing state, we'll quickly see that it becomes more difficult to reason about who owns a piece of data, or how to place our view in multiple parts of our application, which might not have access to that data.

It also becomes more difficult to test views that require a lot of data, especially if this data is accessed implicitly or defined in subsequent views.

These two issues highlight a couple of important design decisions we should focus on when building our views and defining data dependencies:

  • A view should only handle the data it needs
  • A view should be explicit about the data it needs

Aligning your views with those recommendations will make them more self-contained and easier to test. You'll know which data is required, and you can be sure that the view will work once all dependencies are provided.

Luckily, we can use a couple of language features, such as protocols, to define mockable dependencies that can be customized for multiple cases, for example preview and actual usage.

We should develop our application with our views in mind, starting out by defining which data is needed based on what the view should do instead of distributing everything that's available to our views. Once we know the data requirements of our views, we can think about where the source of truth should lie and how we can then wire up our views to pass that data around.

Finally, views that can be tested and previewed quickly allow you to move faster than you think, so the velocity of building your application will increase manifold once any view can be taken aside, previewed, or debugged, and integrated into a bigger system.

Finding the perfect solution won't always be easy, but finding what works best for you is an important first step. It's also easy to get lost in endless optimizations that might not be necessary, so try to invest some time thinking about state upfront, which can save time later on.