HealthKit (6)
Weight Chart
MockData 분리
기존에 HealthMetric
구조체에 같이 있던것을 별도의 구조체를 만들어 MockData를 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct MockData {
static var steps: [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
}
static var weights: [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: (160 + Double(i/3)...165 + Double(i/3)))
)
array.append(metric)
}
return array
}
}
그리고 steps, weights로 나누어 각 View에 맞는 데이터를 만들어둔다.
Weight Chart View 만들기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var selectedStat: HealthMetricContext
var chartData: [HealthMetric]
var body: some View {
VStack {
NavigationLink(value: selectedStat) {
HStack {
VStack(alignment: .leading) {
Label("Steps", systemImage: "figure")
.font(.title3.bold())
.foregroundStyle(.indigo)
Text("Avg: 180 lbs")
.font(.caption)
}
Spacer()
Image(systemName: "chevron.right")
}
}
.foregroundStyle(.secondary)
.padding(.bottom, 12)
Chart {
ForEach(chartData) { weights in
LineMark(x: .value("Day", weights.date, unit: .day), // new
y: .value("Value", weights.value))
}
}
.frame(height: 150)
}
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(Color(.secondarySystemBackground)))
}
Steps에서는 BarMark
를 사용했다면, 이번엔 LineMark
를 사용한다.
LineMark
연결된 선분의 시퀀스를 사용하여 데이터를 나타내는 차트 콘텐츠
이렇게 기본틀이 만들어졌다.
AreaMark
1
2
3
4
5
6
7
8
9
10
Chart {
ForEach(chartData) { weights in
AreaMark(x: .value("Day", weights.date, unit: .day), // new
y: .value("Value", weights.value))
LineMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value))
}
}
AreaMark
하나 이상의 영역의 면적을 사용하여 데이터를 나타내는 차트 콘텐츠
1
2
3
4
AreaMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
.foregroundStyle(Gradient(colors: [.blue, .clear])) // added
이런식으로 데코도 가능하다.
chartYScale
chartYScale
Modifier를 사용하여 y값의 범위를 잡을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Chart {
ForEach(chartData) { weights in
AreaMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
.foregroundStyle(Gradient(colors: [.blue.opacity(0.5), .clear]))
LineMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
}
}
.frame(height: 150)
.chartYScale(domain: 150...180)
분명히 우측의 범위는 바뀌었는데 그래프가 이상하다.
AreaMark 수정
처음에는
1
2
3
4
5
6
7
8
9
// before
AreaMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
// after
AreaMark(x: .value("Day", weights.date, unit: .day),
yStart: .value("Value", weights.value),
yEnd: .value("Min Value", minValue))
이렇게 y의 시작과, 끝을 정할 수 있다.
1
2
3
var minValue: Double {
chartData.map { $0.value }.min() ?? 0
}
minValue는 몸무게 값중 가장 최소값을 구하기 위해 Computed Property로 만든 변수이다.
이전에는 .chartYScale의 범위를 숫자로 직접 설정하는 하드 코딩이었다면
.chartYScale(domain: .automatic(includesZero: false))
이렇게 자동으로 설정도 가능하다.
interpolationMethod
1
2
3
4
5
LineMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
.foregroundStyle(.indigo)
.interpolationMethod(.stepCenter) // new
여러 선택지가 많으니 나중에 한번 이것저것 바꿔봐도 좋을듯.
symbol
1
2
3
4
5
6
LineMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
.foregroundStyle(.indigo)
.interpolationMethod(.catmullRom)
.symbol(.circle)
각 값들의 하나의 점으로 보여준다. (circle)
.symbolSize
를 통해 크기를 조절 할 수 있다.
1
2
3
4
5
6
7
LineMark(x: .value("Day", weights.date, unit: .day),
y: .value("Value", weights.value)
)
.foregroundStyle(.indigo)
.interpolationMethod(.catmullRom)
.symbol(.diamond)
.symbolSize(90)
Chart Axis
이전글에서 했지만 리마인드겸 다시 적어본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Chart {
}
.frame(height: 150)
.chartYScale(domain: .automatic(includesZero: false))
.chartXAxis { // new
AxisMarks {
AxisValueLabel(format: .dateTime.month(.defaultDigits).day())
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
.foregroundStyle(Color.secondary.opacity(0.3))
AxisValueLabel()
}
}
y축 값은 우리가 커스터 마이징을 안하려면 AxisValueLabel()
이렇게 Default로 하게되면 알아서 만들어 준다.
밑에 날짜 표기 형식이 달라진것 말고는 차이는 없다.
RuleMark
1
2
3
Chart {
RuleMark(y: .value("Goal", 155)) // new
ForEach(chartData) { weights in
이전에 평균선을 만들듯이 이렇게 선을 추가 할 수 있다.
이때 value의 값을 너무 낮추거나 높이게 되면
너무 낮은 경우: RuleMark(y: .value("Goal", 50))
너무 높은 경우: RuleMark(y: .value("Goal", 350))
그래프가 단조로워 질 수 있으니 조심하자.
lineStyle
언급은 패스하는걸로.
1
2
3
RuleMark(y: .value("Goal", 155))
.foregroundStyle(.mint)
.lineStyle(.init(lineWidth: 1, dash: [5])) // new
실행화면
annotation
1
2
3
4
5
6
7
8
RuleMark(y: .value("Goal", 155))
.foregroundStyle(.mint)
.lineStyle(.init(lineWidth: 1, dash: [5]))
.annotation(alignment: .leading) { // new
Text("Goal")
.foregroundStyle(.secondary)
.font(.caption)
}
alignment를 사용하지 않으면 Default는 center
이렇게 RuleMark에 대해 다시 언급을 해봤는데, 지금은 155로 약간 하드코딩식으로 값을 고정 했는데, 추후에 조금 더 개선을 한다면 NavigationBarButton을 만들어서 그 버튼을 눌렀을때 목표 값을 설정하게 하여 유져들로 하여금 기준선을 커스터마이징 할 수 있게 하는것도 좋은 방법이 될 것 같다.
Weight Chart Interactivity
이전에 언급을 미처 하지 못했던 내용인데
BarChart에서 rawSelectedDate
를 만들어 사용을 하는 Modifier가 바로 chartXSelection
이다.
x축에 대하여 유져가 선택된 값을 추적하는 Modifier 라고 간단하게 정의를 할 수 있겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var selectedHealthMetric: HealthMetric? {
guard let rawSelectedDate else { return nil }
return chartData.first {
Calendar.current.isDate(rawSelectedDate, inSameDayAs: $0.date)
}
}
Chart {
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)) { }
}
이전글과 마찬가지로 BarChart 처럼 선택된 값에 대한 Computed Property, RuleMark 코드는 거의 같다.
실행하면 다음과 같다.
아직은 annotation modifier에 View나 text 같은 UI 요소가 없기에 mark만 보인다.
annotationView 추가
이전의 annotationView 변수를 그대로 가져와서
1
2
3
4
5
6
7
.annotation(position: .top,
spacing: 0,
overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) { annotationView } // modified
Text(selectedHealthMetric?.value ?? 0, format: .number.precision(.fractionLength(1))) // modified
.fontWeight(.heavy)
.foregroundStyle(.indigo) // modified
소수점 표시와, 색상만 바꿔주었다.
적용하여 실행하면 다음과 같다.
Segment Control에 맞게 화면 전환하기
현재 DashBoardView에는 Picker를 사용하여 UIKit에서 사용했던 Segment Control이 만들어져 있으나, 관련 있는 View들을 연결하는 부분은 작성하지 않은 상태이다.
즉, 실행하면 Step관련 Chart는 보이지만 Weight 관련 View들은 보이지 않는다.
물론 DashBoardView에서 몸무게를 가져오는 부분도 현재 코드에는 없는 상태
switch-case
를 사용하여 각 케이스에 맞게 화면전환을 하도록 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
switch selectedStat {
case .steps:
StepBarChartView(selectedStat: selectedStat, chartData: hkManager.stepData)
StepPieChartView(chartData: ChartMath.averageWeekdayCount(for: hkManager.stepData))
case .weight:
WeightLineChartView(selectedStat: selectedStat, chartData: hkManager.weightData)
}
.task {
await hkManager.fetchStepCount()
await hkManager.fetchWeights() // new
isShowingPermissionPrimingSheet = !hasSeenPermissionPriming
}
아래와 같이 적용이 된걸 알 수 있다.
Github: Step-Tracker Repository