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.

4.9 (18) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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
}
  1. ThingStore now publishes an array of Thing values instead of an array of String values.
  2. It’s possible to have the same acronym with different meanings, so Thing needs a unique id value.

In ContentView.swift, modify ContentView to use Thing:

ForEach(myThings.things) { thing in   // 1
  Text(thing.short)   // 2
}
  1. ForEach doesn’t need the id parameter now that it’s iterating over an Identifiable type.
  2. 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)
  1. The placeholder text “TIL” indicates this text field is for the acronym.
  2. 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:

Adding an acronym and its meaning

Adding an acronym and its meaning

Adding an acronym and its meaning

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:

Viewing the acronym you added

Viewing the acronym you added

Viewing the acronym you added

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())
Note: The preview doesn’t need 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.

Adding acronyms from list and detail views

Adding acronyms from list and detail views

Adding acronyms from list and detail views