iOS 27 and watchOS 27 integrate heart rate and cycling power workout zones directly into HealthKit, enabling apps to retrieve time-in-zone summaries from completed workouts and receive live zone-change notifications during active sessions.
⢠Apps can now read automatically-calculated zone breakdowns (time per zone) directly from HKWorkout without building custom zone logic
⢠Live delegate callbacks via didUpdateWorkoutZone let training apps surface real-time intensity coaching during a workout
⢠Custom zone configurations let fitness platforms inject proprietary zone models (e.g. 7-zone power models) into a workout before collection begins
Fetches the most recent HKWorkout from HealthKit and displays the time spent in each heart rate zone, using the new iOS 27 zoneGroupsByType API.
import SwiftUI
import HealthKit
struct ZoneDurationRow: Identifiable {
let id: Int
let label: String
let seconds: Double
}
@MainActor
class WorkoutZoneViewModel: ObservableObject {
@Published var zoneRows: [ZoneDurationRow] = []
@Published var errorMessage: String?
private let store = HKHealthStore()
func fetchLatestWorkoutZones() async {
let workoutType = HKObjectType.workoutType()
let heartRateType = HKQuantityType(.heartRate)
do {
try await store.requestAuthorization(
toShare: [],
read: [workoutType, heartRateType]
)
} catch {
errorMessage = "Authorization failed: \(error.localizedDescription)"
return
}
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQueryDescriptor(
predicates: [.workout()],
sortDescriptors: [SortDescriptor(\.endDate, order: .reverse)],
limit: 1
)
do {
let results = try await query.result(for: store)
guard let workout = results.first as? HKWorkout else {
errorMessage = "No workouts found."
return
}
guard let zoneGroup = workout.zoneGroupsByType[HKQuantityType(.heartRate)] else {
errorMessage = "No heart rate zone data for this workout."
return
}
zoneRows = zoneGroup.zoneDurations.map { zoneDuration in
let zone = zoneDuration.zone
let seconds = zoneDuration.duration
let label: String
if let max = zone.maximumQuantity {
label = "Zone \(zone.index + 1) (up to \(Int(max.doubleValue(for: .count().unitDivided(by: .minute())))) bpm)"
} else {
label = "Zone \(zone.index + 1) (peak)"
}
return ZoneDurationRow(id: zone.index, label: label, seconds: seconds)
}
} catch {
errorMessage = "Query failed: \(error.localizedDescription)"
}
}
}
struct WorkoutZoneSummaryView: View {
@StateObject private var vm = WorkoutZoneViewModel()
var body: some View {
NavigationStack {
List(vm.zoneRows) { row in
HStack {
Text(row.label)
Spacer()
Text(String(format: "%.0f min", row.seconds / 60))
.foregroundStyle(.secondary)
}
}
.navigationTitle("Heart Rate Zones")
.overlay {
if let error = vm.errorMessage {
ContentUnavailableView(error, systemImage: "heart.slash")
}
}
.task { await vm.fetchLatestWorkoutZones() }
}
}
}Custom zone configurations must be added to HKWorkoutBuilder before calling beginCollection ā they cannot be added mid-workout. When comparing time-in-zone across workouts with different zone counts, normalize by zone boundaries rather than zone index, as 'Zone 3' in a 5-zone model differs from 'Zone 3' in a 7-zone model. Custom zone configs are not persisted by HealthKit ā the app must save and sync them independently.
Requires HealthKit authorization; heart rate zones are auto-calculated by the system based on age and resting heart rate if no manual preference is set
More iOS 27 APIs land every week.
Get notified when new capabilities are published ā no noise, just signal.