SwiftUI State Management System Guide

SwiftUI State Management System Guide

Introduction

SwiftUI differs from Apple’s previous UI frameworks not only in how views and other UI components are defined, but also in how the state of the view hierarchy is managed throughout an app that uses it.

Rather than using delegates, data sources, or any other state management patterns common in imperative frameworks like UIKit and AppKit, SwiftUI comes with a few property wrappers[1] that allow us to declare exactly how our data should be observed, rendered, and mutated by our views.

This week, let’s take a closer look at each of these property wrappers, how they relate to each other, and how they form different parts of SwiftUI’s overall state management system.

Property Status

Since SwiftUI is primarily a UI framework (although it’s also starting to get APIs for defining higher-level structures like apps and scenes), its declarative design doesn’t necessarily need to affect the entire model and data layer of our app — but rather just bind directly to the state of our various views.

For example, let's say we're developing a SignupView that enables users to register a new account in our application by entering a username and email address. We'll use these two values ​​to form a User model and pass it to a closure:

 struct SignupView : View {
var handler : ( User ) -> Void
var username = ""
var email = ""

var body : some View {
...
}
}

Since only two of these three properties — username and email — will actually be modified by our view, and these two states can remain private, we’ll mark them using SwiftUI’s State property wrapper — like this:

 struct SignupView : View {
var handler : ( User ) -> Void

@State private var username = ""
@State private var email = ""

var body : some View {
...
}
}

Doing this will automatically create a connection between these two values ​​and our view itself - meaning our view will be re-rendered every time these two values ​​are changed. In our body, we will bind each of these two properties to a corresponding TextField to make them editable by the user:

 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 ( )
}
}

State is therefore used to represent the internal state of a SwiftUI view and automatically cause the view to update when that state is changed. As such, it is most common practice to keep State property wrappers private, which ensures that they are only changed within the body of that view (trying to change them elsewhere will actually cause a runtime crash).

Two-way binding

Looking at the code sample above, the way we pass each property into its TextField is by prefixing those property names with $. This is because we are not just passing in plain String values ​​into those text fields, but are binding to our State wrapped properties themselves.

To explore what this means in more detail, let's now assume that we want to create a view that lets our users edit the profile information they originally entered when they signed up. Since we're now modifying external state values, rather than just private state values, this time we mark the username and email properties as Bingding:

 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 ( )
}
}

The cool thing is that bindings aren’t just limited to single built-in values, like strings or integers, but can be used to bind any Swift value to one of our views. For example, instead of passing two separate username and email, we can pass the User model itself to the ProfileEditingView:

 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 we are combining State and

Just like we prefixed the State and Binding wrapped properties into the various TextField instances with $, we can do the same thing when connecting any State value to our own defined Binding properties.

For example, here is an implementation of a ProfileView that uses a Stage wrapper property to keep track of a user model, and then passes that model a binding when rendering an instance of the above ProfileEditingView as a sheet - this will automatically sync any changes the user makes to that original State property value:

 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" ) }
)
}
}
}
}

Note that we can also change a State​-wrapped property by assigning a new value to it - like when we set isEditingViewShown​ to false in the action handler of the "Done" button.

Thus, a Binding​-tagged property provides a two-way connection between a given view and a state property defined outside of that view, while both Statr and Binding​-wrapped properties can be passed as bindings by prefixing their property name with $.

Observation object

What State and Binding have in common is that they deal with values ​​that are managed within the SwiftUI view hierarchy itself. However, while it is certainly possible to build an app that keeps all of its state within its various views, it’s generally not a good idea from an architectural and separation of concerns perspective, and can easily lead to our views becoming quite large and complex.

Thankfully, SwiftUI also provides some mechanisms that allow us to connect external model objects to our various views. One of these mechanisms is the ObservableObject protocol, which, when combined with the ObservedObject property wrapper, allows us to set up bindings to reference types that are managed outside of our view layer.

As an example, let’s update the ProfileView​ defined above — by moving the responsibility for managing the User​ model from the view itself into a new, specialized object. Now, we could describe such an object in many different ways, but since we’re looking to create a type to control an instance of one of our models — let’s make it a model controller [2] that conforms to SwiftUI’s ObservableObject protocol:

 class UserModelController : ObservableObject {
@Published var user : User
...
}

The Published property wrapper is used to define which properties of an object should have observation notifications triggered when they are modified.

With the above types in place, let's now go back to our ProfileView and have it observe our new UserModelController instance as an ObservedObject instead of keeping track of our user model with a State property wrapper. Best of all, we can still easily bind this model to our ProfileEditingView just like before, since ObservedObject property wrappers can also be converted to bindings:

 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, one important difference between our new implementation and the state-based implementation we used previously is that our UserModelController now needs to be injected into the ProfileView as part of its initializer.

