-
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.
Recursos
Vídeos relacionados
WWDC23
WWDC21
-
Buscar neste vídeo...
-
-
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() }
-