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

Build a workout app for Apple Watch

观看原视频

Highlight

Apple 演示了用 SwiftUI 和 HealthKit 从零构建 Apple Watch 运动应用,开发者可以采集实时心率、能量、距离数据,保存运动记录,并用 TimelineView 适配 Always On 状态。

核心内容

做一个 Apple Watch 运动应用,难点不在按钮有多少。

真正的麻烦在运动开始之后。用户正在跑步或骑车,手腕会抬起、放下,屏幕会进入 Always On(常亮)低频刷新状态。应用还要持续显示经过时间、能量、心率、距离,并在结束后把记录写入 HealthKit。

以前,如果只把这些逻辑散落在 SwiftUI 视图里,界面、权限、会话状态、数据采集很快会混在一起。暂停按钮要改会话状态,摘要页要等保存完成,实时指标还要根据屏幕刷新频率调整显示。

Apple 在这场 Code-Along 中把问题拆成一条完整路径:先用 SwiftUI 搭出 watchOS 运动应用的三屏结构,再用 WorkoutManager 管理 HealthKit 会话,最后用 TimelineView 让指标页在 Always On 下继续合理刷新。

从一次点击开始运动

用户打开运动应用时,不应该先看到复杂设置。演讲中的起点是一个 StartView:三种运动类型,骑车、跑步、步行。每一项都是一个可以直接进入运动会话的 NavigationLink

点击后,运动页进入 watchOS 常见的三页结构:左侧控制,中央指标,右侧 Now Playing。默认显示指标页,因为用户开始运动后最先关心的是时间、能量、心率和距离。

用一个对象管理运动生命周期

HealthKit 的运动会话有一套明确流程:请求权限,创建配置,启动 HKWorkoutSession,用 HKLiveWorkoutBuilder 收集数据,结束时保存成 HKWorkout

这场 session 把这些状态放进 WorkoutManager。SwiftUI 视图通过 @EnvironmentObject 读取它。这样,按钮只负责触发暂停或结束,指标页只负责显示发布出来的数据。

Always On 改变刷新方式

Apple Watch 的 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 HealthKit 引入 HKWorkoutActivityType
  • workoutTypes 保存要展示的三种运动。
  • Identifiable 让枚举值可以作为 List 的数据源。
  • id 使用 rawValue,对应 HealthKit 枚举原始值。
  • name 把 HealthKit 类型转成手表界面上的短文案。

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) 为每一种运动生成一行。
  • NavigationLink 给每行提供进入下一页的能力。
  • padding 增大点击区域,适合运动前快速操作。
  • .listStyle(.carousel) 使用 watchOS 的旋转列表效果。
  • .navigationBarTitle("Workouts") 给开始页设置标题。

运动中的三页结构

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 枚举列出三种页面。
  • TabView(selection:) 绑定当前页状态。
  • tag 把每个子视图和枚举值对应起来。

16:09)占位文字随后替换成真实视图。

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

关键点:

  • ControlsView 放结束、暂停、恢复按钮。
  • MetricsView 显示经过时间、能量、心率、距离。
  • NowPlayingView 提供运动中的媒体控制。
  • 三个页面继续复用同一组 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
    }
}

关键点:

  • ElapsedTimeView 接收 TimeInterval
  • showSubseconds 控制是否显示百分秒。
  • Text(NSNumber(value:), formatter:) 让 SwiftUI 使用自定义 formatter。
  • DateComponentsFormatter 负责分钟和秒。
  • allowedUnits = [.minute, .second] 限定输出单位。
  • zeroFormattingBehavior = .pad 保持 03:15 这种补零格式。
  • truncatingRemainder(dividingBy: 1) 取小数部分。
  • Locale.current.decimalSeparator 使用当前地区的小数分隔符。

请求 HealthKit 权限

29:35)运动应用要写入 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 过滤掉空值。
  • startWorkout(workoutType:) 把导航选择和 HealthKit 会话连接起来。

暂停、恢复和结束

33:29)运动控制通过 WorkoutManager 暴露。界面按钮不直接碰 HealthKit session,只调用这些方法。

// 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:05HKLiveWorkoutBuilderDelegate 通知应用哪些样本类型有新数据。

// 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 统计值转成界面数据。

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 直接返回。
  • 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
        )
    }
}

关键点:

  • MetricsTimelineSchedule 遵守 TimelineSchedule
  • 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 区分指标。

核心启发

  • 做什么:做一个室外步行记录器,只保留开始、暂停、结束和四个核心指标。 为什么值得做:这场 session 已经给出 HKWorkoutSessionHKLiveWorkoutBuilder 和指标页结构。 怎么开始:把 workoutTypes 缩小到 .walking,用 startWorkout(workoutType:) 启动会话,用 distanceWalkingRunningheartRate 更新界面。

  • 做什么:做一个骑行时的极简心率屏。 为什么值得做TimelineView 可以在 Always On 下保留可读信息,monospacedDigit() 适合运动中快速扫一眼。 怎么开始:复用 MetricsTimelineSchedule,只显示 heartRateaverageHeartRate 和 elapsed time,把 context.cadence == .live 用于控制时间精度。

  • 做什么:做一个运动结束后的复盘摘要页。 为什么值得做HKWorkout 已经包含总时长、总距离、总能量,活动圆环可以通过 HKActivitySummaryQuery 读取。 怎么开始:在 finishWorkout 后保存 workout,用 SummaryMetricView 展示 durationtotalDistancetotalEnergyBurned,再嵌入 ActivityRingsView

  • 做什么:做一个支持 Apple Watch 媒体控制的跑步应用。 为什么值得做:运动中用户常常需要切歌,session 的三页结构已经把 NowPlayingView 放在右侧。 怎么开始:在 SessionPagingView 中保留 NowPlayingView().tag(Tab.nowPlaying),用 PageTabViewStyleisLuminanceReduced 控制 Always On 下的分页显示。

  • 做什么:做一个只面向某种训练的 watchOS 小工具,比如跑走交替训练。 为什么值得做runningpause()resume()togglePause() 已经把会话控制抽象出来,视图可以专注训练流程。 怎么开始:在 WorkoutManager 外增加训练阶段状态,阶段切换时更新 SwiftUI 文案,仍然用 session?.pause()session?.resume() 控制 HealthKit 会话。

关联 Session

评论

GitHub Issues · utterances