포스트

HealthKit (12)

Optimizing

1. sheet to fullScreenCover

이부분은 예전글에 여러 수강생의 의견이라는 섹션으로 작성을 했던 부분이다.

그래서 크게 언급은 하지 않는걸로 하겠다.

fullScreenCover를 사용한다면 primingView에서 .interactiveDismissDisabled() 이부분은 지워주자.

2. DataListView: LabeledContent 사용

현재 List에서는

1
2
3
4
5
HStack {
    Text(data.date, format: .dateTime.month(.wide).day().year())
    Spacer()
    Text(data.value, format: .number.precision(.fractionLength(metric == .steps ? 0 : 1)))
}

위와 같이 List 각각의 행에 들어갈 내용을 이런식으로 담고 있다.

LabeledContent를 사용해본다. (iOS 16.0 이상부터 지원)

1
2
3
4
5
LabeledContent {
    Text(data.value, format: .number.precision(.fractionLength(metric == .steps ? 0 : 1)))
} label: {
    Text(data.date, format: .dateTime.month(.wide).day().year())
}

simulator_screenshot_DBBD7454-35F8-44A5-A5FE-A040B6E6A31Dsimulator_screenshot_99D62819-8618-4273-9D2D-EFE4FA7A6634

첫번째가 HStack, 두번째가 LabeledContent 인데, 육안상으론 크게 차이는 없고 단지 뒤의 실제 값들의 폰트색이 달라져서 이게 더 느낌있어 보이긴 한다.

addDataView에서 Picker쪽에도 적용을 해본다.

1
2
3
4
5
6
LabeledContent(metric.title) {
    TextField("Value", text: $valueToAdd)
        .multilineTextAlignment(.trailing)
        .frame(width: 140)
        .keyboardType(metric == .steps ? .numberPad : .decimalPad)
}

simulator_screenshot_4121091B-A7B2-4505-BA91-189F069CA0B8simulator_screenshot_E95D2B16-7B40-4F7E-BE54-0A0D40B672C9

이건 차이는 없다. 단지 코드자체로만 봤을때 간결해 보이는 효과는 있다.

3. ChartMath: Sort

현재는 sorted와 클로저를 사용해서 일요일 부터 토요일까지 소팅을 하고있다.

이번엔 다른 방식으로 소팅을 해본다.

CleanShot 2024-12-17 at 10 37 16

order를 통해 오름차순, 내림차순 설정이 가능.

1
let sortedByWeekday = metric.sorted(using: KeyPathComparator(\.date.weekdayInt))

이렇게 적용을 해준다.

4. ChartView: overlay

overlay Modifier를 사용한다.

기존 뷰의 위에 새로운 뷰를 겹쳐서 표시하는 역할을 한다. 즉, 기존 뷰의 “레이어 위에 뷰를 덧붙이는” 동작을 수행한다.

CleanShot 2024-12-17 at 10 48 39

overlay를 사용하게 되면 다음과 같은 구조로 View가 형성 될것이다.

1
2
3
4
5
[ChartContainer]
   |
   └── [Chart]
           |
           └── [Overlay - ChartEmptyView]

Background와 비교

Modifier뷰의 배치설명
overlay위에 겹침기존 뷰의 위에 새로운 뷰를 겹쳐서 표시.
background아래에 깔림기존 뷰의 아래에 새로운 뷰를 배치.

다시 돌아와서

1
2
3
if chartData.isEmpty {
                ChartEmptyView(systemImageName: "calendar", title: "No Data", description: "There is no step count data from the Health App.")
            }

위에 해당하는 기존에 if문을 지우고 다음과 같이 overlay를 적용한다.

1
2
3
4
5
6
7
8
.chartYAxis {
    // 생략
}
.overlay {
    if chartData.isEmpty {
        ChartEmptyView(systemImageName: "chart.bar", title: "No Data", description: "There is no step count data from the Health App.")                    
    }
}

데이터가 없을때를 overlay를 사용하였다.

CleanShot 2024-12-17 at 10 55 58

Preview를 보니 RuleMark가 그대로 적용이 되는걸 확인

