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
Timer
publisher; 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)
}
}
-
timeRemaining
is 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 settimeRemaining
very 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
ExerciseView
to showTimerView
, passing a binding to thetimerDone
Boolean flag that enables the Done button. You’ll change the value oftimerDone
when the timer reaches zero, but this value isn’t owned byTimerView
so it has to be aBinding
variable. - You call the class method
Timer.publish(every:on:in:)
to create aTimer
publisher that publishes an eventevery
1 secondon
the run loop of the main — user interface — threadin
common
mode.
Note: Run loops are the underlying mechanism iOS uses for asynchronous event source processing.
- The
Timer
publisher 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 yourText
view subscribes to it. - The actual
TimerView
displaystimeRemaining
in a large rounded system font, surrounded by padding. - The
onReceive(_:perform:)
modifier subscribes to theTimer
publisher and updatestimeRemaining
as long as its value is positive. - When
timeRemaining
reaches 0, it setstimerDone
totrue
. 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
@ObservableObject
and@EnvironmentObject
to 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:
-
ContentView
callsWelcomeView
andExerciseView
. -
WelcomeView
andExerciseView
callHistoryView
. -
ExerciseView
changesHistoryStore
, soHistoryStore
must be either aState
or aBinding
variable inExerciseView
. -
HistoryView
only needs to readHistoryStore
. -
WelcomeView
andExerciseView
callHistoryView
, soWelcomeView
needs read access toHistoryStore
only 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
Person
is a structure, and you create Person objectaudrey
, thenaudrey2 = audrey
creates a separate copy ofaudrey
. You can change properties ofaudrey2
without affectingaudrey
. Classes are reference types. IfPerson
is a class, and you create Person objectaudrey
, thenaudrey2 = audrey
creates a reference to the sameaudrey
object. 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
date
of the first element ofexerciseDays
is the user’s most recent exercise day. Iftoday
is the same as thisdate
, you append the currentexerciseName
to theexercises
array of thisexerciseDay
. - If
today
is a new day, you create a newExerciseDay
object and insert it at the beginning of theexerciseDays
array.
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
mutating
any 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
HistoryStore
to anObservableObject
. Or open the starter project in the challenge folder. - Save time and effort by commenting out
previews
inWelcomeView
,ExerciseView
andHistoryView
. Just pin the preview ofContentView
so you can inspect your work while editing any view file. - Initialize
history
inContentView
and pass it toWelcomeView
andExerciseView
. UseState
and bindings where you need to. - Pass
history
toHistoryView
fromWelcomeView
andExerciseView
. InHistoryView
, changelet history = HistoryStore()
tolet history: HistoryStore
. - Add
addDoneExercise(_ exerciseName:)
toHistoryStore
as amutating
method 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
Timer
publisher created byTimer.publish(every:tolerance:on:in:options:)
. -
@Binding
declares dependency on a@State
variable owned by another view.@EnvironmentObject
declares dependency on some shared data, such as a reference type that conforms toObservableObject
. -
Use an
ObservableObject
as an@EnvironmentObject
to let subviews access data without having to pass parameters.