7.
Observing Objects
Written by Audrey Tam
In the previous chapter, you managed the flow of values to implement most of the functionality your users expect when navigating and using your app. In this chapter, you’ll manage some of your app’s data objects. You’ll use a Timer publisher and give some views access to HistoryStore as an EnvironmentObject.
Showing/Hiding the timer
Skills you’ll learn in this section: using a
Timerpublisher; showing and hiding a subview
Here’s your next feature: In ExerciseView, tapping Start Exercise shows a countdown timer; the Done button is disabled until the timer reaches 0. Tapping Done hides the timer. In this section, you’ll create a TimerView, then use a Boolean flag to show or hide it in ExerciseView.
Using a real Timer
Your app currently uses a Text view with style: .timer. This counts down just fine, but then it counts up and keeps going. You don’t have any control over it. You can’t stop it. You can’t even check when it reaches zero.
Swift has a Timer class with a class method that creates a Timer publisher. Publishers are fundamental to Apple’s new Combine concurrency framework, and a Timer publisher is much easier to work with than a plain old Timer.
Note: For complete coverage of this framework, check out our book Combine: Asynchronous Programming with Swift at https://bit.ly/3sW1L3I.
➤ Continue with your project from the previous chapter or open the project in this chapter’s starter folder.
➤ Create a new SwiftUI view file and name it TimerView.swift.
➤ Replace the View and PreviewProvider structures with the following:
struct TimerView: View {
@State private var timeRemaining = 3 // 1
@Binding var timerDone: Bool // 2
let timer = Timer.publish( // 3
every: 1,
on: .main,
in: .common)
.autoconnect() // 4
var body: some View {
Text("\(timeRemaining)") // 5
.font(.system(size: 90, design: .rounded))
.padding()
.onReceive(timer) { _ in // 6
if self.timeRemaining > 0 {
self.timeRemaining -= 1
} else {
timerDone = true // 7
}
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView(timerDone: .constant(false))
.previewLayout(.sizeThatFits)
}
}
-
timeRemainingis the number of seconds the timer runs for each exercise. Normally, this is 30 seconds. But one of the features you’ll implement in this section is disabling the Done button until the timer reaches zero. You settimeRemainingvery small so you won’t have to wait 30 seconds when you’re testing this feature. - You’ll set up the Start Exercise button in
ExerciseViewto showTimerView, passing a binding to thetimerDoneBoolean flag that enables the Done button. You’ll change the value oftimerDonewhen the timer reaches zero, but this value isn’t owned byTimerViewso it has to be aBindingvariable. - You call the class method
Timer.publish(every:on:in:)to create aTimerpublisher that publishes an eventevery1 secondonthe run loop of the main — user interface — threadincommonmode.
Note: Run loops are the underlying mechanism iOS uses for asynchronous event source processing.
- The
Timerpublisher is aConnectablePublisher. It won’t start firing upon subscription until you explicitly call itsconnect()method. Here, you use theautoconnect()operator to connect the publisher as soon as yourTextview subscribes to it. - The actual
TimerViewdisplaystimeRemainingin a large rounded system font, surrounded by padding. - The
onReceive(_:perform:)modifier subscribes to theTimerpublisher and updatestimeRemainingas long as its value is positive. - When
timeRemainingreaches 0, it setstimerDonetotrue. This enables the Done button inExerciseView.
Note:
onReceive(_:perform:)returns a published event, but your action doesn’t use it, so you acknowledge its existence with_.
Showing the timer
➤ In ExerciseView.swift, replace let interval: TimeInterval = 30 with the following code:
@State private var timerDone = false
@State private var showTimer = false
You’ll pass $timerDone to TimerView, which will set it to true when the timer reaches zero. You’ll use this to enable the Done button.
And, you’ll toggle showTimer just like you did with showHistory and showSuccess.
➤ Next, locate the Text view timer:
Text(Date().addingTimeInterval(interval), style: .timer)
.font(.system(size: 90))
There’s an error flag on it because you deleted the interval property.
➤ Replace this Text view and font modifier with the following code:
if showTimer {
TimerView(timerDone: $timerDone)
}
You call TimerView when showTimer is true, passing it a binding to the State variable timerDone.
➤ Then, replace Button("Start Exercise") { } with the following code:
Button("Start Exercise") {
showTimer.toggle()
}
This is just like your other buttons that toggle a Boolean to show another view.
Enabling the Done button and hiding the timer
➤ Now, add these two lines to the Done button action, above the if-else:
timerDone = false
showTimer.toggle()
If the Done button is enabled, timerDone is now true, so you reset it to false to disable the Done button.
Also, TimerView is showing. This means showTimer is currently true, so you toggle it back to false, to hide TimerView.
➤ Next, add this modifier to the Button, above the sheet(isPresented:) modifier:
.disabled(!timerDone)
You disable the Done button while timerDone is false.
Testing the timer and Done button
➤ Now check previews still shows the last exercise:
ExerciseView(selectedTab: .constant(3), index: 3)
This exercise page provides visible feedback. It responds to tapping Done by showing SuccessView.
➤ Start live preview:
The Done button is disabled.
➤ Tap Start Exercise and wait while the timer counts down from three:
When the timer reaches 0, the Done button is enabled.
➤ Tap Done.
This is the last exercise, so SuccessView appears.
➤ Tap Continue.
Because you’re previewing ExerciseView, not ContentView, you return to ExerciseView, not WelcomeView.
Now the timer is hidden and Done is disabled again.
➤ Tap Start Exercise to see the timer starts from 3 again.
Tweaking the UI
Tapping Start Exercise shows the timer and pushes the buttons and rating symbols down the screen. Tapping Done moves them up again. So much movement is probably not desirable, unless you believe it’s a suitable “feature” for an exercise app.
To stop the buttons and ratings from doing squats, you’ll rearrange the UI elements.
➤ In ExerciseView.swift, locate the line if showTimer { and the line Spacer(). Replace these lines, and everything between them, with the following code:
HStack(spacing: 150) {
Button("Start Exercise") { // Move buttons above TimerView
showTimer.toggle()
}
Button("Done") {
timerDone = false
showTimer.toggle()
if lastExercise {
showSuccess.toggle()
} else {
selectedTab += 1
}
}
.disabled(!timerDone)
.sheet(isPresented: $showSuccess) {
SuccessView(selectedTab: $selectedTab)
}
}
.font(.title3)
.padding()
if showTimer {
TimerView(timerDone: $timerDone)
}
Spacer()
RatingView(rating: $rating) // Move RatingView below Spacer
.padding()
You move the buttons above the timer and RatingView(rating:) below Spacer(). This leaves a stable space to show and hide the timer.
➤ Run live preview. Tap Start Exercise, wait for the Done button, then tap it. The timer appears then disappears. None of the other UI elements moves.
There’s just one last feature to add to your app. It’s another job for the Done button.
Adding an exercise to history
Skills you’ll learn in this section: using
@ObservableObjectand@EnvironmentObjectto let subviews access data; class vs structure
This is the last feature: Tapping Done adds this exercise to the user’s history for the current day. You’ll add the exercise to the exercises array of today’s ExerciseDay object, or you’ll create a new ExerciseDay object and add the exercise to its array.
Examine your app to see which views need to access HistoryStore and what kind of access each view needs:
-
ContentViewcallsWelcomeViewandExerciseView. -
WelcomeViewandExerciseViewcallHistoryView. -
ExerciseViewchangesHistoryStore, soHistoryStoremust be either aStateor aBindingvariable inExerciseView. -
HistoryViewonly needs to readHistoryStore. -
WelcomeViewandExerciseViewcallHistoryView, soWelcomeViewneeds read access toHistoryStoreonly so it can pass this toHistoryView.
More than one view needs access to HistoryStore, so you need a single source of truth. There’s more than one way to do this.
The last list item above is the least satisfactory. You’ll learn how to manage HistoryStore so it doesn’t have to pass through WelcomeView.
➤ Make a copy of this project now and use it to start the challenge at the end of this chapter.
Creating an ObservableObject
To dismiss SuccessView, you used its presentationMode environment property. This is one of the system’s predefined environment properties. You can define your own environment object on a view, and it can be accessed by any subview of that view. You don’t need to pass it as a parameter. Any subview that needs it simply declares it as a property.
So if you make HistoryStore an EnvironmentObject, you won’t have to pass it to WelcomeView just so WelcomeView can pass it to HistoryView.
To be an EnvironmentObject, HistoryStore must conform to the ObservableObject protocol. An ObservableObject is a publisher, like Timer.publisher.
To conform to ObservableObject, HistoryStore must be a class, not a structure.
Swift Tip: Structures and enumerations are value types. If
Personis a structure, and you create Person objectaudrey, thenaudrey2 = audreycreates a separate copy ofaudrey. You can change properties ofaudrey2without affectingaudrey. Classes are reference types. IfPersonis a class, and you create Person objectaudrey, thenaudrey2 = audreycreates a reference to the sameaudreyobject. If you change a property ofaudrey2, you also change that property ofaudrey.
➤ In HistoryStore.swift, replace the first two lines of HistoryStore with the following:
class HistoryStore: ObservableObject {
@Published var exerciseDays: [ExerciseDay] = []
You make HistoryStore a class instead of a structure, then make it conform to the ObservableObject protocol.
You mark the exerciseDays array of ExerciseDay objects with the @Published property wrapper. Whenever exerciseDays changes, it publishes itself to any subscribers, and the system redraws any affected views.
In particular, when ExerciseView adds an ExerciseDay to exerciseDays, HistoryView gets updated.
➤ Now, add the following method to HistoryStore, below init():
func addDoneExercise(_ exerciseName: String) {
let today = Date()
if today.isSameDay(as: exerciseDays[0].date) { // 1
print("Adding \(exerciseName)")
exerciseDays[0].exercises.append(exerciseName)
} else {
exerciseDays.insert( // 2
ExerciseDay(date: today, exercises: [exerciseName]),
at: 0)
}
}
You’ll call this method in the Done button action in ExerciseView.
- The
dateof the first element ofexerciseDaysis the user’s most recent exercise day. Iftodayis the same as thisdate, you append the currentexerciseNameto theexercisesarray of thisexerciseDay. - If
todayis a new day, you create a newExerciseDayobject and insert it at the beginning of theexerciseDaysarray.
Note:
isSameDay(as:)is defined in DateExtension.swift.
➤ Now to fix the error in Preview Content/HistoryStoreDevData.swift, delete mutating:
func createDevData() {
You had to mark this method as mutating when HistoryStore was a structure. You must not use mutating for methods defined in a class.
Swift Tip: Structures tend to be constant, so you must mark as
mutatingany method that changes a property. If you mark a method in a class asmutating, Xcode flags an error. See Chapter 15, “Structures, Classes & Protocols” for further discussion of reference and value types.
Using an EnvironmentObject
Now, you need to set up HistoryStore as an EnvironmentObject in the parent view of ExerciseView. ContentView contains TabView, which calls ExerciseView, so you’ll create the EnvironmentObject “on” TabView.
➤ In ContentView.swift, add this modifier to TabView(selection:) above .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)):
.environmentObject(HistoryStore())
You initialize HistoryStore and pass it to TabView as an EnvironmentObject. This makes it available to all views in the subview tree of TabView, including HistoryView.
➤ In HistoryView.swift, replace let history = HistoryStore() with this property:
@EnvironmentObject var history: HistoryStore
You don’t want to create another HistoryStore object here. Instead, HistoryView can access history directly without needing it passed as a parameter.
➤ Next, add this modifier to HistoryView(showHistory:) in previews:
.environmentObject(HistoryStore())
You must tell previews about this EnvironmentObject or it will crash with no useful information on what went wrong.
➤ In ExerciseView.swift, add the same property to ExerciseView:
@EnvironmentObject var history: HistoryStore
ExerciseView gets read-write access to HistoryStore without passing history from ContentView to ExerciseView as a parameter.
➤ Replace ExerciseView(selectedTab:index:) in previews with the following:
ExerciseView(selectedTab: .constant(0), index: 0)
.environmentObject(HistoryStore())
You’ll preview the first exercise, and you attach HistoryStore as an EnvironmentObject, just like in HistoryView.swift.
➤ Now add this line at the top of the Done button’s action closure:
history.addDoneExercise(Exercise.exercises[index].exerciseName)
You add this exercise’s name to HistoryStore.
➤ Run live preview, then tap History to see what’s already there:
➤ Dismiss HistoryView, then tap Start Exercise. When Done is enabled, tap it. Because you’re previewing ExerciseView, it won’t progress to the next exercise.
➤ Now tap History again:
There’s your new ExerciseDay with this exercise!
Your app is working pretty well now, with all the expected navigation features. But you still need to save the user’s ratings and history so they’re still there after quitting and restarting your app. And then, you’ll finally get to make your app look pretty.
Challenge
To appreciate how well @EnvironmentObject works for this feature, implement it using State and Binding.
Challenge: Use @State and @Binding to add exercise to HistoryStore
- Start from the project copy you made just before you changed
HistoryStoreto anObservableObject. Or open the starter project in the challenge folder. - Save time and effort by commenting out
previewsinWelcomeView,ExerciseViewandHistoryView. Just pin the preview ofContentViewso you can inspect your work while editing any view file. - Initialize
historyinContentViewand pass it toWelcomeViewandExerciseView. UseStateand bindings where you need to. - Pass
historytoHistoryViewfromWelcomeViewandExerciseView. InHistoryView, changelet history = HistoryStore()tolet history: HistoryStore. - Add
addDoneExercise(_ exerciseName:)toHistoryStoreas amutatingmethod and call it in the action of the Done button inExerciseView.
My solution is in the challenge/final folder for this chapter.
Key points
-
Create a timer by subscribing to the
Timerpublisher created byTimer.publish(every:tolerance:on:in:options:). -
@Bindingdeclares dependency on a@Statevariable owned by another view.@EnvironmentObjectdeclares dependency on some shared data, such as a reference type that conforms toObservableObject. -
Use an
ObservableObjectas an@EnvironmentObjectto let subviews access data without having to pass parameters.