How To: Pass Data Between Views with SwiftUI

Written by LearnAppMaking on February 18 2021 in App Development, SwiftUI

How To: Pass Data Between Views with SwiftUI

How do you pass data between views in SwiftUI? If you’ve got multiple views in your SwiftUI app, you’re gonna want to share the data from one view with the next. We’re going to discuss 4 approaches to do so in this tutorial!

Here’s what we’ll get into:

  • Passing data between views using a property
  • Working with @State and @Binding
  • Passing data via the view’s environment
  • Passing data via @ObservedObject and @StateObject

Ready? Let’s go.

  1. Passing Data between Views with a Property
  2. Pass Data between Views with @Binding
  3. Pass Data with @EnvironmentObject
  4. Pass Data with @ObservedObject and ObservableObject
  5. Further Reading

This tutorial is the SwiftUI counterpart to my original UIKit-based Pass Data Between View Controllers tutorial. Give both a try, and compare the approaches!

Passing Data between Views with a Property

The simplest approach to share data between views in SwiftUI is to pass it as a property. SwiftUI views are structs. If you add a property to a view and don’t provide an initial value, you can use the memberwise initializer to pass data into the view. Let’s take a look at an example!

Consider this simple struct called Book:

struct Book {
    var title: String
    var author: String
}

This is the view we want to pass data into:

struct BookRow: View
{
    var book: Book

    var body: some View {
        VStack {
            Text(book.title)
            Text(book.author)
        }
    }
}

See how the BookRow view has a property book of type Book? This property does not have an initial value. As a result, an initializer init(book:) is automatically created for us; it’s the memberwise initializer.

Here’s how to use it:

let books = [···]

List(books) { currentBook in
    BookRow(book: currentBook)
}

In the above code, which is part of a SwiftUI view, we’re creating a List view. This List iterates over the books array, creating a BookRow view for each Book object in the array.

Inside the closure, for the list row’s content, we have access to the “current book” in the iteration. This Book object is passed into the BookRow view, as a parameter for its initializer. That’s all there is to it!

What’s so special about this approach? Nothing, really. And that’s exactly what makes it so powerful! You may be tempted to find a clever worflow for sharing data between SwiftUI views, but the simplest approach is often the best.

Advantages:

  • It’s simple and concise!
  • Memberwise initializer is automatically created
  • Clean separation of concerns

Disadvantages:

  • View is not dependent on data; won’t automatically update
  • Data is passed one-way, there’s no flow of data back
  • In many scenarios, you’ll need to create the right initializer yourself

Pass Data between Views with @Binding

SwiftUI uses bindings to create a connection between a view or UI component, like a Toggle, and some data, like a boolean isOn. When the value of isOn changes, so does the state of Toggle (“switches on”). It also works the other way: if you flick the switch, the value of isOn changes accordingly.

Here’s an example:

struct LivingRoom: View
{
    @State private var lightsAreOn = false

    var body: some View {
        Toggle(isOn: $lightsAreOn) {
            Text("Living room lights")
        }
    }
}

Note: Just as in the previous section, the data is passed into the view using the initializer! This is the starting point for (almost) any sharing of data between views; the rest is up to property wrappers, Combine, modifiers, etcetera.

In the above code, we’ve created a view with a Toggle UI element. It has a label, “Living room lights”, and is passed a binding $lightsAreOn. This binding is the projected value for the lightsAreOn property, which is automatically added to it by the @State property wrapper.

It’s easiest to see this as a connection between the lightsAreOn property and the Toggle view. Whenever the state of one changes, so does the other. When you flick the toggle to “On”, the value of lightsAreOn becomes true, and vice-versa.

Now that the lightsAreOn property is marked with @State, the view that this code is a part of will become dependant on the state of lightsAreOn. In other words, when the value of lightsAreOn changes, the view will update to represent the new state. This is a core principle of SwiftUI; state drives the UI.

Here’s some code to demonstrate that:

@State private var lightsAreOn = false
···

VStack {
    Toggle(isOn: $lightsAreOn) {
        Text("Living room lights")
    }
    Text(lightsAreOn ? "Lights are on!" : "Lights are off")    
}

In the above code, the value of lightsAreOn is used to put some text in a Text view. Because of @State, whenever that boolean changes, the view reloads and the appropriate string is subsequently shown in the Text view.

Internally, the Toggle view has a property of type Binding<Bool>. It could look something like this:

@Binding var isOn: Bool

