Back to blog

How To: Pass Data Between Views with SwiftUI


Aasif Khan
By Aasif Khan | Last Updated on December 10th, 2023 10:03 am | 5-min read

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 app development 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

Describe your app idea
and AI will build your App

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.

Passing Complex Data Structures

While the example provided uses a simple Book struct, SwiftUI’s data-passing capabilities aren’t limited to such basic structures. Consider a scenario where you have a more complex data structure, like a Library that contains multiple Book instances.

struct Library {
var name: String
var books: [Book]
}

To pass an entire Library instance to a view, the process remains largely the same.

struct LibraryView: View {
var library: Library

var body: some View {
VStack {
Text(library.name)
List(library.books) { book in
BookRow(book: book)
}
}
}
}

In this example, the LibraryView takes in a Library instance and displays its name. It then uses a List to iterate over the books array and display each Book using the previously defined BookRow view.

Combining Data with UI Logic

When passing data between views, it’s not just about displaying data. Often, you’ll want to combine data with UI logic. For instance, if a Book has a property isRead, you might want to display the book title in a different color based on its value.

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

struct BookRow: View {
var book: Book

var body: some View {
VStack {
Text(book.title)
.foregroundColor(book.isRead ? .gray : .black)
Text(book.author)
}
}
}

In the above example, the title of the book is displayed in gray if it has been read (isRead is true) and in black otherwise. This showcases how data passed to a view can directly influence the UI presentation.

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. 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.

Two-way Data Binding with Custom Components

While the provided examples showcase basic bindings with built-in SwiftUI components, the power of @Binding truly shines when you create custom components that require two-way data binding.

Imagine you’ve created a custom slider component to select a book’s rating.

struct RatingSlider: View {
@Binding var rating: Int

var body: some View {
Slider(value: $rating, in: 1…5, step: 1)
.overlay(Text(“\(rating) stars”))
}
}

In the above custom component, the RatingSlider takes a binding to an Int which represents the rating. This allows the parent view to pass its data to the RatingSlider and also get updates when the rating changes.

Usage in a parent view might look like this.

struct BookRatingView: View {
@State private var rating: Int = 3

var body: some View {
VStack {
RatingSlider(rating: $rating)
Text(“You rated this book \(rating) stars.”)
}
}
}

Here, the BookRatingView has its own rating state. It passes this state as a binding to the RatingSlider. When the user adjusts the slider, the rating in BookRatingView is updated automatically, showcasing the two-way data binding.

Binding with Optionals:
Bindings can also work with optional values. This is particularly useful when you’re unsure if a value exists but still want to bind it to a UI component. For instance, an optional String can be bound to a TextField.

struct OptionalTextField: View {
@Binding var text: String?

var body: some View {
TextField(“Enter text”, text: $text ?? .constant(“”))
}
}

In the above example, if text is nil, the TextField will display its placeholder. If the user enters text, the bound optional String will be updated.

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

Conclusion

Navigating the world of SwiftUI offers both challenges and rewards. Its data-driven approach not only simplifies many tasks but also introduces new paradigms that can reshape the way we design and develop apps. As with any tool or framework, the key lies in understanding its capabilities and knowing when and how to deploy them effectively. As you delve deeper into SwiftUI, embrace its flexibility, and remember that every project provides an opportunity to learn and refine your skills. The journey through app development is as enriching as the destination. Keep exploring, keep innovating, and most importantly, enjoy the process!


Aasif Khan

Head of SEO at Appy Pie

App Builder

Most Popular Posts