WWDC Quick Look 💓 By SwiftGGTeam
Build a workout app for Apple Watch

Build a workout app for Apple Watch

元の動画を見る

ハイライト

Apple は SwiftUI と HealthKit で Apple Watch 運動 App をゼロから構築するデモを行いました。リアルタイムの心拍、エネルギー、距離を収集し、運動記録を保存し、TimelineView で Always On に適応します。

主要内容

Apple Watch 運動 App を作る上で、難しいのはボタンの数ではありません。

本当の難しさは運動開始後に始まります。ユーザーが走ったり漕いだりすると、手首は上下し、画面は Always On の低頻度更新に入ります。App は経過時間、エネルギー、心拍、距離を表示し続け、終了後に HealthKit に記録を保存する必要があります。

以前はこのロジックを SwiftUI ビューに散らばせると、UI、権限、セッション状態、データ収集が混ざります。一時停止ボタンはセッション状態を変え、サマリーページは保存完了を待ち、リアルタイム指標は画面の更新頻度に合わせる必要がありました。

この Code-Along では Apple が完全なパスに分解します。SwiftUI の 3 画面構造、WorkoutManager による HealthKit セッション管理、Always On 下で指標ページを適切に更新する TimelineView です。

ワンタップで運動を開始

ユーザーが運動 App を開いたとき、最初に複雑な設定を見せるべきではありません。起点は StartView で、3 種類の運動——自転車、ランニング、ウォーキングです。各項目は NavigationLink で直接運動セッションに入れます。

タップ後、運動画面は watchOS 共通の 3 ページ構成になります。左にコントロール、中央に指標、右に Now Playing。デフォルトは指標ページ——運動開始後、ユーザーが最も気にするのは時間、エネルギー、心拍、距離です。

1 つのオブジェクトで運動ライフサイクルを管理

HealthKit の運動セッションには明確なフローがあります。権限要求、設定作成、HKWorkoutSession の開始、HKLiveWorkoutBuilder によるデータ収集、完了時に HKWorkout として保存です。

このセッションではその状態を WorkoutManager に置きます。SwiftUI ビューは @EnvironmentObject で読み取ります。ボタンは一時停止や終了だけをトリガーし、指標ページは公開されたデータを表示するだけです。

Always On が更新方式を変える

Apple Watch の Always On は更新頻度を下げます。運動 App は高頻度で百分秒を表示し続けられません——不適切であり、Always On の情報ニーズにも合いません。

解決策は TimelineView です。TimelineScheduleMode で更新間隔を決め、context.cadence で百分秒表示を制御します。画面がアクティブなときは細かい時間、低頻度状態では分と秒だけを表示します。

詳細

運動タイプのリスト

03:25) Apple は HealthKit の HKWorkoutActivityType で運動タイプをモデル化します。SwiftUI の List に直接使うため、列挙型に Identifiable と表示名を追加します。

import HealthKit

var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]

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 ""
        }
    }
}

キーポイント:

  • import HealthKitHKWorkoutActivityType を導入する。
  • workoutTypes は表示する 3 種類の運動を保持する。
  • Identifiable により列挙値を List のデータソースにできる。
  • idrawValue を使い、HealthKit 列挙の生の値に対応する。
  • name は HealthKit タイプを時計 UI の短いラベルに変換する。

04:22) リストは carousel スタイルを使います。watchOS で縦スクロールリストに適した視覚効果です。

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")

キーポイント:

  • List(workoutTypes) は各運動タイプごとに 1 行を生成する。
  • NavigationLink は各行から次ページへの遷移を提供する。
  • padding は運動前の素早い操作向けにタップ領域を広げる。
  • .listStyle(.carousel) は watchOS のカルーセルリスト効果を使う。
  • .navigationBarTitle("Workouts") で開始ページのタイトルを設定する。

運動中の 3 ページ構成

06:55) 運動開始後、ユーザーはコントロール、リアルタイム指標、メディアが必要です。TabView で横方向ページングし、デフォルトを指標ページにします。

@State private var selection: Tab = .metrics

enum Tab {
    case controls, metrics, nowPlaying
}

TabView(selection: $selection) {
    Text("Controls").tag(Tab.controls)
    Text("Metrics").tag(Tab.metrics)
    Text("Now Playing").tag(Tab.nowPlaying)
}

キーポイント:

  • selection は現在のページを保持する。
  • デフォルトは .metrics——運動開始後はまずデータを表示する。
  • Tab 列挙は 3 ページを列挙する。
  • TabView(selection:) は現在のページ状態をバインドする。
  • tag は各子ビューを列挙値に対応づける。