You don’t pass a boolean to Toggle, but you pass a binding to a boolean to Toggle. It’s the binding we get from @State, with $lightsAreOn. In other words, some data is shared between Toggle and the LivingRoom view because of the binding.

You can bind to property wrappers that expose a binding through their projected value. For example, every property marked with @State provides a binding via $property name.

You can, of course, create your own views and properties that use the @Binding property wrapper. Using @Binding to share data between views that way is quite powerful, because you and observe and change data without actually owning that data.

Bindings are available on properties marked with @ObservedObject, @StateObject, @EnvironmentObject, and more. Property wrappers like @ObservedObject don’t provide bindings to the object itself, but rather, to the properties of that object. You can find an example of binding to properties of a @ObservedObject in this tutorial.

Advantages:

  • @Binding uses data from elsewhere, which is insightful and clean
  • Many views support bindings, like Toggle or TextField
  • Plenty of property wrappers, like @State, provide a binding
  • You can create subviews, with properties with @Binding, to split up views

Disadvantages:

  • A binding is just a binding – it doesn’t do much else than that!
  • Finding the right property wrapper, and see if it has bindings, isn’t always clear from Apple’s documentation
  • The syntax for bindings to properties, like with @ObservedObject, can be confusing

Pass Data with @EnvironmentObject

So far, we’ve looked at passing a single piece of data into views (and back) with @State and @Binding, and by using properties on views. But what if you want to share the same object with multiple views? That’s where @EnvironmentObject comes in.

In short, the @EnvironmentObject property wrapper and its .environmentObject(_:) modifier enable you to insert objects into the “environment” of a view.

Think of environment as a space for data, separate to the view. This environment is shared between views and their descendants (subviews), which makes it perfect for passing an object down into a hierarchy of views.

Let’s take a look at an example:

struct DetailHeader: View
{
    @EnvironmentObject var book: Book

    var body: some View {
        VStack {
            Text(book.title)
            Text(book.author)
        }
    }
}

In the above code, we’ve created a DetailView struct. It’s got 2 simple Text views that display some data from a Book class. That object comes from a property book, which is wrapped by @EnvironmentObject.

When the DetailHeader view is shown in your app, SwiftUI will look for an object of type Book in the view’s environment and assign it to the book property.

Here’s the Book class we’re using:

class Book: ObservableObject {
    @Published var title: String
    @Published var author: String

    init(title: String, author: String) {
        self.title = title
        self.author = author
    }
}

Just as before, it’s got 2 properties title and author. But unlike before, Book is a class now. It conforms to the ObservableObject protocol, and the 2 properties are marked with @Published. In short, this means that any changes to an instance of Book will now be emitted to subscribers, such as with @ObservedObject, which subsequently updates its view.

The view hierarchy for this app is as follows:

  • BookApp struct
    • DetailView struct
      • DetailHeader struct

At the top level, we’ve got this App struct:

@main
struct BookApp: App
{
    var book = Book(title: "The Hitchhiker's Guide to the Galaxy", author: "Douglas Adams")

    var body: some Scene {
        WindowGroup {
            DetailView()
                .environmentObject(book)
        }
    }
}

See the Book object? That’s the data we’re sharing with the DetailView, and its descendants, by using the environmentObject(_:) modifier. The Book object is passed into the view, and is shared with all its subviews.

Here’s the DetailView:

struct DetailView: View
{
    var body: some View {
        VStack {
            DetailHeader()
            Text("Lorem ipsum dolor sit amet")
        }
    }
}

If you look closely, you’ll notice that the Book object is injected into the environment at the top-level. The mid-level DetailView does nothing with it, and yet the object is passed into the DetailHeader view at the low-level. How is that possible?

Sharing data between views using @EnvironmentObject takes 2 steps:

  1. Inject the object into the view hierarchy with .environmentObject(_:)
  2. Grab the object from the environment with @EnvironmentObject

Because the environment is shared between views, you can grab any object from the environment, even if it’s a few descendants down. In this example, we’re only grabbing the shared object once, but you can get the same object from the environment from any number of views.

It doesn’t matter if you “skip over” a subview; the object’s there in the environment. That makes using @EnvironmentObject to share objects between views quite useful, because you don’t have to manually pass down an object, that you want to use multiple times, into each subview.

You can only pass one object per type into the environment, so we cannot pass 2 Book objects. When you do this, the first object passed with .environmentObject(_:) will be used. If you need to pass multiple objects, consider organizing your data models better, ex. use a view model with an array.

