[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
---
|
||||
id: ios-charts-health
|
||||
title: iOS Charts / HealthKit / Spatial
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [ios, charts, healthkit, spatial, vibe-coding]
|
||||
tech_stack: { language: "Swift", applicable_to: ["iOS"] }
|
||||
applied_in: []
|
||||
aliases: [Swift Charts, HealthKit, ScreenCaptureKit, spatial audio, AVFoundation, health data]
|
||||
---
|
||||
|
||||
# iOS Charts / HealthKit / Spatial
|
||||
|
||||
> iOS 16+ Swift Charts (built-in), HealthKit (sensor data), Spatial Audio (immersive), ScreenCaptureKit (screen recording).
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Swift Charts: declarative chart.
|
||||
- HealthKit: 사용자 health data (consent).
|
||||
- Spatial Audio: 3D positional sound.
|
||||
- ScreenCaptureKit: macOS / iOS 17+ screen capture.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Swift Charts
|
||||
```swift
|
||||
import Charts
|
||||
|
||||
struct SalesData: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let amount: Double
|
||||
}
|
||||
|
||||
struct SalesChart: View {
|
||||
let data: [SalesData]
|
||||
|
||||
var body: some View {
|
||||
Chart(data) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Amount", item.amount)
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Amount", item.amount)
|
||||
)
|
||||
.foregroundStyle(.blue.opacity(0.2))
|
||||
}
|
||||
.frame(height: 300)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bar / Pie / Scatter
|
||||
```swift
|
||||
Chart(data) {
|
||||
BarMark(x: .value("Cat", $0.category), y: .value("Sales", $0.sales))
|
||||
}
|
||||
|
||||
Chart(data) {
|
||||
SectorMark(angle: .value("Sales", $0.sales))
|
||||
}
|
||||
|
||||
Chart(data) {
|
||||
PointMark(x: .value("X", $0.x), y: .value("Y", $0.y))
|
||||
}
|
||||
```
|
||||
|
||||
### Mixed
|
||||
```swift
|
||||
Chart {
|
||||
ForEach(data) { d in
|
||||
BarMark(x: .value("Day", d.day), y: .value("Sales", d.sales))
|
||||
LineMark(x: .value("Day", d.day), y: .value("Trend", d.trend))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interaction (iOS 17+)
|
||||
```swift
|
||||
@State var selectedDate: Date?
|
||||
|
||||
Chart(data) { d in
|
||||
LineMark(x: .value("Date", d.date), y: .value("Sales", d.sales))
|
||||
}
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.overlay {
|
||||
if let date = selectedDate, let item = data.first(where: { $0.date == date }) {
|
||||
Tooltip(item: item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Customization
|
||||
```swift
|
||||
Chart(data) {
|
||||
LineMark(...)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading)
|
||||
}
|
||||
.chartLegend(position: .top, alignment: .leading)
|
||||
```
|
||||
|
||||
### HealthKit setup
|
||||
```swift
|
||||
import HealthKit
|
||||
|
||||
class HealthManager: ObservableObject {
|
||||
let store = HKHealthStore()
|
||||
|
||||
func request() async throws {
|
||||
let read: Set<HKObjectType> = [
|
||||
HKQuantityType(.stepCount),
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
try await store.requestAuthorization(toShare: [], read: read)
|
||||
}
|
||||
|
||||
func steps(for date: Date) async throws -> Double {
|
||||
let type = HKQuantityType(.stepCount)
|
||||
let predicate = HKQuery.predicateForSamples(withStart: date.startOfDay, end: date.endOfDay)
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in
|
||||
if let error { cont.resume(throwing: error); return }
|
||||
let steps = result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
|
||||
cont.resume(returning: steps)
|
||||
}
|
||||
self.store.execute(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- Info.plist -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>We use your step count to show daily activity</string>
|
||||
```
|
||||
|
||||
### Live workout (HealthKit)
|
||||
```swift
|
||||
let session = try HKWorkoutSession(
|
||||
healthStore: store,
|
||||
configuration: HKWorkoutConfiguration().apply {
|
||||
$0.activityType = .running
|
||||
$0.locationType = .outdoor
|
||||
}
|
||||
)
|
||||
let builder = session.associatedWorkoutBuilder()
|
||||
|
||||
session.startActivity(with: Date())
|
||||
|
||||
// Live data
|
||||
session.delegate = self
|
||||
// → didChangeTo, didCollectDataOf
|
||||
|
||||
// End
|
||||
session.end()
|
||||
let workout = try await builder.finishWorkout()
|
||||
```
|
||||
|
||||
### Watch + iPhone sync
|
||||
```swift
|
||||
// Workout 가 watch 에서 시작, iPhone 에서 view.
|
||||
// HKWorkoutSession 가 자동 sync.
|
||||
```
|
||||
|
||||
### Spatial Audio (AVFoundation)
|
||||
```swift
|
||||
import AVFoundation
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
let env = AVAudioEnvironmentNode()
|
||||
|
||||
env.position = AVAudio3DPoint(x: 0, y: 0, z: 0) // listener
|
||||
|
||||
engine.attach(player)
|
||||
engine.attach(env)
|
||||
|
||||
engine.connect(player, to: env, format: nil)
|
||||
engine.connect(env, to: engine.outputNode, format: env.outputFormat(forBus: 0))
|
||||
|
||||
// Source position
|
||||
player.position = AVAudio3DPoint(x: 5, y: 0, z: -10) // 5m right, 10m forward
|
||||
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
player.scheduleFile(file, at: nil)
|
||||
|
||||
try engine.start()
|
||||
player.play()
|
||||
```
|
||||
|
||||
### Music Spatial Audio
|
||||
```swift
|
||||
let asset = AVAsset(url: musicURL)
|
||||
|
||||
// Check spatial
|
||||
let mix = AVAudioMix()
|
||||
let params = AVMutableAudioMixInputParameters()
|
||||
params.audioTimePitchAlgorithm = .spectral
|
||||
|
||||
// Apple Music API 가 spatial track 표시.
|
||||
```
|
||||
|
||||
### ScreenCaptureKit (iOS 17+ / macOS)
|
||||
```swift
|
||||
import ScreenCaptureKit
|
||||
|
||||
func startScreenRecording() async throws {
|
||||
let content = try await SCShareableContent.current
|
||||
guard let display = content.displays.first else { return }
|
||||
|
||||
let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
|
||||
|
||||
let config = SCStreamConfiguration()
|
||||
config.width = display.width * 2
|
||||
config.height = display.height * 2
|
||||
config.minimumFrameInterval = CMTime(value: 1, timescale: 60)
|
||||
|
||||
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
||||
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .main)
|
||||
try await stream.startCapture()
|
||||
}
|
||||
|
||||
// Output
|
||||
extension ViewController: SCStreamOutput {
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
||||
// Frame buffer — file 또는 stream
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ macOS 가 가장 일반. iOS 17+ 도 지원.
|
||||
|
||||
### App Intents (Charts + HealthKit)
|
||||
```swift
|
||||
struct DailyStepsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "View Daily Steps"
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let steps = try await HealthManager.shared.steps(for: Date())
|
||||
return .result(dialog: "You walked \(steps) steps today.")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Siri / Shortcut 가 chart data 호출.
|
||||
|
||||
### Widget + Chart
|
||||
```swift
|
||||
struct StepsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "steps", provider: StepsProvider()) { entry in
|
||||
VStack {
|
||||
Text("Today")
|
||||
Chart(entry.data) {
|
||||
BarMark(x: .value("Hour", $0.hour), y: .value("Steps", $0.steps))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Live Activity + Chart
|
||||
```swift
|
||||
struct WorkoutLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: WorkoutAttributes.self) { context in
|
||||
HStack {
|
||||
Text("\(context.state.heartRate) bpm")
|
||||
Chart(context.state.recentBpm) {
|
||||
LineMark(x: .value("Time", $0.time), y: .value("BPM", $0.bpm))
|
||||
}
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vision OS Charts
|
||||
```swift
|
||||
// SwiftUI Charts 자동 호환 visionOS.
|
||||
// 3D depth 추가 가능 (.padding3D, depth modifier).
|
||||
```
|
||||
|
||||
### Health Connect (Android 비교)
|
||||
```
|
||||
Android:
|
||||
- Health Connect (Google) — 사용자 health data
|
||||
- Background read / write
|
||||
- 비슷 idea, 다른 API
|
||||
|
||||
iOS HealthKit + Android Health Connect = cross-platform health app.
|
||||
```
|
||||
|
||||
### CoreMotion (sensor)
|
||||
```swift
|
||||
import CoreMotion
|
||||
|
||||
let manager = CMMotionManager()
|
||||
manager.deviceMotionUpdateInterval = 1.0 / 60 // 60 Hz
|
||||
|
||||
manager.startDeviceMotionUpdates(to: .main) { motion, error in
|
||||
guard let m = motion else { return }
|
||||
let heading = m.attitude.yaw
|
||||
let pitch = m.attitude.pitch
|
||||
let roll = m.attitude.roll
|
||||
}
|
||||
```
|
||||
|
||||
→ AR / motion-aware app.
|
||||
|
||||
### Privacy / consent (HealthKit)
|
||||
```
|
||||
1. Info.plist usage description.
|
||||
2. Request 시 사용자 dialog.
|
||||
3. 거부 가능 — graceful handle.
|
||||
4. Background read = 추가 권한.
|
||||
5. Apple Watch 가 별도.
|
||||
```
|
||||
|
||||
### Data export
|
||||
```swift
|
||||
let predicate = HKQuery.predicateForSamples(...)
|
||||
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 0, sortDescriptors: nil) { _, samples, _ in
|
||||
// CSV / JSON export
|
||||
}
|
||||
```
|
||||
|
||||
→ User-controlled data export.
|
||||
|
||||
### Anchor query (incremental)
|
||||
```swift
|
||||
let query = HKAnchoredObjectQuery(type: type, predicate: nil, anchor: lastAnchor, limit: HKObjectQueryNoLimit) { _, samples, _, anchor, _ in
|
||||
// 새 sample 만
|
||||
self.lastAnchor = anchor
|
||||
}
|
||||
query.updateHandler = { _, samples, _, anchor, _ in
|
||||
// Real-time updates
|
||||
}
|
||||
store.execute(query)
|
||||
```
|
||||
|
||||
→ Live updates.
|
||||
|
||||
### Apple Music API
|
||||
```swift
|
||||
import MusicKit
|
||||
|
||||
let request = MusicCatalogSearchRequest(term: query, types: [Song.self])
|
||||
let response = try await request.response()
|
||||
for song in response.songs {
|
||||
print(song.title, song.artistName)
|
||||
}
|
||||
```
|
||||
|
||||
→ Music app 통합.
|
||||
|
||||
### Live Activities + Sensor
|
||||
```swift
|
||||
// 매 5초 업데이트
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
Task {
|
||||
let heartRate = try await HealthManager.shared.currentHeartRate()
|
||||
let state = WorkoutAttributes.ContentState(heartRate: heartRate)
|
||||
await activity.update(.init(state: state, staleDate: nil))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 작업 | 추천 |
|
||||
|---|---|
|
||||
| Chart | Swift Charts |
|
||||
| Health data | HealthKit |
|
||||
| Workout | HKWorkoutSession |
|
||||
| Screen record | ScreenCaptureKit |
|
||||
| Spatial audio | AVAudioEngine env |
|
||||
| Sensor | CoreMotion |
|
||||
| Music | MusicKit |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **HealthKit 직접 raw save**: validate + transform.
|
||||
- **Background fetch 빈번**: battery. throttle.
|
||||
- **Privacy description 빈약**: deny 자주.
|
||||
- **Chart 실시간 큰 dataset**: throttle / aggregate.
|
||||
- **Spatial 단일 source 만**: 의미 X. 환경 + 다중 source.
|
||||
- **App store review 시 fake data**: reject.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Swift Charts = built-in. Type-safe.
|
||||
- HealthKit + watch + widget 통합.
|
||||
- ScreenCaptureKit = modern (iOS 17+).
|
||||
- Spatial audio = environment + position.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[iOS_App_Intents_Shortcuts]]
|
||||
- [[iOS_watchOS_Patterns]]
|
||||
- [[iOS_Live_Activities]]
|
||||
Reference in New Issue
Block a user