Previewing Stateful SwiftUI Views

Interactive Previews for your SwiftUI views


Dec 21, 2022 • 6 min readSource Code

When building UIs in SwiftUI, we tend to build two kinds of UI components: screens and (reusable) views. Usually, we start by prototyping a screen, which will inevitably result in a Massive ContentView that we then start refactoring into smaller, reusable components.

Let’s assume we’re building a todo list application. Here is how it might look like:

struct TodoListView: View {
  @State var todos = [Todo]()
 
  var body: some View {
    NavigationStack {
      List($todos) { $todo in
        Toggle(isOn: $todo.completed) {
          Text(todo.title)
            .strikethrough(todo.completed)
        }
      }
      .listStyle(.plain)
      .navigationTitle("My tasks")
    }
  }
}

I’ve simplified this, but you can imagine that the code for a todo list application like Apple’s Reminders app looks a bit more complicated (in fact, if you’re curious, check out MakeItSo, a sample project I created to replicate the Reminders app using SwiftUI and Firebase).

One way to simplify this code is to refactor the List view and extract the row into a separate reusable component:

struct TodoRowView: View {
  @Binding var todo: Todo
  
  var body: some View {
    Toggle(isOn: $todo.completed) {
      Text(todo.title)
        .strikethrough(todo.completed)
    }
  }
}

Since TodoRowView is now a child of the List view, we want TodoListView to be the owner of the data. To make sure any changes the user makes (by clicking on the toggle inside the TodoRowView get reflected on the List (and vice versa), we need to set up a bi-directional data binding. The right property wrapper for this job is @Binding - it lets us connect to data that is owned by another view.

The Problem

However, when trying to set up the PreviewProvider for this view, we quickly run into some limitations: how can we set up the preview with some demo data so that it can be edited inside the TodoRowView?

We might start by creating a static variable on the PreviewProvider, and then pass it to the view we want to preview. However, this will inevitably result in a compiler error, as TodoRowView expects todo to be a Binding<Todo> :

struct TodoRowView_Previews: PreviewProvider {
  static var todo = Todo(title: "Draft article", completed: false)
 
  static var previews: some View {
    TodoRowView(todo: todo)
    // ^ error: "Cannot convert value of type 'Todo' to expected argument type 'Binding<Todo>'"
  }
}

Marking the static todo variable as @State resolves the compiler error, but it doesn’t result in an interactive preview:

struct TodoRowView_Previews: PreviewProvider {
  @State static var todo = Todo(title: "Draft article", completed: false)
 
  static var previews: some View {
    TodoRowView(todo: $todo)
  }
}

The go-to solution

The usual way to solve this is to use a constant binding. Apple provides us with a static function on Binding that makes this straightforward:

struct TodoRowView_Previews_withConstantBinding: PreviewProvider {
  static var previews: some View {
    TodoRowView(todo: .constant(Todo.sampple))
  }
}

As the documentation states, this creates a binding with an immutable value, which prevents the underlying property from being updated, so our implementation might seem broken when we run it in the preview pane.

Using a custom binding

Another strategy for dealing with this situation is to use a custom binding. Here is a static function mock that stores a value in a local variable and provides read/write access to it.

extension Binding {
  static func mock(_ value: Value) -> Self {
    var value = value
    return Binding(get: { value },
                   set: { value = $0 })
  }
}

The nice thing about this technique is that is allows us to replace any calls to .constant with a call to .mock:

struct TodoRowView_Previews_withMockBinding: PreviewProvider {
  static var previews: some View {
    TodoRowView(todo: .mock(Todo.sampple))
  }
}

However, this solution only works partially. While it is now possible to flip the toggle, the rest of the UI doesn’t update: when a todo is marked as complete, its title should be striked through, but as you can see in the animation, this doesn’t work.

The Real Solution

It helps to remind ourselves that we have full control over the preview - for example, adding .preferredColorScheme(.dark) to a view inside the PreviewProvider will turn on dark mode.

With this in mind, the solution becomes obvious: wrap the view inside a container view, and hold the state in this container view:

struct TodoRowView_Previews_Container: PreviewProvider {
  struct Container: View {
    @State var todo = Todo.sampple
    var body: some View {
      TodoRowView(todo: $todo)
    }
  }
  
  static var previews: some View {
    Container()
  }
}

This is the solution recommended by Apple in their WWDC 2020 session Structure your app for SwiftUI previews (see this timestamp). Since the container view owns the data via the @State property wrapper, this approach gives us a SwiftUI preview that is fully interactive and responds to any changes of the view’s state.

One more thing

We can even make this easier by creating a generic container view that passes a binding to a value to its contained view:

struct StatefulPreviewContainer<Value, Content: View>: View {
  @State var value: Value
  var content: (Binding<Value>) -> Content
  
  var body: some View {
    content($value)
  }
  
  init(_ value: Value, content: @escaping (Binding<Value>) -> Content) {
    self._value = State(wrappedValue: value)
    self.content = content
  }
}

This allows us to implement a stateful preview with just a few lines of code:

struct TodoRowView_Previews_withGenericWrapper: PreviewProvider {
  static var previews: some View {
    StatefulPreviewContainer(Todo.sampple) { binding in
      TodoRowView(todo: binding)
    }
  }
}

Here is a gist with the code for the StatefulPreviewContainer, including a sample for how to use it.

Conclusion

SwiftUI previews are a great tool for developing SwiftUI views, and when they work, they provide an amazing developer experience: no need to re-run the application for every little change you make. This significantly reduces turn-around times, and results in a much more efficient workflow.

It feels a bit like an oversight that Apple didn’t provide an easy way to preview views that connect with their host views view @Binding, but fortunately, there are some ways around this shortcoming.

In this article, I showed you a couple of approaches that you can use when your SwiftUI views make use of @Binding to communicate with the outside world. Personally, I like the preview container view the most, and you can even make this more efficient by defining an Xcode code snippet.

Source code
You can find the source code for this post in this gist.
Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