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

6. Adding Functionality to Your App
Written by Audrey Tam

In the previous chapter, you structured your app’s data to be more efficient and less error-prone. In this chapter, you’ll implement most of the functionality your users expect when navigating and using your app. Now, you’ll need to manage your app’s data so values flow smoothly through the views and subviews of your app.

Managing your app’s data

SwiftUI has two guiding principles for managing how data flows through your app:

  • Data access = dependency: Reading a piece of data in your view creates a dependency for that data in that view. Every view is a function of its data dependencies — its inputs or state.
  • Single source of truth: Every piece of data that a view reads has a source of truth, which is either owned by the view or external to the view. Regardless of where the source of truth lies, you should always have a single source of truth.

Tools for data flow

SwiftUI provides several tools to help you manage the flow of data in your app. The SwiftUI framework takes care of creating views when they should appear and updating them whenever there’s a change to data they depend on.

Property wrappers augment the behavior of properties. SwiftUI-specific wrappers like @State, @Binding, and @EnvironmentObject declare a view’s dependency on the data represented by the property.

Some of the data flow in HIITFit
Some of the data flow in HIITFit

Each wrapper indicates a different source of data:

  • A @State property is a source of truth. One view owns it and passes its value or reference, known as a binding, to its subviews.
  • A @Binding property is a reference to a @State property owned by another view. It gets its initial value when the other view passes it a binding, using the $ prefix. Having this reference to the source of truth enables the subview to change the property’s value, and this changes the state of any view that depends on this property.
  • @EnvironmentObject declares dependency on some shared data — data that’s visible to all views in a subtree of the app. It’s a convenient way to pass data indirectly instead of passing data from parent view to child to grandchild, especially if the in-between child view doesn’t need it.

You’ll learn more about these, and other, property wrappers in Chapter 11, “Understanding Property Wrappers”.

Navigating TabView

Skills you’ll learn in this section: using @State and @Binding properties; pinning a preview; adding @Binding parameters in previews

Here’s your first feature: Set up TabView to use tag values. When a button changes the value of selectedTab, TabView displays that tab.

Open the starter project. It’s the same as the final no-localization project from the previous chapter.

Tagging the tabs

➤ In ContentView.swift, add this property to ContentView:

@State private var selectedTab = 9

Note: You almost always mark a State property private, to emphasize that it’s owned and managed by this view specifically. Only this view’s code in this file can access it directly. An exception is when the App needs to initialize ContentView, so it needs to pass values to its State properties. Learn more about access control in Swift Apprentice, Chapter 18, “Access Control, Code Organization & Testing” bit.ly/37EUQDk.

Declaring selectedTab as a @State property in ContentView means ContentView owns this property, which is the single source of truth for this value.

Other views will use the value of selectedTab, and some will change this value to make TabView display another page. But, you won’t declare it as a State property in any other view.

The initial value of selectedTab is 9, which you’ll set as the tag value of the welcome page.

➤ Now replace the entire body closure of ContentView with the following code:

var body: some View {
  TabView(selection: $selectedTab) {
    WelcomeView(selectedTab: $selectedTab)  // 1
      .tag(9)  // 2
    ForEach(0 ..< Exercise.exercises.count) { index in
      ExerciseView(selectedTab: $selectedTab, index: index)
        .tag(index)  // 3
    }
  }
  .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}

Xcode complains you’re passing an extra argument because you haven’t yet added a selectedTab property to WelcomeView or ExerciseView. You’ll do that soon.

  1. You pass the binding $selectedTab to WelcomeView and ExerciseView so TabView can respond when they change its value.
  2. You use 9 for the tag of WelcomeView.
  3. You tag each ExerciseView with its index in Exercise.exercises.

➤ Before you head off to edit WelcomeView.swift and ExerciseView.swift, click the pin button to pin the preview of ContentView:

Pin the preview of ContentView.
Pin the preview of ContentView.

When you change code in WelcomeView.swift and ExerciseView.swift, you’ll be able to live-preview the results without needing to go back to ContentView.swift.

