12.
HealthKit
Written by Scott Grosch
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?
- Open the Apple Health app on your iPhone.
- Tap Browse in the toolbar.
- Tap Other Data from the Health Categories list.
- 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:
-
In the Project Navigator (Command‑1) select the project.
-
Select the the Health Watch App target.
-
Select Signing & Capabilities.
-
Click + Capability.
-
In the dialog that appears, choose HealthKit.
-
Click + Capability again and this time choose Background Modes.
-
For the session type, select Self Care.
Note: Refer back to Chapter 7, “Lifecycle” for an explanation of the background modes.
Info.plist Descriptions
Once you’ve done that, click on the Info tab and add two keys related to privacy:
- Privacy - Health Share Usage Description
- Privacy - Health Update Usage Description
For the first key, set the value to the reason you’re asking the user to let you write data to HealthKit. For example, you might put: Tracks brushing and water consumption.
Note: Spoiler alert, you’ll also add water tracking to the app.
For the second key, set the value to the reason why you need to read data from HealthKit. For example, you might put: Bodyweight helps determine optimal water intake requirements.
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()
}
}
What’s that code doing? Here’s a breakdown:
- HealthKit is a framework, so you need to import the package.
- The
HealthStoreis a singleton. - You need a variable to hold the
HKHealthStore. - A private initializer ensures that callers must use the
sharedsingleton instance. - If the device can’t use Apple Health, you quietly exit. If it can, you initialize the
HKHealthStore.
Your wrapper class implements the singleton pattern because Apple specifies that you should only create a single instance of HKHealthStore for your app. If the device can’t use HealthKit, then you shouldn’t try to initialize the store.
To initialize HealthStore, add the following code to HealthApp just after the NavigationView’s closing curly bracket:
.task {
_ = HealthStore.shared
}
.task runs a single time when the app starts. Even though you threw away the return of the call to HealthStore.shared, the initializer ran.
Note: There are multiple camps of thought on a class like
HealthStore. Some people like the singleton pattern, while others feel the class should be anObservableObjectso you can place it into the environment. In the case ofHealthStore, I chose the singleton pattern so it would be available outside of a SwiftUIView, if necessary.
Now that you have HealthKit configured, it’s time to track some data.
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)
Now it’s time to ask the user for permission to read and write data related to how often they brush their teeth. Add the following code to the end of the initializer:
Task {
try await healthStore!.requestAuthorization(
toShare: [brushingCategoryType],
read: [brushingCategoryType]
)
}
When your app calls requestAuthorization(toShare:read:), the user will see a request to authorize your app to read and write the specified data. As long as you don’t ask for types that you haven’t previously, watchOS will only display the request a single time.
Wrapping the asynchronous call in a Task lets you use the call in a method not marked async.
Sharp-eyed readers will notice that requestAuthorization(toShare:read:) is a throwing method, yet you’ve not protected against the method throwing. A peculiar side effect of Task is that it eats all exceptions.
Build and run. Once it launches, watchOS will present screens to you requesting permission to read and write to Apple Health. Once you’ve approved both read and write access, relaunch the app. This time, there’s no prompt as the call to requestAuthorization(toShare:read:) doesn’t include any new types.
Now you’ll add a method to log when you’ve brushed your teeth. Add this code to HealthStore:
// 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)
}
Here’s what’s happening:
- You define a method that takes the
Datewhen the user started brushing their teeth and note that it’s both asynchronous and can throw. - At any point, your user can enable or disable access to Apple Health. Therefore, you need a way to know whether they’re letting you write data for brushing their teeth.
- You create a sample, which is what HealthKit stores, of type
brushingCategoryType. Brushing doesn’t have a value, so you pass theHKCategoryValue.notApplicable.rawValueenum value. Then you log the start and end time of the brushing. - Finally, you save the sample to HealthKit.
Note: You can’t check for read access, only write. If you don’t have read access, you just get no results back.
Based on your app’s configuration, you need to decide whether to silently return in the guard clause of step two or throw some type of exception. In this app, it makes sense to let them brush their teeth and get the 30-second timer without writing to Apple Health.
All that’s left is to update the session end to call logBrushing(startDate:).
Open BrushingModel inside the Teeth folder. You’ll recognize this file from Chapter 7: “Lifecycle”. Near the bottom of extendedRuntimeSessionDidStart(_:), you’ll find the line of code that invalidates the session:
self.session.invalidate()
When the session completes successfully, you need to write to Apple Health. Replace that line of code with:
// 1
Task {
// 2
try? await HealthStore.shared.logBrushing(startDate: self.started)
// 3
self.session.invalidate()
}
The pattern is probably starting to look familiar:
- You’re in a method that is not
async, but you want to call anasyncmethod, so you pass the call to aTask. - Log the fact that the user finished brushing their teeth. If it fails, you probably want to ignore it as there’s nothing for the user to do.
- Mark the session as complete once the update to Apple Health finishes.
It’s time to build and run. Before doing so, you may wish to change the value of secondsPerRound in Teeth/BrushingModel to something smaller, like 2.0, so that you don’t have to wait two minutes to see results.
Once you’ve built and run the app, tap the brush button and watch the magic happen. When the timer runs out, switch over to the Apple Health app. Now repeat the steps from the start of this chapter to look at the toothbrushing category, and you’ll see an entry:
Tracking Water
Like brushing their teeth, many kids have a hard time remembering to drink water. Seems like a great addition to your app!
While brushing teeth logged time rather than a value, water intake is the opposite. You’re likely less concerned with when they drank water and more interested in how much water they drank each day.
There’s quite a bit of work to do to support water intake. This may be a good time to take a quick break and grab a snack!
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)
Notice that you’re now using quantity types, not category types.
Next, update the call to requestAuthorization(toShare:read:) so that it includes the new identifiers:
Task {
try await healthStore!.requestAuthorization(
toShare: [brushingCategoryType, waterQuantityType],
read: [brushingCategoryType, waterQuantityType, bodyMassType]
)
}
Pay attention to the fact that you would like to read the person’s weight, or body mass, but you have no reason to write their weight. Don’t request permissions that you don’t need.
Build and run now, and you’ll notice that Apple Health again asks for permissions. The dialog appeared again because you added new items to the list of authorizations you’re requesting. You’ll also notice that nothing related to brushing teeth is shown in the request, as you’ve already answered those questions. Don’t give permission quite yet, just stop the app in Xcode. You will handle the permission change in a little bit.
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)
}
In the preceding code, you:
- Need to know whether the user is currently allowing you to write water-related data.
- Implement an
asyncmethod to store water consumption as you did for brushing your teeth. - Generate a sample to save. Notice how you specify an actual quantity this time, and the start and end dates are the current time.
- Save the data, if you can. It’s safe to force unwrap the
healthStoreas your status check succeeded.
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.
When the user taps the button, you need to generate the appropriately sized HKQuantity to tell Apple Health how much water the person drank. Edit tapped() and paste:
// 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)
Here’s what the code is doing:
- HealthKit uses an
HKUnitto identify the unit type for the value you’ll store. - If the user’s device uses the metric system, you create a value in milliliters.
- If the user’s device isn’t using metric, they’re clearly from the United States and thus want fluid ounces. Yes, that’s sarcasm. I told you we were cheating here.
- Using
unitandvalue, you create anHKQuantity, which you can later convert to anHKSamplefor saving. - You pass that quantity to the completion handler.
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
In the body, replace the default “Hello, World!” text with the following code. Don’t worry about the closure argument list errors you see – you’ll fix it in a moment:
// 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.")
}
}
}
The view right now is pretty simple:
- You have a vertically scrolling view to contain everything. You don’t need the
ScrollViewright now, but you will soon. - If the user lets you write to Apple Health, display some text and the two buttons to add the amounts of water consumed.
- If they don’t grant permissions, display a message.
While you shouldn’t even present this view to the user if they haven’t granted write access, it’s always a good idea to protect as many areas of the app as possible. You may refactor other views and inadvertently remove a critical permission check.
When the user taps a button, you need to take action. So, implement:
private func logWater(quantity: HKQuantity) {
Task {
try await HealthStore.shared.logWater(quantity: quantity)
}
}
The helper wraps the asynchronous call in Task since the SwiftUI button doesn’t know how to call an async. In a production app, you’ll likely want to set a @State that would result in the user seeing an alert on failure. Now that you have the helper method, call it on button taps:
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
Create another state property:
@State private var wantsToDrink = false
And then, right after the existing Button, add another one:
if HealthStore.shared.isWaterEnabled {
Button {
wantsToDrink.toggle()
} label: {
Image(systemName: "drop.fill")
.foregroundColor(.blue)
}
}
If, and only if, the user allows your app to write water consumption to Apple Health, you’ll show a button that toggles the state variable.
Right after the .sheet which displays BrushingTimerView(), add one to display the WaterView():
.sheet(isPresented: $wantsToDrink) {
WaterView()
}
Build and run. HealthKit will ask you for permission to read and write water consumption data. Once you approve, the dialog goes away and… nothing looks different!
Where’s your water button? Take a moment to think through what’s happening to see if you can figure it out.
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.
Start by adding another let in Notification.Name+Extension:
static let healthStoreLoaded = Notification.Name(rawValue: UUID().uuidString)
Then, in HealthStore, at the end of the Task in the initializer, post that notification:
await MainActor.run {
NotificationCenter.default.post(name: .healthStoreLoaded, object: nil)
}
Once HealthKit finishes asking for any required permissions, you let the rest of the app know via the notification. Remember that you’re no longer on the main thread because you’re running inside of a Task from a static initializer. Since you intend for the notification you’re posting to trigger a UI update, you dispatch the post to the MainActor, which means the thread running the UI.
Change back to ContentView and add two new properties:
// 1
@State private var waitingForHealthKit = true
// 2
private let healthStoreLoaded = NotificationCenter.default.publisher(
for: .healthStoreLoaded
)
Here’s what those properties are for:
- Using a
@Statevariable to track whether HealthKit has completed checking permissions means the body will see the update. - A publisher is the SwiftUI way of identifying a notification that you’ll listen for.
Next, wrap the code inside the body’s VStack with a check:
if waitingForHealthKit {
Text("Waiting for HealthKit prompt.")
} else {
// Existing code from VStack
}
Having the body look at @State means the view will refresh when it changes.
Finally, right after the existing .onReceive(stopBrusing), add one more:
.onReceive(healthStoreLoaded) { _ in
self.waitingForHealthKit = false
}
Once the notification is received, you update the property, which causes the view to refresh. To force HealthKit to ask for permissions the next time you run the app, delete the app from the watch. If you are running on the simulator, you can also reset the simulator by going to its menu and selecting Device ▸ Erase All Content and Settings….
Build and run, this time tapping the water icon and then one of the two buttons. Once you’ve done so, switch to the Apple Health app on your iPhone and check out the water category in the Nutrition section. Congratulations, you drank some water! :]
Writing the data is great, but what about viewing it? You need to present the data to your users, especially on the Apple Watch, where the Apple Health app is mysteriously missing.
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())
}
Here’s a breakdown:
- You create an
asyncwrapper to get the user’s body mass. - If the store isn’t assigned, then you throw an error.
- You only want the current weight, so you sort the results by the start date in descending order, and limit to just a single result.
- Read the results from the query and return the first item, converted to pounds. If there’s no value, then a
nilwill be returned.
Hardcoding pounds here seems wrong, doesn’t it? What about kilograms or stones? In the United States, the general theory is you divide your body weight in half and then drink that many ounces of water a day. Countries using the metric system would multiply their weight in kilograms by 0.033 to accomplish the same thing.
Coding every type of calculation would be a difficult task. Instead, take advantage of Apple providing the tools you need to convert between different measurements for you. By internally using pounds and ounces, you can perform a single known calculation and then display it to the user in their preferred measurement system.
Now that you potentially know how much they weigh, it’s time to determine how much they’ve already drank today. In HealthStore, add:
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))
}
That method is a bit more complicated:
- You want to determine all water consumed between the start of the day and now.
- Tell HealthKit that the predicate relates to water samples.
- Using an
HKStatisticsQueryDescriptorlets you ask HealthKit to add all the values via the.cumulativeSum, so you get a single result. - If you don’t have read permission or data, you state that the user drank 0 liters.
- return both the number of US fluid ounces and the number of liters the user drank.
You provide the numerical value of ounces so you can perform math on it later. Using Measurement<UnitVolume> makes it easier for consumers to display the data in whatever format they need. Liters tend to be the “standard” value to use by default.
Finally, write the public method to determine what to display to the user:
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)
}
In the preceding code, you:
- First, determine how much water the person drank today.
- If you can’t determine how much they weigh, you send back the water measurement with no percentage.
- Then, perform simple math to determine how far they are towards the daily goal.
- Return the water measurement and the percentage complete.
Notice that a single calculation is performed internally against US conversions, but what goes outside to the user is a Measurement<UnitVolume> using appropriate types.
Updating WaterView
Add two properties to WaterView:
@State private var consumed = ""
@State private var percent = ""
Then write the method which will populate those properties:
// 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"
}
Apologies for the formatting required to keep the lines from wrapping. Here’s what’s happening:
- This method will be called from other threads, so make sure watchOS always runs the update on the main thread.
- If you can’t read the current water status, you set a couple of default values.
- If you can read the data, you format the amount of water consumed via a
MeasurementFormatterthat you’ll create in a moment. - Taking advantage of Foundation’s new number formatters, you either calculate the daily percentage consumed or specify that it’s
"Unknown"if you couldn’t determine the person’s weight.
To resolve the compiler error, add the following formatter at the bottom of the file, outside of the struct:
private let consumedFormat: MeasurementFormatter = {
var fmt = MeasurementFormatter()
fmt.unitOptions = .naturalScale
return fmt
}()
Next, update the body to show the data by adding this code right after the existing HStack:
HStack {
Text("Today:")
.font(.headline)
Text(consumed)
.font(.body)
}
HStack {
Text("Goal:")
.font(.headline)
Text(percent)
.font(.body)
}
Finally, update the status when the view appears by adding a task after the ScrollView:
.task {
await updateStatus()
}
To update the display if you record new data, add the update line just before the end of the Task block in WaterView’s logWater(quantity:) method:
await updateStatus()
Build and run again. This time you’ll see that you have logged water previously:
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()
And then set the value in the initializer, just before the await MainActor.run call:
if
let types = try? await healthStore!.preferredUnits(for: [waterQuantityType]),
let type = types[waterQuantityType]
{
preferredWaterUnit = type
}
As you can see, it’s possible to determine what type of units your user prefers to see their measurements. You initialized the property to HKUnit.fluidOuncesUS() because there must be a value before the initializer ends.
Now add a new method to query the week’s water consumption:
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)
}
Here’s a code breakdown:
- After verifying you can use HealthKit, you determine the start of the day six days ago and then set up a predicate to query all data from that time forward.
- Then, you construct a query for water consumption, using the
predicate, and ask HealthKit to sum up each day for you. You specify that it should perform the summation across day boundaries. - watchOS calls
initialResultsHandlerthe first time the query completes. - watchOS calls
statisticsUpdateHandlerany time the user adds new data. By not specifying an end date on the predicate, you ensure that updates are captured.
Since both of the handlers perform the same actions, create a helper method that they’ll both call:
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)
}
Can you see why you don’t want to duplicate that code twice? Here’s what the code does:
- If no results return, then you exit early.
- Loop through each day, adding the total quantity of water, rounded up to the nearest whole number.
- Pass the data back to the completion handler
Finally, call this method from the handlers:
query.initialResultsHandler = { _, results, _ in
self.updateGraph(start: start, results: results, completion: completion)
}
query.statisticsUpdateHandler = { _, _, results, _ in
self.updateGraph(start: start, results: results, completion: completion)
}
Back in WaterView, add a new property:
@State private var graphData: [WaterGraphData]?
Add the BarChart to the body just after the final HStack:
BarChart(data: graphData)
.padding()
Then call the query method from the .task at the end of the body:
.task {
await updateStatus()
try? HealthStore.shared.waterConsumptionGraphData() {
self.graphData = $0
}
}
Build and run one last time. You have a nice bar chart that keeps itself updated as you record entries:
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.