16:09) プレースホルダーテキストはその後、実際のビューに置き換えられます。

ControlsView().tag(Tab.controls)
MetricsView().tag(Tab.metrics)
NowPlayingView().tag(Tab.nowPlaying)

キーポイント:

  • ControlsView は終了・一時停止・再開ボタンを置く。
  • MetricsView は経過時間、エネルギー、心拍、距離を表示する。
  • NowPlayingView は運動中のメディアコントロールを提供する。
  • 3 ページすべてが同じ Tab タグを再利用する。

経過時間のフォーマット

11:42) 経過時間は普通の数字では表示できません。ElapsedTimeViewElapsedTimeFormatter を作り、DateComponentsFormatter で分と秒を出力し、必要に応じて百分秒を追加します。

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
    }
}

キーポイント:

  • ElapsedTimeViewTimeInterval を受け取る。
  • showSubseconds は百分秒表示を制御する。
  • Text(NSNumber(value:), formatter:) で SwiftUI にカスタム formatter を使わせる。
  • DateComponentsFormatter は分と秒を担当する。
  • allowedUnits = [.minute, .second] で出力単位を限定する。
  • zeroFormattingBehavior = .pad03:15 のようなゼロ埋め形式を保つ。
  • truncatingRemainder(dividingBy: 1) で小数部分を取得する。
  • Locale.current.decimalSeparator は現在のロケールの小数点を使う。

HealthKit 権限のリクエスト

29:35) 運動 App は workout を書き込み、心拍、エネルギー、距離、アクティビティリングを読み取ります。HealthKit 権限は WorkoutManager で一括リクエストします。

// 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.
    }
}

キーポイント:

  • typesToShare は書き込む運動記録を宣言する。
  • HKQuantityType.workoutType() は workout タイプを表す。
  • typesToRead は読み取る HealthKit データを宣言する。
  • .heartRate は現在と平均心拍に使う。
  • .activeEnergyBurned は活動エネルギーに使う。
  • .distanceWalkingRunning.distanceCycling は歩行・ランニング距離とサイクリング距離に対応する。
  • HKObjectType.activitySummaryType() はサマリーのアクティビティリングに使う。
  • requestAuthorization はシステム権限リクエストを開始する。

30:20) 開始ページが表示されたときに権限をリクエストします。

.onAppear {
    workoutManager.requestAuthorization()
}

キーポイント:

  • .onAppear は開始ページ表示時に実行される。
  • workoutManager は SwiftUI 環境に注入される共有オブジェクトである。
  • 権限フローはユーザーが運動を始める前に行われる。

運動セッションの開始

27:42) リアルタイムの HealthKit 運動は HKWorkoutSessionHKLiveWorkoutBuilder に依存します。前者がセッション状態、後者がリアルタイムデータ収集と最終 workout 生成を担当します。

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.
    }
}

キーポイント:

  • HKWorkoutConfiguration は運動タイプと位置タイプを保持する。
  • activityType は開始ページでのユーザー選択から来る。
  • locationType = .outdoor は屋外運動に設定する。
  • HKWorkoutSession は HealthKit 運動セッションを作成する。
  • associatedWorkoutBuilder() はセッションに紐づく builder を取得する。
  • HKLiveWorkoutDataSource は builder がリアルタイム HealthKit データを取得できるようにする。
  • startDate はセッションとデータ収集の共通起点である。
  • startActivity(with:) で運動セッションを開始する。
  • beginCollection(withStart:) でリアルタイムサンプルの収集を開始する。

29:06) 運動タイプを選ぶと自動的にセッションが開始されます。

var selectedWorkout: HKWorkoutActivityType? {
    didSet {
        guard let selectedWorkout = selectedWorkout else { return }
        startWorkout(workoutType: selectedWorkout)
    }
}

キーポイント:

  • selectedWorkout はユーザーが選んだ運動を記録する。
  • didSet は選択が変わった後に実行される。
  • guard は nil を除外する。
  • startWorkout(workoutType:) はナビゲーション選択と HealthKit セッションを接続する。

一時停止、再開、終了

33:29) 運動コントロールは WorkoutManager 経由で公開されます。UI ボタンは HealthKit セッションに直接触れず、これらのメソッドだけを呼びます。

// 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()
}

キーポイント:

  • @Published var running は実行状態を SwiftUI に公開する。
  • pause() は HealthKit セッションを一時停止する。
  • resume() は HealthKit セッションを再開する。
  • togglePause() は現在の状態に応じて動作を切り替える。
  • endWorkout() はセッションを終了する。

