Chapters

Hide chapters

watchOS With SwiftUI by Tutorials

First Edition · watchOS 8 · Swift 5.5 · Xcode 13.1

Section I: watchOS With SwiftUI

Section 1: 16 chapters
Show chapters Hide chapters

15. HealthKit
Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Apple Watch is an incredible device for tracking health and fitness. The sheer number of apps related to workout tracking is staggering. Therefore, in this chapter, you’ll build a workout tracking…

No! Why not do something a bit different? In Chapter 7, “Lifecycle”, you built an app to help kids know how long to brush their teeth. Did you know that Apple Health has a section for tracking that?

  1. Open the Apple Health app on your iPhone.
  2. Tap Browse in the toolbar.
  3. Tap Other Data from the Health Categories list.
  4. You’ll see Toothbrushing in the No Data Available section.

Who knew? :] Might as well log the fact you brushed your teeth, right?

Note: There’s nothing different about using HealthKit on the Apple Watch. However, it’s such a pervasive use case that I wanted to include a chapter on it.

Note: While the simulator can read and write to Apple Health, you can’t launch the Apple Health app yourself. You’ll need a physical device to truly complete this chapter.

Adding HealthKit

The starter materials for this chapter contain a slightly modified version of the Toothbrush app you built previously. HealthKit is one of the frameworks that requires permission from the user before you use it since it contains personal information.

Signing & Capabilities

First, you need to add the HealthKit capability to Xcode:

Info.plist descriptions

Once you’ve done that, you’ll then need to open Info.plist from the Health WatchKit Extension and add two keys:

Creating the store

Select the Health WatchKit Extension folder in the Project Navigator. Create a new swift file and name it HealthStore.swift and add to it:

// 1
import Foundation
import HealthKit

final class HealthStore {
  // 2
  static let shared = HealthStore()

  // 3
  private var healthStore: HKHealthStore?

  // 4
  private init() {
    // 5
    guard HKHealthStore.isHealthDataAvailable() else {
      return
    }

    healthStore = HKHealthStore()
  }
}
.task {
  _ = HealthStore.shared
}

Saving data

Saving data to Apple Health is an asynchronous operation. All data types convert to an HKSample before saving. To let the app continue to use the async / await pattern, add the following method to HealthStore.swift:

private func save(_ sample: HKSample) async throws {
  // 1
  guard let healthStore = healthStore else {
    throw HKError(.errorHealthDataUnavailable)
  }

  // 2
  let _: Bool = try await withCheckedThrowingContinuation {
    continuation in

    // 3
    healthStore.save(sample) { _, error in
      if let error = error {
        // 4
        continuation.resume(throwing: error)
        return
      }

      // 5
      continuation.resume(returning: true)
    }
  }
}

Tracking brushing

Apple Health stores activities differently depending on the type of data. For example, when brushing your teeth, the Apple Health app tracks the start and end times.

Brushing HealthKit configuration

Add the following property to your HealthStore class so that Apple Health knows you’re going to log data related to brushing your teeth:

private let brushingCategoryType = HKCategoryType.categoryType(
  forIdentifier: .toothbrushingEvent
)!
Task {
  try await healthStore!.requestAuthorization(
    toShare: [brushingCategoryType],
    read: [brushingCategoryType]
  )
}
// 1
func logBrushing(startDate: Date) async throws {
  // 2
  let status = healthStore?.authorizationStatus(
    for: brushingCategoryType
  )

  guard status == .sharingAuthorized else {
    return
  }

  // 3
  let sample = HKCategorySample(
    type: brushingCategoryType,
    value: HKCategoryValue.notApplicable.rawValue,
    start: startDate,
    end: Date.now
  )

  // 4
  try await save(sample)
}
self.session.invalidate()
// 1
Task {
  // 2
  try? await HealthStore.shared.logBrushing(
    startDate: self.started
  )

  // 3
  self.session.invalidate()
}

Tracking water

Like brushing their teeth, many kids have a hard time remembering to drink water. Seems like a great addition to your app!

Updating permissions

Recommended water intake is based on body mass. So, you’ll have to ask for two more types of data from HealthKit. In HealthStore.swift, add:

private let waterQuantityType = HKQuantityType.quantityType(
  forIdentifier: .dietaryWater
)!

private let bodyMassType = HKQuantityType.quantityType(
  forIdentifier: .bodyMass
)!
Task {
  try await healthStore!.requestAuthorization(
    toShare: [brushingCategoryType, waterQuantityType],
    read: [brushingCategoryType, waterQuantityType, bodyMassType]
  )
}

Water HealthKit configuration

Time to update the HealthStore to handle water. Add the following to HealthStore.swift:

// 1
var isWaterEnabled: Bool {
  let status = healthStore?.authorizationStatus(
    for: waterQuantityType
  )

  return status == .sharingAuthorized
}

