Dunfey · Hotel WWDC as data, est. 1983
Front desk everything
Years
Topics

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 ↗

Transcript all transcripts

Code shown on screen · 65 snippets

StartView - import HealthKit swift · at 3:17 ↗
import HealthKit
StartView - workoutTypes swift · at 3:25 ↗
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]
StartView - HKWorkoutActivityType identifiable and name swift · at 3:26 ↗
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 swift · at 4:22 ↗
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 swift · at 6:55 ↗
@State private var selection: Tab = .metrics

enum Tab {
    case controls, metrics, nowPlaying
}
SessionPagingView - TabView swift · at 7:20 ↗
TabView(selection: $selection) {
    Text("Controls").tag(Tab.controls)
    Text("Metrics").tag(Tab.metrics)
    Text("Now Playing").tag(Tab.nowPlaying)
}
MetricsView - VStack and TextViews swift · at 9:02 ↗
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 swift · at 11:42 ↗
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
    }
}
MetricsView - replace TextView with ElapsedTimeView swift · at 13:56 ↗
ElapsedTimeView(
    elapsedTime: 3 * 60 + 15.24,
    showSubseconds: true
).foregroundColor(Color.yellow)
ControlsView - Stacks, Buttons and TextViews swift · at 14:47 ↗
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 swift · at 16:05 ↗
import WatchKit
SessionPagingView - TabView using actual views swift · at 16:09 ↗
ControlsView().tag(Tab.controls)
MetricsView().tag(Tab.metrics)
NowPlayingView().tag(Tab.nowPlaying)
StartView - NavigationLink to use SessionPagingView swift · at 17:08 ↗
destination: SessionPagingView()
SummaryView - SummaryMetricView swift · at 17:50 ↗
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 swift · at 18:27 ↗
@State private var durationFormatter: DateComponentsFormatter = {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.hour, .minute, .second]
    formatter.zeroFormattingBehavior = .pad
    return formatter
}()
SummaryView - body swift · at 18:45 ↗
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 swift · at 21:00 ↗
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 swift · at 22:15 ↗
Text("Activity Rings")
ActivityRingsView(
    healthStore: HKHealthStore()
).frame(width: 50, height: 50)
SummaryView - import HealthKit swift · at 22:28 ↗
import HealthKit
WorkoutManager swift · at 25:22 ↗
import HealthKit

class WorkoutManager: NSObject, ObservableObject {

}
MyWorkoutsApp - add workoutManager @StateObject swift · at 25:53 ↗
@StateObject var workoutManager = WorkoutManager()
MyWorkoutsApp - .environmentObject to NavigationView swift · at 26:00 ↗
.environmentObject(workoutManager)
WorkoutManager - selectedWorkout swift · at 26:25 ↗
var selectedWorkout: HKWorkoutActivityType?
StartView - add workoutManager swift · at 26:49 ↗
@EnvironmentObject var workoutManager: WorkoutManager
StartView - Add tag and selection to NavigationLink swift · at 26:56 ↗
,
tag: workoutType,
selection: $workoutManager.selectedWorkout
WorkoutManager - Add healthStore, session, builder swift · at 27:32 ↗
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
WorkoutManager - startWorkout(workoutType:) swift · at 27:42 ↗
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 swift · at 29:06 ↗
{
    didSet {
        guard let selectedWorkout = selectedWorkout else { return }
        startWorkout(workoutType: selectedWorkout)
    }
}
WorkoutManager - requestAuthorization from HealthKit swift · at 29:35 ↗
// 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 swift · at 30:20 ↗
.onAppear {
    workoutManager.requestAuthorization()
}
Privacy - Health Share Usage Description - Key swift · at 31:30 ↗
NSHealthShareUsageDescription
Privacy - Health Share Usage Description - Value swift · at 31:38 ↗
Your workout related data will be used to display your saved workouts in MyWorkouts.
Privacy - Health Update Usage Description - Key swift · at 31:47 ↗
NSHealthUpdateUsageDescription
Privacy - Health Update Usage Description - Value swift · at 31:54 ↗
Workouts tracked by MyWorkouts on Apple Watch will be saved to HealthKit.
WorkoutManager - session state control swift · at 33:29 ↗
// 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()
}
WorkoutManager - HKWorkoutSessionDelegate swift · at 34:11 ↗
// 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() swift · at 34:58 ↗
session?.delegate = self
ControlsView - workoutManager environmentObject swift · at 35:22 ↗
@EnvironmentObject var workoutManager: WorkoutManager
ControlsView - End Button action swift · at 35:33 ↗
workoutManager.endWorkout()
ControlsView - Pause / Resume Button and Text swift · at 35:43 ↗
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 swift · at 36:30 ↗
@EnvironmentObject var workoutManager: WorkoutManager
SessionPagingView - navigationBar swift · at 36:42 ↗
.navigationTitle(workoutManager.selectedWorkout?.name ?? "")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(selection == .nowPlaying)
SessionPagingView - onChange of workoutManager.running swift · at 37:10 ↗
.onChange(of: workoutManager.running) { _ in
        displayMetricsView()
    }
}