1
2
3
4
5
if !chartData.isEmpty {
    RuleMark(y: .value("Average", ChartHelper.averageValue(for: chartData)))
        .foregroundStyle(.secondary)
        .lineStyle(.init(lineWidth: 1, dash: [5]))
}

이렇게 if문으로 감싸주자.

CleanShot 2024-12-17 at 11 06 21

해결이 되었다.

나머지도 동일하게 바꿔주자.

5. pieChart: LastSelectedValue

현재 PieChart의 경우 드래그를 하고 나면 값이 사라지는 현상이 발생한다.

CleanShot 2024-12-17 at 11 21 50CleanShot 2024-12-17 at 11 21 40

드래그전엔 초기값이 있기에 Sunday를 표시하지만 조작후엔 감쪽같이 사라진다.

아래는 실행 사진

Dec-17-2024 11-21-19

이젠 유저가 마지막으로 드래그를 마친 부분에 대해서 값을 계속 표시하게 해보려 한다.

1
2
3
4
5
6
7
8
9
10
var selectedWeekday: DateValueChartData? {
        guard let rawSelectedChartValue else { return nil }
        var total = 0.0
        
        let selectedData = chartData.first {
            total += $0.value
            return rawSelectedChartValue <= total
        }
        return selectedData
    }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@State private var lastSelectedValue: Double = 0

var selectedWeekday: DateValueChartData? {
    var total = 0.0
    
    return chartData.first { // modified
        total += $0.value
        return lastSelectedValue <= total
    }
}


.chartAngleSelection(value: $rawSelectedChartValue.animation(.easeOut))
.onChange(of: rawSelectedChartValue) { oldValue, newValue in // new
    guard let newValue else {
        lastSelectedValue = oldValue ?? 0
        return
    }
    lastSelectedValue = newValue
}

onchange를 통해 마지막 값이 저장이 되고 그게 selectedWeekday에 리턴값으로 들어간다.

즉, 마지막으로 선택한 값이 selectedWeekday로 들어가게 된다.

실행하면 다음과 같다.

Dec-17-2024 11-32-54

애니메이션 적용

1
2
3
4
5
6
7
8
9
10
.chartAngleSelection(value: $rawSelectedChartValue)
.onChange(of: rawSelectedChartValue) { oldValue, newValue in
    withAnimation(.easeInOut) {
        guard let newValue else {
            lastSelectedValue = oldValue ?? 0
            return
        }
        lastSelectedValue = newValue
    }
}

실행하면 다음과 같다.

Dec-17-2024 11-36-28

6. ChartHelper

Helper에 있던 averageValue 함수를 지우고 Extension으로 기능을 구현한다.

기존 함수의 문제는

1
2
3
4
5
static func averageValue(for data: [DateValueChartData]) -> Double {
    guard !data.isEmpty else { return 0 }
    let totalSteps = data.reduce(0) { $0 + $1.value }
    return totalSteps / Double(data.count)
}

아무래도 걸음수, 체중 이렇게 두개의 값을 평균을 낼때 하나의 함수로 하다보니 체중을 기준으로 리턴타입을 Double로 맞춰야만 했다.

이렇게 사용을 하면 소수점을 원하는대로 표현하는데 있어 제약이 존재했다.

이런 문제를 해결하기 위해 자체적으로 extension을 활용하여 평균을 구하도록 만들어 본다.

1
2
3
4
5
6
7
extension Array where Element == Double {
    var average: Double {
        guard !self.isEmpty else { return 0 }
        let total = self.reduce(0, +)
        return total/Double(self.count)
    }
}

이제 적용을 해본다.

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
// StepBarChartView
var averageSteps: Int {
    Int(chartData.map{ $0.value }.average)
}

let config = ChartContainerConfiguration(title: "Steps",
                                                symbol: "figure.walk",
                                                subtitle: "Avg: \(averageSteps.formatted()) steps", // modified
                                                context: .steps,
                                                isNav: true)

// WeightLineChartView
var averageWeight: Double {
    chartData.map{ $0.value }.average
}

var subtitle: String {
    return "Avg: \(averageWeight.formatted(.number.precision(.fractionLength(1)))) lbs"
}

