[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
---
|
||||
id: ios-charts-animation
|
||||
title: iOS Charts (Swift Charts) — chart + animation
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [ios, charts, swiftui, vibe-coding]
|
||||
tech_stack: { language: "Swift", applicable_to: ["iOS"] }
|
||||
applied_in: []
|
||||
aliases: [Swift Charts, BarMark, LineMark, AreaMark, chartXScale, chartGesture, animated chart]
|
||||
---
|
||||
|
||||
# Swift Charts
|
||||
|
||||
> iOS 16+ native chart. **BarMark / LineMark / AreaMark / PointMark**. Declarative + animatable.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- iOS 16+ (SwiftUI).
|
||||
- Mark = data point.
|
||||
- Scale = axis range.
|
||||
- Composable + animatable.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Bar chart
|
||||
```swift
|
||||
import Charts
|
||||
|
||||
struct Sale: Identifiable {
|
||||
let id = UUID()
|
||||
let day: String
|
||||
let amount: Double
|
||||
}
|
||||
|
||||
let data: [Sale] = [
|
||||
Sale(day: "Mon", amount: 100),
|
||||
Sale(day: "Tue", amount: 150),
|
||||
Sale(day: "Wed", amount: 80),
|
||||
]
|
||||
|
||||
Chart(data) { sale in
|
||||
BarMark(
|
||||
x: .value("Day", sale.day),
|
||||
y: .value("Amount", sale.amount)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Line chart
|
||||
```swift
|
||||
Chart(data) { sale in
|
||||
LineMark(
|
||||
x: .value("Day", sale.day),
|
||||
y: .value("Amount", sale.amount)
|
||||
)
|
||||
.interpolationMethod(.catmullRom) // smooth curve
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-series
|
||||
```swift
|
||||
Chart {
|
||||
ForEach(salesA) { sale in
|
||||
LineMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
.foregroundStyle(by: .value("Series", "A"))
|
||||
}
|
||||
ForEach(salesB) { sale in
|
||||
LineMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
.foregroundStyle(by: .value("Series", "B"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combined chart
|
||||
```swift
|
||||
Chart(data) { sale in
|
||||
BarMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
.opacity(0.5)
|
||||
|
||||
LineMark(x: .value("Day", sale.day), y: .value("Trend", sale.trend))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
```
|
||||
|
||||
→ Bar + line overlay.
|
||||
|
||||
### Area chart
|
||||
```swift
|
||||
Chart(data) { sale in
|
||||
AreaMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
.foregroundStyle(LinearGradient(
|
||||
gradient: Gradient(colors: [.blue.opacity(0.5), .clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
### Point + rule
|
||||
```swift
|
||||
Chart(data) { sale in
|
||||
PointMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
|
||||
if let max = data.max(by: { $0.amount < $1.amount }) {
|
||||
RuleMark(y: .value("Max", max.amount))
|
||||
.foregroundStyle(.red)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5]))
|
||||
.annotation(position: .top) {
|
||||
Text("Max: \(max.amount, specifier: "%.0f")")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scale (axis)
|
||||
```swift
|
||||
.chartXScale(domain: 0...100)
|
||||
.chartYScale(domain: 0...200, type: .log)
|
||||
```
|
||||
|
||||
### Axis customization
|
||||
```swift
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day)) { value in
|
||||
AxisGridLine()
|
||||
AxisTick()
|
||||
AxisValueLabel(format: .dateTime.day().month())
|
||||
}
|
||||
}
|
||||
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel { Text("$\(value.as(Int.self) ?? 0)") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animation
|
||||
```swift
|
||||
@State var data: [Sale] = []
|
||||
@State var animate = false
|
||||
|
||||
Chart(data) { sale in
|
||||
BarMark(x: .value("Day", sale.day), y: .value("Amount", animate ? sale.amount : 0))
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Initial animate.
|
||||
|
||||
### Update animation (자동)
|
||||
```swift
|
||||
@State var data: [Sale] = initial
|
||||
|
||||
Chart(data) { sale in
|
||||
BarMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
}
|
||||
.animation(.spring(), value: data)
|
||||
|
||||
// 매 변경 시 smooth.
|
||||
Button("Refresh") {
|
||||
data = newData
|
||||
}
|
||||
```
|
||||
|
||||
→ State 변경 = 자동 animate.
|
||||
|
||||
### Interaction (tap / drag)
|
||||
```swift
|
||||
@State var selected: Sale?
|
||||
|
||||
Chart(data) { sale in
|
||||
BarMark(x: .value("Day", sale.day), y: .value("Amount", sale.amount))
|
||||
.foregroundStyle(selected?.id == sale.id ? .red : .blue)
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture()
|
||||
.onChanged { value in
|
||||
let x = value.location.x
|
||||
if let day: String = proxy.value(atX: x) {
|
||||
selected = data.first { $0.day == day }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let s = selected {
|
||||
Text("\(s.day): $\(s.amount)")
|
||||
}
|
||||
```
|
||||
|
||||
### chartGesture (iOS 17)
|
||||
```swift
|
||||
.chartGesture { proxy in
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
// proxy 가 chart space → data space.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selection (iOS 17+)
|
||||
```swift
|
||||
@State var selected: Date?
|
||||
|
||||
Chart(data) { sale in
|
||||
BarMark(x: .value("Date", sale.date), y: .value("Amount", sale.amount))
|
||||
}
|
||||
.chartXSelection(value: $selected)
|
||||
```
|
||||
|
||||
→ 자동 selection UI.
|
||||
|
||||
### HealthKit data
|
||||
```swift
|
||||
import HealthKit
|
||||
|
||||
let store = HKHealthStore()
|
||||
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
|
||||
|
||||
let query = HKStatisticsCollectionQuery(
|
||||
quantityType: stepType,
|
||||
quantitySamplePredicate: nil,
|
||||
options: .cumulativeSum,
|
||||
anchorDate: startDate,
|
||||
intervalComponents: DateComponents(day: 1)
|
||||
)
|
||||
|
||||
query.initialResultsHandler = { _, results, _ in
|
||||
// → Chart data.
|
||||
}
|
||||
```
|
||||
|
||||
→ [[iOS_Charts_Health]].
|
||||
|
||||
### Performance
|
||||
```
|
||||
- 1000+ point = lazy / sample.
|
||||
- Animation 가 큰 data = jank.
|
||||
- Chart 의 size 가 작으면 skip mark.
|
||||
|
||||
@State var sampledData = downsample(rawData, to: 100)
|
||||
```
|
||||
|
||||
### Format
|
||||
```swift
|
||||
.chartXAxisLabel("Day")
|
||||
.chartYAxisLabel("Sales ($)")
|
||||
|
||||
// Annotation
|
||||
.chartXAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
```
|
||||
|
||||
### Gradient
|
||||
```swift
|
||||
LineMark(...)
|
||||
.foregroundStyle(.linearGradient(
|
||||
colors: [.blue, .purple],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
))
|
||||
```
|
||||
|
||||
### Sector chart (pie, iOS 17)
|
||||
```swift
|
||||
Chart(data) { item in
|
||||
SectorMark(
|
||||
angle: .value("Amount", item.amount),
|
||||
innerRadius: .ratio(0.6),
|
||||
outerRadius: .ratio(1.0)
|
||||
)
|
||||
.foregroundStyle(by: .value("Type", item.type))
|
||||
}
|
||||
```
|
||||
|
||||
→ Donut / pie chart.
|
||||
|
||||
### Dynamic data (live update)
|
||||
```swift
|
||||
@State var data: [Sale] = []
|
||||
|
||||
Chart(data) { sale in
|
||||
LineMark(x: .value("Time", sale.time), y: .value("Value", sale.value))
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
data.append(Sale(time: Date(), value: random()))
|
||||
if data.count > 100 { data.removeFirst() }
|
||||
}
|
||||
```
|
||||
|
||||
→ Real-time chart.
|
||||
|
||||
### vs other charts
|
||||
```
|
||||
Swift Charts: native, iOS 16+, declarative.
|
||||
Charts (DGCharts): iOS 12+, mature, big.
|
||||
SwiftUICharts (open): simple.
|
||||
WordSwiftUICharts: niche.
|
||||
|
||||
→ iOS 16+ = Swift Charts default.
|
||||
```
|
||||
|
||||
### Limits
|
||||
```
|
||||
- iOS 16+ 만 (15 아래 사용 불가).
|
||||
- 매우 복잡 chart (financial OHLC) = 어려움.
|
||||
- WatchOS 가 작은 size 친화.
|
||||
|
||||
→ Complex = DGCharts / native draw.
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 작업 | Mark |
|
||||
|---|---|
|
||||
| Bar | BarMark |
|
||||
| Line / smooth | LineMark + interpolationMethod |
|
||||
| Area / gradient | AreaMark |
|
||||
| Scatter | PointMark |
|
||||
| Threshold | RuleMark + annotation |
|
||||
| Pie / donut | SectorMark (iOS 17) |
|
||||
| Combined | Bar + Line |
|
||||
| Real-time | onReceive + state |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **1000+ point 그대로**: jank.
|
||||
- **Animation 가 큰 list**: drop frame.
|
||||
- **iOS 15 + Swift Charts**: 안 됨.
|
||||
- **Custom UI 없이 mark 만**: 인터랙션 X.
|
||||
- **Format / scale 무시**: scale 깨짐.
|
||||
- **HealthKit 없이 mock**: real data X.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- iOS 16+ Swift Charts 가 default.
|
||||
- Mark + Scale + Animation 가 declarative.
|
||||
- chartOverlay 가 interaction.
|
||||
- HealthKit / async data 친화.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[iOS_Charts_Health]]
|
||||
- [[iOS_SwiftUI_Animation_Deep]]
|
||||
- [[Frontend_Animation_Motion]]
|
||||
Reference in New Issue
Block a user