private func displayMetricsView() {
    withAnimation {
        selection = .metrics
    }
}
WorkoutManager - showingSummaryView swift · at 37:45 ↗
@Published var showingSummaryView: Bool = false {
    didSet {
        // Sheet dismissed
        if showingSummaryView == false {
            selectedWorkout = nil
        }
    }
}
WorkoutManager - showingSummaryView true in endWorkout swift · at 37:59 ↗
showingSummaryView = true
MyWorkoutApp - add summaryView sheet to NavigationView swift · at 38:22 ↗
.sheet(isPresented: $workoutManager.showingSummaryView) {
    SummaryView()
}
SummaryView - add dismiss environment variable swift · at 38:49 ↗
@Environment(\.dismiss) var dismiss
SummaryView - add dismiss() to done button swift · at 38:58 ↗
dismiss()
WorkoutManager - Metric publishers swift · at 40:25 ↗
// MARK: - Workout Metrics
@Published var averageHeartRate: Double = 0
@Published var heartRate: Double = 0
@Published var activeEnergy: Double = 0
@Published var distance: Double = 0
WorkoutManager - assigned as HKLiveWorkoutBuilderDelegate in startWorkout() swift · at 40:48 ↗
builder?.delegate = self
WorkoutManager - add HKLiveWorkoutBuilderDelegate extension swift · at 41:05 ↗
// 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() swift · at 42:01 ↗
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 swift · at 43:25 ↗
@EnvironmentObject var workoutManager: WorkoutManager
MetricsView - VStack with Text bound to workoutManager variables swift · at 43:35 ↗
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 swift · at 45:51 ↗
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 swift · at 46:38 ↗
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 swift · at 48:23 ↗
@Published var workout: HKWorkout?
WorkoutManager - assign HKWorkout in finishWorkout swift · at 48:38 ↗
DispatchQueue.main.async {
    self.workout = workout
}
WorkoutManager - resetWorkout() swift · at 48:57 ↗
func resetWorkout() {
    selectedWorkout = nil
    builder = nil
    session = nil
    workout = nil
    activeEnergy = 0
    averageHeartRate = 0
    heartRate = 0
    distance = 0
}
WorkoutManager - add resetWorkout to showingSummaryView didSet swift · at 49:21 ↗
resetWorkout()
SummaryView - add workoutManager swift · at 49:48 ↗
@EnvironmentObject var workoutManager: WorkoutManager
SummaryView - add ProgressView swift · at 50:06 ↗
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 swift · at 50:43 ↗
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 swift · at 51:45 ↗
@Environment(\.isLuminanceReduced) var isLuminanceReduced
SessionPagingView - add tabViewStyle and onChangeOf based on isLuminanceReduced swift · at 51:57 ↗
.tabViewStyle(
    PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic)
)
.onChange(of: isLuminanceReduced) { _ in
    displayMetricsView()
}

Resources