HealthKit (4)
Charts 사용하기.
MockData를 생성하고, 그걸 fetch하는 것 까지 했으니, 이젠 Dashboard에 Charts를 사용하여 도식화를 해보도록 한다.
모델링
fetch해서 가져온 데이터들을 객체로 담아서 Chart에 전달하기위해 모델링을 해준다.
1
2
3
4
5
struct HealthMetric: Identifiable {
let id = UUID()
let date: Date
let value: Double
}
HKManager에 담기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var stepData: [HealthMetric] = []
var weightData: [HealthMetric] = []
func fetchStepCount() async {
// 생략
let stepsCounts = try! await stepsQuery.result(for: store)
stepData = stepsCounts.statistics().map({
.init(date: $0.startDate, value: $0.sumQuantity()?.doubleValue(for: .count()) ?? 0)
})
}
func fetchWeights() async {
// 생략
let weights = try! await weightQuery.result(for: store)
weightData = weights.statistics().map({
.init(date: $0.startDate, value: $0.mostRecentQuantity()?.doubleValue(for: .pound()) ?? 0)
})
}
sumQuantity
- sumQuantity는 특정 기간 동안 수집된 데이터의 합계를 반환한다.
- 걸음 수(stepCount)는 시간 경과에 따라 누적되는 값이다.
- 예를 들어, 하루 동안 사용자가 걷는 모든 걸음 수를 합산해서 하루 총 걸음 수를 계산.
- StatisticsCollectionQuery를 사용하여 하루 단위로 데이터를 그룹화했기 때문에, 각 그룹(하루)에 대해 총합을 계산해야 한다.
- map을 사용하여 HealthMetric 타입으로 값들을 형변환 해준다.
mostRecentQuantity
- mostRecentQuantity는 특정 기간 동안 수집된 데이터 중 가장 최근 값을 반환한다.
- 몸무게(bodyMass)처럼 값이 일정 기간 동안 변하지 않거나, 사용자가 특정 시점에만 기록하는 데이터에 적합.
- 예를 들어, 하루 동안 여러 번 몸무게를 기록했다면 가장 마지막으로 기록된 값을 가져온다.
- map을 사용하여 HealthMetric 타입으로 값들을 형변환 해준다.
Charts 만들기
우선 Charts를 import를 해준다.
1
2
3
4
5
6
7
8
9
10
import Charts
Chart {
ForEach(hkManager.stepData) { steps in
BarMark(
x: .value("Date", steps.date, unit: .day),
y: .value("Steps", steps.value))
}
}
.frame(height: 150)
- x: x축에 표시될 데이터. 날짜(steps.date)를 기준으로 한다.
- y: y축에 표시될 데이터. 걸음 수(steps.value)를 기준으로 한다.
기존에 RoundedRectangle
이 있던 자리에 charts로 대신해준다.
그리고 task에서 await hkManager.fetchStepCount()
를 사용하여 화면이 나올떄 값을 가져오게 한다.
실행하면 다음과 같이 나온다.
Preview 문제 해결
지금 preview에서 확인을 못하고 계속 시뮬레이터를 통해 실행하여 해결했는데 이부분을 먼저 해결하고 다음으로 넘어가려한다.
1
2
3
4
5
6
7
8
9
10
11
func fetchStepCount() async {
// 생략
do {
let stepsCounts = try! await stepsQuery.result(for: store)
stepData = stepsCounts.statistics().map({
.init(date: $0.startDate, value: $0.sumQuantity()?.doubleValue(for: .count()) ?? 0)
})
} catch {
}
}
에러에 대한 핸들링을 해주게 되면 preview가 나타난다.
다만 데이터는 들어가지 않았기에 차트는 보이지 않는다.
Preview용 MockData 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct HealthMetric: Identifiable {
// 생략
static var mockData: [HealthMetric] {
var array: [HealthMetric] = []
for i in 0..<28 {
let metric = HealthMetric(
date: Calendar.current.date(byAdding: .day, value: -i, to: .now)!,
value: .random(in: 4_000...15_000)
)
array.append(metric)
}
return array
}
}
배열을 사용하여 Steps에 대한 MockData를 만들어 주었다.
그리고 잠시
1
2
3
4
5
6
7
8
Chart {
ForEach(HealthMetric.mockData) { steps in // changed
BarMark(
x: .value("Date", steps.date, unit: .day),
y: .value("Steps", steps.value))
}
}
.frame(height: 150)
Foreach 안에 들어가는 데이터값을 바꿔준다. 그러면 preview에서 보이기에 시뮬레이터를 실행하지 않고도 파악이 가능해진다.
Chart Customizing
늘 그렇듯 Docs 한번 읽어보는걸 추천
평균선 긋기
1
2
3
4
5
6
7
8
9
10
11
var averageStepCount: Double {
guard !hkManager.stepData.isEmpty else { return 0 }
let totalSteps = hkManager.stepData.reduce(0) { $0 + $1.value }
return totalSteps/Double(hkManager.stepData.count)
}
// view
Chart {
RuleMark(y: .value("Average", averageStepCount))
.foregroundStyle(.secondary)
.lineStyle(.init(lineWidth: 1, dash: [5]))
약간의 부연설명을 하자면.
$0와 $1의 역할
- $0: 현재까지 누적된 값. 초기값으로 0이 전달된다.
- $1: 배열의 현재 요소.
- 우린
HealthMetric
에서 value의 값만 더한다.
- 우린
그리고 RuleMark를 통해 평균선을 그어준다.
이때 dash는 점선의 간격을 표시
각 축 label 표현
Y축
1
2
3
4
5
6
7
8
.chartYAxis {
AxisMarks { value in
AxisGridLine()
.foregroundStyle(Color.secondary.opacity(0.3))
AxisValueLabel((value.as(Double.self) ?? 0).formatted(.number.notation(.compactName)))
}
}
1
AxisValueLabel((value.as(Double.self) ?? 0).formatted(.number.notation(.compactName)))
- value.as(Double.self):
- 축에 표시될 값을 Double 타입으로 변환 시도.
- 만약 변환에 실패하면 기본값으로 0을 사용.
- .formatted(.number.notation(.compactName)):
- 변환된 숫자를 포맷팅.
- .number: 숫자 형식을 지정.
- .notation(.compactName):
- Compact Name Format: 숫자를 간결한 형태로 표시.
- ex) 10,000 → 10k
1
AxisGridLine()
이녀석을 의미한다.
X축
1
2
3
4
5
.chartXAxis {
AxisMarks {
AxisValueLabel(format: .dateTime.month(.defaultDigits).day())
}
}
1
AxisValueLabel(format: .dateTime.month(.defaultDigits).day())
- format: .dateTime.month(.defaultDigits).day():
- X축 레이블에 표시할 값의 포맷을 정의한다.
- .dateTime: 데이터 값이 Date 타입임을 지정.
- .month(.defaultDigits): 월을 디지털 형식으로 표시(예: “12”).
- .day(): 날짜를 디지털 형식으로 표시(예: “14”).
- 결과: X축 레이블이 “12/14”와 같은 형식으로 날짜를 나타낸다.
x, y 간격은 우리가 별도로 설정을 할 수 있지만, 일반적으로 코드에 명시를 하지않으면 자체적으로 구간을 나누어준다.
코드예시 from Docs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 3)) { value in
if let date = value.as(Date.self) {
let hour = Calendar.current.component(.hour, from: date)
switch hour {
case 0, 12:
AxisValueLabel(format: .dateTime.hour())
default:
AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)))
}
AxisGridLine()
AxisTick()
}
}
AxisMarks에 values로 값을 설정해주면
이렇게 x축의 Label이 더 세분화 된걸 알 수 있다.
Interactivity in Swift Charts
WWDC2023에 소개된 내용이니 한번 시청해보는걸 추천.
Drag 매커니즘 확인
1
2
3
4
5
6
@State private var rawSelectedDate: Date?
.chartXSelection(value: $rawSelectedDate)
.onChange(of: rawSelectedDate, { oldValue, newValue in
print(newValue)
})
우선 이렇게 onChange를 통해 값을 출력하게 해보면
console에 날짜가 출력되는걸 알 수 있다.
마지막에 nil이되는건 드래그가 끝날때, 한번만 클릭했을때, 새로운 값이 없기에 nil로 반환되는 것
그렇기에 rawSelectedDate를 옵셔널로 해준것이다.
이런 방식을 이용하여 드래그 했을때 각 날짜에 해당하는 값을 보여주는 즉 Interactivity를 구현 해보려 한다.
1. Drag시, 해당 위치를 알려주는 축 생성
1
2
3
4
5
6
7
var selectedHealthMetric: HealthMetric? {
guard let rawSelectedDate else { return nil }
let selectedMetric = hkManager.stepData.first {
Calendar.current.isDate(rawSelectedDate, inSameDayAs: $0.date)
}
return selectedMetric
}
사용자가 선택한 날짜(rawSelectedDate)와 동일한 날짜를 가진 HealthMetric 데이터를 반환하기 위해 위와 같이 Computed Property로 만들어 준다.
여기서는 날짜 비교만 알아두면 좋을듯 하다
- stepData 배열에서 사용자가 선택한 날짜와 동일한 날짜를 가진 첫 번째 HealthMetric 데이터를 검색.
- Calendar.current.isDate(_:inSameDayAs:) 메서드를 사용하여 날짜를 비교.
1
2
3
4
5
6
Chart {
if let selectedHealthMetric {
RuleMark(x: .value("Selected Metric", selectedHealthMetric.date, unit: .day))
.foregroundStyle(Color.secondary.opacity(0.3))
.offset(y: -10)
}
아까는 RuleMark에 y축에 값을 넣어서 평균선을 만들었다면, 이번에 x축에 날짜 값을 넣어 표시를 하게 했다.
실행하면 다음과 같다.
2. Annotation 추가
1
2
3
4
5
6
7
8
if let selectedHealthMetric {
RuleMark(x: .value("Selected Metric", selectedHealthMetric.date, unit: .day))
.foregroundStyle(Color.secondary.opacity(0.3))
.offset(y: -10)
.annotation { // new
Text(selectedHealthMetric.value, format: .number)
}
}
annotation
Modifier를 사용하고 안에는 Contents가 들어가기에 Text로 해당값을 리턴하게 했다.
실행하면 다음과 같다.
하지만 이건 우리가 의도한게 아니다. 그리고 양끝으로 가면 text 때문에 그래프가 튀는 현상이 발생도 한다.
3. Annotation Text 조정
1
2
3
4
5
6
7
8
9
10
if let selectedHealthMetric {
RuleMark(x: .value("Selected Metric", selectedHealthMetric.date, unit: .day))
.foregroundStyle(Color.secondary.opacity(0.3))
.offset(y: -10)
.annotation(position: .top,
spacing: 0,
overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
Text(selectedHealthMetric.value, format: .number)
}
}
2번의 경우와 달리 이번엔 Annotation에 여러 파라미터를 받아서 처리를 하도록 한다.
position: Annotation의 위치
spacing: 간격
overflowResolution: 차트의 annotation이 차트 영역을 벗어날 경우 이를 어떻게 처리할지를 정의.
1
overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
- x: X축 방향에서의 annotation 동작을 설정.
- .fit(to: .chart): 차트 영역 내에 annotation을 맞춘다.
- 결과: annotation이 차트를 벗어나지 않도록 자동으로 조정.
- y: Y축 방향에서의 annotation 동작을 설정.
- .disabled: Y축 방향으로는 조정하지 않는다.
- 결과: annotation이 차트를 벗어나더라도 그대로 둔다.
실행하면 다음과 같다.
숫자때문에 그래프가 튀지 않는걸 확인할 수 있다.
4. AnnotationView 생성
Annotation의 contents안에 Text 대신 들어갈 View를 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var annotationView: some View {
VStack(alignment: .leading) {
Text(selectedHealthMetric?.date ?? .now, format: .dateTime.weekday(.abbreviated).month(.abbreviated).day())
.font(.footnote.bold())
.foregroundStyle(.secondary)
Text(selectedHealthMetric?.value ?? 0, format: .number.precision(.fractionLength(0)))
.fontWeight(.heavy)
.foregroundStyle(.pink)
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.secondarySystemBackground))
.shadow(color: .secondary.opacity(0.3), radius: 2, x: 2, y: 2)
}
}
1
Text(selectedHealthMetric?.value ?? 0, format: .number.precision(.fractionLength(0)))
- .number: 숫자 값임을 지정.
- .precision(.fractionLength(0)):
- 표시할 소수점 자리수를 설정.
- .fractionLength(0)은 소수점을 표시하지 않고 정수 값만 출력.
- 예시: 4567.89 → 4568
실행하면 다음과 같다.
5. 드래그 부분만 강조
1
2
3
4
5
6
7
BarMark(
x: .value("Date", steps.date, unit: .day),
y: .value("Steps", steps.value)
)
.foregroundStyle(Color.pink.gradient)
.opacity(rawSelectedDate == nil || steps.date == selectedHealthMetric?.date ? 1.0 : 0.3)
}// new
삼항연산자를 사용하여 선택을 했을때 선택한 녀석은 1.0으로 진하게, 그렇지 않은 나머지는 0.3으로 투명하게 처리하여, 현재 선택중인 값을 강조한다.
실행하면 다음과 같다.
6. 드래그 시 애니메이션 효과
1
2
3
4
5
6
7
8
9
10
BarMark(
x: .value("Date", steps.date, unit: .day),
y: .value("Steps", steps.value)
)
.foregroundStyle(Color.pink.gradient)
.opacity(rawSelectedDate == nil || steps.date == selectedHealthMetric?.date ? 1.0 : 0.3) // new
}
}
.frame(height: 150)
.chartXSelection(value: $rawSelectedDate.animation(.easeInOut)) // modified
실행하면 다음과 같다.
Github: Step-Tracker Repository