HealthKit (5)
ChartView 분리
현재 DashBoardView의 View를 구성하는 코드가 길어지기에 분리를 해준다.
기존에 DashBoardView에 있던, rawSelectedDate, averageStepCount, selectedHealthMetric
을 옮겨준다.
그리고 새롭게
1
2
var selectedStat: HealthMetricContext
var chartData: [HealthMetric]
두 변수를 만들어준다, 위의 두변수는 DashBoardView에서 데이터를 전달한다.
기존에 hkManager.stepData로 받던것들은 chartData로 모두 바꿔주고, annotationView
도 가져온다.
이후 DashboardView에서는
1
StepBarChartView(selectedStat: selectedStat, chartData: hkManager.stepData)
Chart가 있던자리에 이렇게 대신하고 값만 전달해준다.
Average pie chart 구현
- 요일별로 데이터를 그룹화
- 요일별 평균 계산
- 요일별 평균을 계산하는 데이터를 새 평일 차트 객체에 넣고 차트에 표시
이 순서대로 진행을 하면 된다.
모델링
1
2
3
4
5
struct WeekdayChartData: Identifiable {
let id = UUID()
let date: Date
let value: Double
}
1. 요일별로 데이터를 그룹화
Extension 구현
각 요일을 수치로 나타내기위해 Date에 새로운 변수를 사용하기위해 Extension을 사용
1
2
3
4
5
extension Date {
var weekdayInt: Int {
Calendar.current.component(.weekday, from: self)
}
}
이렇게 되면 일~토 순으로 각요일이, 1~7로 수치화 된다.
1
2
3
4
5
6
7
8
9
static func averageWeekdayCount(for metric: [HealthMetric]) -> [WeekdayChartData] {
let sortedByWeekday = metric.sorted { $0.date.weekdayInt < $1.date.weekdayInt }
for metric in sortedByWeekday {
print(metric.date.weekdayInt)
}
return []
}
sorted를 사용하여 작은 순대로 오름차순 정렬을 한다.
실행하면 어떻게 나오는지 확인하기 위해 for 문으로 print를 찍어본다.
1
2
3
4
5
.task {
await hkManager.fetchStepCount()
ChartMath.averageWeekdayCount(for: hkManager.stepData)
isShowingPermissionPrimingSheet = !hasSeenPermissionPriming
}
이때 함수를 호출할때 반드시 fetch 다음에 적어야한다.
- fetch를 해서 값을 가져오기 전에는 함수 호출이 의미가 없다.
실행하면 다음과 같은 결과가 나온다.
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
1
1
1
1
2
2
2
2
3
3
3
3
4
4
4
4
5
5
5
5
6
6
6
6
7
7
7
7
이제 각 요일별로 데이터의 그룹화가 되었다.
2. 요일별 평균 계산
Swift-algorithms Package 사용
Package를 추가하기위해 Repository를 추가.
이때 우리는 chunk라는 메서드를 사용한다.
먼저 해당 메서드를 사용하기위해
import Algorithms
임포트 하는것 잊지말자.
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
import Algorithms
static func averageWeekdayCount(for metric: [HealthMetric]) -> [WeekdayChartData] {
let sortedByWeekday = metric.sorted { $0.date.weekdayInt < $1.date.weekdayInt }
let weekdayArray = sortedByWeekday.chunked { $0.date.weekdayInt == $1.date.weekdayInt }
var weekdayChartData: [WeekdayChartData] = []
for array in weekdayArray {
guard let firstValue = array.first else { continue }
let total = array.reduce(0) { $0 + $1.value }
let avgSteps = total/Double(array.count)
weekdayChartData.append(.init(date: firstValue.date, value: avgSteps))
}
// Demonstrate
for metric in sortedByWeekday {
print("Day: \(metric.date.weekdayInt), value: \(metric.value)")
}
print("----")
for day in weekdayChartData {
print("Day: \(day.date.weekdayInt), value: \(day.value)")
}
return weekdayChartData
}
여기서 firstValue를 사용한 이유는 first로 대표 요일를 가져오기 위함이다.
let weekdayArray = sortedByWeekday.chunked { $0.date.weekdayInt == $1.date.weekdayInt }
$0.date.weekdayInt == $1.date.weekdayInt
를 통해 같은 요일의 데이터를 배열안에 배열로 만든다.- 요일이 달라지면 새롭게 배열을 만들어서 그 요일에 해당하는 요일에 데이터만 새롭게 담는다.
- 실행 결과
1 2
Input 데이터: [월요일(1), 월요일(1), 화요일(2), 화요일(2), 수요일(3)] Chunk 결과: [[월요일(1), 월요일(1)], [화요일(2), 화요일(2)], [수요일(3)]]
실행하면
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
36
Day: 1, value: 7785.2535487433415
Day: 1, value: 50162.147187641895
Day: 1, value: 42471.52430555305
Day: 1, value: 36295.063254895074
Day: 2, value: 44028.54182602777
Day: 2, value: 37742.79145187516
Day: 2, value: 47387.79961544836
Day: 2, value: 27168.551298016544
Day: 3, value: 42474.977340623795
Day: 3, value: 32215.292158814344
Day: 3, value: 41597.284353018746
Day: 3, value: 40151.93854547695
Day: 4, value: 39613.873956412725
Day: 4, value: 45595.2528026459
Day: 4, value: 36638.69483201661
Day: 4, value: 36249.68842979061
Day: 5, value: 41009.801312990014
Day: 5, value: 40065.46734613688
Day: 5, value: 32001.438090129075
Day: 5, value: 30650.892971833677
Day: 6, value: 42455.89647019689
Day: 6, value: 28662.235916839494
Day: 6, value: 38682.99549120337
Day: 6, value: 40720.13487827648
Day: 7, value: 38918.49568968225
Day: 7, value: 38450.05031951279
Day: 7, value: 35219.51695299136
Day: 7, value: 36666.9658748508
----
Day: 1, value: 34178.49707420834
Day: 2, value: 39081.921047841955
Day: 3, value: 39109.87309948346
Day: 4, value: 39524.37750521646
Day: 5, value: 35931.89993027241
Day: 6, value: 37630.31568912906
Day: 7, value: 37313.7572092593
이렇게 출력이 된다.
Chunked 메서드 작동 방식
예시 코드 from Repository
1
2
3
let numbers = [10, 20, 30, 10, 40, 40, 10, 20]
let chunks = numbers.chunked(by: { $0 <= $1 })
// [[10, 20, 30], [10, 40, 40], [10, 20]]
- 현재값과 다음값을 비교해서 현재 값보다 같거나 큰 값을 담는다.
- 현재 값보다 낮은 값이 나올 경우, 새롭게 배열을 생성 하여 계속 진행
3. 요일별 평균을 계산하는 데이터를 새 평일 차트 객체에 넣고 차트에 표시
Pie Chart
StepPieChartView
를 새로 만들어 준다.
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
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Label("Averages", systemImage: "calendar")
.font(.title3.bold())
.foregroundStyle(.pink)
Text("Last 28 Days")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.bottom, 12)
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value))
}
}
.frame(height: 240)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
)
}
현재는 원하나만 덩그러니 있다.
ForegroundStyle
1
2
3
4
5
6
7
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value))
.foregroundStyle(by: .value("Weekday", weekday.value)) // new
}
}
.frame(height: 240)
우린 보통 foregroundStyle을 사용하여 단순히 색상을 사용하였지만
이렇게 하면 값의 분포에 따라 pie chart가 나뉘어 진다.
숫자대신 요일을 사용하기위해 Extension을 만들어 준다.
1
2
3
4
5
6
7
8
9
10
11
// Date Extenstion
var weekdayTitle: String {
self.formatted(.dateTime.weekday(.wide))
}
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value))
.foregroundStyle(by: .value("Weekday", weekday.date.weekdayTitle)) // modified
}
}
이렇게 하면 요일에 따라 값의 분포가 생기게 된다.
.chartLegend(.hidden)
Modifier를 사용하면
더이상 색에 대한 설명이 보이지 않는다.
SectorMark Customizing
1
2
3
4
5
6
7
8
9
10
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.618),
angularInset: 1)
.foregroundStyle(.pink.gradient)
.cornerRadius(6)
}
}
.frame(height: 240)
기존에는 angle에만 값을 넣었는데 Customizing을 하기 위해 paramter들을 더 사용한다.
- angle: 섹터의 각도 크기에 매핑되는 plottable value.
- innerRadius: 내부 원의 반지름
- 값이 클수록 내부 원이 커진다.
- 고정 값을 하거나, 비율로 설정이 가능
- angularInset: 각 section별 사이 거리
- 값이 클수록 사이 간격이 멀어진다.
값을 변경하면 다음과 같다.
1
2
3
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.1), // modified
angularInset: 10) // modified
annotation
BarChart와 마찬가지로 Annotation추가가 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.618),
angularInset: 1)
.foregroundStyle(.pink.gradient)
.cornerRadius(6)
.annotation(position: .overlay) { // new
Text(weekday.value, format: .number.precision(.fractionLength(0)))
.foregroundStyle(.white)
.fontWeight(.bold)
}
}
}
.frame(height: 240)
Average Pie Chart Interactivity
이번에도 이전글과 같이 onChange
를 통해 값이 어떻게 변하는지 알아본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@State private var rawSelectedChartValue: Double?
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.618),
angularInset: 1)
.foregroundStyle(.pink.gradient)
.cornerRadius(6)
}
}
.chartAngleSelection(value: $rawSelectedChartValue) // new
.frame(height: 240)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
)
.onChange(of: rawSelectedChartValue) { oldValue, newValue in // new
print(newValue)
}
이렇게 값이 나오게 된다.
출력 관련 정리
- Pie Chart와 누적 합계
- 원형 차트(Pie Chart) 는 데이터를 각도로 변환하여 표시한다. 각 데이터의 비율은 해당 데이터 값이 차지하는 각도로 나타난다.
- 드래그 동작은 사용자가 차트를 터치하거나 드래그할 때 차트의 특정 각도 위치를 선택하는 것을 의미한다.
- 이 각도 위치는 차트 데이터를 누적하여 계산한 총합의 일부로 매핑된다.
- 차트 드래그의 동작 원리
- 누적 합계 계산
- 차트는 데이터를 누적 합계(Cumulative Sum) 방식으로 관리한다.
- 드래그한 위치에 해당하는 누적 합계 값을 반환하여 사용자가 선택한 데이터와 연관시킨다.
- 계산 방식
- 각 데이터 값(chartData)의 비율(각도)을 계산.
- 비율(각도)을 기준으로 각 데이터 값을 누적.
- 드래그 위치에 해당하는 각도의 누적 합계 값을 반환.
- 누적 합계 계산
- 출력된 값의 의미
- 출력된 값은 rawSelectedChartValue의 값으로, 사용자가 선택한 차트의 각도에 해당하는 누적 합계이다.
- 예를 들어, 주어진 chartData의 값이 다음과 같다고 가정 (현재 chartData를 출력했을 때의 값.)
1 2 3 4 5 6 7 8 9
let chartData = [ WeekdayChartData(date: ..., value: 34178.49707420834), // 일요일 WeekdayChartData(date: ..., value: 39081.921047841955), // 월요일 WeekdayChartData(date: ..., value: 39109.87309948346), // 화요일 WeekdayChartData(date: ..., value: 39524.37750521646), // 수요일 WeekdayChartData(date: ..., value: 35931.89993027241), // 목요일 WeekdayChartData(date: ..., value: 37630.31568912906), // 금요일 WeekdayChartData(date: ..., value: 37313.7572092593) // 토요일 ]
- 사용자가 드래그한 위치가 누적 합계 값 80000에 해당한다고 가정하면:
- rawSelectedChartValue = 80000
- 누적 합계 값이 80000 이하가 되는 마지막 데이터는 화요일 (112370.3)이다.
- 부연 설명
1 2 3
WeekdayChartData(date: ..., value: 34178.49707420834), // 일요일 (누적 합계: 34178.5) WeekdayChartData(date: ..., value: 39081.921047841955), // 월요일 (누적 합계: 73260.4) WeekdayChartData(date: ..., value: 39109.87309948346), // 화요일 (누적 합계: 112370.3)
- 일요일:
- total = 34178.5
- 조건: 80000 <= 34178.5 → False. 다음 요일로 이동.
- 월요일:
- total = 34178.5 + 39081.9 = 73260.4 - 조건: 80000 <= 73260.4 → False. 다음 요일로 이동.
- 화요일:
- total = 73260.4 + 39109.9 = 112370.3
- 조건: 80000 <= 112370.3 → True. 화요일 반환.
- 일요일:
- 사용 이유
- 이 값들은 사용자가 선택한 차트의 각도에 해당하는 데이터 값을 찾기 위해 사용된다. 즉:
- rawSelectedChartValue를 이용해 누적 합계와 비교.
- 사용자가 드래그한 위치와 관련된 데이터를 반환.
- 이 값들은 사용자가 선택한 차트의 각도에 해당하는 데이터 값을 찾기 위해 사용된다. 즉:
이 방식은 차트 상호작용(Interactivity)을 구현하는 데 핵심 역할을 한다.
Pie Chart 요일 확인하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var selectedWeekday: WeekdayChartData? {
guard let rawSelectedChartValue else { return nil }
var total = 0.0
let selectedData = chartData.first {
total += $0.value
return rawSelectedChartValue <= total
}
return selectedData
}
.onChange(of: rawSelectedChartValue) { oldValue, newValue in // new
print(selectedWeekday?.date.weekdayTitle)
}
selectedWeekday
에서는
1
2
3
4
let selectedData = chartData.first {
total += $0.value
return rawSelectedChartValue <= total
}
이부분만 정리를 하면 될것같다.
- 누적 합계 계산:
- chartData 배열을 순회하며 각 요일 데이터의 value를 total에 더한다.
- 이때, total은 현재까지 순회한 값들의 합계를 나타낸다.
- 조건 만족 시 반환:
- 누적된 합계(total)가 rawSelectedChartValue와 같거나 클 경우, 해당 데이터를 반환한다.
- rawSelectedChartValue는 차트에서 선택된 값의 위치를 나타낸다.
- 이 과정을 통해 현재 선택된 섹터(요일 데이터)를 판별한다.
이때 중요한건 드래그하면서 값을 너무 생각하면 안된다. 헷갈리기 때문.
print(chartData)
를 하여 각 요일에 해당하는 걸음수 평균값이 무엇인지를 먼저 떠올리자.
1
2
3
4
5
6
7
Day: 1, value: 34178.49707420834
Day: 2, value: 39081.921047841955
Day: 3, value: 39109.87309948346
Day: 4, value: 39524.37750521646
Day: 5, value: 35931.89993027241
Day: 6, value: 37630.31568912906
Day: 7, value: 37313.7572092593
위의 값이 바로 우리가 담아 두었던 값이다.
- 드래그 시, 선택된 위치에 따라 total 값이 누적된다.
- 드래그하지 않고 클릭만 해도, 해당 포인트에서 누적된 값이 계산된다.
- 선택된 값(rawSelectedChartValue)은 차트 데이터(chartData)의 누적 합계와 비교되며, 조건에 맞는 요일 데이터를 반환한다.
- rawSelectedChartValue가 특정 요일의 데이터 값보다 크면, 그 다음 요일로 이동하여 조건을 확인한다
그래서 이런 내용을 바탕으로 실행을 했을때 요일이 출력이 되는것이다.
선택한 요일만 강조 (opacity)
1
2
3
4
5
6
7
8
9
10
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.618),
angularInset: 1)
.foregroundStyle(.pink.gradient)
.cornerRadius(6)
.opacity(selectedWeekday?.date.weekdayInt == weekday.date.weekdayInt ? 1.0 : 0.3 ) // new
}
}
이전에 했던것과 방식은 같다.
outerRadius 사용
SectorMark의 Paramter에 대해 언급할떄 저부분은 당시 코드에 들어가지 않아 포함시키지 않았다.
outerRadius: 외부 원의 반지름
- 클수록 섹터의 크기가 전체적으로 커진다.
- 내부 반지름(
innerRadius
)과의 차이로 섹터의 두께를 조정할 수 있다.
이렇게 값을 통해서 전반적인 크기를 설정할 수 있다.
outerRadius: selectedWeekday?.date.weekdayInt == weekday.date.weekdayInt ? 150 : 50
이런식으로 선택 부분에 대해 강조를 할 수 있다. 지금은 너무 동떨어져서 값을 다시 수정한다.
outerRadius: selectedWeekday?.date.weekdayInt == weekday.date.weekdayInt ? 140 : 110
이젠 좀 더 자연스럽게 된걸 확인할 수 있다.
animation 사용
1
2
3
4
5
6
7
8
9
10
11
12
Chart {
ForEach(chartData) { weekday in
SectorMark(angle: .value("Average Steps", weekday.value),
innerRadius: .ratio(0.618),
outerRadius: selectedWeekday?.date.weekdayInt == weekday.date.weekdayInt ? 140 : 110,
angularInset: 1)
.foregroundStyle(.pink.gradient)
.cornerRadius(6)
.opacity(selectedWeekday?.date.weekdayInt == weekday.date.weekdayInt ? 1.0 : 0.3 )
}
}
.chartAngleSelection(value: $rawSelectedChartValue.animation(.easeOut)) // new
실행하면 다음과 같다.
ChartBackground
.chartBackground
Modifier를 사용한다.
이때 새로운 ChartProxy가 나타는데 ChartProxy Docs를 읽어보자.
WWDC2023 초반에 간단한 예시가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.chartAngleSelection(value: $rawSelectedChartValue.animation(.easeOut))
.frame(height: 240)
.chartBackground { proxy in // new
GeometryReader { geo in
if let plotFrame = proxy.plotFrame {
let frame = geo[plotFrame]
if let selectedWeekday {
VStack {
Text(selectedWeekday.date.weekdayTitle)
.font(.title3.bold())
Text(selectedWeekday.value, format: .number.precision(.fractionLength(0)))
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
.position(x: frame.midX, y: frame.midY)
}
}
}
}
여긴 조금 생소한 부분이 많기에 Docs를 기반으로 정리를 해본다.
1. Chart Background
chartBackground
: 차트 배경에 커스텀 뷰를 추가하기 위한 Modifier.ChartProxy
:
차트의 스케일과 플롯 영역에 접근하기 위해 사용하는 프록시(proxy).- 차트 프록시를 사용하여 데이터 값을 화면 좌표로 변환하거나 그 반대로 변환할 수 있다.
proxy.plotFrame
:- 차트의 플롯 영역에 대한 프레임을 나타내는 앵커(anchor) 이며,
CGRect
값(Optional). - 앵커를 GeometryProxy를 사용하여 프레임으로 변환할 수 있다..
- 차트의 플롯 영역에 대한 프레임을 나타내는 앵커(anchor) 이며,
플롯 영역은 실제로 파이 차트, 막대 그래프, 선 그래프 등 데이터 시각화가 그려지는 공간을 말한다. 즉, 차트의 데이터를 시각적으로 나타내는 핵심 영역이다.
2. GeometryReader
GeometryReader
: 자신의 크기와 좌표 공간을 기반으로 내용을 정의하는 컨테이너 뷰.GeometryProxy
: 컨테이너 뷰의 크기와 좌표 공간(앵커를 해석하기 위한)에 접근하기 위한 프록시.geo[plotFrame]
:proxy.plotFrame
을GeometryProxy
객체(geo
)와 함께 사용하여 플롯 영역의 실제 좌표와 크기를 계산.- 반환값은 차트 배경 내에서의 플롯 영역 위치(
CGRect
).
이건 예전에 회고 할때 잠깐 나왔었다. GeometryReader와 GeometryProxy 를 읽어보는걸 추천
3. 정리
- 차트의 배경에 텍스트를 표현하기 위해
chartBackground
Modifier 사용. ChartProxy
를 사용하여 차트의 플롯 영역(데이터 시각화가 이루어지는 공간)에 접근.GeometryReader
를 사용하여 좌표 기반으로 내용을 정의하는 컨테이너 뷰를 생성.GeometryProxy
를 사용하여 플롯 영역의 앵커(proxy.plotFrame)를 화면 좌표(CGRect)로 변환.- 선택한 날짜가 있다면(드래그 또는 클릭), 플롯 영역의 중심에 텍스트를 표시.
실행하면 다음과 같다.
Transition 효과
1
2
3
4
5
6
7
8
9
10
11
12
13
if let selectedWeekday {
VStack {
Text(selectedWeekday.date.weekdayTitle)
.font(.title3.bold())
.contentTransition(.identity) // new
Text(selectedWeekday.value, format: .number.precision(.fractionLength(0)))
.fontWeight(.medium)
.foregroundStyle(.secondary)
.contentTransition(.numericText()) // new
}
.position(x: frame.midX, y: frame.midY)
}
실행하면 다음과 같다.
댓글을 보니
1
2
3
Text(selectedWeekday.date.weekdayTitle)
.font(.title3.bold())
.animation(nil)
animation
Modifier를 사용하여 요일에는 변화를 주지 않았다.
이게 더 나은듯 하다.
Default Value 설정
@State private var rawSelectedChartValue: Double? = 0
0이라는 초기값을 주면서 일요일을 보여준다.
즉, 사용자가 처음 차트를 볼 때 공백 상태를 방지하기 위함.
아무것도 없으면 유져 입장에선 차트를 어떻게 해야하는지 모를수 있기에 초기값을 주면 유져가 다른 차트의 섹션을 클릭해보거나 드래그를 하게 될것이다.
Github: Step-Tracker Repository