Adding a Binding to a view

➤ Now, in ExerciseView.swift, add this property to ExerciseView, above let index: Int:

@Binding var selectedTab: Int

You’ll soon write code to make ExerciseView change the value of selectedTab, so it can’t be a plain old var selectedTab. Views are structures, which means you can’t change a property value unless you mark it with a property wrapper like @State or @Binding.

ContentView owns the source of truth for selectedTab. You don’t declare @State private var selectedTab here in ExerciseView because that would create a duplicate source of truth, which you’d have to keep in sync with the selectedTab value in ContentView. Instead, you declare @Binding var selectedTab — a reference to the State variable owned by ContentView.

➤ You need to update previews because it creates an ExerciseView instance. Add this new parameter like this:

ExerciseView(selectedTab: .constant(1), index: 1)

You just want the preview to show the second exercise, but you can’t pass 1 as the selectedTab value. You must pass a Binding, which is tricky in a standalone situation like this, where you don’t have a @State property to bind to. Fortunately, SwiftUI provides the Binding type method constant(_:) to create a Binding from a constant value.

➤ Now add the same property to WelcomeView in WelcomeView.swift:

@Binding var selectedTab: Int

➤ And add this parameter in its previews:

WelcomeView(selectedTab: .constant(9))

➤ Now that you’ve fixed the errors, you can Resume the preview in WelcomeView.swift:

WelcomeView preview with pinned ContentView preview
WelcomeView preview with pinned ContentView preview

Progressing to the first exercise

Next, you’ll implement the Welcome page Get Started button action to display the first ExerciseView.

