Many apps use charts to represent data to the user. This is especially so for apps such as weight tracking, step counting, and anything else that is HealthKit related. Thankfully, Apple has created the Swift Charts framework to help you build these charts.
In today’s tutorial, we’ll look at extracting step count data from HealthKit and representing that in a chart within a SwiftUI application. We will have two charts, the first being a bar chart showing today’s step counts broken into hours and the second showing daily step counts for the last thirty days.
This tutorial will cover fetching information from HealthKit, processing that data and formatting it to make it easier to pass values to the charts framework. It will cover creating the views and the prompt needed to grant the necessary permissions.
Requirements
Swift Charts works on iOS 16+ devices, so all you need is the latest version of Xcode and iOS 16 or above on a physical device.
Most tutorials here on DevFright typically have all logic within the same file. For this one we will split out into a couple of classes and a struct.
ContentView will contain the view and will be responsible for showing the chart on the view. It will observe an object called StepCountViewModel that contains a published property containing an array of StepData.
HealthKitService is the class we will use to request authorisation from the user to access step counts. It also contains methods to get the step counts for today and the last thirty days.
We have a StepData struct that will be used in an array to have a date and step count for each hour or day.
Our ViewModel (part of the MVVM architecture) is called StepCountViewModel and conforms to ObservableObject and publishes an array of StepData.
The last file is called StepDataPeriod, an enum so that we can force users to specify “today” or “month” when selecting the data they want to see.
Creating the App
We are using the Charts framework for this app. Charts first appeared in iOS 16 when it launched in 2022. It provides a great way to represent data visually in your app. We will use the BarMark for this tutorial which allows us to represent step counts as bars.
To avoid getting errors while working through this code, we’ll start with the struct and enum first, then the HealthKit, and work from the model to the view afterwards.
StepDataPeriod Enum
enum StepDataPeriod {
case today
case month
}
First, create a new swift file and call it StepDataPeriod. We use an enum here to make requests to get step data type-safe. It’s a fairly common practice to handle it this way.
StepData Struct
Create another new file and call it StepData.
struct StepData {
var date: Date
var count: Int
}
This will be used in an array later on. The date contains the date of the step count sample, and the count is the number of steps taken.
HealthKitService Class
Create another new class and call it HealthKitService.
class HealthKitService {
let healthStore = HKHealthStore()
func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) {
let readTypes: Set<HKObjectType> = [HKObjectType.quantityType(forIdentifier: .stepCount)!]
healthStore.requestAuthorization(toShare: nil, read: readTypes) { (success, error) in
completion(success, error)
}
}
Be sure to import HealthKit at the top of this file.
This method is needed to request authorisation from the user. HealthKit only allows you to access information that the user has approved. If you attempt and the user has yet to approve, you won’t receive any data back.
Here, we set the readTypes and say that we want to access .stepCount data.
Next, we use requestAuthorization and pass in the readTypes. You can pass in more than just a single item. If you want to work with many values, then you can specify them all in the readTypes, and the user will be presented with a screen showing all of the types you want to access.
The completion handler at the end is called when the user has authorised (or declined) the request to access information. In short, a completion handler is used because this request is asynchronous, meaning the app won’t wait around for it to complete. As soon as the user finishes the required task, it gets called, and the result is processed as needed.
You might have noticed that toShare was set to nil on the requestAuthorization call. This is because we do not want to write anything to HealthKit. We only need to read data for this example.
The next step is to add the logic for getting step counts:
private func getSteps(from startDate: Date, to endDate: Date, interval: DateComponents, completion: @escaping ([StepData]) -> Void) {
guard let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
completion([])
return
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(quantityType: quantityType, quantitySamplePredicate: predicate, options: .cumulativeSum, anchorDate: startDate, intervalComponents: interval)
query.initialResultsHandler = { query, results, error in
if let error = error {
print("Error fetching step data: \(error.localizedDescription)")
completion([])
return
}
guard let results = results else {
completion([])
return
}
var data: [StepData] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let count: Double = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0
data.append(StepData(date: statistics.startDate, count: Int(count)))
}
DispatchQueue.main.async {
completion(data)
}
}
healthStore.execute(query)
}
The getSteps method is what we use to get the information we need from HealthKit. It requires that we pass in a start date, an end date, and an interval. It also has a completion handler, as we do not know how long this will run for, and we want to keep the phone open for it.
The next step prepares a quantity type that we can pass in on our query later on so that it knows what we want to get.
Next, it creates a predicate with the start and end date to pass this into the HealthKit query.
We create a query using HKStatisticsCollectionQuery specifying the quantity type we need, in this case, .stepCount. We pass in the predicate, then some options being .cumulutiveSum, meaning we want totals for each interval passed in as a parameter to the getSteps method.
We then create our results handler for the query that checks for errors, and if finds one it completes with an empty array and returns. If we have some results, then we move on to process the results to format the data in the way we want.
To do this, we create an empty array of StepData, then enumerate through the results specifying the start and end date.
We add each result to the data array, and when it completes, we call the completion handler, passing the data in as a parameter.
The final job here is to actually execute the query, which is the last line of the method. The query and results handler are created before, but actually not run until the method is about to complete. As they use completion handlers, the getSteps method will finish, and then the completion handler will be called at some point after.
Two methods are left in the HealthKitService class, called getStepsByHourToday and getStepsByDayForMonth. These are listed below:
func getStepsByHourToday(completion: @escaping ([StepData]) -> Void) {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
getSteps(from: startOfDay, to: now, interval: DateComponents(hour: 1), completion: completion)
}
func getStepsByDayForMonth(completion: @escaping ([StepData]) -> Void) {
let now = Date()
let thirtyDaysBeforeNow = Calendar.current.date(byAdding: .day, value: -30, to: now)!
getSteps(from: thirtyDaysBeforeNow, to: now, interval: DateComponents(day: 1), completion: completion)
}
We don’t call the getSteps method directly from our StepsViewModel. You may have noticed that the method was prepended with private. You could remove that and call it from StepsModelView, but rather than put more logic in there, I figured we could abstract that out and put it in the health kit service.
All the methods do is set a start date and end date and specify the interval of the data we want. The first is set to 1 hour, and the latter to 1 day. Of course, you could adjust this to longer ranges as needed, such as day, week, month, year, etc.
StepsViewModel Class
We’ll now take a look at our StepsViewModel class. This is implemented as follows:
class StepCountViewModel: ObservableObject {
@Published var stepData: [StepData] = []
private var healthKitService: HealthKitService
init(healthKitService: HealthKitService) {
self.healthKitService = healthKitService
}
func requestAuthorization(completion: @escaping (Bool, Error?) -> Void) {
healthKitService.requestAuthorization(completion: completion)
}
func fetchStepData(for period: StepDataPeriod) {
switch period {
case .today:
healthKitService.getStepsByHourToday { [weak self] data in
self?.updateStepData(data)
}
case .month:
healthKitService.getStepsByDayForMonth { [weak self] data in
self?.updateStepData(data)
}
}
}
private func updateStepData(_ data: [StepData]) {
DispatchQueue.main.async {
self.stepData = data
}
}
}
This class conforms to the ObservableObject protocol. It has stepData as an array published so that other classes, aka our view, can observe it. It also has a private instance variable of HealthKitService, the other class we just finished building.
It has an initialiser that accepts a healthKitService. When it is called, it sets the private var of healthKitService to the one passed in. It can be described as dependency injection.
We have a method called requestAuthorization that is responsible for making the request to the healthKitService and seeking permission from the user to read step count data from their Health records.
We then have one method that is called fetchStepData. It has one parameter from the StepDataPeriod enum and then uses a switch statement to call the relevant method from the HealthKitService. When that completes, it calls self?.updateStepData(data), which is defined below as a private function and is used to dispatch on the main queue the update of stepData. This is then published so that any observer will pick up the change.
The View
The final step is creating the view. This is how it is implemented:
var body: some View {
VStack {
Button("Request HealthKit Access") {
viewModel.requestAuthorization { success, error in
if success {
viewModel.fetchStepData(for: .today)
} else {
// Handle the error...
}
}
}
Chart {
ForEach(viewModel.stepData, id: \.date) {
BarMark(
x: .value("Date", $0.date),
y: .value("Steps", $0.count)
)
.foregroundStyle(by: .value("Steps", $0.count))
}
.alignsMarkStylesWithPlotArea()
}
.chartYAxis {
AxisMarks(position: .leading)
}
.onAppear {
viewModel.fetchStepData(for: .today)
}
HStack {
Button("Today") {
viewModel.requestAuthorization { success, error in
viewModel.fetchStepData(for: .today)
}
}
.buttonStyle(.borderedProminent)
Button("This Month") {
viewModel.requestAuthorization { success, error in
viewModel.fetchStepData(for: .month)
}
}
.buttonStyle(.borderedProminent)
}
}
}
We have a variable defined of type StepsCountViewModel. We mark it as @ObservedObject because we want to be informed when the published properties of that class have changed.
The view comprises of a VStack that has a button so that the user can request HealthKit access (the part that prompts the user if they want to grant read access to their step count data).
We then have a chart which contains a BarMark. We use a ForEach to loop through each item viewModels’s stepData array. There are a couple of ways to do this, but this way works well for the needs of today.
We then set the x and y values of the bar chart to the date and step count for that date, potentially being just a single hour or a full day in our cases.
The foregroundStyle is set to the count and shades each bar lighter or darker as per the highest value bar. Consider calculating averages over time and setting the foregroundStyle to the average so that you know if you are behind on your step count for the day or above. We will cover this in a future tutorial.
.chartYAxis is set to .leading, meaning that the y-axis is moved to the left of the screen. The default in iOS charts is to the right in the cases I have tested.
We then have an HStack containing two buttons, one for getting today’s steps and another for getting the last 30 days of steps. Of course, when either of these is called, the published property in the view model will eventually be updated and trigger a view refresh, which will then show the data.
Overall, the code is relatively simple for this project. The Charts framework is robust, and this tutorial has only just touched upon getting a chart to display.
In the following tutorial, we’ll look at adding more data in the form of averages and styling the charts so that you would want to use them in a professional app for the App Store.
Final Steps
Because we’re accessing health data, there is a little more to do to get this to work.
The ContentView_Preview needs to be updated as follows:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: StepCountViewModel(healthKitService: HealthKitService()))
}
}
Finally, our @main found in Swift_ChartsApp needs to be adjusted as follows:
@main
struct Swift_ChartsApp: App {
let healthKitService = HealthKitService()
let viewModel: StepCountViewModel
init() {
viewModel = StepCountViewModel(healthKitService: healthKitService)
}
var body: some Scene {
WindowGroup {
ContentView(viewModel: viewModel)
}
}
}
Finally, you must enable HealthKit access in your project and add a usage description to info.plist.
You can enable HealthKit by going to the project, then clicking Signing & Capabilities and then on the + Capability button at the top left while the Swift Charts target is selected. Select HealthKit from the list. We do not need access to clinical health records and also do not need background delivery.
Next, you need to move to the Info tab and add a new key called NSHealthShareUsageDescription with a description. If you get an error when trying to run the app, you should make a more detailed description. I put “Required to access step data to show in your app”, which works fine.
The entire project is available on GitHub.
Leave a Reply
You must be logged in to post a comment.