// 2
func logWater(quantity: HKQuantity) async throws {
  guard isWaterEnabled else {
    return
  }

  // 3
  let sample = HKQuantitySample(
    type: waterQuantityType,
    quantity: quantity,
    start: Date.now,
    end: Date.now
  )

  // 4
  try await save(sample)
}

Log water button

To keep the app simple, you’ll provide two buttons to let the user enter an amount of water. Inside the Water folder, you’ll find a file named LogWaterButton.swift. I’ve cheated a bit in the interest of simplicity and hardcoded two water sizes based on whether you’re using the metric system.

// 1
let unit: HKUnit
let value: Double

if Locale.current.usesMetricSystem {
  // 2
  unit = .literUnit(with: .milli)
  value = size == .small ? 250 : 500
} else {
  // 3
  unit = .fluidOunceUS()
  value = size == .small ? 8 : 16
}

// 4
let quantity = HKQuantity(unit: unit, doubleValue: value)

// 5
onTap(quantity)

Water view

Now create another SwiftUI view called WaterView.swift to act as the UI when taking a drink. Be sure to import HealthKit at the top of the file:

import HealthKit
// 1
ScrollView {
  VStack {
    // 2
    if HealthStore.shared.isWaterEnabled {
      Text("Add water")
        .font(.headline)

      HStack {
        LogWaterButton(size: .small) {  }
        LogWaterButton(size: .large) {  }
      }
      .padding(.bottom)
    } else {
      // 3
      Text("Please enable water tracking in Apple Health.")
    }
  }
}
private func logWater(quantity: HKQuantity) {
  Task {
    try await HealthStore.shared.logWater(quantity: quantity)
  }
}
LogWaterButton(size: .small) { logWater(quantity: $0) }
LogWaterButton(size: .large) { logWater(quantity: $0) }

Updating ContentView

Go back to the main extension folder. In ContentView.swift, import HealthKit at the top of the file:

import HealthKit
@State private var wantsToDrink = false
if HealthStore.shared.isWaterEnabled {
  Button {
    wantsToDrink.toggle()
  } label: {
    Image(systemName: "drop.fill")
      .foregroundColor(.blue)
  }
}
.sheet(isPresented: $wantsToDrink) {
  WaterView()
}

Getting the view to update

Remember that in SwiftUI, the body only updates if something being observed, like a @State property, changes. SwiftUI doesn’t know when the value for HealthStore.shared.isWaterEnabled changes. You need to explicitly tell the view that a change has happened.

static let healthStoreLoaded = Notification.Name(
  rawValue: UUID().uuidString
)
await MainActor.run {
  NotificationCenter.default.post(
    name: .healthStoreLoaded,
    object: nil
  )
}
// 1
@State private var waitingForHealthKit = true

// 2
private let healthStoreLoaded = NotificationCenter.default.publisher(
  for: .healthStoreLoaded
)
if waitingForHealthKit {
  Text("Waiting for HealthKit prompt.")
} else {
  // Existing code from VStack
}
.onReceive(healthStoreLoaded) { _ in
  self.waitingForHealthKit = false
}

Reading single day data

Common practice bases the amount of water you should drink on how much you weigh. It would be great to tell the user how much water they still need to drink today.

Querying HealthKit

In HealthStore.swift, add a method to determine the user’s current body mass:

// 1
private func currentBodyMass() async throws -> Double? {
  // 2
  guard let healthStore = healthStore else {
    throw HKError(.errorHealthDataUnavailable)
  }

  // 3
  let sort = NSSortDescriptor(
    key: HKSampleSortIdentifierStartDate,
    ascending: false
  )

  // 4
  return try await withCheckedThrowingContinuation { continuation in
    // 5
    let query = HKSampleQuery(
      sampleType: bodyMassType,
      predicate: nil,
      limit: 1,
      sortDescriptors: [sort]
    ) { _, samples, _ in
      // 6
      guard let latest = samples?.first as? HKQuantitySample else {
        continuation.resume(returning: nil)
        return
      }

      // 7
      let pounds = latest.quantity.doubleValue(for: .pound())
      continuation.resume(returning: pounds)
    }

    // 8
    healthStore.execute(query)
  }
}
private func drankToday() async throws -> (
  ounces: Double,
  amount: Measurement<UnitVolume>
) {

  guard let healthStore = healthStore else {
    throw HKError(.errorHealthDataUnavailable)
  }

  // 1
  let start = Calendar.current.startOfDay(for: Date.now)

  let predicate = HKQuery.predicateForSamples(
    withStart: start,
    end: Date.now,
    options: .strictStartDate
  )

  // 2
  return await withCheckedContinuation { continuation in
    // 3
    let query = HKStatisticsQuery(
      quantityType: waterQuantityType,
      quantitySamplePredicate: predicate,
      options: .cumulativeSum
    ) { _, statistics, _ in
      // 4
      guard let quantity = statistics?.sumQuantity() else {
        continuation.resume(
          returning: (0, .init(value: 0, unit: .liters))
        )
        return
      }

      // 5
      let ounces = quantity.doubleValue(for: .fluidOunceUS())
      let liters = quantity.doubleValue(for: .liter())

      // 6
      continuation.resume(
        returning: (ounces, .init(value: liters, unit: .liters))
      )
    }

    // 7
    healthStore.execute(query)
  }
}
func currentWaterStatus() async throws -> (
  Measurement<UnitVolume>, Double?
) {
  // 1
  let (ounces, measurement) = try await drankToday()

  // 2
  guard let mass = try? await currentBodyMass() else {
    return (measurement, nil)
  }

  // 3
  let goal = mass / 2.0
  let percentComplete = ounces / goal

  // 4
  return (measurement, percentComplete)
}

