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.
- Passing Data between Views with a Property
- Pass Data between Views with @Binding
- Pass Data with @EnvironmentObject
- Pass Data with @ObservedObject and ObservableObject
- 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
orTextField
- 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:
- Inject the object into the view hierarchy with
.environmentObject(_:)
- 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:
- Passing data via a simple property
- Passing data via the environment, with
@EnvironmentObject
- Passing data via
@Binding
, and how to get that binding - Passing data via
@ObservedObject
(and alternatives)
Want to learn more? Check out these resources: