Streaming is available in most browsers,
and in the Developer app.
-
Build a workout app for Apple Watch
Build a workout app from scratch using SwiftUI and HealthKit during this code along. Learn how to support the Always On state using timelines to update workout metrics. Follow best design practices for workout apps.
Resources
Related Videos
WWDC23
WWDC21
-
Download
♪ Bass music playing ♪ ♪ Brady White: Hi, I’m Brady. I’m an engineer on the Fitness team. Thanks for joining me. There are lots of great workout apps already available in the App Store. Apple Watch is an amazing device capable of tracking all sorts of fitness activity. It can track distance and elevation on those challenging bike rides. You can monitor your heart rate and energy burned during intense workouts. For swimming, it can even detect stroke type and count laps. All of this and more is available for you to build a great workout app. Let’s take a look at what we will be covering today. This session is a Code-Along. We'll go over what that means and how you can participate. We'll build our workout views in SwiftUI. Then, we'll integrate HealthKit to our views. We'll also show you how to support the Always On state. I'm excited, let's get started. This session is a code-along. We will build a workout app together from scratch. While you get your Xcode ready, let's cover a couple of concepts. What is a workout app? A workout app tracks fitness activity during a workout. A workout can be started with a single tap. While the workout is in session, live metrics are displayed, such as elapsed time, energy burned, heart rate, and distance. When the workout ends, a summary shows the metrics recorded for the workout. This is what we will build today. Let’s get started building our workout views. Let’s open Xcode and start a new project. Click Create a new Xcode project, watchOS, Watch App, click Next. Give your workout app a name, such as "MyWorkouts". Ensure Interface is SwiftUI, and language is Swift and click Next. Find a location for your project and click Create.
Let’s hide the inspector and resize our canvas.
Let’s click Resume to see what our app looks like, using Xcode previews. Great, our SwiftUI app is ready. Let’s provide a way for someone to start their workout with a StartView. A workout can be started with just a single tap. The list view with carousel-style layout will provide a list of workouts that scrolls vertically with a great depth effect. Our list of workouts will include bike, run, and walk. Let’s create our StartView. Let’s rename "ContentView" to "StartView". Command-click on ContentView, click Rename, Enter "StartView" as the new name. Notice that in MyWorkoutsApp.swift StartView is now NavigationView’s root view. Click Rename. Let’s define an array of workout types to display in our StartView’s list. First, let’s import HealthKit to get access to HKWorkoutActivityType.
Next, let’s add our array of workout types.
Our workout list will have HKWorkoutActivityTypes of cycling, running, and walking. Let’s make HKWorkoutActivityType accessible to our list by extending the HKWorkoutActivityType enum to conform to the identifiable protocol and add a name variable.
The ID-computed variable will return the rawValue of the enum.
The name variable will switch through the cases to return a name like “Run”, “Bike”, or “Walk”. Let’s add a List view to our StartView’s body to display the list of workouts.
The list uses the workoutTypes variable as its model.
A NavigationLink is displayed for each workoutType. A NavigationLink defines a destination for a navigation-based interface. For now, the destination will be a text view. We'll set these navigation links up later to make sure they are tracking the right workout.
Padding makes the navigation links taller to give them a larger tap area to easily start a workout.
The list uses a carousel listStyle to provide a depth effect when scrolling.
The navigationBarTitle will display “Workouts”. Let’s click Resume to see a preview of our StartView.
Click Live Preview to be able to scroll. Scroll up to see the carousel ListStyle depth effect. Looks great. The workout session will be presented as a modal experience. During a workout, people typically need only session-specific functionality. They don't need to review the list of workouts or access other parts of your app. Offering the most important items in a modal experience can help people manage their session while minimizing distraction. People who use workout apps on Apple Watch expect the views to be in this order. On the left, our controls view has buttons that control the in-progress session such as End, Pause, and Resume. In the center, metrics appear on a dedicated screen that people can read at a glance. On the right, media playback controls allow media to be controlled during a workout. A TabView on watchOS switches between multiple child views when someone swipes left or right. A TabView also provides a page indicator at the bottom of the view. A TabView will work great to display our in-session views. Let’s create a SessionPagingView with a TabView for our three workout session views. Click File > New > File... SwiftUI View, click Next, name this "SessionPagingView" and click Create.
Let’s create a Tab enum to model each view that can be selected in our TabView.
The Tab enum has three cases: controls, metrics, and nowPlaying. We’ve also added a @State variable named "selection" to provide the binding for the TabView’s selection. Selection’s default value will be metrics, so that when the workout starts, the metrics view is displayed. Let’s add the TabView.
The TabView’s selection parameter uses a binding to our selection state variable. Text views are placeholders for each view until they are created. Each of the views have a tag so they can be selected. Let’s click Resume and see what our SessionPagingView looks like.
Click Live Preview to be able to swipe between the views.
Notice the Metrics text view is displayed first, because SessionPagingView's selection variable's default value is metrics. Swiping to the left shows the Controls text view. Swiping all the way to the right shows the Now Playing text view. Great! While the workout is running, live metrics are displayed. When a session requires movement, like running, your app should use large font sizes and arrange text so that the most important information is easy to read. Our MetricsView will display elapsed time, active energy, current heart rate, and distance. HealthKit has many more HKQuantityTypes available for you to use. Let’s create the MetricsView. Click File > New > File SwiftUI View, click Next, name this "MetricsView" and click Create.
A VStack will contain our four metric Text views.
The Text views have default values for now until we connect these Text views to our model.
Let’s make elapsed time the focal point by giving it a yellow foregroundColor and semibold fontWeight.
The active energy text view creates a measurement using a default value in energy unit kilocalories. The Measurement uses a new formatted function which abbreviates the unit, usage is workout for workout energy burned, and numberFormat has zero fractionLength to trim fractions.
The heart rate text view uses a default value formatted with fractionLength zero. It appends “bpm” -- for beats per minute -- to the formatted string.
The distance text view uses a default value with UnitLength.meters. The measurement is formatted with abbreviated units. usage is road, which displays naturally progressing imperial or metric units based on locale.
We’ll use a system font of title with rounded design, monospacedDigits, and lowercaseSmallCaps.
We want our metrics to be aligned to the leading edge, so we’ve given the VStack a frame view modifier with maxWidth infinity, and leading alignment.
We want to allow content of this VStack to extend all the way to the bottom of the screen. To allow this, we ignore the bottom safe area.
We want our metrics to be aligned to the navigation bar title, so we've used scenePadding() to do this.
We want our elapsed time Text view to format the elapsed time properly and hide or show subseconds based on the Always On state. To do this, let’s create an ElapsedTimeView and create a custom ElapsedTimeFormatter. Click File > New > File, SwiftUI View, click Next, name it "ElapsedTimeView", and click Create.
Our ElapsedTimeView has elapsedTime, which is a TimeInterval, defaulted to zero. showSubseconds is a Boolean argument defaulted to true. timeFormatter is an ElapsedTimeFormatter state variable that we define below.
The View’s body contains a Text view that casts the elapsedTime to an NSNumber so that the timeFormatter can use it. Text view has a semibold fontWeight. When showSubseconds changes, the timeFormatter’s showSubseconds variable also changes.
The ElapsedTimeFormatter is a custom formatter that uses a DateComponentsFormatter. We want elapsed time to show minutes and seconds and pad zeros. The showSubseconds variable dictates if subseconds are shown.
We override the string for value function, which returns an optional String. The first guard ensures the value is a TimeInterval.
The second guard ensures the componentsFormatter returned a string.
If showSubseconds is true, calculate the subseconds by getting the truncatedRemainder by dividingBy 1, then multiplying by 100. Use a localized decimalSeparator, then return a formattedString, appending the subseconds.
If showSubseconds is false, then just return the formattedString without subseconds. Click resume to see a preview of our ElapsedTimeView.
Looks good. Minutes has zeros padded on the left side of the colon. Seconds has zeros padded on the right side of the colon. Subseconds are displayed after the decimal. Let’s add the ElapsedTimeView to MetricsView. Click MetricsView.
Replace the elapsed time Text view with the ElapsedTimeView.
Let’s preview our MetricsView. Looks great! The ControlsView has buttons that control the in-progress session, such as End, Pause, and Resume. When the End button is tapped, the workout summary will be displayed. When the Pause button is tapped, the workout will pause and the MetricsView will be displayed. Let’s create the ControlsView. Click File > New > File, SwiftUI View, name this "ControlsView" and click Create.
Let’s add the End and Pause buttons.
An HStack contains two VStacks where each VStack contains a button and a text view.
The End button’s label is an Image with systemName “xmark”. The button has a red tint and uses title2 font to increase the size of the symbol. The text view below has an “End” string.
The pause button uses an image with systemName “pause”. It has a yellow tint. The text view below has a “Pause” string. Click Resume to preview the ControlsView.
Looks great.
The NowPlayingView provides media playback controls while the workout is in session. This includes controls for third-party apps that are currently playing media. Let’s add the NowPlayingView. Select SessionPagingView.
The NowPlayingView is provided by WatchKit. Let’s import WatchKit.
Let’s replace our text views with ControlsView, MetricsView, and the NowPlayingView.
The NowPlayingView is a SwiftUI view provided by WatchKit. It’s that simple. Click Resume to see a preview of SessionPagingView.
In Preview, we can see our MetricsView. Swipe to the left to see the ControlsView. Swipe all the way to the right... ...to see the NowPlayingView.
Let’s go back to our StartView and change the destination of the NavigationLink to SessionPagingView. Select StartView.
Update the destination to SessionPagingView.
A Summary screen confirms that a workout is finished and displays the recorded information. We will enhance the summary by including Activity Rings so that people can easily check their current progress. Let’s create the Summary view. Click File > New > File, SwiftUI View, click Next, name this "SummaryView" and click Create.
Let’s create a custom SummaryMetricView that describes the metric and its value.
SummaryMetricView takes a title that describes the metric and a value string of the metric.
Body contains the two text views and a divider. The text view showing the metric value uses a title system font with rounded design and lowercaseSmallCaps. It uses the accentColor as its foregroundColor. Let’s create the workout duration formatter for the SummaryView.
durationFormatter is a DateComponentsFormatter that displays hours, minutes, and seconds separated by colons and pads zeros. Let’s add the SummaryMetricViews and the Done button to our SummaryView.
A ScrollView and VStack contain our four SummaryMetricViews and Done button.
Total Time text view uses the durationFormatter to display hours, minutes, and seconds, separated by colons.
Total Distance SummaryMetricView uses Measurement with a default value that is formatted using an abbreviated unit. usage is road, which displays naturally progressing imperial or metric units based on locale.
Total Energy SummaryMetricView uses Measurement with a default value and energy unit of kilocalories. It is formatted using an abbreviated unit. usage is workout for workout energy, and numberFormat has a precision with fractionLength zero.
Average Heart Rate SummaryMetricView uses a default value formatted with number precision, fractionLength of zero, and appends "bpm" for beats per minute. We’ll provide actual workout values later for the SummaryMetricViews. We want the text views and dividers to align to the navigation bar title so we've used .scenePadding() on the VStack.
The navigationTitle will be "Summary" and will display inline in the navigation bar. Next let’s add Activity Rings to the workout summary. Click File > New > File, Swift File, click Next, name this "ActivityRingsView" and click Create.
Import HealthKit to get access to the HKHealthStore. Import SwiftUI to get access to WKInterfaceObjectRepresentable.
The ActivityRingsView struct conforms to WKInterfaceObjectRepresentable. The healthStore constant is assigned at initialization.
Two functions are required to conform to the protocol: makeWKInterfaceObject and updateWKInterfaceObject.
Inside makeWKInterfaceObject, we declare the activityRingsObject which is a WKInterfaceActivityRing.
Next we create the predicate for the HKActivitySummaryQuery and use date components for today. Then we create the query and handle the result, which sets the activity summary on the activityRingsObject on the main queue.
Then execute the query on the HKHealthStore. Finally, return the activityRingsObject. Let’s add the ActivityRingsView to the SummaryView. Click SummaryView.
Let's import HealthKit to get access to HKHealthStore.
Next, let's add a Text view and an ActivityRingsView above the Done button.
We’ve added a Text view and an ActivityRingsViews with a frame width and height of 50. We’ll create an HKHealthStore for now. Later, we’ll reuse one. Let’s preview our SummaryView. Click Resume.
Click Live Preview to be able to scroll.
See each of our SummaryMetricViews, Activity Rings, and Done button. Let’s talk about HealthKit integration. HealthKit provides built-in functionality to track fitness activity during the workout and save that workout to HealthKit. This saves you time as a developer, and your customers will have all their workouts saved to one location. HK workout session prepares the device’s sensors for data collection so that you can accurately collect data that’s relevant to the workout, like calories and heart rate. It also allows your application to run in the background when the workout is active. HKLiveWorkoutBuilder will create and save an HKWorkout object. It automatically collects samples and events for you. To learn more, check out the “New ways to work with workouts” session. Let’s see what the data flow will be for our app. WorkoutManager will be responsible for interfacing with HealthKit. It interfaces with an HKWorkoutSession to start, pause, and end the workout. It interfaces with an HKLiveWorkoutBuilder to listen for workout samples and provide that data to our views. WorkoutManager will be an environment object. An environment object invalidates the current view whenever the observable object changes. We will assignMyWorkoutsApp’s NavigationView the WorkoutManager environmentObject, which will propagate WorkoutManager to views in the NavigationView’s view hierarchy. Views will then declare an @EnvironmentObject to gain access to WorkoutManager in the environment. Let’s create the WorkoutManager. Click File > New > File, Swift File, click Next, name this "WorkoutManager" and click Create.
Import HealthKit so that WorkoutManager has access to HealthKit’s APIs.
Then define the WorkoutManager class which is an NSObject that conforms to the ObservableObject protocol. We want to give all of our views access to WorkoutManager. We do this by assigning WorkoutManager as an environment object on MyWorkoutsApp’s NavigationView. Select MyWorkoutsApp.
Add workoutManager as a StateObject.
Add the environmentObject view modifier to the NavigationView.
When a NavigationView is assigned an environmentObject, it automatically passes the environmentObject to views in its view hierarchy. Let’s set up our navigation model. Select WorkoutManager.
WorkoutManager will manage the selected workout, which is an optional HKWorkoutActivityType.
We’ve added the selectedWorkout variable to track the selected workout. Now our StartView’s NavigationLink needs to bind its selection to WorkoutManager’s selectedWorkout. Select StartView.
Add workoutManager EnvironmentObject to StartView.
Let’s update NavigationLink with tag and selection.
tag is the workoutType. selection is a binding to selectedWorkout on workoutManager. Now, whenever a workout is tapped, selectedWorkout on workoutManager will update. Now let’s start an HKWorkoutSession and HKLiveWorkoutBuilder when a workout is selected. Select WorkoutManager.
Add the HKHealthStore, HKWorkoutSession, and HKLiveWorkoutBuilder. Now, let’s create a startWorkout function to start the workout.
The startWorkout function takes a workoutType parameter. A HKWorkoutConfiguration is created using the workoutType. For our app, all of our workouts will be outdoor. Note that location type determines how the HKWorkoutSession and HKLiveWorkoutBuilder behaves. For example, an outdoor cycling activity generates accurate location data, while an indoor cycling activity does not. Create the HKWorkoutSession using the healthStore and configuration.
Assign builder to the session’s associatedWorkoutBuilder. This is done in a do-catch block to handle any errors thrown.
Assign the builder’s dataSource to an HKLiveWorkoutDataSource using the healthStore and workoutConfiguration. An HKLiveWorkoutDataSource automatically provides live data from an active workout session.
Create a startDate, call startActivity on the session, and beginCollection on the builder. Whenever selectedWorkout changes, let’s call startWorkout.
selectedWorkout can be nil. Use a guard statement to only call startWorkout when selectedWorkout is not nil. Before our app can create a workout session, we need to set up HealthKit and request authorization to read and share any health data our app intends to use. Let's add a function to request authorization.
For workout sessions, we must request permission to share workout types.
We also want to read any data types automatically recorded by Apple Watch as part of the session. We also want permission to read the Activity Rings summary.
Then call requestAuthorization on the healthStore. Let’s have the StartView request authorization from HealthKit when the view appears. Click StartView.
On appear, workoutManager’s requestAuthorization function will be called. Let’s enable HealthKit for our extension. Select MyWorkouts's project file...
...MyWorkouts WatchKit Extension, Signing & Capabilities. Select Add Capability, scroll down, select HealthKit.
Apps with an active workout session can run in the background, so you need to add the background modes capability to your WatchKit Extension. Workout sessions require the Workout processing background mode. Select Add Capability, Background Modes. Select Workout processing. We need to add usage descriptions to our WatchKit Extension’s Info.plist file. Select Info.plist.
Select the last row, then press Return.
Use the NSHealth ShareUsageDescription key.
Describe why your app needs to read the requested data. Press Return. Use the NSHealth UpdateUsageDescription key.
Describe the data your app intends to write.
Let’s build and run our app to see our app request permission from HealthKit. Click Run.
Our app has requested HealthKit authorization. Scroll down and click Review.
Select All Requested Data Below.
See that our app has requested to share Workouts. See our provided explanation. Tap Next. Our app has requested read access. Select All Requested Data Below. See the data types that our app has requested read access to.
See our provided explanation. Tap Done.
Now that the workout session can start, we need to control the HKWorkoutSession. Select WorkoutManager.
Let's add our session state control logic.
A @Published variable named "running" tracks if the session is running.
The pause and resume functions pause and resume the session. The togglePause function will either pause or resume the session based on if the session is running.
The endWorkout function will end the session. Let’s extend WorkoutManager to be an HKWorkoutSessionDelegate to listen for changes to the session state.
The workoutSession didChangeTo toState fromState with Date function is called whenever the session state changes.
Our running variable will update based on if the toState is running and is dispatched to the main queue for UI updates.
When the session transitions to ended, call endCollection on the builder with the end date provided to stop collecting workout samples. Once endCollection finishes, call finishWorkout to save the HKWorkout to the Health database. Make sure to assign WorkoutManager as the HKWorkoutSession delegate.
Now let’s have the ControlsView pause, resume, and end the session. Select ControlsView.
Add workoutManager as an EnvironmentObject so that our view can control the session.
Have the End button’s action call endWorkout on workoutManager.
The Pause/Resume button needs to pause or resume the session and update its image and text based on the session state.
The button’s action calls workoutManager’s togglePause function to pause or resume the session.
The button’s Image’s systemName is either "pause" or "play", based on workoutManager’s running variable. The text below the button shows either “Pause” or “Resume”, also based on workoutManager’s running variable. Let’s update our SessionPagingView to display the workout name in the navigation bar. Select SessionPagingView.
The SessionPagingView needs access to the WorkoutManager environment variable, so let’s add that. Now let’s configure our navigation bar. The navigation title is the WorkoutManager’s selectedWorkout’s name. The navigation bar’s back button is hidden, because we don’t want someone to go back to the StartView while they are in a workout. When the NowPlayingView is shown, we want to hide the navigation bar. When someone pauses or resumes their workout, they shouldn’t need to swipe to the MetricsView. We can do this for them by adding an onChange view modifier.
When WorkoutManager’s running published variable changes, the displayMetricsView function is called. displayMetricsView sets the selection state variable to metrics withAnimation. Now that the workout can end, let’s add the ability to show and dismiss the SummaryView. Click WorkoutManager.
Add a published variable named "showingSummaryView" which is a Boolean defaulted to false.
This variable will provide a binding to a sheet’s selection on our app’s navigation view. In endWorkout, set showingSummaryView to true.
Let’s add the SummaryView as a Sheet to MyWorkoutsApp’s NavigationView. Click MyWorkoutsApp.
Add a sheet view modifier to NavigationView.
The isPresented parameter is a binding to workoutManager’s showingSummaryView. The sheet’s content is a SummaryView. In SummaryView, let’s add the ability to dismiss the sheet. Click SummaryView.
Add the dismiss Dnvironment variable.
In the Done button’s action, call dismiss().
Let’s run our application to start and end the session and see the SummaryView displayed. Click Stop to stop the previous run.
Click Run.
Tap the Run workout.
Default metric values will still be displayed in-session and in the summary. We'll set that up later. Swipe to the left. Tap Pause. Notice the MetricsView is displayed. Swipe to the left.
Notice the button now shows "Resume". Tap End.
Our workout summary displays as a sheet. Scroll down. Tap Done. The sheet is dismissed and the StartView is displayed. Let’s get our MetricsView and SummaryView to show actual workout metrics. WorkoutManager will expose published workout metrics that MetricsView and SummaryView can observe. Select WorkoutManager.
Let’s add the Published metric variables to WorkoutManager.
averageHeartRate will be used by SummaryView. heartRate, activeEnergy, and distance will be observed by MetricsView. WorkoutManager needs to observe workout samples added to the builder by being an HKLiveWorkoutBuilderDelegate. Let’s do this now. First, let’s assign the builder’s delegate as WorkoutManager.
Now let’s make WorkoutManager conform to the HKLiveWorkout BuilderDelegate protocol.
We’ve extended WorkoutManager to conform to the HKLiveWorkout BuilderDelegate protocol. workoutBuilderDidCollectEvent is called whenever the builder collects an event. We will leave this function empty for our app.
workoutBuilder didCollectDataOf collectedTypes is called whenever the builder collects new samples. We will iterate over each type in collectedTypes. The guard ensures the collected type is an HKQuantityType. Statistics are read from the builder for that quantity type. updateForStatistics -- a function we will create shortly -- will be called, which updates the published metric values. Let’s create the updateForStatistics function.
updateForStatistics takes an optional HKStatistics object. A guard early returns if statistics is nil.
Dispatch the metric updates asynchronously to the main queue. Switch through each quantity type. For heartRate, we want beats per minute, so we use a count HKUnit divided by a minute HKUnit. Assign heartRate as the mostRrecentQuantity’s doubleValue for beats per minute. Assign averageHeartRate as statistics.averageQuantity’s doubleValue for beats per minute.
For the activeEnergyBurned quantityType, use the kilocalorie energyUnit. Assign activeEnergy as the sumQuantity’s doubleValue for the energyUnit.
For walking, running, and cycling distance get the sumQuantity’s doubleValue for meterUnit. Now let’s have MetricsView use metric values from the WorkoutManager. Select MetricsView.
Add workoutManager as an Environment variable.
Let's update our views to use metric values from WorkoutManager.
The ElapsedTimeView uses the workoutManager’s builder’s elapsedTime.
The activeEnergy Text view’s Measurement uses workoutManager’s activeEnergy.
The heartRate Text view uses workoutManager’s heartRate.
The distance text view’s Measurement uses workoutManager’s distance.
The builder’s elapsed time variable isn’t published, so our view currently will not update when builder’s elapsedTime updates. What we can do is wrap the VStack in a TimelineView.
TimelineView is new this year. A TimelineView updates over time in line with its schedule. watchOS apps now support Always On state. TimelineViews make our view aware of changes to the Always On context. To learn more, check out the “What’s new in watchOS 8” and "What's new in SwiftUI" sessions. Apps can be in either active state or Always On state. Apps with active workout sessions can update, at most, once every second in Always On state. This means the MetricsView needs to hide subseconds in Always On state. Other design considerations should be made for Always On state, such as hiding the page indicator controls to simplify the view. Our TimelineView needs a custom TimelineSchedule that changes its interval based on the TimelineScheduleMode dictated by the Always On context. Let’s create our custom TimelineSchedule.
MetricsTimelineSchedule has a startDate for when the schedule should start. Its initializer takes a startDate.
MetricsTimelineSchedule implements the entries function to produce PeriodicTimelineSchedule entries. The function creates a PeriodicTimelineSchedule using the startDate. The interval is determined by the TimelineScheduleMode. When the TimelineScheduleMode is lowFrequency, the TimelineSchedule interval is one second. When the TimelineScheduleMode is normal, the interval is 30 times per second. Let’s wrap our VStack in a TimelineView.
The TimelineView uses our MetricsTimelineSchedule, using the builder’s startDate. ElapsedTimeView’s showSubseconds is determined by the TimelineView’s context.cadence. When cadence is live, subseconds are shown. Otherwise subseconds are hidden in Always On state. Let’s run our application to see the metrics updating during the workout. Click Stop to stop the current run. Click Run.
Tap the Run workout. Notice that elapsed time is incrementing. The watchOS simulator automatically simulates collecting live workout samples for you. Calories are accruing. Heart rate is updating. Distance is accumulating. Let’s try the Always On state by clicking the Lock button on the simulator.
Notice subseconds are hidden and metrics update only once per second. Click the Unlock button to return to active state. Swipe left and end the workout. The SummaryView still needs the actual HKWorkout values. Let’s do this now. First, let’s add the HKWorkout to the WorkoutManager to be used in the SummaryView. Select WorkoutManager. Add a HKWorkout Published variable.
When the builder has finished saving the workout, assign the workout to WorkoutManager when builder’s finishWorkout function completes.
We do this assignment on the main queue for UI updates. When the SummaryView dismisses, we need to reset our model. Let’s create a resetWorkout function that does this.
The resetWorkout function resets all of our model variables back to an initial state. Let’s call resetWorkout when the summary dismisses. This is done in the didSet of showingSummaryView.
Let’s display a progress view when the workout ends, while the workout is saving, before displaying the SummaryView. Let’s go to our SummaryView. Click SummaryView. First, add the workoutManager EnvironmentObject to SummaryView.
We want to display the ProgressView until workoutManager has the HKWorkout assigned when the builder finishes saving the workout.
If workoutManager’s workout is nil, then display the ProgressView with the text showing “Saving workout”, and hide the navigation bar.
We've also updated ActivityRingsView to use workoutManager’s HKHealthStore. You only need a single HKHealthStore per app. Let’s update our SummaryMetricViews to use the HKWorkout values.
The Total Time metric view uses the workout duration.
The totalDistance metric view uses the workout’s total distance.
The Total Energy metric view uses the workout’s totalEnergyBurned.
The Average Heart Rate metric view uses the workoutManager’s averageHeartRate. If you want to save average heart rate for later, you can add it as metadata to the builder before saving the workout. Let’s update our SessionPagingView to react to Always On state. Select SessionPagingView.
Add the isLuminanceReduced Environment variable.
During Always On state, we want to hide the TabView’s page indicator and ensure the MetricsView is displayed.
We’ve set the tabViewStyle's indexDisplayMode to either never or automatic, based on isLuminanceReduced. When isLuminanceReduced changes, call the displayMetricsView function to display the MetricsView. Let’s run our app in the simulator and try it out. Click Stop to stop the last run. Click Run.
Select the Run workout. Notice metrics are updating live from the builder. Swipe left. Tap Pause. Notice metrics have stopped updating, because the workout is paused. Swipe left. Tap Resume. Metrics resume updating. Swipe right, see the NowPlayingView. Swipe left. Click Lock to trigger the Always On state. Notice subseconds are hidden and page control indicators are hidden. Click Unlock to resume active state.
Swipe left, tap End.
The workout saves. The summary is displayed. Scroll down to view each of the metrics. Activity Rings will populate based on the amount of energy, exercise minutes, and stand hours. Tap Done. We are taken back to the start view, ready for our next workout.
You saw how easy it is to use SwiftUI to implement a fully functioning workout app integrated with HealthKit that supports the Always On state. We can’t wait to see what great workout apps you’ll build next! ♪
-
-
3:17 - StartView - import HealthKit
import HealthKit
-
3:25 - StartView - workoutTypes
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]
-
3:26 - StartView - HKWorkoutActivityType identifiable and name
extension HKWorkoutActivityType: Identifiable { public var id: UInt { rawValue } var name: String { switch self { case .running: return "Run" case .cycling: return "Bike" case .walking: return "Walk" default: return "" } } }
-
4:22 - StartView - body
List(workoutTypes) { workoutType in NavigationLink( workoutType.name, destination: Text(workoutType.name) ).padding( EdgeInsets(top: 15, leading: 5, bottom: 15, trailing: 5) ) } .listStyle(.carousel) .navigationBarTitle("Workouts")
-
6:55 - SessionPagingView - Tab enum and selection
@State private var selection: Tab = .metrics enum Tab { case controls, metrics, nowPlaying }
-
7:20 - SessionPagingView - TabView
TabView(selection: $selection) { Text("Controls").tag(Tab.controls) Text("Metrics").tag(Tab.metrics) Text("Now Playing").tag(Tab.nowPlaying) }
-
9:02 - MetricsView - VStack and TextViews
VStack(alignment: .leading) { Text("03:15.23") .foregroundColor(Color.yellow) .fontWeight(.semibold) Text( Measurement( value: 47, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( 153.formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: 515, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) } .font(.system(.title, design: .rounded) .monospacedDigit() .lowercaseSmallCaps() ) .frame(maxWidth: .infinity, alignment: .leading) .ignoresSafeArea(edges: .bottom) .scenePadding()
-
11:42 - ElapsedTimeView - ElapsedTimeView and ElapsedTimeFormatter
struct ElapsedTimeView: View { var elapsedTime: TimeInterval = 0 var showSubseconds: Bool = true @State private var timeFormatter = ElapsedTimeFormatter() var body: some View { Text(NSNumber(value: elapsedTime), formatter: timeFormatter) .fontWeight(.semibold) .onChange(of: showSubseconds) { timeFormatter.showSubseconds = $0 } } } class ElapsedTimeFormatter: Formatter { let componentsFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.minute, .second] formatter.zeroFormattingBehavior = .pad return formatter }() var showSubseconds = true override func string(for value: Any?) -> String? { guard let time = value as? TimeInterval else { return nil } guard let formattedString = componentsFormatter.string(from: time) else { return nil } if showSubseconds { let hundredths = Int((time.truncatingRemainder(dividingBy: 1)) * 100) let decimalSeparator = Locale.current.decimalSeparator ?? "." return String(format: "%@%@%0.2d", formattedString, decimalSeparator, hundredths) } return formattedString } }
-
13:56 - MetricsView - replace TextView with ElapsedTimeView
ElapsedTimeView( elapsedTime: 3 * 60 + 15.24, showSubseconds: true ).foregroundColor(Color.yellow)
-
14:47 - ControlsView - Stacks, Buttons and TextViews
HStack { VStack { Button { } label: { Image(systemName: "xmark") } .tint(Color.red) .font(.title2) Text("End") } VStack { Button { } label: { Image(systemName: "pause") } .tint(Color.yellow) .font(.title2) Text("Pause") } }
-
16:05 - SessionPagingView - import WatchKit
import WatchKit
-
16:09 - SessionPagingView - TabView using actual views
ControlsView().tag(Tab.controls) MetricsView().tag(Tab.metrics) NowPlayingView().tag(Tab.nowPlaying)
-
17:08 - StartView - NavigationLink to use SessionPagingView
destination: SessionPagingView()
-
17:50 - SummaryView - SummaryMetricView
struct SummaryMetricView: View { var title: String var value: String var body: some View { Text(title) Text(value) .font(.system(.title2, design: .rounded) .lowercaseSmallCaps() ) .foregroundColor(.accentColor) Divider() } }
-
18:27 - SummaryView - durationFormatter
@State private var durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] formatter.zeroFormattingBehavior = .pad return formatter }()
-
18:45 - SummaryView - body
ScrollView(.vertical) { VStack(alignment: .leading) { SummaryMetricView( title: "Total Time", value: durationFormatter.string(from: 30 * 60 + 15) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: 1625, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Energy", value: Measurement( value: 96, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: 143 .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ).accentColor(Color.red) Button("Done") { } } .scenePadding() } .navigationTitle("Summary") .navigationBarTitleDisplayMode(.inline)
-
21:00 - ActivityRingsView
import HealthKit import SwiftUI struct ActivityRingsView: WKInterfaceObjectRepresentable { let healthStore: HKHealthStore func makeWKInterfaceObject(context: Context) -> some WKInterfaceObject { let activityRingsObject = WKInterfaceActivityRing() let calendar = Calendar.current var components = calendar.dateComponents([.era, .year, .month, .day], from: Date()) components.calendar = calendar let predicate = HKQuery.predicateForActivitySummary(with: components) let query = HKActivitySummaryQuery(predicate: predicate) { query, summaries, error in DispatchQueue.main.async { activityRingsObject.setActivitySummary(summaries?.first, animated: true) } } healthStore.execute(query) return activityRingsObject } func updateWKInterfaceObject(_ wkInterfaceObject: WKInterfaceObjectType, context: Context) { } }
-
22:15 - SummaryView - add ActivityRingsView
Text("Activity Rings") ActivityRingsView( healthStore: HKHealthStore() ).frame(width: 50, height: 50)
-
22:28 - SummaryView - import HealthKit
import HealthKit
-
25:22 - WorkoutManager
import HealthKit class WorkoutManager: NSObject, ObservableObject { }
-
25:53 - MyWorkoutsApp - add workoutManager @StateObject
@StateObject var workoutManager = WorkoutManager()
-
26:00 - MyWorkoutsApp - .environmentObject to NavigationView
.environmentObject(workoutManager)
-
26:25 - WorkoutManager - selectedWorkout
var selectedWorkout: HKWorkoutActivityType?
-
26:49 - StartView - add workoutManager
@EnvironmentObject var workoutManager: WorkoutManager
-
26:56 - StartView - Add tag and selection to NavigationLink
, tag: workoutType, selection: $workoutManager.selectedWorkout
-
27:32 - WorkoutManager - Add healthStore, session, builder
let healthStore = HKHealthStore() var session: HKWorkoutSession? var builder: HKLiveWorkoutBuilder?
-
27:42 - WorkoutManager - startWorkout(workoutType:)
func startWorkout(workoutType: HKWorkoutActivityType) { let configuration = HKWorkoutConfiguration() configuration.activityType = workoutType configuration.locationType = .outdoor do { session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) builder = session?.associatedWorkoutBuilder() } catch { // Handle any exceptions. return } builder?.dataSource = HKLiveWorkoutDataSource( healthStore: healthStore, workoutConfiguration: configuration ) // Start the workout session and begin data collection. let startDate = Date() session?.startActivity(with: startDate) builder?.beginCollection(withStart: startDate) { (success, error) in // The workout has started. } }
-
29:06 - WorkoutManager - selectedWorkout didSet
{ didSet { guard let selectedWorkout = selectedWorkout else { return } startWorkout(workoutType: selectedWorkout) } }
-
29:35 - WorkoutManager - requestAuthorization from HealthKit
// Request authorization to access HealthKit. func requestAuthorization() { // The quantity type to write to the health store. let typesToShare: Set = [ HKQuantityType.workoutType() ] // The quantity types to read from the health store. let typesToRead: Set = [ HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!, HKQuantityType.quantityType(forIdentifier: .distanceCycling)!, HKObjectType.activitySummaryType() ] // Request authorization for those quantity types. healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in // Handle error. } }
-
30:20 - StartView - requestAuthorization onAppear
.onAppear { workoutManager.requestAuthorization() }
-
31:30 - Privacy - Health Share Usage Description - Key
NSHealthShareUsageDescription
-
31:38 - Privacy - Health Share Usage Description - Value
Your workout related data will be used to display your saved workouts in MyWorkouts.
-
31:47 - Privacy - Health Update Usage Description - Key
NSHealthUpdateUsageDescription
-
31:54 - Privacy - Health Update Usage Description - Value
Workouts tracked by MyWorkouts on Apple Watch will be saved to HealthKit.
-
33:29 - WorkoutManager - session state control
// MARK: - State Control // The workout session state. @Published var running = false func pause() { session?.pause() } func resume() { session?.resume() } func togglePause() { if running == true { pause() } else { resume() } } func endWorkout() { session?.end() }
-
34:11 - WorkoutManager - HKWorkoutSessionDelegate
// MARK: - HKWorkoutSessionDelegate extension WorkoutManager: HKWorkoutSessionDelegate { func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { DispatchQueue.main.async { self.running = toState == .running } // Wait for the session to transition states before ending the builder. if toState == .ended { builder?.endCollection(withEnd: date) { (success, error) in self.builder?.finishWorkout { (workout, error) in } } } } func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { } }
-
34:58 - WorkoutManager - assign HKWorkoutSessionDelegate in startWorkout()
session?.delegate = self
-
35:22 - ControlsView - workoutManager environmentObject
@EnvironmentObject var workoutManager: WorkoutManager
-
35:33 - ControlsView - End Button action
workoutManager.endWorkout()
-
35:43 - ControlsView - Pause / Resume Button and Text
Button { workoutManager.togglePause() } label: { Image(systemName: workoutManager.running ? "pause" : "play") } .tint(Color.yellow) .font(.title2) Text(workoutManager.running ? "Pause" : "Resume")
-
36:30 - SessionPagingView - add workoutManager environment variable
@EnvironmentObject var workoutManager: WorkoutManager
-
36:42 - SessionPagingView - navigationBar
.navigationTitle(workoutManager.selectedWorkout?.name ?? "") .navigationBarBackButtonHidden(true) .navigationBarHidden(selection == .nowPlaying)
-
37:10 - SessionPagingView - onChange of workoutManager.running
.onChange(of: workoutManager.running) { _ in displayMetricsView() } } private func displayMetricsView() { withAnimation { selection = .metrics } }
-
37:45 - WorkoutManager - showingSummaryView
@Published var showingSummaryView: Bool = false { didSet { // Sheet dismissed if showingSummaryView == false { selectedWorkout = nil } } }
-
37:59 - WorkoutManager - showingSummaryView true in endWorkout
showingSummaryView = true
-
38:22 - MyWorkoutApp - add summaryView sheet to NavigationView
.sheet(isPresented: $workoutManager.showingSummaryView) { SummaryView() }
-
38:49 - SummaryView - add dismiss environment variable
@Environment(\.dismiss) var dismiss
-
38:58 - SummaryView - add dismiss() to done button
dismiss()
-
40:25 - WorkoutManager - Metric publishers
// MARK: - Workout Metrics @Published var averageHeartRate: Double = 0 @Published var heartRate: Double = 0 @Published var activeEnergy: Double = 0 @Published var distance: Double = 0
-
40:48 - WorkoutManager - assigned as HKLiveWorkoutBuilderDelegate in startWorkout()
builder?.delegate = self
-
41:05 - WorkoutManager - add HKLiveWorkoutBuilderDelegate extension
// MARK: - HKLiveWorkoutBuilderDelegate extension WorkoutManager: HKLiveWorkoutBuilderDelegate { func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { } func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) { for type in collectedTypes { guard let quantityType = type as? HKQuantityType else { return } let statistics = workoutBuilder.statistics(for: quantityType) // Update the published values. updateForStatistics(statistics) } } }
-
42:01 - WorkoutManager - add updateForStatistics()
func updateForStatistics(_ statistics: HKStatistics?) { guard let statistics = statistics else { return } DispatchQueue.main.async { switch statistics.quantityType { case HKQuantityType.quantityType(forIdentifier: .heartRate): let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute()) self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0 self.averageHeartRate = statistics.averageQuantity()?.doubleValue(for: heartRateUnit) ?? 0 case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): let energyUnit = HKUnit.kilocalorie() self.activeEnergy = statistics.sumQuantity()?.doubleValue(for: energyUnit) ?? 0 case HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), HKQuantityType.quantityType(forIdentifier: .distanceCycling): let meterUnit = HKUnit.meter() self.distance = statistics.sumQuantity()?.doubleValue(for: meterUnit) ?? 0 default: return } } }
-
43:25 - MetricsView - add workoutManager as environment variable to MetricsView
@EnvironmentObject var workoutManager: WorkoutManager
-
43:35 - MetricsView - VStack with Text bound to workoutManager variables
VStack(alignment: .leading) { ElapsedTimeView( elapsedTime: workoutManager.builder?.elapsedTime ?? 0, showSubseconds: true ).foregroundColor(Color.yellow) Text( Measurement( value: workoutManager.activeEnergy, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( workoutManager.heartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: workoutManager.distance, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) }
-
45:51 - MetricsView - MetricsTimelineSchedule
private struct MetricsTimelineSchedule: TimelineSchedule { var startDate: Date init(from startDate: Date) { self.startDate = startDate } func entries(from startDate: Date, mode: TimelineScheduleMode) -> PeriodicTimelineSchedule.Entries { PeriodicTimelineSchedule( from: self.startDate, by: (mode == .lowFrequency ? 1.0 : 1.0 / 30.0) ).entries( from: startDate, mode: mode ) } }
-
46:38 - MetricsView - TimelineView wrapping VStack
TimelineView( MetricsTimelineSchedule( from: workoutManager.builder?.startDate ?? Date() ) ) { context in VStack(alignment: .leading) { ElapsedTimeView( elapsedTime: workoutManager.builder?.elapsedTime ?? 0, showSubseconds: context.cadence == .live ).foregroundColor(Color.yellow) Text( Measurement( value: workoutManager.activeEnergy, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ) Text( workoutManager.heartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text( Measurement( value: workoutManager.distance, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ) } .font(.system(.title, design: .rounded) .monospacedDigit() .lowercaseSmallCaps() ) .frame(maxWidth: .infinity, alignment: .leading) .ignoresSafeArea(edges: .bottom) .scenePadding() }
-
48:23 - WorkoutManager - workout: HKWorkout added
@Published var workout: HKWorkout?
-
48:38 - WorkoutManager - assign HKWorkout in finishWorkout
DispatchQueue.main.async { self.workout = workout }
-
48:57 - WorkoutManager - resetWorkout()
func resetWorkout() { selectedWorkout = nil builder = nil session = nil workout = nil activeEnergy = 0 averageHeartRate = 0 heartRate = 0 distance = 0 }
-
49:21 - WorkoutManager - add resetWorkout to showingSummaryView didSet
resetWorkout()
-
49:48 - SummaryView - add workoutManager
@EnvironmentObject var workoutManager: WorkoutManager
-
50:06 - SummaryView - add ProgressView
if workoutManager.workout == nil { ProgressView("Saving workout") .navigationBarHidden(true) } else { ScrollView(.vertical) { VStack(alignment: .leading) { SummaryMetricView( title: "Total Time", value: durationFormatter.string(from: 30 * 60 + 15) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: 1625, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Calories", value: Measurement( value: 96, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: 143.formatted( .number.precision(.fractionLength(0)) ) + " bpm" ) Text("Activity Rings") ActivityRingsView(healthStore: workoutManager.healthStore) .frame(width: 50, height: 50) Button("Done") { dismiss() } } .scenePadding() } .navigationTitle("Summary") .navigationBarTitleDisplayMode(.inline) }
-
50:43 - SummaryView - SummaryMetricViews using HKWorkout values
SummaryMetricView( title: "Total Time", value: durationFormatter .string(from: workoutManager.workout?.duration ?? 0.0) ?? "" ).accentColor(Color.yellow) SummaryMetricView( title: "Total Distance", value: Measurement( value: workoutManager.workout?.totalDistance? .doubleValue(for: .meter()) ?? 0, unit: UnitLength.meters ).formatted( .measurement( width: .abbreviated, usage: .road ) ) ).accentColor(Color.green) SummaryMetricView( title: "Total Energy", value: Measurement( value: workoutManager.workout?.totalEnergyBurned? .doubleValue(for: .kilocalorie()) ?? 0, unit: UnitEnergy.kilocalories ).formatted( .measurement( width: .abbreviated, usage: .workout, numberFormat: .numeric(precision: .fractionLength(0)) ) ) ).accentColor(Color.pink) SummaryMetricView( title: "Avg. Heart Rate", value: workoutManager.averageHeartRate .formatted( .number.precision(.fractionLength(0)) ) + " bpm" ).accentColor(Color.red)
-
51:45 - SessionPagingView - add isLuminanceReduced
@Environment(\.isLuminanceReduced) var isLuminanceReduced
-
51:57 - SessionPagingView - add tabViewStyle and onChangeOf based on isLuminanceReduced
.tabViewStyle( PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic) ) .onChange(of: isLuminanceReduced) { _ in displayMetricsView() }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.