Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

11. Understanding Property Wrappers
Written by Audrey Tam

In your SwiftUI app, every data value or object that can change needs a single source of truth and a mechanism to enable views to change or observe it. SwiftUI’s property wrappers enable you to declare how each view interacts with mutable data.

In this chapter, you’ll review how you managed data values and objects in HIITFit with @State, @Binding, @Environment, ObservableObject, @StateObject and @EnvironmentObject. And, you’ll build a simple app that lets you focus on how to use these property wrappers. You’ll also learn about TextField, the environment modifier and the @ObservedObject property wrapper.

To help answer the question “struct or class?”, you’ll see why HistoryStore should be a class, not a structure, and learn about the natural architecture for SwiftUI apps: Model-View-ViewModel (MVVM).

Getting started

➤ Open the TIL project in the starter folder. The project name “TIL” is the acronym for “Today I Learned”. Or, you can think of it as “Things I Learned”. Here’s how the app should work: The user taps the + button to add acronyms like “YOLO” and “BTW”, and the main screen displays these.

TIL in action
TIL in action

This app embeds a VStack in a NavigationView. This gives you the navigation bar where you display the title and the + button. You’ll learn more about NavigationView in Section 3.

This project has a ThingStore, like HistoryStore in HIITFit. This app is much simpler than HIITFit, so you can focus on how you manage the data.

Remember how you managed changes to HistoryStore in HIITFit:

HIITFit: HistoryStore shared as EnvironmentObject
HIITFit: HistoryStore shared as EnvironmentObject

In Chapter 6, “Adding Functionality to Your App”, you converted HistoryStore from a structure to a class conforming to ObservableObject, then set it up as an @EnvironmentObject so ExerciseView and HistoryView could access it directly. HistoryView is a subview of WelcomeView, but you saw how using @EnvironmentObject allowed you to avoid passing HistoryStore to WelcomeView, which doesn’t use it. If you did the challenge in that chapter, you also managed HistoryStore with @State and @Binding.

In Chapter 9, “Saving History Data”, you moved the initialization of HistoryStore from ContentView to HIITFitApp to initialize it with or without saved history data.

ThingStore has the property things, which is an array of String values. Like the HistoryStore in the first version of HIITFit, it’s a structure.

In this chapter, you’ll first manage changes to the ThingStore structure using @State and @Binding, then convert it to an ObservableObject class and manage changes with @StateObject and @ObservedObject:

TIL: ThingStore shared as Binding and as ObservedObject
TIL: ThingStore shared as Binding and as ObservedObject

You’ll learn that these two approaches are very similar.

Note: Our tutorial Property Wrappers (bit.ly/3vLOpbl) extends this project to use ThingStore as an @EnvironmentObject.

Tools for managing data

You already know that a @State property is a source of truth. A view that owns a @State property can pass either its value or its binding to its subviews. If it passes a binding to a subview, that subview now has a reference to the source of truth. This allows it to update that property’s value or redraw itself when that value changes. When a @State value changes, any view with a reference to it invalidates its appearance and redraws itself to display the new state.

Your app needs to manage changes to two kinds of data:

Managing UI values and model objects
Managing UI values and model objects

  • User interface values, like Boolean flags to show or hide views, text field text, slider or picker values.
  • Data model objects, often collections of objects that model the app’s data, like daily logs of completed exercises.

Property wrappers

Property wrappers wrap a value or object in a structure with two properties:

  • wrappedValue is the underlying value or object.
  • projectedValue is a binding to the wrapped value or a projection of the object that creates bindings to its properties.

Swift syntax lets you write just the name of the property, like showHistory, instead of showHistory.wrappedValue. And, its binding is $showHistory instead of showHistory.projectedValue.

