Chapters

Hide chapters

watchOS With SwiftUI by Tutorials

Second Edition · watchOS 9 · Swift 5.8 · Xcode 14.3

Section I: watchOS With SwiftUI

Section 1: 13 chapters
Show chapters Hide chapters

12. HealthKit
Written by Scott Grosch

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

When using HealthKit you’ll also need to enable a background mode to specify the type of session that watchOS should use.

Signing & Capabilities

After opening Health.xcodeproj, from the starter materials, you need to add the HealthKit capability to Xcode:

Info.plist Descriptions

Once you’ve done that, click on the Info tab and add two keys related to privacy:

Creating the Store

Select the Health Watch App folder in the Project Navigator. Create a new swift file and name it HealthStore, using the following contents:

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

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(.toothbrushingEvent)
Task {
  try await healthStore!.requestAuthorization(
    toShare: [brushingCategoryType],
    read: [brushingCategoryType]
  )
}
// 1
func logBrushing(startDate: Date) async throws {
  // 2
  guard
    let healthStore,
    healthStore.authorizationStatus(for: brushingCategoryType) == .sharingAuthorized
  else {
    return
  }

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

  // 4
  try await healthStore.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, add:

private let waterQuantityType = HKQuantityType(.dietaryWater)
private let bodyMassType = HKQuantityType(.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:

// 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 healthStore!.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. I’ve cheated a bit in the interest of simplicity and hardcoded two water sizes based on whether you’re using the metric system. A real app should handle all measurement systems, not just assume metric or US.

// 1
let unit: HKUnit
let value: Double

if Locale.current.measurementSystem == .metric {
  // 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 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

In ContentView, 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, add a method to determine the user’s current body mass:

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

  // 3
  let descriptor = HKSampleQueryDescriptor(
    predicates: [.quantitySample(type: bodyMassType)],
    sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)],
    limit: 1
  )

  // 4
  let results = try await descriptor.result(for: healthStore)
  return results.first?.quantity.doubleValue(for: .pound())
}
private func drankToday() async throws -> (
  ounces: Double,
  amount: Measurement<UnitVolume>
) {
  guard let healthStore else {
    throw HKError(.errorHealthDataUnavailable)
  }

  // 1
  let dateRangePredicate = HKQuery.predicateForSamples(
    withStart: Calendar.current.startOfDay(for: Date.now),
    end: Date.now,
    options: .strictStartDate
  )

  // 2
  let sumPredicate = HKSamplePredicate.quantitySample(
    type: waterQuantityType,
    predicate: dateRangePredicate
  )

  // 3
  let descriptor = HKStatisticsQueryDescriptor(predicate: sumPredicate, options: .cumulativeSum)

  // 4
  guard let quantity = try await descriptor.result(for: healthStore)?.sumQuantity() else {
    return (ounces: 0, amount: .init(value: 0, unit: .liters))
  }

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

  // 6
  return (ounces, .init(value: liters, unit: .liters))
}
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:

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

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

  // 4
  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, add a new property to the top of the class:

private var preferredWaterUnit = HKUnit.fluidOunceUS()
if
  let types = try? await healthStore!.preferredUnits(for: [waterQuantityType]),
  let type = types[waterQuantityType]
{
  preferredWaterUnit = type
}
func waterConsumptionGraphData(
  completion: @escaping ([WaterGraphData]?) -> Void
) throws {
  guard let 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)
}
private func updateGraph(
  start: Date,
  results: HKStatisticsCollection?,
  completion: @escaping ([WaterGraphData]?) -> Void
) {
  // 1
  guard let results else {
    return
  }

  var statistics: [WaterGraphData] = []

  // 2
  results.enumerateStatistics(from: start, to: Date.now) { statistic, _ in
    var value = 0.0

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

    statistics.append(.init(value, for: statistic.startDate))
  }

  // 3
  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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now