포스트

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

연결된 선분의 시퀀스를 사용하여 데이터를 나타내는 차트 콘텐츠

CleanShot 2024-12-15 at 09 19 48

이렇게 기본틀이 만들어졌다.

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

하나 이상의 영역의 면적을 사용하여 데이터를 나타내는 차트 콘텐츠

CleanShot 2024-12-15 at 09 21 31

1
2
3
4
AreaMark(x: .value("Day", weights.date, unit: .day),
                             y: .value("Value", weights.value)
                    )
                    .foregroundStyle(Gradient(colors: [.blue, .clear])) // added

이런식으로 데코도 가능하다.

CleanShot 2024-12-15 at 09 24 46

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)

CleanShot 2024-12-15 at 10 01 18

분명히 우측의 범위는 바뀌었는데 그래프가 이상하다.

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로 만든 변수이다.

CleanShot 2024-12-15 at 10 05 27

이전에는 .chartYScale의 범위를 숫자로 직접 설정하는 하드 코딩이었다면

.chartYScale(domain: .automatic(includesZero: false))

이렇게 자동으로 설정도 가능하다.

CleanShot 2024-12-15 at 10 08 14

interpolationMethod

1
2
3
4
5
LineMark(x: .value("Day", weights.date, unit: .day),
            y: .value("Value", weights.value)
)
.foregroundStyle(.indigo)
.interpolationMethod(.stepCenter) // new

CleanShot 2024-12-15 at 10 11 13

여러 선택지가 많으니 나중에 한번 이것저것 바꿔봐도 좋을듯.

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)

CleanShot 2024-12-15 at 10 12 47

.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)

CleanShot 2024-12-15 at 10 14 22

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로 하게되면 알아서 만들어 준다.

CleanShot 2024-12-15 at 10 19 09

밑에 날짜 표기 형식이 달라진것 말고는 차이는 없다.

RuleMark

1
2
3
Chart {
        RuleMark(y: .value("Goal", 155)) // new
        ForEach(chartData) { weights in

이전에 평균선을 만들듯이 이렇게 선을 추가 할 수 있다.

CleanShot 2024-12-15 at 10 21 10

이때 value의 값을 너무 낮추거나 높이게 되면

너무 낮은 경우: RuleMark(y: .value("Goal", 50))

CleanShot 2024-12-15 at 10 22 03

너무 높은 경우: RuleMark(y: .value("Goal", 350))

CleanShot 2024-12-15 at 10 23 01

그래프가 단조로워 질 수 있으니 조심하자.

lineStyle

언급은 패스하는걸로.

1
2
3
RuleMark(y: .value("Goal", 155))
                    .foregroundStyle(.mint)
                    .lineStyle(.init(lineWidth: 1, dash: [5])) // new

실행화면

CleanShot 2024-12-15 at 10 29 39

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

CleanShot 2024-12-15 at 10 33 15

이렇게 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 코드는 거의 같다.

실행하면 다음과 같다.

Dec-15-2024 19-38-11

아직은 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

소수점 표시와, 색상만 바꿔주었다.

적용하여 실행하면 다음과 같다.

Dec-15-2024 19-43-08

Segment Control에 맞게 화면 전환하기

현재 DashBoardView에는 Picker를 사용하여 UIKit에서 사용했던 Segment Control이 만들어져 있으나, 관련 있는 View들을 연결하는 부분은 작성하지 않은 상태이다.

즉, 실행하면 Step관련 Chart는 보이지만 Weight 관련 View들은 보이지 않는다.

Dec-15-2024 19-46-05

물론 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
}

아래와 같이 적용이 된걸 알 수 있다.

Dec-15-2024 19-51-01


Github: Step-Tracker Repository

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.