Besides "forcing" us to have a more explicit dependency graph in our codebase, the reason is that a property marked with ObservedObject does not imply any kind of ownership over the object that this property points to.

So while the following might technically compile, it will ultimately cause issues at runtime - because when our view is recreated when it is updated, the UserModelController instance may be deleted (since our view is now its primary owner):

 struct ProfileView : View {
@ObservedObject var userController = UserModelController .load ( )
...
}

It’s important to remember: SwiftUI views are not references to actual UI components being rendered on the screen, but rather lightweight values ​​that describe our UI — so they don’t have a lifecycle like UIView instances do.

To solve the above problems, Apple introduced a new property wrapper in iOS 14 and macOS Big Sur called StateObject. Properties marked as StateObject behave exactly like ObservedObject-in addition, SwiftUI will ensure that any objects stored in such properties will not be accidentally released because the framework re-creates a new instance when the view is re-rendered:

 struct ProfileView : View {
@StateObject var userController = UserModelController .load ( )
...
}

Even though you can technically only use StateObject​ from now on - I still recommend using ObservedObject​ when observing external objects, and only using StateObject​ when dealing with objects owned by the view itself. Think of StateObject and ObservedObject​ as reference types to State and Binding, or the SwiftUI version of strong and weak properties.

View and modify environment variables

Finally, let’s look at how SwiftUI’s environment system can be used to pass various states between two views that aren’t directly connected to each other. While it’s usually easy to create a binding between a parent view and one of its children, passing an object or value throughout a view hierarchy can be quite cumbersome — and this is exactly the type of problem that environment variables are designed to solve.

There are two main ways to use the environment with SwiftUI. One is to first define an EnvironmentObject​wrapped property in the view from which you want to retrieve a given object — for example, like this ArticleView​how to retrieve a Theme object containing color information:

 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 )
}
}
}

We then have to make sure to provide our environment object (in this case a Theme instance) in one of our view’s parent classes, and SwiftUI will take care of the rest. This is done by using the environmentalObject modifier, for example, like this:

 struct RootView : View {
@ObservedObject var theme : Theme
@ObservedObject var articleLibrary : ArticleLibrary

var body : some View {
ArticleListView ( articles : articleLibrary .articles )
.environmentObject ( theme )
}
}

Note that we don't need to apply the above modifier to the exact view that will use our environment object - we can apply it to any view above it in our hierarchy.

The second way to use the SwiftUI environment system is to define a custom EnvironmentKey​ — which can then be used to assign and retrieve values ​​from the built-in EnvironmentValues ​​type:

 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 in place, we can now mark our view's theme property with the Enviroment property wrapper (instead of EnvironmentObject ), and pass in the key value path of the environment key we wish to retrieve:

 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 obvious difference between the two approaches described above is that the key-based approach requires us to define a default value at compile time, while the EnvironmentObject-based approach assumes that such a value is provided at runtime (failure to do so will result in a crash).

summary

The way SwiftUI manages state is definitely one of the most interesting aspects of the framework, and it may require us to slightly rethink the way data is passed around in our apps — at least when it comes to data that will be directly consumed and modified by our UIs.

I hope this guide has been a good way to get an overview of SwiftUI’s various state handling mechanisms, and while some of the more specific APIs have been left out, the concepts highlighted in this post should cover the vast majority of use cases for all SwiftUI-based state handling.

Thanks for reading!

References

[1] Property Wrappers: https://www.swiftbysundell.com/articles/property-wrappers-in-swift

[2] Model Controller: https://www.swiftbysundell.com/articles/model-controllers-in-swift/​

<<:  Research on audio and video playback technology in Android system

>>:  Transaction Gold Link Apollo Mobile Terminal Abnormal Monitoring System

Recommend

The secret of increasing followers of million-level up masters

The secret of increasing followers of millions of...

Why do maternal and infant brands need to conduct private domain operations?

When all industries are digging for gold in the p...

B-side operation strategy and growth model!

B-end operation is an unfamiliar field for many p...

Who will win when online dramas overtake traditional TV dramas?

The current TV drama ratings remain sluggish, whi...

I can't write MVP architecture after reading it. I'm kneeling on the washboard

In order to earn a monthly salary of 18,000 yuan,...

Douyin Ecosystem Full Service User Manual

In this article, the author will describe Douyin’...

Hunter Camp Community·Multiple VIP Course Collection

The course comes from a collection of multiple tu...

2021, we looked at these photos again and again

2021 From the beginning of the year to the end of...

Alipay and JD.com have established an “active user” indicator system!

The Alipay app, which has hundreds of millions of...

Is the trend of de-Googleing smart TV systems an innovation or nonsense?

Since when, the Android operating system has beco...

How to acquire and operate seed users?

The story of how Xiaomi started with 100 seed use...