34:11) セッション状態の変化は HKWorkoutSessionDelegate で返されます。終了時はまずデータ収集を止め、その後 workout 保存を完了します。

// 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) {

    }
}

キーポイント:

  • HKWorkoutSessionDelegate はセッション状態変化を受け取る。
  • didChangeTo は新状態、旧状態、変化時刻を提供する。
  • DispatchQueue.main.asyncrunning 更新をメインスレッドに戻す。
  • toState == .running は一時停止ボタン状態の更新に使う。
  • toState == .ended はセッション終了を意味する。
  • endCollection(withEnd:) でデータ収集を停止する。
  • finishWorkout は保存し HKWorkout を生成する。

リアルタイム指標の収集

40:25) リアルタイム指標は @Published で SwiftUI に公開されます。

// MARK: - Workout Metrics
@Published var averageHeartRate: Double = 0
@Published var heartRate: Double = 0
@Published var activeEnergy: Double = 0
@Published var distance: Double = 0

キーポイント:

  • averageHeartRate は平均心拍を保持する。
  • heartRate は直近の心拍を保持する。
  • activeEnergy は累積活動エネルギーを保持する。
  • distance は累積距離を保持する。
  • @Published は指標変化時にバインドされたビューを自動更新する。

41:05) HKLiveWorkoutBuilderDelegate はどのサンプルタイプに新データがあるかを通知します。

// 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)
        }
    }
}

キーポイント:

  • HKLiveWorkoutBuilderDelegate は builder の収集イベントを受け取る。
  • didCollectDataOf は今回の更新に関わるデータタイプを提供する。
  • collectedTypes には心拍、エネルギー、距離が含まれる場合がある。
  • guard let quantityType は数量タイプのみを処理する。
  • statistics(for:) は builder から統計を取得する。
  • updateForStatistics は HealthKit 統計を UI データに変換する。

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
        }
    }
}

キーポイント:

  • 空の statistics では即座に return する。
  • UI 関連状態はメインスレッドで更新する。
  • 心拍単位は count/minute である。
  • mostRecentQuantity() は現在の心拍を取得する。
  • averageQuantity() は平均心拍を取得する。
  • 活動エネルギーはキロカロリー単位を使う。
  • sumQuantity() は累積エネルギーを取得する。
  • 歩行・ランニング距離とサイクリング距離はメートルに変換する。
  • 未処理タイプは default に入る。

リアルタイム指標の表示

43:35) 指標ページは WorkoutManager から値を読み取り、Measurement.formatted で地域と運動シーンに適したテキストを生成します。

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
            )
        )
    )
}

キーポイント:

  • ElapsedTimeView は builder の経過時間を表示する。
  • ?? 0 は builder 未作成時のデフォルトを与える。
  • エネルギーは UnitEnergy.kilocalories を使う。
  • .measurement(width: .abbreviated, usage: .workout) は運動シーン向けの短い単位を出力する。
  • numberFormat でエネルギーの小数を除去する。
  • 心拍は数値フォーマットに bpm を追加する。
  • 距離は UnitLength.meters を入力単位にする。
  • usage: .road で距離をロケールに応じて自然に表示する。

Always On でのタイムライン更新

45:51) Always On では運動指標の更新頻度を下げる必要があります。TimelineSchedule をカスタマイズします。

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
        )
    }
}

キーポイント:

  • MetricsTimelineScheduleTimelineSchedule に準拠する。
  • startDate は運動開始時刻を記録する。
  • entries(from:mode:) はシステムモードに応じて更新時刻を生成する。
  • mode == .lowFrequency は低頻度更新を意味する。
  • 低頻度では 1 秒ごとに更新する。
  • アクティブ状態では 1.0 / 30.0 秒ごとに更新する。
  • PeriodicTimelineSchedule は周期的な entries を作る。

46:38) 指標ビューを TimelineView に入れ、context.cadence に基づいて百分秒を非表示にします。

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()
}

キーポイント:

  • TimelineView はカスタムタイムラインでコンテンツを再計算する。
  • from は builder の開始時刻を使う。
  • context.cadence == .live はリアルタイム更新状態を意味する。
  • リアルタイム状態では百分秒を表示する。
  • 低頻度状態では百分秒を隠す。
  • rounded title フォントは時計での素早い読み取りに適する。
  • monospacedDigit() は数字のジャンプによる視覚的揺れを防ぐ。
  • .ignoresSafeArea(edges: .bottom) で指標領域が下部スペースを使う。
  • .scenePadding() は watchOS のシーンパディングに揃える。