Advantages:

  • Super convenient to share an object with multiple, separate subviews
  • You can grab the environment object when needed, i.e. “skip over” a view
  • Objects conform to ObservableObject, with all its benefits (see below)

Disadvantages:

  • Only works for reference types, i.e. the ObservableObject must be a class
  • You can only pass one environment object per type
  • It’s tempting to use @EnvironmentObject for everything, much like singletons

The @EnvironmentObject property wrapper is similar to @Environment (without “Object”) and .environment(_:_:). You use them to set objects based on the keys from EnvironmentValues. This allows you to get/set common values in the environment, such as .managedObjectContext, accessibility features, color scheme, locale, or the presentation mode.

Pass Data with @ObservedObject and ObservableObject

Last but not least! The mighty ObservableObject protocol. This approach to share data between SwiftUI is the most complex, and the most powerful.

It affects 3 different property wrappers:

  • @StateObject
  • @EnvironmentObject
  • @ObservedObject

In short, you use objects that conform to the ObservableObject protocol, in combination with the 3 property wrappers above, to observe and publish data changes to views that depend on that data. You can also bind to their properties.

Here’s an example:

struct DetailView: View
{
    @ObservedObject var book: Book

    var body: some View {
        VStack {
            Text(book.title)
            Text(book.author)
        }
    }
}

In the above code, we’ve marked the book property with the @ObservedObject property wrapper. We’re using the same Book class as before, so it conforms to ObservableObject and publishes changes for properties marked with @Published.

You pass a Book object to the DetailView as you would any other property:

var book = Book(title: "The Hitchhiker's Guide to the Galaxy", author: "Douglas Adams")
···

DetailView(book: book)

When the data for the Book object changes, the DetailView will update itself because it depends on that object. The Book object is pretty static in the above code; you use @ObservedObject for data that’s created and changed elsewhere in your code.

We’ve already touched upon the @EnvironmentObject property wrapper and the ObservableObject protocol before, and then there’s @StateObject too. They’re actually closely related to the @ObservedObject property wrapper.

A brief overview:

  • You use the ObservableObject protocol for a class, whose properties can be observed (if marked with @Publisher)
  • You use @StateObject for reference types (i.e., classes), whose objects are initialized locally in a view
  • You use @EnvironmentObject for reference types that are inserted into the environment via the .environmentObject(_:) modifier
  • You use @ObservedObject for reference types, whose objects are created externally to a view

We’ve already discussed @EnvironmentObject; which deals with the view’s environment. You use @StateObject for objects that are created locally in a view, like this:

struct NewBookView: View
{
    @StateObject var book = Book()

    var body: some View {
        VStack {
            TextField("Title", text: $book.title)
            TextField("Author", text: $book.author)
        }
    }
}

In the above code, we’re initializing a Book object locally inside the NewBookView. We’re using bindings to connect the TextField with the properties from the book object, which will set their values when the text field changes (and vice-versa).

You can still pass this book object to other views with the @ObservedObject property wrapper, but the source of truth will be the @StateObject. In that sense, the @StateObject property wrapper is a mix between @State and @ObservedObject. Awesome!

Advantages:

  • Working with ObservableObject is as custom and flexible as it gets
  • Support for different scenarios, i.e. @StateObject or @ObservedObject
  • Working with @Published is a sensible segway into using Combine
  • You can bind to properties on objects marked with @StateObject, @ObservedObject and @EnvironmentObject

Disadvantages:

  • Challenging to determine what an object’s source of truth needs to be – where’s the data coming from?
  • Easy to pick the “wrong” property wrapper, and only realize the mistake much later
  • Requires more code than the alternatives, if you’re working with a custom ObservableObject class

Looking for an in-depth guide on working with @ObservedObject? Check out this tutorial: @ObservedObject and Friends in SwiftUI

Further Reading

You could argue that, once you forget about building User Interfaces, sharing data is all that SwiftUI does. You’ve got state and UIs, and that’s it!

So it shouldn’t come as a surprise that SwiftUI has syntax and tools that make sharing data between views easier, but it isn’t always clear what approach you should use. In this tutorial, we’ve discussed 4 approaches to pass data between views.

Here they are once more:

  1. Passing data via a simple property
  2. Passing data via the environment, with @EnvironmentObject
  3. Passing data via @Binding, and how to get that binding
  4. Passing data via @ObservedObject (and alternatives)

Want to learn more? Check out these resources:

LearnAppMaking

LearnAppMaking

At LearnAppMaking.com, app developers learn how to build and launch awesome iOS apps.