SwiftUI provides tools — mostly property wrappers — to create and modify the single source of truth for values and for objects:

  • User interface values: Use @State and @Binding for values like showHistory that affect the view’s appearance. The underlying type must be a value type like Bool, Int, String or Exercise. Use @State to create a source of truth in one view, then pass a @Binding to this property to subviews. A view can access built-in @Environment values as @Environment properties or with the environment(_:_:) view modifier.

  • Data model objects: For objects like HistoryStore that model your app’s data, use either @StateObject with @ObservedObject or environmentObject(_:) with @EnvironmentObject. The underlying object type must be a reference type — a class — that conforms to ObservableObject, and it should publish at least one value. Then, either use @StateObject and @ObservedObject or declare an @EnvironmentObject with the same type as the environment object created by the environmentObject(_:) view modifier.

While prototyping your app, you can model your data with structures and use @State and @Binding. When you’ve worked out how data needs to flow through your app, you can refactor your app to accommodate data types that need to conform to ObservableObject.

This is what you’ll do in this chapter to consolidate your understanding of how to use these property wrappers.

Saving/Persisting app or scene state

There are two other property wrappers you’ve used. @AppStorage wraps UserDefault values. In Chapter 8, “Saving Settings”, you used @AppStorage to save exercise ratings in UserDefaults and load them when the app launches.

In the same chapter, you used @SceneStorage to save and restore the state of scenes — windows in the iPad simulator, each showing a different exercise.

Managing UI state values

@State and @Binding value properties are mainly used to manage the state of your app’s user interface.

A view is a structure, so you can’t change a property value unless you wrap it as a @State or @Binding property.

The view that owns a @State property is responsible for initializing it. The @State property wrapper creates persistent storage for the value outside the view structure and preserves its value when the view redraws itself. This means initialization happens exactly once.

You already got lots of practice with @State and @Binding in Chapter 6, “Adding Functionality to Your App”:

  • selectedTab controls TabView.
  • showHistory, showSuccess, showTimer, timerDone show or hide views.
  • rating and timeRemaining values must be able to change.

In the challenge for that chapter, you used @State and @Binding to manage changes to HistoryStore. That was just an exercise to demonstrate it’s possible, and it’s one approach you can take to prototyping. For most apps, your final data model will involve ObservableObject classes.

Managing ThingStore with @State and @Binding

TIL is a very simple app, making it easy to examine different ways to manage the app’s data. First, you’ll manage ThingStore the same way as any other mutable value you share between your app’s views.

➤ In ContentView.swift, run live preview and tap the + button:

Starter TIL
Starter TIL

TIL uses a Boolean flag, showAddThing, to show or hide AddThingView. It’s a @State property because its value changes when you tap the + button, and ContentView owns it.

➤ In ContentView.swift, add this line to ContentView:

@State private var myThings = ThingStore()

You’ll add items to myThings.things, so myThings must be a wrapped property. In this case, it’s @State, because ContentView owns it and initializes it.

➤ Now, delete the temporary array:

let tempThings = ["YOLO", "BTW"]  // delete this line

You’ll store strings in myThings.things, so you no longer need this array.

➤ Then, update the ForEach argument:

