What separates SwiftUI from Apple’s previous UI frameworks is not just how views and other UI components are defined, but also how view-level state is managed within an app. Instead of using a delegate, datasource, or any other state management patterns commonly found in mandatory frameworks like UIKit and AppKit – SwiftUI provides several property wrappers that allow us to accurately declare how data is observed, rendered, and mutated. by our view. In this article, take a closer look at each of those property wrappers, how they relate to each other, and how they form the different parts of the overall state management system in SwiftUI.
State properties
Since SwiftUI is primarily a UI framework (although it starts to have APIs for defining higher-level constructs, like apps and scenes), its declarative design doesn’t necessarily affect the entire model and data layer. of an application – but rather just the state directly bound to our various views. For example, let’s say we are working on SignupView
which allows a user to register a new account in an application, by entering a username
and email
. We’ll then use those two values to form the User
model, which is passed to the handler
closure – giving us three parts of the state:
1 2 3 4 5 6 7 8 9 10 | struct SignupView: View { var handler: (User) -> Void var username = "" var email = "" var body: some View { ... } } |
Since only two of those three properties – username
and email
– will actually be modified according to our view, and since those two parts of state can be private, we’ll highlight both using the State property wrapper. of SwiftUI – as follows:
1 2 3 4 5 6 7 8 9 10 11 | struct SignupView: View { var handler: (User) -> Void @State private var username = "" @State private var email = "" var body: some View { ... } } |
Doing so automatically creates a connection between those two values and our own view – meaning our view will be re-rendered every time one of the two values is changed. In the body
, we’ll bind each of those two properties with a corresponding TextField
to make them editable – giving us the following implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | struct SignupView: View { var handler: (User) -> Void @State private var username = "" @State private var email = "" var body: some View { VStack { TextField("Username", text: $username) TextField("Email", text: $email) Button( action: { self.handler(User( username: self.username, email: self.email )) }, label: { Text("Sign up") } ) } .padding() } } |
So the State
is used to represent the internal state of the SwiftUI view and to automatically update the view when that state is changed. Hence, to keep State
-wrapped properties private
, this ensures that they will only be modified in the view body (trying to change them elsewhere will actually cause a runtime crash).
Two-way bindings
Looking at the code sample above, the way we pass each of our properties into TextField is by prefixing those property names with $
. That’s because they don’t just pass pure String
values into TextFields. That, but also associated with the main State
-wrapped properties. To explore in more detail what that means, let’s now assume that we want to create a view that allows users to edit the profile information they initially entered upon registration. Since we are currently looking to modify external state values, instead of just private values, we will mark our username
and email
as Binding:
1 2 3 4 5 6 7 8 9 10 11 12 13 | struct ProfileEditingView: View { @Binding var username: String @Binding var email: String var body: some View { VStack { TextField("Username", text: $username) TextField("Email", text: $email) } .padding() } } |
Interestingly, bindings are not limited to single built-in values, such as Strings or integers, but can be used to associate any Swift value with one of our views. For example, here’s how we can actually pass our own User
model into ProfileEditingView
, instead of passing two separate username
and email
values:
1 2 3 4 5 6 7 8 9 10 11 12 | struct ProfileEditingView: View { @Binding var user: User var body: some View { VStack { TextField("Username", text: $user.username) TextField("Email", text: $user.email) } .padding() } } |
Just like how we prefix State
and Binding
-wrapped properties with $
when passing them into different TextField
instances, we can do the same thing when we concatenate any State
value with the Binding
property. We have also self-defined. For example, this is an implementation of ProfileView
that tracks the User
model with the State
-wrapped property, then passes a link to that model when displaying an instance of ProfileEditingView as a sheet – which automatically synchronizes. User change period:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | struct ProfileView: View { @State private var user = User.load() @State private var isEditingViewShown = false var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Username: ") .foregroundColor(.secondary) + Text(user.username) Text("Email: ") .foregroundColor(.secondary) + Text(user.email) Button( action: { self.isEditingViewShown = true }, label: { Text("Edit") } ) } .padding() .sheet(isPresented: $isEditingViewShown) { VStack { ProfileEditingView(user: self.$user) Button( action: { self.isEditingViewShown = false }, label: { Text("Done") } ) } } } } |
Thus, the Binding
-marked property provides a bidirectional connection between a given view and a state property defined outside of that view, and both the State
and Binding
-wrapped properties can be passed as binding by prefixing. their attribute names are equal to $
.
Observing objects
What both State and Binding have in common is that they handle values managed in the SwiftUI view hierarchy itself. However, while it is definitely possible to build an application that holds all of its states in different views – that’s usually not an architecturally good idea and isolating concerns and possibly this easily leads to our view becoming quite large and complex. Thankfully, SwiftUI also provides several mechanisms that allow us to connect external model objects to different views. One such mechanism is the ObservableObject
protocol, when combined with the ObservedObject
property wrapper, which allows us to establish associations with managed reference types outside of our view layer. For example, let’s update the ProfileView
we defined above – by moving our User
model management responsibility out of the view and into a new, dedicated object. Now, there are several different metaphors that we can use to describe such an object, but since we are looking to create a type that will control an instance of one of their models. Let’s make it a controller model that conforms to SwiftUI’s ObservableObject
protocol:
1 2 3 4 5 | class UserModelController: ObservableObject { @Published var user: User ... } |
With the above type now let’s go back to ProfileView
and make it observe an instance of the new UserModelController
as ObservedObject
, instead of using the State
-wrapped property to track the User model. What’s really neat is that we can still easily bind that model to our ProfileEditingView
, just like before, since ObservedObject-wrapped properties can also be converted to bindings – like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | struct ProfileView: View { @ObservedObject var userController: UserModelController @State private var isEditingViewShown = false var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Username: ") .foregroundColor(.secondary) + Text(userController.user.username) Text("Email: ") .foregroundColor(.secondary) + Text(userController.user.email) Button( action: { self.isEditingViewShown = true }, label: { Text("Edit") } ) } .padding() .sheet(isPresented: $isEditingViewShown) { VStack { ProfileEditingView(user: self.$userController.user) Button( action: { self.isEditingViewShown = false }, label: { Text("Done") } ) } } } } |
However, the key difference between the new and the State-based implementation we’ve used before, is that the UserModelController
now needs to be included in ProfileView
as part of its constructor. The reason for that, in addition to it “forces” us to set a more clearly defined dependency graph in the code, but also a property marked with ObservedObject
doesn’t imply any form of ownership. to the object that the property points to. So while things like the following can successfully compile, it can cause runtime issues – since the version of the UserModelController
stored in our view can be allocated when our view is recreated in update process (since our view is now its main owner):
1 2 3 4 5 | struct ProfileView: View { @ObservedObject var userController = UserModelController.load() ... } |
To fix the above problem, Apple introduced a new property wrapper as part of iOS 14 and macOS Big Sur named StateObject
. A property marked with StateObject
behaves exactly like an ObservedObject
– with the addition of SwiftUI will ensure that any object stored in such a property will not be accidentally released when the framework recreates the New instance of a view when re-rendering it:
1 2 3 4 5 | struct ProfileView: View { @StateObject var userController = UserModelController.load() ... } |
Although it’s technically only possible to use StateObject
from now on – I still recommend using ObservedObject
when observing external objects and only StateObject
when dealing with objects owned by one view. Consider StateObject
and ObservedObject
as reference types equivalent to State
and Binding
, or the SwiftUI versions of strong and weak properties.
Observing and modifying the environment
Finally, let’s see how SwiftUI’s enviroment system can be used to pass different parts of state between two views that are not directly connected to each other. While it’s often easy to create a constraint between the main view and one of its child views, passing a certain object or value around in the entire view hierarchy can be quite complicated – and that’s exactly. the type of problem the enviroment aims to solve. There are two main ways to use SwiftUI’s enviroment. One is to start by defining an EnvironmentObject-wrapped property in the view that wants to access a certain object – for example, how this ArticleView
retrieves a Theme
object that contains color information:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct ArticleView: View { @EnvironmentObject var theme: Theme var article: Article var body: some View { VStack(alignment: .leading) { Text(article.title) .foregroundColor(theme.titleTextColor) Text(article.body) .foregroundColor(theme.bodyTextColor) } } } |
Then we have to make sure to provide the enviroment object (the Theme
example in this case) in one of its parent views, and SwiftUI takes care of the rest. That’s done using the environmentObject
modifier, for example like this:
1 2 3 4 5 6 7 8 9 10 | struct RootView: View { @ObservedObject var theme: Theme @ObservedObject var articleLibrary: ArticleLibrary var body: some View { ArticleListView(articles: articleLibrary.articles) .environmentObject(theme) } } |
The second way to use SwiftUI’s enviroment system is to define a custom EnvironmentKey
– which can then be used to assign and retrieve values to and from the EnvironmentValues
type:
1 2 3 4 5 6 7 8 9 10 11 | struct ThemeEnvironmentKey: EnvironmentKey { static var defaultValue = Theme.default } extension EnvironmentValues { var theme: Theme { get { self[ThemeEnvironmentKey.self] } set { self[ThemeEnvironmentKey.self] = newValue } } } |
With the above, we can now highlight the theme
‘s theme
property using the Environment
property wrapper (not EnvironmentObject) and pass the key path of the environment key for which we want to retrieve the value:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct ArticleView: View { @Environment(.theme) var theme: Theme var article: Article var body: some View { VStack(alignment: .leading) { Text(article.title) .foregroundColor(theme.titleTextColor) Text(article.body) .foregroundColor(theme.bodyTextColor) } } } |
One notable difference between the above two methods is that the key-based method requires us to specify a default value at compile time, while the EnvironmentObject-based method assumes such a value. will be offered at runtime.
Hope the article will be useful to you
Reference: https://www.swiftbysundell.com/articles/swiftui-state-management-guide/