15.
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.
Signing & Capabilities
First, you need to add the HealthKit capability to Xcode:
- Open Health.xcodeproj from the starter materials.
- In the Project Navigator (Command‑1) select the extension target.
- Select Signing & Capabilities.
- Click + Capability.
- In the dialog that appears, choose HealthKit.
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:
- 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 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()
}
}
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.swift just after the NavigationView’s closing squiggly 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.
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)
}
}
}
Wrapping a callback asynchronous method into the newer await/async syntax might be new to you. Here’s what’s happening:
- If
healthStorewasn’t set, you shouldn’t have called this method to begin with, so you throw an appropriate error. -
withCheckedThrowingContinuationtakes a body of code and pauses until the providedCheckedContinuation<T, Error>is called. - Then, you save the sample to Apple Health.
- If the asynchronous save fails, you pass the error thrown to
resume(throwing:). - If the call succeeds, you have to return something. In this case, it’s just a boolean true value.
The signature for step two is a bit ugly. withCheckedThrowingContinuation is a generic method. If you don’t specify the return type as a Bool, Xcode can’t determine the type of the generic, which results in an error. You don’t care about the return value, so just assign to the _ placeholder.
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
)!
As this book targets watchOS 8, it’s safe to force unwrap the category type. You’ll use brushingCategoryType in multiple places, and there’s no reason to have to unwrap the optional constantly.
You would only get a failure if you tried to create an identifier on an OS version where it didn’t yet exist. The toothbrushingEvent has existed since watchOS 6. If you’re targeting older releases, which don’t support all of your identifiers, you’ll need to make them optional and perform the appropriate safety checks in all relevant locations.
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.
Note: At the time of writing, HealthKit doesn’t include
asyncmethods.
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.
Open HealthStore.swift and add:
// 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)
}
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. - Save the sample using your just written
save(_:).
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.
Hopefully, you agree that by wrapping the default save with an async / await pattern, you’ve made the rest of your code cleaner.
All that’s left is to update the session end to call logBrushing(startDate:).
Open BrushingModel.swift 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.swift 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.swift, add:
private let waterQuantityType = HKQuantityType.quantityType(
forIdentifier: .dietaryWater
)!
private let bodyMassType = HKQuantityType.quantityType(
forIdentifier: .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.
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)
}
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.
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.
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.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)
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.swift 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 error 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
Go back to the main extension folder. In ContentView.swift, 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.swift:
static let healthStoreLoaded = Notification.Name(
rawValue: UUID().uuidString
)
Then, in HealthStore.swift, 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. 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.swift 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
}
The user should never see the message as HealthKit will pop up the permission check too quickly. However, 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. Reset the simulator by going to its menu and selecting Device ▸ Erase All Content and Settings… so that HealthKit has to ask for permissions again.
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.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)
}
}
There’s a lot going on in that code. 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.
- As before, you use
withCheckedThrowingContinuationto wrap a completion handler method as anasynctype method. - When reading data, you use
HKSampleQuery. You ask for a single entry with no date limit. Use whatever they recorded as their most recent weight. - If there’s not a sample to read, or you don’t have read access, a
nilvalue returns. - Otherwise, you determine how many pounds the person weighs and return that value.
- Finally, you ask HealthKit to execute the query.
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 drunk today. In HealthStore.swift, add:
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)
}
}
That method is a bit more complicated:
- You want to determine all water consumed between the start of the day and now.
- Once again, you wrap a completion handler method to use it
asyncinstead. Notice this time, nothing inside the block will throw an error, so you usewithCheckedContinuationinstead ofwithCheckedThrowingContinuation. - Using an
HKStatisticsQuerylets 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.
- Determine both the number of US fluid ounces and the number of liters the user drank.
- Return both the number of ounces the user drank as well as the liters.
- Execute the query.
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.swift:
@State private var consumed = ""
@State private var percent = ""
Then write the method which will populate those properties:
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"
}
Apologies for the formatting required to keep the lines from wrapping. Here’s what’s happening:
- 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 Stack:
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()
}
Now, to update the display if you record new data, add the update line just before the end of the Task block in logWater(quantity:):
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.swift, add a new property to the top of the class:
private var preferredWaterUnit = HKUnit.fluidOunceUS()
And then set the value at the end of the Task block in the initializer:
guard let types = try? await healthStore!.preferredUnits(
for: [waterQuantityType]
) else {
return
}
preferredWaterUnit = types[waterQuantityType]!
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 = 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. -
You call
initialResultsHandlerthe first time the query completes. -
Then you call
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:
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)
}
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.
- You create a dictionary keyed by the last seven days that contains the data to graph.
- Then, you loop through the computed results.
- If there’s data for the day, you sum the data counts, convert the value to their preferred unit of measurement and round up to the nearest whole number.
- Then, you record the amount of water consumed for the day.
- You take the values for each day in ascending order.
- Finally, you pass the data back through the completion handler.
If there’s no data for a given day, you won’t receive a value for that date. That’s why you store the values in a dictionary, so you have an easy way to generate an array with seven elements in the proper order, even if there’s no data for a given day.
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.swift, 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.