| name | ios-charts-and-data-visualization |
| description | iOS Swift Charts applied — picking the right mark for the intent (trend→line/area, comparison→bar, composition→stacked/sector, single value→Gauge/ring), the Chart + Mark / axis / scale / interactivity APIs, the zero-baseline honesty rule, charts-that-animate-on-data-change, the SwiftUI Gauge and custom-arc ring, and free + hand-authored chart accessibility (auto AX tree, AXChartDescriptor, Audio Graphs). Use when adding, choosing, or reviewing any chart or data visualization on a native iOS screen. |
| tags | ["ios","charts","swift-charts","data-visualization","swiftui","accessibility"] |
iOS charts & data visualization — Swift Charts, marks, and honest, accessible graphics
Source: Apple HIG Charting data + developer.apple.com Swift Charts framework reference (Chart, the Mark types, axis/scale APIs, scrolling + selection introduced WWDC24, 3D charts introduced WWDC25 / iOS 26) + SwiftUI Gauge + Swift Charts accessibility (AXChartDescriptor / Audio Graphs, WWDC21 Bring accessibility to charts); cross-checked across WWDC22/23/24/25 Swift Charts sessions and ≥2 sources per API. Currency (June 2026): iOS 26 is the shipping baseline; iOS 27 (WWDC 2026) is the current cycle. WWDC 2026 shipped no Swift Charts headline — the What's New in SwiftUI session does not mention the framework — so everything below holds unchanged through iOS 27; the only Swift-Charts-adjacent WWDC26 change is Liquid Glass v2 styling, which applies to chart container surfaces (the card/material a chart sits in), not to the marks themselves (see ios-liquid-glass-swiftui). Apple's stance: build charts from marks, scales, and axes; let the framework own the accessibility tree; keep the encoding honest. (Apple's own doc pages are JS-rendered and don't fetch to plain text — API names below are cross-checked, not pasted from a live page; confirm exact signatures on-device / in Xcode Quick Help before locking production code.)
1. Pick the mark for the intent (this is the whole game)
Choose by the question the data answers, not by what looks rich. Match the encoding to the message:
| Intent / question | Chart | Swift Charts mark |
|---|
| Trend over time ("is weight going down?") | Line, or area for one emphasized series | LineMark, AreaMark |
| Comparison across categories ("which day most calories?") | Bar (vertical) / horizontal bar | BarMark |
| Part-to-whole, few slices ("macro split today") | Stacked bar, or donut/sector | stacked BarMark, SectorMark |
| Composition over time | Stacked area / stacked bar | AreaMark(stacked), BarMark(stacked) |
| Correlation / distribution of points | Scatter | PointMark |
| Single value vs a goal ("calories left") | Gauge / ring / progress arc | SwiftUI Gauge (§7) |
| Threshold / target / average overlay | Reference line/band on any chart | RuleMark, RectangleMark |
Rules of thumb: time on X, value on Y. Bars for magnitude comparison; lines for rate/trend. Pie/donut only for 2–4 slices that sum to a meaningful whole — beyond ~5, a bar beats it every time. One primary message per chart.
2. Swift Charts essentials
A chart is Chart { } wrapping one or more marks, each fed PlottableValues via .value(_:_:):
import Charts
struct WeightPoint: Identifiable { let id = UUID(); let day: Date; let kg: Double }
Chart(points) { p in
LineMark(x: .value("Day", p.day), y: .value("Weight", p.kg))
.interpolationMethod(.catmullRom)
PointMark(x: .value("Day", p.day), y: .value("Weight", p.kg))
}
The seven marks: BarMark · LineMark · PointMark · AreaMark · RuleMark (full-width/height reference line) · RectangleMark (region/heat cell) · SectorMark (pie/donut, iOS 17+).
3D charts (iOS 26+, shipping baseline — WWDC25). A separate Chart3D { } container plots PointMark/RuleMark/RectangleMark with a z: axis plus SurfacePlot for mathematical surfaces; pose/projection via .chart3DPose(_:) and .chart3DCameraProjection(_:), and .foregroundStyle(.heightBased)/.normalBased surface coloring. It is a niche tool — use it only for genuinely 3-variable data (and mostly on visionOS, where it's gesture-rotatable); for the nutrition/trend charts in §10 a 2D mark is clearer and more honest. Verify-at-source flag: confirm Chart3D, SurfacePlot, and the chart3D* modifier signatures in Xcode Quick Help before use — these were cross-checked, not pasted from a live Apple page, and remain unchanged at iOS 27.
Multiple series & encoding by data, not by hand:
Chart(samples) { s in
LineMark(x: .value("Day", s.day), y: .value("Grams", s.grams))
.foregroundStyle(by: .value("Macro", s.macro))
.symbol(by: .value("Macro", s.macro))
}
.chartForegroundStyleScale([
"Protein": Color.macroProtein, "Carbs": Color.macroCarbs, "Fat": Color.macroFat
])
SectorMark for a donut: SectorMark(angle: .value("Grams", $0.grams), innerRadius: .ratio(0.6), angularInset: 1.5).foregroundStyle(by:). Use .position(by:) to group/dodge bars; .annotation(position:) to attach a label/SwiftUI view to a mark.
3. Axes & scales
.chartXAxis { AxisMarks(values: .stride(by: .day, count: 7)) { v in
AxisGridLine(); AxisTick()
AxisValueLabel(format: .dateTime.month().day())
}}
.chartYAxis { AxisMarks(position: .leading) }
.chartYScale(domain: 0...maxCalories)
.chartXScale(domain: firstDay...lastDay)
.chartLegend(position: .bottom, alignment: .leading)
Use AxisMarks(preset: .extended), .foregroundStyle, and AxisValueLabel(format:) (a FormatStyle — currency, .number, .dateTime) for clean, localized tick labels. Hide a noisy axis with .chartXAxis(.hidden). Honesty rule (non-negotiable): for BarMark, the Y domain MUST include zero — bar length encodes magnitude, so a clipped baseline lies about ratios. For LineMark showing a trend, a non-zero, zoomed domain is legitimate (it shows change, not magnitude) — but label it and never mix the two intents in one chart (§6).
4. Interactivity (WWDC24)
@State private var rawSelectedDate: Date?
Chart(points) { p in
BarMark(x: .value("Day", p.day, unit: .day), y: .value("Calories", p.kcal))
if let d = rawSelectedDate, let hit = points.first(where: { sameDay($0.day, d) }) {
RuleMark(x: .value("Selected", hit.day))
.foregroundStyle(.secondary)
.annotation(position: .top, overflowResolution: .init(x: .fit(to: .chart))) {
CalloutView(hit)
}
}
}
.chartXSelection(value: $rawSelectedDate)
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 3600 * 24 * 14)
.chartScrollPosition(x: $scrollX)
.chartScrollTargetBehavior(.valueAligned(matching: .init(hour: 0)))
WWDC24 also added vectorized plotting (LinePlot/AreaPlot over a whole collection) and function plotting for large/continuous data — reach for it only when per-element marks get slow (>~1k points).
5. Animation
Swift Charts animates mark transitions automatically when the underlying data changes — drive it by mutating the @State data inside withAnimation, not by animating frames:
withAnimation(.easeInOut) { points = newWeekData }
Respect Reduce Motion: gate non-essential chart animation on @Environment(\.accessibilityReduceMotion). Don't animate a scale/axis change and the data at once — the reader can't tell which moved.
6. Mistakes that read as cheap / amateur (and dishonest)
- Truncated / non-zero bar baseline — exaggerates differences; the classic misleading chart. Bars ⇒ zero baseline, always (§3).
- Misleading scale: inconsistent Y across small-multiples, dual Y-axes implying false correlation, log scale unlabeled, reversed axis.
- Pie chart with 7+ slices or slices that don't sum to a whole — unreadable; use a bar.
- Chartjunk: 3-D bars, drop shadows on data, gradients-as-decoration, gridlines louder than the data (violates Apple's "let data lead").
- No baseline/target context — a number with no
RuleMark goal or axis range is just a squiggle.
- Color as the only differentiator (fails ~8% of men) — pair
.foregroundStyle(by:) with .symbol(by:) or direct labels (§8).
- Unlabeled units / non-localized numbers — always
AxisValueLabel(format:); never hard-code "1,840".
- Hard-coded chart colors instead of semantic/asset colors — breaks dark mode and the design system.
- Skipping accessibility — a chart with no descriptor is invisible to VoiceOver (§9).
7. Single value: Gauge & custom ring
For "X left / Y goal", the SwiftUI Gauge is the HIG-native primitive:
Gauge(value: consumed, in: 0...goal) {
Text("Calories")
} currentValueLabel: {
Text("\(Int(goal - consumed))").monospacedDigit()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(.accentColor)
Styles: .accessoryCircular, .accessoryCircularCapacity (the ring), .accessoryLinear, .accessoryLinearCapacity, .linearCapacity. When the brand needs a thicker/branded ring than Gauge allows, hand-roll with Trim (or Canvas):
ZStack {
Circle().stroke(.quaternary, lineWidth: 14)
Circle()
.trim(from: 0, to: min(consumed / goal, 1))
.stroke(Color.accentColor, style: .init(lineWidth: 14, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut, value: consumed)
VStack { Text("\(Int(goal - consumed))").font(.system(.largeTitle, design: .rounded).weight(.bold)).monospacedDigit()
Text("left").font(.caption).foregroundStyle(.secondary) }
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Calories")
.accessibilityValue("\(Int(consumed)) of \(Int(goal)) eaten, \(Int(goal - consumed)) left")
8. Color & legend design
- Semantic, not literal. Drive
.chartForegroundStyleScale from asset/semantic colors so charts track light/dark and the design system (see ios-color-and-materials).
- Sequential vs categorical: a single trend = one accent + opacity ramp (
AreaMark .opacity); categories = a small qualitative set with redundant shape via .symbol(by:).
- Legend earns its keep: inline/direct labels (an
.annotation at a line's end) beat a detached legend; if you keep one, .chartLegend(position:) it close. .chartLegend(.hidden) once marks are labeled.
- Dynamic Type in chart labels: axis/annotation
Text uses Dynamic Type — at large sizes, thin or rotate AxisMarks(values:), don't truncate. Numbers .monospacedDigit() so columns don't jitter (see ios-typography).
9. Accessibility (free baseline → hand-authored)
Free: Swift Charts auto-builds an accessibility tree — VoiceOver navigates marks and offers an Audio Graph (pitch ↑ with value; Bring accessibility to charts, WWDC21). Don't break it by wrapping the chart in .accessibilityElement(children: .ignore).
Improve per-mark so spoken output is meaningful:
BarMark(x: .value("Day", p.label), y: .value("Calories", p.kcal))
.accessibilityLabel(p.label)
.accessibilityValue("\(Int(p.kcal)) calories")
Author a full descriptor for a real Audio Graph (axes + series) via .accessibilityChartDescriptor(_:) and a type conforming to AXChartDescriptorRepresentable, building an AXChartDescriptor from AXNumericDataAxisDescriptor / AXCategoricalDataAxisDescriptor + AXDataSeriesDescriptor. Always: redundant color+shape (§8), Dynamic Type labels (§8), and a one-line text summary near the chart for cognitive accessibility (see ios-accessibility).
10. Greg application
Greg = native SwiftUI iOS 26 nutrition app; cobalt #2C50F0 primary, macro trio protein = pink / carbs = yellow / fat = mint (drive these from asset colors, never literals). The Progress screen (per Greg's docs, daily-metric charts live on Progress, not Today):
- 30-day weight trend —
LineMark (.catmullRom) + faint AreaMark fill in cobalt; .chartYScale zoomed (trend, not magnitude → non-zero baseline OK, labeled "kg"); a RuleMark at goal weight; .chartXSelection + RuleMark lollipop tooltip; .chartScrollableAxes(.horizontal) with .chartXVisibleDomain(length:) ≈ 14 days.
- Weekly digest —
BarMark, one bar per day, Y domain from 0 (bars = magnitude); selected bar highlighted; calorie target as a dashed RuleMark.
- "Calories left" ring —
Gauge(value: consumed, in: 0...goal).gaugeStyle(.accessoryCircularCapacity).tint(cobalt), or the §7 custom Trim arc for a thicker branded ring; center value .rounded.monospacedDigit(), label "left".
- Macro composition — stacked
BarMark (or a SectorMark donut) with .foregroundStyle(by:) pinned to the pink/yellow/mint scale; .symbol(by:)/direct labels for color-blind safety.
- Accessibility on every chart: keep the auto AX tree; add
.accessibilityLabel/.accessibilityValue per mark and an .accessibilityChartDescriptor on the weight trend so VoiceOver users get an Audio Graph (e.g. value: "\(Int(kg)) kilograms"). The custom ring is not auto-accessible — give it an explicit label/value (§7). Per Greg's product rule, charts only display deterministic values — they never set targets.
Pairs with: ios-color-and-materials (semantic chart colors, dark mode), ios-typography (axis/value labels, monospaced digits), ios-accessibility (VoiceOver, Audio Graphs, Dynamic Type), data-visualization (encoding choice & honesty, platform-agnostic).