Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

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

6. Observing Objects
Written by Audrey Tam

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 TimeLineView and give some views access to HistoryStore as an EnvironmentObject.

Showing/Hiding the Timer

Skills you’ll learn in this section: using a TimeLineView; 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 TimelineView

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.

struct CountdownView: View {
  let date: Date
  @Binding var timeRemaining: Int
  let size: Double

  var body: some View {
    Text("\(timeRemaining)")  // 5
      .font(.system(size: size, design: .rounded))
      .padding()
      .onChange(of: date) { _ in  // 6
        timeRemaining -= 1
      }
  }
}

struct TimerView: View {
  @State private var timeRemaining: Int = 3  // 1
  @Binding var timerDone: Bool  // 2
  let size: Double

  var body: some View {
    TimelineView(  // 3
      .animation(
        minimumInterval: 1.0,
        paused: timeRemaining <= 0)) { context in
          CountdownView(  // 4
            date: context.date,
            timeRemaining: $timeRemaining,
            size: size)
        }
        .onChange(of: timeRemaining) { _ in
          if timeRemaining < 1 {
            timerDone = true  // 7
          }
        }
  }
}

struct TimerView_Previews: PreviewProvider {
  static var previews: some View {
    TimerView(timerDone: .constant(false), size: 90)
  }
}

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
Text(Date().addingTimeInterval(interval), style: .timer)
  .font(.system(size: geometry.size.height * 0.07))
if showTimer {
  TimerView(
    timerDone: $timerDone,
    size: geometry.size.height * 0.07
  )
}
Button("Start Exercise") {
  showTimer.toggle()
}

Enabling the Done Button & Hiding the Timer

➤ Now, in doneButton, add these two lines to the Done button action, above the if-else:

timerDone = false
showTimer.toggle()
.disabled(!timerDone)

Testing the Timer & Done Button

➤ Now check that previews still shows the last exercise:

ExerciseView(selectedTab: .constant(3), index: 3)
ExerciseView with disabled Done button
AtampoxeLead xots vatikjum Putu kiffex

ExerciseView with enabled Done button
EhophoziQaem qifw axujyos Fipe qirkeg

Tap Done to show SuccessView.
Yod Zeli fe yyow PowkucxZaek.

ExerciseView with disabled Done button
UpacvolaGaix cagj dofuvyis Zute nukzad

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.

HStack(spacing: 150) {
  startButton
  doneButton
  .disabled(!timerDone)
  .sheet(isPresented: $showSuccess) {
    SuccessView(selectedTab: $selectedTab)
      .presentationDetents([.medium, .large])
  }
}
.font(.title3)
.padding()

if showTimer {
  TimerView(
    timerDone: $timerDone,
    size: geometry.size.height * 0.07
  )
}
Spacer()
RatingView(rating: $rating) // Move RatingView below Spacer
  .padding()
Show/hide timer without moving other UI elements.
Xdij/noji giyiz fiqsouh yexazr acvek UE orevanvg.

Using an EnvironmentObject

Skills you’ll learn in this section: using @ObservableObject and @EnvironmentObject to let subviews access data; class vs structure

HistoryStore view tree
MadlinfSyeje tuan dqeu

Creating an ObservableObject

To dismiss SuccessView, you used its dismiss 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.

class HistoryStore: ObservableObject {
  @Published var exerciseDays: [ExerciseDay] = []
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)
  }
}
func createDevData() {

Using Your @EnvironmentObject

Now, you need to set up HistoryStore as an @EnvironmentObject in the parent view of ExerciseView. ContentView contains TabView, which contains ExerciseView, so you’ll create the @EnvironmentObject “on” TabView.

.environmentObject(HistoryStore())
@EnvironmentObject var history: HistoryStore
.environmentObject(HistoryStore())
@EnvironmentObject var history: HistoryStore
ExerciseView(selectedTab: .constant(0), index: 0)
  .environmentObject(HistoryStore())
history.addDoneExercise(Exercise.exercises[index].exerciseName)
History: before
Kawjotp: toruna

History: after
Sinkihb: ezhes

Challenge

To appreciate how well @EnvironmentObject works for this feature, implement it using State and Binding.

Challenge: Use @State & @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.

Key Points

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.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now