ForEach(myThings.things, id: \.self) { thing in

You loop over the things array instead of tempThings.

➤ Refresh the preview:

Nothing to see here
Nothing to see here

Now, there’s nothing to show because myThings initializes with an empty things array. It’s a better user experience if you display a message, instead of this blank page, the first time your user launches your app.

➤ In ContentView.swift, add this code at the top of the VStack, before the ForEach line:

if myThings.things.isEmpty {
  Text("Add acronyms you learn")
    .foregroundColor(.gray)
}

First-time empty-array screen
First-time empty-array screen

You give your users a hint of what they can do with your app. The text is grayed out so they know it’s just a placeholder until they add their own data.

AddThingView needs to modify myThings, so you need a @Binding in AddThingView.

➤ In AddThingView.swift, add this property to AddThingView:

@Binding var someThings: ThingStore

You’ll soon pass this binding from ContentView.

➤ You’ll also add a text field, but for now, just to have something happen when you tap Done, add this line to the button action, before you dismiss this sheet:

someThings.things.append("FOMO")

You append a specific string to the array.

➤ Fix this view’s previews:

AddThingView(someThings: .constant(ThingStore()))

You create a binding for the constant initial value of ThingStore.

➤ Now, go back to ContentView.swift and fix the call to AddThingView():

AddThingView(someThings: $myThings)

You pass a binding to the ContentView @State property to AddThingView.

Note: Passing a binding gives the subview write access to everything in ThingStore. In this case, ThingStore has only the things array but, if it had more properties and you wanted to restrict write access to its things array, you could pass $myThings.things — a binding to only the things array. You’d need to initialize an array of String for the preview of AddThingView.

➤ Start live preview, tap + then tap Done:

Adding a string works.
Adding a string works.

Great, you’ve got data flowing from AddThingView to ContentView via ThingStore!

Now to get input from your user, you’ll add a TextField to AddThingView.

➤ First, pin the preview of ContentView so it’s there when you’re ready to test your TextField.

Using a TextField

Many UI controls work by binding a parameter to a @State property of the view: These include Slider, Toggle, Picker and TextField.

To get user input via a TextField, you need a mutable String property to store the user’s input.

➤ In AddThingView.swift, add this property to AddThingView:

@State private var thing = ""

It’s a @State property because it must persist when the view redraws itself. AddThingView owns this property, so it’s responsible for initializing thing. You initialize it to the empty string.

➤ Now, add your TextField in the VStack, above the Done button:

TextField("Thing I Learned", text: $thing)  // 1
  .textFieldStyle(RoundedBorderTextFieldStyle())  // 2
  .padding()  // 3
  1. The label “Thing I Learned” is the placeholder text. It appears grayed out in the TextField as a hint to the user. You pass a binding to thing so TextField can set this value to what the user types.
  2. You dress up this TextField with a rounded border.
  3. You add padding so there’s some space from the top of the view and also to the button.

➤ Then, edit what the button action appends:

if !thing.isEmpty {
  someThings.things.append(thing)
}

Instead of "FOMO", you append the user’s text input to your things array after checking it’s not the empty string.

➤ Refresh live-preview in the ContentView preview and tap +. Type an acronym like YOLO in the text field. It automatically capitalizes the first letter, but you must hold down the Shift key for the rest of the letters. Tap Done:

TextField input
TextField input

ContentView displays your new acronym.

Sometimes the app auto-corrects your acronym: FTW to GET or FOMO to DINO.

➤ Add this modifier to TextField:

.disableAutocorrection(true)

Accessing environment values

A view can access many environment values like accessibilityEnabled, colorScheme, lineSpacing, font and presentationMode. Apple’s SwiftUI documentation has a full list of environment values at apple.co/37cOxak.

A view’s environment is a kind of inheritance mechanism. A view inherits environment values from its ancestor views, and its subviews inherit its environment values.

➤ To see this, open ContentView.swift and click anywhere in this line:

Text("Add acronyms you learn")

➤ Now, open the Attributes inspector:

Text view attributes: Many are inherited.
Text view attributes: Many are inherited.

Font, Weight, Line Limit, Padding and Frame Size are Inherited. Font Color would also be inherited if you hadn’t set it to Gray.

A view can override an inherited environment value. It’s common to set a default font for a stack then override it for the text in a subview of the stack. You did this in Chapter 3, “Prototyping the Main View”, when you made the first page number larger than the others:

HStack {
  Image(systemName: "1.circle")
    .font(.largeTitle)
  Image(systemName: "2.circle")
  Image(systemName: "3.circle")
  Image(systemName: "4.circle")
}
.font(.title2)

Modifying environment values

AddThingView already uses the presentationMode environment value, declared as a view property the same as in HIITFit’s SuccessView. But, you can also set environment values by modifying a view.

Acronyms should appear as all caps, but it’s easy to forget to hold down the Shift key. You can actually set an environment value to automatically convert text to upper case.

➤ In TILApp.swift, add this modifier to ContentView():

.environment(\.textCase, .uppercase)

You set uppercase as the default value of textCase for ContentView and all its subviews.

Note: textCase(.uppercase) also works, but the .environment syntax highlights the fact that textCase is an environment value.

➤ To see it in live-preview, also add this modifier in ContentView.swift to ContentView() in previews.

➤ Refresh live-preview, add acronyms without bothering to keep all the letters upper case. Just type yolo or fomo. Tap DONE. Notice this label and the placeholder text are now all uppercase:

Automagic uppercase
Automagic uppercase

Note: If the placeholder text isn’t all upper case, press Shift-Command-K to clean the build folder.

Your strings are automatically converted to uppercase.

The environment value applies to all text in your app, which looks a little strange. No problem — you can override it.

➤ In AddThingView.swift, add this modifier to the VStack:

.environment(\.textCase, nil)

You set the value to nil, so none of the text displayed by this VStack is converted to uppercase.

➤ Refresh live-preview, tap +, type icymi then tap Done:

No upper case conversion in AddThing
No upper case conversion in AddThing

Now, the button label and placeholder text are back to normal. The uppercase environment default still converts your strings to all caps on the main screen.

Managing model data objects

@State, @Binding and @Environment only work with value data types. Simple built-in data types like Int, Bool or String are useful for defining the state of your app’s user interface.

You can use custom value data types like struct or enum to model your app’s data. And, you can use @State and @Binding to manage updates to these values, as you did earlier in this chapter.

Most apps also use classes to model data. SwiftUI provides a different mechanism to manage changes to class objects: ObservableObject, @StateObject, @ObservedObject and @EnvironmentObject. To practice using @ObservedObject, you’ll refactor TIL to use @StateObject and @ObservedObject to update ThingStore, which conforms to ObservableObject. You’ll see a lot of similarities, and a few differences, to using @State and @Binding.

Note: You can wrap a class object as a @State property, but its “value” is its address in memory, so dependent views will redraw themselves only when its address changes — for example, when the app reinitializes it.

Class and structure

But, this section isn’t just to practice managing objects. ThingStore actually should be a class, not a structure.

@State and @Binding work well enough to update the ThingStore source of truth value in ContentView from AddThingView. But ThingStore isn’t the most natural use of a structure. For the way your app uses ThingStore, a class is a better fit.

A class is more suitable when you need shared mutable state like a HistoryStore or ThingStore. A structure is more suitable when you need multiple independent states like ExerciseDay structures.

For a class object, change is normal. A class object expects its properties to change. For a structure instance, change is exceptional. A structure instance requires advance notice that a method might change a property.

A class object expects to be shared, and any reference can be used to change its properties. A structure instance lets itself be copied, but its copies change independently of it and of each other.

You’ll find out more about classes and structures in Chapter 15, “Structures, Classes & Protocols”.

Managing ThingStore with StateObject and ObservedObject

You’ve already used @EnvironmentObject in Chapter 6, “Adding Functionality to Your App”, to avoid passing HistoryStore through WelcomeView to reach HistoryView.

To use it as an @EnvironmentObject, you converted HistoryStore from a structure to class that conforms to ObservableObject. This is also the first step before you can use @StateObject and @ObservedObject with ThingStore. Once that’s done, you’ll create it as a @StateObject and pass it to a subview that uses it as an @ObservedObject. Sounds a lot like “create a @State property and pass its @Binding”, doesn’t it?

Note: You can pass a @State value or a @StateObject to a subview as a @Binding or @ObservedObject property, even if that subview needs only read access. This enables the subview to redraw itself whenever the @State value or ObservableObject changes. You did this with selectedTab in HeaderView, in Chapter 6, “Adding Functionality to Your App”.

➤ In ContentView.swift, replace the ThingStore structure with the following:

final class ThingStore: ObservableObject {
  @Published var things: [String] = []
}

Just like you did with HistoryStore, you make ThingStore a class instead of a structure, then make it conform to ObservableObject. You mark this class final to tell the compiler it doesn’t have to check for any subclasses overriding properties or methods.

Like HistoryStore, ThingStore publishes its array of data. A view subscribes to this publisher by declaring it as a @StateObject, @ObservedObject or @EnvironmentObject. Any change to things notifies subscriber views to redraw themselves.

You used @EnvironmentObject in Chapter 7, “Observing Objects”. In HIITFit, ExerciseView and HistoryView declared a dependency on a HistoryStore object:

@EnvironmentObject var history: HistoryStore

If a view uses an @EnvironmentObject, you must create the model object by calling the environmentObject(_:) modifier on an ancestor view. You first created the HistoryStore object in ContentView, applying the modifier to the TabView:

TabView(selection: $selectedTab) {
...
}
.environmentObject(HistoryStore())

Then, in Chapter 9, “Saving History Data”, you elevated its initialization up one level to HIITFitApp and declared it as a @StateObject.

Note: Initializing HistoryStore in the environmentObject modifier works while you’re prototyping. To make sure the app never reinitializes an environment object, declare and initialize it as a @StateObject, then pass the property in the environmentObject modifier.

In TIL, AddThingView will use an @ObservedObject, so you must instantiate the model object as a @StateObject in an ancestor view, then pass it as a parameter to its subviews. The owning view creates the @StateObject exactly once.

➤ In ContentView, replace @State private var myThings = ThingStore() with this line:

@StateObject private var myThings = ThingStore()

ThingStore is now a class, not a structure, so you can’t use the @State property wrapper. Instead, you use @StateObject.

The @StateObject property wrapper ensures myThings is instantiated only once. It persists when ContentView redraws itself.

➤ In the call to AddThingView(someThings:), remove the binding symbol $:

AddThingView(someThings: myThings)

You don’t need to create a reference to myThings. As a class object, it’s already a reference.

➤ In AddThingView.swift, replace @Binding in AddThingView with @ObservedObject:

@ObservedObject var someThings: ThingStore

Note: If ThingStore had more properties and you wanted to restrict write access to its things array, you could pass $myThings.things to AddThingView, which would have a @Binding someThings: [String] property.

➤ And fix its previews:

AddThingView(someThings: ThingStore())

The argument isn’t a binding anymore.

➤ Refresh live-preview, tap +, type yolo then tap Done:

TIL in action
TIL in action

No surprise: The app still works the same as before.

MVVM

Model-View-Controller
Model-View-Controller

You may be familiar with Model-View-Controller (MVC) architecture for apps in other settings, like web apps. Your data model knows nothing about how your app presents it to users. The view doesn’t own the data, and the controller mediates between the model and the view.

A commonly used architecture for SwiftUI apps is Model-View-View Model (MVVM). There’s no controller, so the view model prepares model data for the view to display.

Model-View-ViewModel
Model-View-ViewModel

A view model’s properties can include the current text for a text field or whether a specific button is enabled. In a view model, you can also specify actions the view can perform, like button taps or gestures.

A user action is an event that triggers the view model to update the model. If the model connects to a back-end database, data can change independently of user actions. The view model uses these to update the view’s state.

When a view displays a collection of objects or values, its view model manages the data collection. In simple apps like HIITFit and TIL, this is the view model’s only job. So the view model’s name often includes the word “Store”.

MVVM in HIITFit
MVVM in HIITFit

HIITFit’s view model, HistoryStore, saves and loads the user’s exercise history. The model consists of the Exercise and ExerciseDay structures. HistoryStore publishes the exerciseDays array. ExerciseView and HistoryView subscribe to HistoryStore. The ExerciseView’s tap-Done event updates the exerciseDays array, which changes the state of HistoryView.

MVVM in TIL
MVVM in TIL

TIL’s view model, ThingStore, saves the user’s array of acronyms. The model is simply a String and the view model publishes the things array. ContentView and AddThingView subscribe to ThingStore. The AddThingView tap-Done event updates the things array, which changes the state of ContentView.

In the Section 3 app, RWFreeView, the view model stores a collection of Episode instances. It’s responsible for downloading data from raywenderlich.com and decoding the data into Episodes.

Wrapping up property wrappers

Here’s a summary to help you wrap your head around property wrappers.

First, decide whether you’re managing the state of a value or the state of an object. Values are mainly used to describe the state of your app’s user interface. If you can model your app’s data with value data types, you’re in luck because you have a lot more property wrapper options for working with values. But at some level, most apps need reference types to model their data, often to add or remove items from a collection.

Property wrappers for values and objects
Property wrappers for values and objects

Wrapping values

@State and @Binding are the workhorses of value property wrappers. A view owns the value if it doesn’t receive it from any parent views. In this case, it’s a @State property — the single source of truth. When a view is first created, it initializes its @State properties. When a @State value changes, the view redraws itself, resetting everything except its @State properties.

The owning view can pass a @State value to a subview as an ordinary read-only value or as a read-write @Binding.

When you’re prototyping an app and trying out a subview, you might write it as a stand-alone view with only @State properties. Later, when you fit it into your app, you just change @State to @Binding for values that come from a parent view.

Your app can access the built-in @Environment values. An environment value persists within the subtree of the view you attach it to. Often, this is simply a container like VStack, where you use an environment value to set a default like font size.

Note: You can also define your own custom environment value, for example to expose a view’s property to ancestor views. This is beyond the scope of this book, but check out Chapter 9, “State & Data Flow – Part II” of SwiftUI by Tutorials (bit.ly/39bz5vv).

You can store a few values in the @AppStorage or @SceneStorage dictionary. @AppStorage values are in UserDefaults, so they persist after the app closes. You use a @SceneStorage value to restore the state of a scene when the app reopens. In an iOS context, scenes are easiest to see as multiple windows on an iPad.

Wrapping objects

When your app needs to change and respond to changes in a reference type, you create a class that conforms to ObservableObject and publishes the appropriate properties. In this case, you use @StateObject and @ObservedObject in much the same way as @State and @Binding for values. You instantiate your publisher class in a view as a @StateObject then pass it to subviews as an @ObservedObject. When the owning view redraws itself, it doesn’t reset its @StateObject properties.

If your app’s views need more flexible access to the object, you can lift it into the environment of a view’s subtree, still as a @StateObject. You must instantiate it here. Your app will crash if you forget to create it. Then you use the .environmentObject(_:) modifier to attach it to a view. Any view in the view’s subtree can subscribe to the publisher object by declaring an @EnvironmentObject of that type.

To make an environment object available to every view in your app, attach it to the root view when the App creates its WindowGroup.

Key points

  • Every data value or object that can change needs a single source of truth and a mechanism to enable views to update it.

  • Use @State and @Binding to manage changes to user interface values.

  • Access @Environment values as @Environment view properties or by using the environment view modifier.

  • Use @StateObject and @ObservedObject to manage changes to data model objects. The object type must conform to ObservableObject and should publish at least one value.

  • If only a few subviews need access to an ObservableObject, instantiate it as a @StateObject then pass it in the environmentObject view modifier. Declare an @EnvironmentObject property in any subviews that need access to it.

  • When prototyping your app, you can use @State and @Binding with structures that model your app’s data. When you’ve worked out how data needs to flow through your app, you can refactor your app to accommodate data types that need to conform to ObservableObject.

  • A commonly used architecture for SwiftUI apps is Model-View-View Model (MVVM), where the view model is an ObservableObject. Changes to the view model’s published properties cause updates to the model and view.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.