Understanding Data Flow in SwiftUI
In this tutorial, you’ll learn how data flow in SwiftUI helps maintain a single source of truth for the data in your app. By Keegan Rush.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Understanding Data Flow in SwiftUI
30 mins
- Getting Started
- The Problem With State
- SwiftUI to the Rescue
- Working With Internal State
- Property Wrappers in SwiftUI
- Reference and Value Types
- Observing Objects
- Why @State Doesn’t Work Here
- A Single Source of Truth
- Passing State as a Binding
- Setting a Favorite Genre
- Working With External Data
- Observing an ObservableObject
- Using the Environment
- Using an Environment Object Down the Chain
- Environment Variables
- Observing Objects
- Choosing the Right Property Wrapper
- Where to Go From Here?
Setting a Favorite Genre
Open UserView.swift. At the top of the view, add this line after the one which defines userName
:
@State private var favoriteGenre = ""
Next, inside the Form
, add a new Section
after the existing one:
Section(header: Text("Favorite Genre")) {
GenrePicker(genre: $favoriteGenre)
}
This uses your GenrePicker
to set the user’s favorite genre.
Build and run. You’ll see a picker on the user view just like the one on the Add Movie screen. When GenrePicker
‘s value changes, it’ll update favoriteGenre
.
If you leave UserView
and come back to it though, you’ll notice your selection isn’t persisted. You’ll take care of this in just a moment.
Working With External Data
Currently, UserView
sets a username and a favorite genre using @State
. You want to be able to pass these elsewhere in the app so next, you’ll create a UserStore
class that keeps track of the current user’s data.
Create a new view in Xcode by going to File ▸ New ▸ File… in the menu bar. Select Swift File and click Next. Name it UserInfo.swift and click Create. Replace the contents with this:
import Foundation
struct UserInfo {
let userName: String
let favoriteGenre: String
}
UserInfo
is a Swift struct that represents a user. Next, create another file in the same manner. Name this one UserStore.swift. Replace its contents with the following:
import Combine
// 1
class UserStore: ObservableObject {
// 2
@Published var currentUserInfo: UserInfo?
}
UserStore
keeps track of the current user by using the UserInfo
struct you declared earlier. Thanks to the Combine framework and property wrappers, there’s a lot of new stuff happening in the code above:
-
UserStore
conforms toObservableObject
. This is something that can be observed by SwiftUI. - The
@Published
property wrapper is what triggers any updates in observers of anObservableObject
. Whenever a published property changes, observers are notified. By declaringcurrentUserInfo
with the@Published
property wrapper, setting a new value will update any views observing theUserStore
.
Observing an ObservableObject
Sometimes, your data’s source of truth doesn’t live inside a SwiftUI view. In this case, use the ObservableObject
protocol to allow a class to interact with SwiftUI. An ObservableObject
is a source of truth that sends updates to a SwiftUI view, and it receives updates based on changes to the UI.
So far, you’ve created a class conforming to ObservableObject
that you can reference in a SwiftUI view: UserStore
. Next, you need do the observing, which means you’ll need UserStore
in the following places:
- UserView: Sets the username and favorite genre
- MovieList: Displays the user’s name
- AddMovie: Uses the favorite genre as the default
Using the Environment
In SwiftUI, the environment is a store for variables and objects that are shared between a view and its children.
You need a reference to an observable object, and one way to get it is to use @ObservedObject
, as you did for MovieStore
, then pass the reference to UserStore
wherever you need it.
This gets tedious in large apps with many nested views. Because of this, UserStore
is a good candidate for an environment object. Rather than passing an object to every view that needs it, environment objects are supplied by an ancestor view and are made available to any of its descendants.
This means that if you create an instance of UserStore
and pass it to MovieList
as an environment object, all the child views of MovieList
will get UserStore
automatically.
To use an environment object, you need to do the following:
- Create a class conforming to
ObservableObject
. - Have at least one variable in the class with the
@Published
property wrapper to trigger any observers to update, or manually provide anobjectWillChange
publisher as required byObservableObject
. - Pass an instance of the observable class to a view by using the
environmentObject()
view modifier when creating the view.
When you created the UserStore
class, you already accomplished the first two steps. Next, you need to pass UserStore
into MovieList
‘s environment. Open SceneDelegate.swift.
In scene(_:willConnectTo:options:)
, replace the first line that creates contentView
with this:
let contentView = MovieList().environmentObject(UserStore())
After creating the MovieList
, you pass an instance of UserStore
into its environment using environmentObject(_:)
. Now the movie list and the views in its hierarchy can access it.
Next, open MovieList.swift. To access UserStore
, add the following at the beginning of the struct, right before the line declaring movieStore
:
@EnvironmentObject var userStore: UserStore
@EnvironmentObject
lets you use environment objects passed to a view or to any of its ancestor views.
Now you can display the username along with the user navigation item. In MovieList.swift, find the Image
with the person.fill
system icon. Replace that line with this:
HStack {
// 1
userStore.currentUserInfo.map { Text($0.userName) }
// 2
Image(systemName: "person.fill")
}
In the code above, you:
- Get the current user’s
userName
property to display as aText
view if it exists.currentUserInfo
is an optional, but you can’t use anif let
inside a view’s body. Usingmap
will create theText
view for you only ifcurrentUserInfo
exists. - Add the same image that was there before to the
HStack
.
Great work! Build and run. You’re not passing the username to userStore
, so you won’t see any changes.
Using an Environment Object Down the Chain
You don’t need to pass UserStore
to UserView
to gain access to it. The environment already handles that for you. Instead, open UserView.swift and, just as with MovieList
, add userStore
at the top of UserView
, along with the other variables:
@EnvironmentObject var userStore: UserStore
Add the following to the empty updateUserInfo()
:
let newUserInfo = UserInfo(userName: userName, favoriteGenre: favoriteGenre)
userStore.currentUserInfo = newUserInfo
Tapping Update calls updateUserInfo()
. The method creates a new userInfo
object with the two state properties that drive this view: userName
and favoriteGenre
. It then updates the userStore
published property so you can use it elsewhere in the app. UserStore
is an ObservableObject
, so this will trigger updates in all the observers.
At the end of the body
, after the navigationBarItems
view modifier, add another view modifier:
.onAppear {
userName = userStore.currentUserInfo?.userName ?? ""
favoriteGenre = userStore.currentUserInfo?.favoriteGenre ?? ""
}
This grabs the current userName
and favoriteGenre
from the userStore
when the view appears.
Build and run. Tap the user button, give yourself a name, and tap Update. Then tap the left navigation bar button to go back to the movie list. You should see your name next to the user button. Hooray! :]
You might have noticed that tapping Update didn’t navigate back to the user view for you. You’ll add that next.