포스트

HealthKit (4)

Charts 사용하기.

CleanShot 2024-12-14 at 00 36 39

MockData를 생성하고, 그걸 fetch하는 것 까지 했으니, 이젠 Dashboard에 Charts를 사용하여 도식화를 해보도록 한다.

WWDC2022

Charts Docs

모델링

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)
    })        
}
  1. sumQuantity
    • sumQuantity는 특정 기간 동안 수집된 데이터의 합계를 반환한다.
    • 걸음 수(stepCount)는 시간 경과에 따라 누적되는 값이다.
    • 예를 들어, 하루 동안 사용자가 걷는 모든 걸음 수를 합산해서 하루 총 걸음 수를 계산.
    • StatisticsCollectionQuery를 사용하여 하루 단위로 데이터를 그룹화했기 때문에, 각 그룹(하루)에 대해 총합을 계산해야 한다.
    • map을 사용하여 HealthMetric 타입으로 값들을 형변환 해준다.
  2. 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()를 사용하여 화면이 나올떄 값을 가져오게 한다.

실행하면 다음과 같이 나온다.

simulator_screenshot_584B0F32-14B3-4099-99E7-022E9F175A49

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는 점선의 간격을 표시

CleanShot 2024-12-14 at 04 01 27

simulator_screenshot_DADE0BDD-B133-4011-8F94-2606FC282FA4


각 축 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()

CleanShot 2024-12-14 at 03 55 12

이녀석을 의미한다.

CleanShot 2024-12-14 at 02 30 15


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”와 같은 형식으로 날짜를 나타낸다.

CleanShot 2024-12-14 at 02 32 39

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로 값을 설정해주면

CleanShot 2024-12-14 at 04 20 54CleanShot 2024-12-14 at 04 20 49

이렇게 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를 통해 값을 출력하게 해보면

Dec-14-2024 04-27-02

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축에 날짜 값을 넣어 표시를 하게 했다.

실행하면 다음과 같다.

Dec-14-2024 04-33-31


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로 해당값을 리턴하게 했다.

CleanShot 2024-12-14 at 05 08 09

CleanShot 2024-12-14 at 05 07 47

실행하면 다음과 같다.

Dec-14-2024 04-35-41

하지만 이건 우리가 의도한게 아니다. 그리고 양끝으로 가면 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에 여러 파라미터를 받아서 처리를 하도록 한다.

CleanShot 2024-12-14 at 05 09 02

CleanShot 2024-12-14 at 05 10 46

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이 차트를 벗어나더라도 그대로 둔다.

실행하면 다음과 같다.

Dec-14-2024 04-38-46

숫자때문에 그래프가 튀지 않는걸 확인할 수 있다.


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

실행하면 다음과 같다.

Dec-14-2024 04-43-49


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으로 투명하게 처리하여, 현재 선택중인 값을 강조한다.

실행하면 다음과 같다.

Dec-14-2024 04-52-21


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

실행하면 다음과 같다.

Dec-14-2024 04-53-38


Github: Step-Tracker Repository

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