SwiftUI Property Wrappers
Learn different ways to use SwiftUI property wrappers to manage changes to an app’s data values and objects. By Audrey Tam.
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
SwiftUI Property Wrappers
35 mins
- Getting Started
- Tools for Managing Data
- Property Wrappers
- Managing UI State Values
- Managing ThingStore With @State and @Binding
- Using a TextField
- Accessing Environment Values
- Modifying Environment Values
- Managing Model Data Objects
- Class and Structure
- Managing ThingStore With @StateObject and @ObservedObject
- Refactoring TIL
- Using Thing Structure
- Navigating to ThingView
- Adding a New Thing From ThingView
- Using @EnvironmentObject
- Wrapping Up Property Wrappers
- Wrapping Values
- Wrapping Objects
- One More Thing
- Where to Go From Here?
Refactoring TIL
You’ve managed the state of data in TIL with @State
and @Binding
for ThingStore
as a structure (value type), then with ObservableObject
, @StateObject
and @ObservedObject
for ThingStore
as a class (reference type).
TIL is a very simple app with a very simple view hierarchy. AddThingView
is a subview of ContentView
, so ContentView
just passes the ThingStore
value or object to AddThingView
.
Most apps have a more complex view hierarchy, where you might find yourself passing an object to a subview just so it can pass it on to one of its subviews. In this situation, you should consider using @EnvironmentObject
.
To try this out, you’ll need to make TIL a little less simple. Keeping a list of acronyms isn’t much use if you can’t remember what they mean. TIL really needs to store the long form of each acronym. So first, you’ll create a Thing
structure and refactor TIL to use this instead of String
. Then, you’ll create a detail view to display when the user taps an acronym in ContentView
. To give you a reason to use @EnvironmentObject
, ThingView
will have the same “Add New Thing” button as ContentView
.
Using Thing Structure
Move ThingStore
to its own file and add struct Thing
:
// ThingStore.swift
import SwiftUI
final class ThingStore: ObservableObject {
@Published var things: [Thing] = [] // 1
}
struct Thing: Identifiable {
let id = UUID() // 2
let short: String
let long: String
}
-
ThingStore
now publishes an array ofThing
values instead of an array ofString
values. - It’s possible to have the same acronym with different meanings, so
Thing
needs a uniqueid
value.
In ContentView.swift, modify ContentView
to use Thing
:
ForEach(myThings.things) { thing in // 1
Text(thing.short) // 2
}
-
ForEach
doesn’t need theid
parameter now that it’s iterating over anIdentifiable
type. - You display the short form of the acronym.
There’s a lot more work to do in AddThingView.swift.
Replace @State private var thing = ""
with two @State
properties:
@State private var short = ""
@State private var long = ""
Replace TextField("Thing I Learned", text: $thing)
with these two text fields:
TextField("TIL", text: $short) // 1
.disableAutocorrection(true)
.autocapitalization(.allCharacters) // 2
TextField("Thing I Learned", text: $long)
.autocapitalization(.words)
- The placeholder text “TIL” indicates this text field is for the acronym.
- Now that user input includes text with different capitalizations, you modify each text field to automatically capitalize either all letters or all words.
Now you don’t need or want to set the textCase
environment value for ContentView
.
Delete .environment(\.textCase, .uppercase)
in TILApp.swift and in struct ContentView_Previews
in ContentView.swift.
And, you don’t need to override .uppercase
in AddThingView
. Back in AddThingView.swift, delete .textCase(nil)
from the VStack
.
Continue to refactor AddThingView
to work with Thing
instead of String
:
Move the textFieldStyle
and padding
to modify the VStack
:
// VStack { ... }
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
Now that you have two text fields, you modify their container instead of attaching the same modifiers to both text fields.
Next, modify the Done button action to create a Thing instance
:
if !short.isEmpty {
someThings.things.append(
Thing(short: short, long: long))
}
Live-preview ContentView
and add the short and long versions of an acronym like FTW:
I actually typed “ftw” and “for the win”. I didn’t touch the Shift key at all.
Now, you need a detail view to navigate to when the user taps an acronym in the list.
Navigating to ThingView
Create a new SwiftUI View file named ThingView.swift and replace its contents with the following:
import SwiftUI
struct ThingView: View {
let thing: Thing
var body: some View {
VStack {
Text(thing.short)
.font(.largeTitle)
Text(thing.long)
.font(.title)
Spacer()
}
.padding()
}
}
struct ThingView_Previews: PreviewProvider {
static var previews: some View {
ThingView(thing: Thing(short: "TIL", long: "Thing I Learned"))
}
}
You just display the acronym and its meaning.
In ContentView.swift, in the ForEach
closure, replace Text(thing.short)
with the following:
NavigationLink(destination: ThingView(thing: thing)) {
Text(thing.short)
}
You pass a Thing
value to ThingView
. To do its main job — display a Thing
value — ThingView
doesn’t need access to the ThingStore
object.
Live-preview ContentView
, add the short and long versions of an acronym, then check out its detail view:
Well yes, you could easily display both short and long texts in the main list. Here’s a possible use case for the detail view: Use the main list to quiz yourself, then display the detail view to check your answer.
Adding a New Thing From ThingView
Now, suppose you want to let the user add a new Thing
from ThingView
.
Copy the showAddThing
property and the .sheet
and .toolbar
modifiers from ContentView
to ThingView
:
@State private var showAddThing = false
// ...
// modify the VStack with these
.sheet(isPresented: $showAddThing) {
AddThingView(someThings: myThings)
}
.toolbar {
ToolbarItem {
Button(action: { showAddThing.toggle() }) {
Image(systemName: "plus.circle")
.font(.title)
}
}
}
And Xcode complains “Cannot find ‘myThings’ in scope”. So, it’s time to make a decision! Do you pass myThings
from ContentView
to ThingView
just so it can pass it on to AddThingView
?
TIL is still a small app so this isn’t a life-changing decision. But your own apps will grow and grow and, at some point, you’ll have to face this kind of decision in earnest. Here’s the other option.
Using @EnvironmentObject
An @EnvironmentObject
is available to every view in a subtree of the app’s view hierarchy. You don’t pass it as a parameter.
You don’t need to do anything to ThingStore
because you still instantiate an ObservableObject
as a @StateObject
before using it as an @EnvironmentObject
.
What does change is where you instantiate the @StateObject
you’re going to use as an @EnvironmentObject
.
If a view uses an @EnvironmentObject
, you must create the model object by calling the environmentObject(_:)
modifier on an ancestor view. ContentView
uses the ThingStore
object, so you create it in TILApp.swift when it creates ContentView
.
Move the declaration of ThingStore()
from ContentView.swift to TILApp.swift:
struct TILApp: App {
@StateObject private var store = ThingStore()
Then, in WindowGroup
, add this modifier to ContentView()
:
.environmentObject(store)
Now, any view in TIL can access this ThingStore
object directly.
In ContentView.swift, replace the myThings
property with this:
@EnvironmentObject private var myThings: ThingStore
ContentView()
doesn’t have to use the same variable name as TILApp
. The ThingStore
type is like a dictionary key, and Xcode matches up its value to myThings
.
Now, modify AddThingView
to use your environment object.
Delete the argument from the call to AddThingView
:
AddThingView()
Xcode complains, but you’re about to fix the error. Although ContentView
can perfectly well pass myThings
to AddThingView
, you don’t want to make ThingView
do the same. So AddThingView
needs to access the ThingStore
object as an @EnvironmentObject
.
Fix the preview by attaching a ThingStore
object:
ContentView()
.environmentObject(ThingStore())
ThingStore
to persist between view refreshes.
In AddThingView.swift, replace someThings
with this:
@EnvironmentObject var someThings: ThingStore
You just change @ObservedObject
, which must be passed in as a parameter, to EnvironmentObject
, which is just there in the environment.
Also fix the preview: Delete the argument and attach a ThingStore
object:
AddThingView()
.environmentObject(ThingStore())
If you don’t create a ThingStore
object for the preview, it crashes when you tap Done.
And, in ThingView.swift, delete the argument from the call to AddThingView
:
AddThingView()
That’s all you need to do! The two views that use the ThingStore
object access it as an @EnvironmentObject
, giving it any name they want. ThingView
doesn’t need to know anything about ThingStore
.
Live-preview ContentView
and run it through its paces.