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

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)
  }
}
  1. 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 set timeRemaining very small so you won’t have to wait 30 seconds when you’re testing this feature.
  2. You’ll set up the Start Exercise button in ExerciseView to show TimerView, passing a binding to the timerDone Boolean flag that enables the Done button. You’ll change the value of timerDone when the timer reaches zero, but this value isn’t owned by TimerView so it has to be a Binding variable.
  3. You call the class method Timer.publish(every:on:in:) to create a Timer publisher that publishes an event every 1 second on the run loop of the main — user interface — thread in common mode.

Note: Run loops are the underlying mechanism iOS uses for asynchronous event source processing.

  1. The Timer publisher is a ConnectablePublisher. It won’t start firing upon subscription until you explicitly call its connect() method. Here, you use the autoconnect() operator to connect the publisher as soon as your Text view subscribes to it.
  2. The actual TimerView displays timeRemaining in a large rounded system font, surrounded by padding.
  3. The onReceive(_:perform:) modifier subscribes to the Timer publisher and updates timeRemaining as long as its value is positive.
  4. When timeRemaining reaches 0, it sets timerDone to true. This enables the Done button in ExerciseView.

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:

ExerciseView with disabled Done button
ExerciseView with disabled Done button

The Done button is disabled.

➤ Tap Start Exercise and wait while the timer counts down from three:

ExerciseView with enabled Done button
ExerciseView with enabled Done button

When the timer reaches 0, the Done button is enabled.

➤ Tap Done.

Tap Done to show SuccessView.
Tap Done to show SuccessView.

This is the last exercise, so SuccessView appears.

➤ Tap Continue.

ExerciseView with disabled Done button
ExerciseView with disabled Done button

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.

Show/hide timer without moving other UI elements.
Show/hide timer without moving other UI elements.

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:

HistoryStore view tree
HistoryStore view tree

  • ContentView calls WelcomeView and ExerciseView.
  • WelcomeView and ExerciseView call HistoryView.
  • ExerciseView changes HistoryStore, so HistoryStore must be either a State or a Binding variable in ExerciseView.
  • HistoryView only needs to read HistoryStore.
  • WelcomeView and ExerciseView call HistoryView, so WelcomeView needs read access to HistoryStore only so it can pass this to HistoryView.

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 object audrey, then audrey2 = audrey creates a separate copy of audrey. You can change properties of audrey2 without affecting audrey. Classes are reference types. If Person is a class, and you create Person object audrey, then audrey2 = audrey creates a reference to the same audrey object. If you change a property of audrey2, you also change that property of audrey.

➤ 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.

  1. The date of the first element of exerciseDays is the user’s most recent exercise day. If today is the same as this date, you append the current exerciseName to the exercises array of this exerciseDay.
  2. If today is a new day, you create a new ExerciseDay object and insert it at the beginning of the exerciseDays 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 as mutating, 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:

History: before
History: before

➤ 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:

History: after
History: after

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 an ObservableObject. Or open the starter project in the challenge folder.
  • Save time and effort by commenting out previews in WelcomeView, ExerciseView and HistoryView. Just pin the preview of ContentView so you can inspect your work while editing any view file.
  • Initialize history in ContentView and pass it to WelcomeView and ExerciseView. Use State and bindings where you need to.
  • Pass history to HistoryView from WelcomeView and ExerciseView. In HistoryView, change let history = HistoryStore() to let history: HistoryStore.
  • Add addDoneExercise(_ exerciseName:) to HistoryStore as a mutating method and call it in the action of the Done button in ExerciseView.

My solution is in the challenge/final folder for this chapter.

Key points

  • Create a timer by subscribing to the Timer publisher created by Timer.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 to ObservableObject.

  • Use an ObservableObject as an @EnvironmentObject to let subviews access data without having to pass parameters.

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.