let config = ChartContainerConfiguration(title: "Weight",
                                                 symbol: "figure",
                                                 subtitle: subtitle,
                                                 context: .weight,
                                                 isNav: true)

기존에 text로 180으로 대체했던것을 이제 평균을 계산한 값이 보여지게 구현

이젠 평균값으로 나온다.

simulator_screenshot_31F4C141-EE2D-4544-9250-3643A3074964

Project Reorganization

프로젝트 파일을 디렉토리를 만들어서 파일을 분류 해준다.

그리고 ChartMath에 있던 코드들을 전부 ChartHelper로 옮겨준다.

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
Step Tracker/
├── Charts/
│   ├── Chart Custom Views/
│   │   ├── ChartAnnotationView.swift
│   │   ├── ChartContainer.swift
│   │   ├── ChartEmptyView.swift
│   │
│   ├── Chart Utilities/
│   │   ├── ChartDataTypes.swift
│   │   ├── ChartHelper.swift
│   │
│   ├── Charts/
│       ├── StepBarChartView.swift
│       ├── StepPieChartView.swift
│       ├── WeightDiffBarChartView.swift
│       ├── WeightLineChartView.swift
│
├── Managers/
│   ├── HealthKitManager.swift
│
├── Model/
│   ├── HealthMetric.swift
│
├── Screens/
│   ├── DashboardView.swift
│   ├── HealthDataListView.swift
│   ├── HealthKitPermissionPrimingView.swift
│
├── Utilities/
│   ├── Extension/
│   │   ├── Array+Extension.swift
│   │   ├── Date+Extension.swift
│   │   ├── MockData.swift
│   │   ├── STError.swift

구조는 위와 같다.

enum 사용하여 Chart 분류

1
2
3
4
5
6
enum ChartType {
    case stepBar(average: Int)
    case stepWeekdayPie
    case weightLine(average: Double)
    case weightDiffBar
}

이렇게 우리가 사용한 차트를 분류해준다.

그리고 ChartContainerConfiguration은 삭제해주자.

ChartContainer에 config에 사용했던 변수들을

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
var isNav: Bool {
    switch chartType {
    case .stepBar(_), .weightLine(_):
        return true
    case .stepWeekdayPie, .weightDiffBar:
        return false
    }
}

var context: HealthMetricContext {
    switch chartType {
    case .stepBar(_), .stepWeekdayPie:
            .steps
    case .weightLine(_), .weightDiffBar:
            .weight
    }
}

var title: String {
    switch chartType {
    case .stepBar(_):
        "Steps"
    case .stepWeekdayPie:
        "Averages"
    case .weightLine(_):
        "Weight"
    case .weightDiffBar:
        "Average Weight Change"
    }
}

var symbol: String {
    switch chartType {
    case .stepBar(_):
        "figure.walk"
    case .stepWeekdayPie:
        "calendar"
    case .weightLine(_), .weightDiffBar:
        "figure"
    }
}

var subtitle: String {
    switch chartType {
    case .stepBar(let average):
        "Avg: \(average.formatted()) Steps"
    case .stepWeekdayPie:
        "Last 28 Days"
    case .weightLine(let average):
        "Avg: \(average.formatted(.number.precision(.fractionLength(1)))) lbs"
    case .weightDiffBar:
        "Per Weekday (Last 28 Days)"
    }
}

이렇게 각 케이스별로 분류를 해준다.

각 ChartView로가서 config를 전부 지워주고

1
2
3
4
5
6
7
8
9
10
11
// before
let config = ChartContainerConfiguration(title: "Weight",
                                                 symbol: "figure",
                                                 subtitle: subtitle,
                                                 context: .weight,
                                                 isNav: true)
        
        ChartContainer(config: config)

// after
ChartContainer(chartType: .weightLine(average: averageWeight))

기존에는 각 차트별로 config를 만들어서 그것에 맞게 적용을 한 방식이라면,

이제는 ChartContainer에 미리 각 타입에 맞게 config를 적용하고 그걸 호출하는 입장에선 내가 어떤 타입이다라고만 명시를 함으로써 그 config 값을 가져오는 방식으로 변경이 되었다.

실행을 해보니 기능상에 문제는 없다.


Github: Step-Tracker Repository

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