2021 SwiftUI & UI FrameworksHealth & Fitness
WWDC21 · 54 min · SwiftUI & UI Frameworks / Health & Fitness
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.
Watch at developer.apple.com ↗Code shown on screen · 65 snippets
StartView - import HealthKit
import HealthKit StartView - workoutTypes
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking] 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 ""
}
}
} 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") SessionPagingView - Tab enum and selection
private var selection: Tab = .metrics
enum Tab {
case controls, metrics, nowPlaying
} SessionPagingView - TabView
TabView(selection: $selection) {
Text("Controls").tag(Tab.controls)
Text("Metrics").tag(Tab.metrics)
Text("Now Playing").tag(Tab.nowPlaying)
} 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() ElapsedTimeView - ElapsedTimeView and ElapsedTimeFormatter
struct ElapsedTimeView: View {
var elapsedTime: TimeInterval = 0
var showSubseconds: Bool = true
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
}
} MetricsView - replace TextView with ElapsedTimeView
ElapsedTimeView(
elapsedTime: 3 * 60 + 15.24,
showSubseconds: true
).foregroundColor(Color.yellow) 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")
}
} SessionPagingView - import WatchKit
import WatchKit SessionPagingView - TabView using actual views
ControlsView().tag(Tab.controls)
MetricsView().tag(Tab.metrics)
NowPlayingView().tag(Tab.nowPlaying) StartView - NavigationLink to use SessionPagingView
destination: SessionPagingView() 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()
}
} SummaryView - durationFormatter
private var durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}() 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) 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) {
}
} SummaryView - add ActivityRingsView
Text("Activity Rings")
ActivityRingsView(
healthStore: HKHealthStore()
).frame(width: 50, height: 50) SummaryView - import HealthKit
import HealthKit WorkoutManager
import HealthKit
class WorkoutManager: NSObject, ObservableObject {
} MyWorkoutsApp - add workoutManager @StateObject
var workoutManager = WorkoutManager() MyWorkoutsApp - .environmentObject to NavigationView
.environmentObject(workoutManager) WorkoutManager - selectedWorkout
var selectedWorkout: HKWorkoutActivityType? StartView - add workoutManager
var workoutManager: WorkoutManager StartView - Add tag and selection to NavigationLink
,
tag: workoutType,
selection: $workoutManager.selectedWorkout WorkoutManager - Add healthStore, session, builder
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder? 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.
}
} WorkoutManager - selectedWorkout didSet
{
didSet {
guard let selectedWorkout = selectedWorkout else { return }
startWorkout(workoutType: selectedWorkout)
}
} 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.
}
} StartView - requestAuthorization onAppear
.onAppear {
workoutManager.requestAuthorization()
} Privacy - Health Share Usage Description - Key
NSHealthShareUsageDescription Privacy - Health Share Usage Description - Value
Your workout related data will be used to display your saved workouts in MyWorkouts. Privacy - Health Update Usage Description - Key
NSHealthUpdateUsageDescription Privacy - Health Update Usage Description - Value
Workouts tracked by MyWorkouts on Apple Watch will be saved to HealthKit. WorkoutManager - session state control
// MARK: - State Control
// The workout session state.
var running = false
func pause() {
session?.pause()
}
func resume() {
session?.resume()
}
func togglePause() {
if running == true {
pause()
} else {
resume()
}
}
func endWorkout() {
session?.end()
} 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) {
}
} WorkoutManager - assign HKWorkoutSessionDelegate in startWorkout()
session?.delegate = self ControlsView - workoutManager environmentObject
var workoutManager: WorkoutManager ControlsView - End Button action
workoutManager.endWorkout() 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") SessionPagingView - add workoutManager environment variable
var workoutManager: WorkoutManager SessionPagingView - navigationBar
.navigationTitle(workoutManager.selectedWorkout?.name ?? "")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(selection == .nowPlaying) SessionPagingView - onChange of workoutManager.running
.onChange(of: workoutManager.running) { _ in
displayMetricsView()
}
}
private func displayMetricsView() {
withAnimation {
selection = .metrics
}
} WorkoutManager - showingSummaryView
var showingSummaryView: Bool = false {
didSet {
// Sheet dismissed
if showingSummaryView == false {
selectedWorkout = nil
}
}
} WorkoutManager - showingSummaryView true in endWorkout
showingSummaryView = true MyWorkoutApp - add summaryView sheet to NavigationView
.sheet(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
} SummaryView - add dismiss environment variable
(\.dismiss) var dismiss SummaryView - add dismiss() to done button
dismiss() WorkoutManager - Metric publishers
// MARK: - Workout Metrics
var averageHeartRate: Double = 0
var heartRate: Double = 0
var activeEnergy: Double = 0
var distance: Double = 0 WorkoutManager - assigned as HKLiveWorkoutBuilderDelegate in startWorkout()
builder?.delegate = self 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)
}
}
} 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
}
}
} MetricsView - add workoutManager as environment variable to MetricsView
var workoutManager: WorkoutManager 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
)
)
)
} 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
)
}
} 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()
} WorkoutManager - workout: HKWorkout added
var workout: HKWorkout? WorkoutManager - assign HKWorkout in finishWorkout
DispatchQueue.main.async {
self.workout = workout
} WorkoutManager - resetWorkout()
func resetWorkout() {
selectedWorkout = nil
builder = nil
session = nil
workout = nil
activeEnergy = 0
averageHeartRate = 0
heartRate = 0
distance = 0
} WorkoutManager - add resetWorkout to showingSummaryView didSet
resetWorkout() SummaryView - add workoutManager
var workoutManager: WorkoutManager 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)
} 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) SessionPagingView - add isLuminanceReduced
(\.isLuminanceReduced) var isLuminanceReduced SessionPagingView - add tabViewStyle and onChangeOf based on isLuminanceReduced
.tabViewStyle(
PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic)
)
.onChange(of: isLuminanceReduced) { _ in
displayMetricsView()
} Resources
Related sessions
-
13 min -
20 min -
40 min -
40 min