➤ In WelcomeView.swift, replace Button(action: { }) { with this:

Button(action: { selectedTab = 0 }) {

➤ Now turn on live preview for the pinned ContentView preview, then tap Get Started.

Tap Get Started to show first exercise.
Tap Get Started to show first exercise.

Note: You can’t preview this action in the WelcomeView preview because it doesn’t include ExerciseView. Tapping Get Started doesn’t go anywhere.

You’ve used selectedTab to navigate from the welcome page to the first exercise!

Next, you’ll work even more magic in ExerciseView.swift.

Progressing to the next exercise

Your users will be exerting a lot of physical energy to perform the exercises. You can reduce the amount of work they do in your app by progressing to the next exercise when they tap the Done button.

➤ First, simplify your life by separating the Start and Done buttons in ExerciseView. In ExerciseView.swift, replace Button("Start/Done") { } with this HStack:

HStack(spacing: 150) {
  Button("Start Exercise") { }
  Button("Done") { }
}

Keep the font and padding modifiers on the HStack, so both buttons use title3 font size, and the padding surrounds the HStack.

Now you’re ready to implement your time-saving action for the Done button: Tapping Done goes to the next ExerciseView, and tapping Done in the last ExerciseView goes to WelcomeView.

➤ Add this to the other properties in ExerciseView:

var lastExercise: Bool {
  index + 1 == Exercise.exercises.count
}

You create a computed property to check whether this is the last exercise.

➤ In ExerciseView.swift, replace Button("Done") { } with the following code:

Button("Done") {
  selectedTab = lastExercise ? 9 : selectedTab + 1
}

Swift Tip: The ternary conditional operator tests the condition specified before ?, then evaluates the first expression after ? if the condition is true. Otherwise, it evaluates the expression after :.

Later in this chapter, you’ll show SuccessView when the user taps Done on the last ExerciseView. Then dismissing SuccessView will progress to WelcomeView.

➤ Refresh live preview for the pinned ContentView preview, then tap Get Started to load the first exercise. Tap Done on each exercise page to progress to the next. Tap Done on the last exercise to return to the welcome page.

Tap your way through the pages.
Tap your way through the pages.

Next-page navigation is great, but your users might want to jump directly to their favorite exercise. You’ll implement this soon.

Interacting with page numbers and ratings

Skills you’ll learn in this section: passing a value vs. passing a Binding; making Image tappable

Users expect the page numbers in HeaderView to indicate the current page. A convenient indicator is the fill version of the symbol. In light mode, it’s a white number on a black background.

Light mode 2.circle and 2.circle.fill
Light mode 2.circle and 2.circle.fill

➤ In HeaderView.swift, replace the contents of HeaderView with the following code:

@Binding var selectedTab: Int  // 1
let titleText: String

var body: some View {
  VStack {
    Text(titleText)
      .font(.largeTitle)
    HStack {  // 2
      ForEach(0 ..< Exercise.exercises.count) { index in  // 3
        let fill = index == selectedTab ? ".fill" : ""
        Image(systemName: "\(index + 1).circle\(fill)")  // 4
      }
    }
    .font(.title2)
  }
}
  1. HeaderView doesn’t change the value of selectedTab, but it needs to redraw itself when other views change this value. You create this dependency by declaring selectedTab as a @Binding.

  2. The Welcome page doesn’t really need a page “number”, so you delete the "hand.wave" symbol from the HStack.

  3. To accommodate any number of exercises, you create the HStack by looping over the exercises array.

  4. You create each symbol’s name by joining together a String representing the integer index + 1, the text ".circle" and either ".fill" or the empty String, depending on whether index matches selectedTab. You use a ternary conditional expression to choose between ".fill" and "".

➤ Now previews needs this new parameter, so replace the Group contents with the following:

HeaderView(selectedTab: .constant(0), titleText: "Squat")
  .previewLayout(.sizeThatFits)
HeaderView(selectedTab: .constant(1), titleText: "Step Up")
  .preferredColorScheme(.dark)
  .environment(\.sizeCategory, .accessibilityLarge)
  .previewLayout(.sizeThatFits)

Next, you need to update the instantiations of HeaderView in WelcomeView and ExerciseView.

➤ In WelcomeView.swift, change HeaderView(titleText: "Welcome") to the following:

HeaderView(selectedTab: $selectedTab, titleText: "Welcome")

➤ In ExerciseView.swift, change HeaderView(titleText: Exercise.exercises[index].exerciseName) to the following:

HeaderView(
  selectedTab: $selectedTab,
  titleText: Exercise.exercises[index].exerciseName) 

➤ Refresh live preview for the pinned ContentView preview, then tap Get Started to load the first exercise. The 1 symbol is filled. Tap Done on each exercise page to progress to the next and see the symbol for each page highlight.

ExerciseView with page numbers
ExerciseView with page numbers

Making page numbers tappable

Many users expect page numbers to respond to tapping by going to that page.

➤ In HeaderView.swift, add this modifier to Image(systemName:):

.onTapGesture {
  selectedTab = index
}

This modifier reacts to the user tapping the Image by setting the value of selectedTab.

➤ Refresh live preview for the pinned ContentView preview, then tap a page number to navigate to that exercise page:

Tap page number to jump to last exercise.
Tap page number to jump to last exercise.

Congratulations, you’ve improved your app’s user experience out of sight by providing all the navigation features your users expect.

Indicating and changing the rating

The onTapGesture modifier is also useful for making RatingView behave the way everyone expects: Tapping one of the five rating symbols changes the color of that symbol and all those preceding it to red. The remaining symbols are gray.

Rating view: rating = 3
Rating view: rating = 3

➤ First, add a rating property to ExerciseView. In ExerciseView.swift, add this to the other properties:

@State private var rating = 0

In Chapter 8, “Saving Settings”, you’ll save the rating value along with the exerciseName, so ExerciseView needs this rating property. You use the property wrapper @State because rating must be able to change, and ExerciseView owns this property.

➤ Now scroll down to RatingView() and replace it with this line:

RatingView(rating: $rating)

You pass a binding to rating to RatingView because that’s where the actual value change will happen.

➤ In RatingView.swift, in RatingView_Previews, replace RatingView() with this line:

RatingView(rating: .constant(3))

➤ Now replace the contents of RatingView with the following code:

@Binding var rating: Int  // 1
let maximumRating = 5  // 2

let onColor = Color.red  // 3
let offColor = Color.gray

var body: some View {
  HStack {
    ForEach(1 ..< maximumRating + 1) { index in
      Image(systemName: "waveform.path.ecg")
        .foregroundColor(
          index > rating ? offColor : onColor)  // 4
        .onTapGesture {  // 5
          rating = index
        }
    }
  }
  .font(.largeTitle)
}
  1. ExerciseView passes to RatingView a binding to its @State property rating.
  2. Most apps use a 5-level rating system, but you can set a different value for maximumRating.
  3. When rating is an integer between 1 and maximumRating, the first rating symbols should be the onColor, and the remaining symbols should be the offColor.
  4. In the HStack, you still loop over the symbols, but now you set the symbol’s foregroundColor to offColor if its index is higher than rating.
  5. When the user taps a symbol, you set rating to that index.

➤ Refresh live preview for the pinned ContentView preview, then tap a page number to navigate to that exercise page. Tap different symbols to see the colors change:

Rating view
Rating view

➤ Navigate to other exercise pages and set their ratings, then navigate through the pages to see the ratings are still the values you set.

➤ Click the pin button to unpin the ContentView preview.

Showing and hiding modal sheets

Skills you’ll learn in this section: more practice with @State and @Binding; using a Boolean flag to show a modal sheet; dismissing a modal sheet by toggling the Boolean flag or by using @Environment(\.presentationMode)

HistoryView and SuccessView are modal sheets that slide up over WelcomeView or ExerciseView. You dismiss the modal sheet by tapping its circled-x or Continue button, or by dragging it down.

Showing HistoryView

One way to show or hide a modal sheet is with a Boolean flag.

➤ In WelcomeView.swift, add this State property to WelcomeView:

@State private var showHistory = false

When this view loads, it doesn’t show HistoryView.

➤ Replace Button("History") { } with the following:

Button("History") {
  showHistory.toggle()
}
.sheet(isPresented: $showHistory) {
  HistoryView(showHistory: $showHistory)
}

Tapping the History button toggles the value of showHistory from false to true. This causes the sheet modifier to present HistoryView.

You pass a binding $showHistory to HistoryView so it can change this value back to false when the user dismisses HistoryView.

➤ You’ll edit HistoryView to do this soon. But first, repeat the steps above in ExerciseView.swift.

Hiding HistoryView

There are actually two ways to dismiss a modal sheet. This way is the easiest to understand. You set a flag to true to show the sheet, so you set the flag to false to hide it.

➤ In HistoryView.swift, add this property:

@Binding var showHistory: Bool

This matches the argument you passed to HistoryView from WelcomeView.

➤ Add this new parameter in previews:

HistoryView(showHistory: .constant(true))

➤ Now replace Button(action: {}) { with the following:

Button(action: { showHistory.toggle() }) {

You toggle showHistory back to false, so HistoryView goes away.

➤ Go back to WelcomeView.swift, start live preview, then tap History:

Testing WelcomeView History button
Testing WelcomeView History button

HistoryView slides up over WelcomeView, as it should. Tap the dismiss button to hide it. You can also drag down on HistoryView.

➤ Also check the History button in ExerciseView.swift:

Testing ExerciseView History button
Testing ExerciseView History button

Your app has another modal sheet to show and hide. You’ll show it the same way as HistoryView, but you’ll use a different way to hide it.

Showing SuccessView

In ExerciseView.swift, you’ll modify the action of the Done button so when the user taps it on the last exercise, it displays SuccessView.

➤ First, add the @State property:

@State private var showSuccess = false

➤ Then replace the Done button action with an if-else statement and add the sheet(isPresented:) modifier:

Button("Done") {
  if lastExercise {
    showSuccess.toggle()
  } else {
    selectedTab += 1
  }
}
.sheet(isPresented: $showSuccess) {
  SuccessView()
}

Notice you don’t pass $showSuccess to SuccessView(). You’re going to use a different way to dismiss SuccessView. And the first difference is, it doesn’t use the Boolean flag.

Hiding SuccessView

The internal workings of this way are complex, but it simplifies your code because you don’t need to pass a parameter to the modal sheet. And you can use exactly the same two lines of code in every modal view.

➤ In SuccessView.swift, add this property to SuccessView:

@Environment(\.presentationMode) var presentationMode

@Environment(\.presentationMode) gives read access to the environment variable referenced by the key path \.presentationMode.

Every view’s environment has properties like colorScheme, locale and the device’s accessibility settings. Many of these are inherited from the app, but a view’s presentationMode is specific to the view. It’s a binding to a structure with an isPresented property and a dismiss() method.

When you’re viewing SuccessView, its isPresented value is true. You want to change this value to false when the user taps the Continue button.

But the @Environment property wrapper doesn’t let you set an environment value directly. You can’t write presentationMode.isPresented = false.

Here’s what you need to do.

➤ In SuccessView.swift, replace Button("Continue") { } with the following:

Button("Continue") {
  presentationMode.wrappedValue.dismiss()
}

You access the underlying PresentationMode instance as the wrappedValue of the presentationMode binding, then call the PresentationMode method dismiss(). This method isn’t a toggle. It dismisses the view if it’s currently presented. It does nothing if the view isn’t currently presented.

➤ Go back to ExerciseView.swift and change the line in previews to the following:

ExerciseView(selectedTab: .constant(3), index: 3)

To test showing and hiding SuccessView, you’ll preview the last exercise page.

➤ Refresh the preview and start live preview. You should see Sun Salute. Tap Done:

Tap Done on the last exercise to show SuccessView.
Tap Done on the last exercise to show SuccessView.

➤ Tap Continue to dismiss SuccessView.

One more thing

The High Five! message of SuccessView gives your user a sense of accomplishment. Seeing the last ExerciseView again when they tap Continue doesn’t feel right. Wouldn’t it be better to see the welcome page again?

➤ In SuccessView.swift, add this property:

@Binding var selectedTab: Int

SuccessView needs to be able to change this value.

➤ Also add it in previews:

SuccessView(selectedTab: .constant(3))

➤ And add this line to the Continue button action:

selectedTab = 9

WelcomeView has tag value 9.

Note: You can add it either above or below the dismiss call, but adding it above feels more like the right order of things.

Now back to ExerciseView.swift to pass this parameter to SuccessView.

➤ Change SuccessView() to this line:

SuccessView(selectedTab: $selectedTab)

➤ And finally, back to ContentView.swift to see it work. Run live preview, tap the page 4 button, tap Done, then tap Continue:

Dismissing SuccessView returns to WelcomeView.
Dismissing SuccessView returns to WelcomeView.

Note: If you don’t see the welcome page, press Command-B to rebuild the app, then try again.

Tapping Continue on SuccessView displays WelcomeView and dismisses SuccessView.

You’ve used a Boolean flag to show modal sheets. And you’ve used the Boolean flag and the environment variable .\presentationMode to dismiss the sheets.

In this chapter, you’ve used view values to navigate your app’s views and show modal sheets. In the next chapter, you’ll observe objects: You’ll subscribe to a Timer publisher and rework HistoryStore as an ObservableObject.

Key points

  • Declarative app development means you declare both how you want the views in your UI to look and also what data they depend on. The SwiftUI framework takes care of creating views when they should appear and updating them whenever there’s a change to data they depend on.
  • Data access = dependency: Reading a piece of data in your view creates a dependency for that data in that view.
  • Single source of truth: Every piece of data has a source of truth, internal or external. Regardless of where the source of truth lies, you should always have a single source of truth.
  • Property wrappers augment the behavior of properties: @State, @Binding and @EnvironmentObject declare a view’s dependency on the data represented by the property.
  • @Binding declares dependency on a @State property owned by another view. @EnvironmentObject declares dependency on some shared data, like a reference type that conforms to ObservableObject.
  • Use Boolean @State properties to show and hide modal sheets or subviews. Use @Environment(\.presentationMode) as another way to dismiss a modal sheet.
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.