51:45) ページレベルでは isLuminanceReduced を読み取り、Always On 低輝度でページインジケーターを隠し、自動的に指標ページに戻ります。

@Environment(\.isLuminanceReduced) var isLuminanceReduced

.tabViewStyle(
    PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic)
)
.onChange(of: isLuminanceReduced) { _ in
    displayMetricsView()
}

キーポイント:

  • isLuminanceReduced は SwiftUI 環境から来る。
  • 低輝度は通常 Always On 表示に対応する。
  • PageTabViewStyle はページインジケーターを制御する。
  • 低輝度では .never でインジケーターを隠す。
  • 通常状態では .automatic を使う。
  • onChange は低輝度状態の変化を監視する。
  • 状態変化時に displayMetricsView() を呼び指標ページに戻る。

運動サマリーとアクティビティリング

21:00) 終了後、サマリーで当日のアクティビティリングを表示できます。SwiftUI は WKInterfaceObjectRepresentable で WatchKit の WKInterfaceActivityRing をラップします。

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) {

    }
}

キーポイント:

  • WKInterfaceObjectRepresentable は WatchKit オブジェクトを SwiftUI にブリッジする。
  • healthStore は HealthKit クエリを実行する。
  • WKInterfaceActivityRing はアクティビティリングコントロールである。
  • dateComponents は当日の日付を取得する。
  • predicateForActivitySummary は当日のアクティビティサマリークエリを作る。
  • HKActivitySummaryQuery はアクティビティサマリーを読み取る。
  • setActivitySummary はクエリ結果をリングコントロールに設定する。
  • DispatchQueue.main.async で UI 更新をメインスレッドで行う。

50:43) 最終サマリーは保存された HKWorkout から総時間、総距離、総エネルギーを読み取ります。

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)

キーポイント:

  • workoutManager.workout は保存完了後の HKWorkout である。
  • duration は総時間を提供する。
  • totalDistance は総距離を提供する。
  • doubleValue(for: .meter()) でメートルに変換する。
  • totalEnergyBurned は総エネルギーを提供する。
  • doubleValue(for: .kilocalorie()) でキロカロリーに変換する。
  • 平均心拍はリアルタイム収集時に保存した averageHeartRate から来る。
  • SummaryMetricView は異なる accentColor で指標を区別する。

重要ポイント

  • 何をするか: 屋外ウォーキング記録器を作り、開始・一時停止・終了と 4 つのコア指標だけを残す。 なぜ価値があるか: このセッションは既に HKWorkoutSessionHKLiveWorkoutBuilder、指標ページ構造を提供している。 どう始めるか: workoutTypes.walking に絞り、startWorkout(workoutType:) でセッション開始、distanceWalkingRunningheartRate で UI を更新。

  • 何をするか: サイクリング向けのミニマル心拍画面を作る。 なぜ価値があるか: TimelineView は Always On でも読みやすい情報を保ち、monospacedDigit() は運動中のちらっと見に適している。 どう始めるか: MetricsTimelineSchedule を再利用し、heartRateaverageHeartRate、経過時間だけ表示。context.cadence == .live で時間精度を制御。

  • 何をするか: 運動後の振り返りサマリーページを作る。 なぜ価値があるか: HKWorkout には既に総時間、総距離、総エネルギーがあり、アクティビティリングは HKActivitySummaryQuery で取得できる。 どう始めるか: finishWorkout 後に workout を保存し、SummaryMetricViewdurationtotalDistancetotalEnergyBurned を表示し、ActivityRingsView を埋め込む。

  • 何をするか: Apple Watch メディアコントロール付きランニング App を作る。 なぜ価値があるか: 運動中は曲の切り替えが必要で、3 ページ構成は既に右側に NowPlayingView を置いている。 どう始めるか: SessionPagingViewNowPlayingView().tag(Tab.nowPlaying) を残し、PageTabViewStyleisLuminanceReduced で Always On のページ表示を制御。

  • 何をするか: 走走交替トレーニングなど、特定トレーニング向けの watchOS ツールを作る。 なぜ価値があるか: runningpause()resume()togglePause() がセッション制御を抽象化し、ビューはトレーニングフローに集中できる。 どう始めるか: WorkoutManager 外にトレーニングフェーズ状態を追加し、フェーズ切り替えで SwiftUI テキストを更新。HealthKit は session?.pause()session?.resume() を使う。

関連セッション

コメント

GitHub Issues · utterances