Updating WaterView

Add two properties to WaterView.swift:

@State private var consumed = ""
@State private var percent = ""
private func updateStatus() async {
  // 1
  guard
    let (measurement, percent)
      = try? await HealthStore.shared.currentWaterStatus()
  else {
    consumed = "0"
    percent = "Unknown"
    return
  }

  // 2
  consumed = consumedFormat.string(from: measurement)

  // 3
  self.percent = percent?
    .formatted(.percent.precision(.fractionLength(0))) ?? "Unknown"
}
private let consumedFormat: MeasurementFormatter = {
  var fmt = MeasurementFormatter()
  fmt.unitOptions = .naturalScale
  return fmt
}()
HStack {
  Text("Today:")
    .font(.headline)
  Text(consumed)
    .font(.body)
}

HStack {
  Text("Goal:")
    .font(.headline)
  Text(percent)
    .font(.body)
}
.task {
  await updateStatus()
}
await updateStatus()

Reading multiple days of data

Finally, to make the interface a bit nicer, why not show the amount of water the user consumed over the last week? Reading multiple days of data is a bit more complicated than reading just a single day. Back in HealthStore.swift, add a new property to the top of the class:

private var preferredWaterUnit = HKUnit.fluidOunceUS()
guard let types = try? await healthStore!.preferredUnits(
  for: [waterQuantityType]
) else {
  return
}

preferredWaterUnit = types[waterQuantityType]!
func waterConsumptionGraphData(
  completion: @escaping ([WaterGraphData]?) -> Void
) throws {
  guard let healthStore = healthStore else {
    throw HKError(.errorHealthDataUnavailable)
  }

  // 1
  var start = Calendar.current.date(
    byAdding: .day, value: -6, to: Date.now
  )!
  start = Calendar.current.startOfDay(for: start)

  let predicate = HKQuery.predicateForSamples(
    withStart: start,
    end: nil,
    options: .strictStartDate
  )

  // 2
  let query = HKStatisticsCollectionQuery(
    quantityType: waterQuantityType,
    quantitySamplePredicate: predicate,
    options: .cumulativeSum,
    anchorDate: start,
    intervalComponents: .init(day: 1)
  )

  // 3
  query.initialResultsHandler = { _, results, _ in
  }

  // 4
  query.statisticsUpdateHandler = { _, _, results, _ in
  }

  healthStore.execute(query)
}
func updateGraph(
  start: Date,
  results: HKStatisticsCollection?,
  completion: @escaping ([WaterGraphData]?) -> Void
) {
  // 1
  guard let results = results else {
    return
  }

  // 2
  var statsForDay: [Date: WaterGraphData] = [:]

  for i in 0 ... 6 {
    let day = Calendar.current.date(
      byAdding: .day, value: i, to: start
    )!
    statsForDay[day] = WaterGraphData(for: day)
  }

  // 3
  results.enumerateStatistics(from: start, to: Date.now) {
    statistic, _ in

    var value = 0.0

    // 4
    if let sum = statistic.sumQuantity() {
      value = sum
        .doubleValue(for: self.preferredWaterUnit)
        .rounded(.up)
    }

    // 5
    statsForDay[statistic.startDate]?.value = value
  }

  // 6
  let statistics = statsForDay
    .sorted { $0.key < $1.key }
    .map { $0.value }

  // 7
  completion(statistics)
}
query.initialResultsHandler = { _, results, _ in
  self.updateGraph(
    start: start, results: results, completion: completion
  )
}

query.statisticsUpdateHandler = { _, _, results, _ in
  self.updateGraph(
    start: start, results: results, completion: completion
  )
}
@State private var graphData: [WaterGraphData]?
BarChart(data: graphData)
  .padding()
.task {
  await updateStatus()
  try? HealthStore.shared.waterConsumptionGraphData() {
    self.graphData = $0
  }
}

Key points

  • Make sure you don’t try to track a type of data that isn’t available on the OS versions you support. If you find yourself in that situation, ensure that you define the identifiers as optionals.
  • Always use the user’s preferred types when displaying or converting data. Never hardcode a unit type to display to the user.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now