SwiftUI Charts: 네이티브 데이터 시각화 완전 가이드

iOS

SwiftUI Charts데이터 시각화iOSSwiftApple Watch

이 글은 누구를 위한 것인가

  • 외부 차트 라이브러리 없이 네이티브 차트를 구현하려는 팀
  • SwiftUI Charts로 건강, 피트니스, 금융 데이터를 시각화하려는 개발자
  • 인터랙티브 차트와 애니메이션을 적용하려는 팀

들어가며

iOS 16에서 Swift Charts가 등장해 서드파티 라이브러리 없이 네이티브 차트를 만들 수 있다. 선언적 문법으로 라인, 바, 파이, 에어리어 차트를 구현하고, 다크모드와 다이나믹 타입을 자동 지원한다.

이 글은 bluefoxdev.kr의 SwiftUI Charts 데이터 시각화 가이드 를 참고하여 작성했습니다.


1. Swift Charts 핵심 개념

[Mark 유형]
  LineMark: 라인 차트
  BarMark: 바 차트 (수직/수평)
  PointMark: 산점도
  AreaMark: 에어리어 차트
  RectangleMark: 히트맵
  RuleMark: 기준선/임계값

[수식어]
  .foregroundStyle(by: .value("분류", 카테고리)): 색상 분류
  .symbol(by: .value("타입", 타입)): 심볼 분류
  .interpolationMethod(.catmullRom): 곡선 보간
  .annotation(position: .top): 값 레이블

[축 커스터마이징]
  AxisMarks: 눈금, 레이블, 그리드
  AxisValueLabel: 레이블 포맷
  AxisGridLine: 그리드 스타일

[성능]
  데이터 1000개 이상: LazyVStack으로 분할
  .chartXScale(), .chartYScale(): 스케일 고정
  애니메이션: .animation(.easeInOut, value: data)

2. Swift Charts 구현

import Charts
import SwiftUI

struct SalesData: Identifiable {
    let id = UUID()
    let month: String
    let sales: Double
    let category: String
}

// 멀티 라인 차트
struct MultiLineChart: View {
    let data: [SalesData]
    @State private var selectedMonth: String?
    
    var body: some View {
        Chart {
            ForEach(data) { item in
                LineMark(
                    x: .value("월", item.month),
                    y: .value("매출", item.sales)
                )
                .foregroundStyle(by: .value("카테고리", item.category))
                .interpolationMethod(.catmullRom)
                .symbol(by: .value("카테고리", item.category))
                
                // 선택된 월 표시
                if item.month == selectedMonth {
                    PointMark(
                        x: .value("월", item.month),
                        y: .value("매출", item.sales)
                    )
                    .symbolSize(100)
                    .annotation(position: .top) {
                        Text("₩\(Int(item.sales / 10000))만")
                            .font(.caption2).fontWeight(.bold)
                            .padding(4).background(.white).cornerRadius(4)
                    }
                }
            }
            
            // 평균 기준선
            if let avg = data.map(\.sales).reduce(0, +) / Double(max(data.count, 1)) as Double? {
                RuleMark(y: .value("평균", avg))
                    .foregroundStyle(.gray.opacity(0.5))
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [5]))
                    .annotation(position: .trailing) {
                        Text("평균").font(.caption2).foregroundColor(.gray)
                    }
            }
        }
        .chartYAxis {
            AxisMarks(format: .currency(code: "KRW").presentation(.narrow))
        }
        .chartXAxis {
            AxisMarks(values: .automatic) { value in
                AxisValueLabel()
                AxisGridLine()
            }
        }
        .chartOverlay { proxy in
            GeometryReader { geo in
                Rectangle().fill(.clear).contentShape(Rectangle())
                    .gesture(DragGesture(minimumDistance: 0)
                        .onChanged { value in
                            let x = value.location.x - geo[proxy.plotAreaFrame].origin.x
                            if let month: String = proxy.value(atX: x) {
                                selectedMonth = month
                            }
                        }
                        .onEnded { _ in selectedMonth = nil }
                    )
            }
        }
        .frame(height: 250)
        .padding()
    }
}

// 스택 바 차트
struct StackedBarChart: View {
    let data: [SalesData]
    
    var body: some View {
        Chart(data) { item in
            BarMark(
                x: .value("월", item.month),
                y: .value("매출", item.sales)
            )
            .foregroundStyle(by: .value("카테고리", item.category))
            .cornerRadius(4)
        }
        .chartForegroundStyleScale([
            "전자기기": Color.blue,
            "의류": Color.green,
            "식품": Color.orange
        ])
        .frame(height: 200)
    }
}

// 애니메이션 차트
struct AnimatedChart: View {
    @State private var animate = false
    let data: [SalesData]
    
    var body: some View {
        Chart(data) { item in
            BarMark(
                x: .value("월", item.month),
                y: .value("매출", animate ? item.sales : 0)
            )
        }
        .animation(.spring(duration: 0.8).delay(0.1), value: animate)
        .onAppear { animate = true }
    }
}

// Apple Watch 차트 (watchOS 9+)
struct WatchHeartRateChart: View {
    let heartRates: [(date: Date, bpm: Double)]
    
    var body: some View {
        Chart {
            ForEach(heartRates.indices, id: \.self) { i in
                LineMark(
                    x: .value("시간", heartRates[i].date),
                    y: .value("심박수", heartRates[i].bpm)
                )
                .foregroundStyle(.red)
            }
        }
        .chartYScale(domain: 40...200)
        .frame(height: 80)
    }
}

마무리

Swift Charts의 핵심은 선언적 구성이다. ChartMark.foregroundStyle(by:) 조합으로 복잡한 다중 시리즈 차트를 간결하게 표현한다. chartOverlay로 드래그 제스처를 추가하면 인터랙티브 선택이 가능하고, .animation(.spring, value: data)로 데이터 변경 시 자연스러운 전환을 구현한다.