Swift Charts
Declarative marks over your data — bars to donuts, axes and selection, and the scale math that decides whether a price chart tells the truth.
By the end you will be able to
- Build bar, line, area and donut charts from marks and PlottableValue
- Split series with foregroundStyle(by:) and customise axes with AxisMarks
- Add selection scrubbing and scrollable domains, with the right OS gates
- Own the scale — domain to range — well enough to hand-roll a sparkline
A sparkline shows a stock that traded between 102.1 and 103.9 all day. The implementation maps each price to a bar height as price / maxPrice. What does the user see?
A chart is marks over data
Swift Charts (import Charts, iOS 16+) treats a chart the way SwiftUI treats a screen: not "call the bar-chart function", but declare content — marks — inside a Chart, one per data point:
import Charts
struct SpendingChart: View {
let categories: [CategorySpend] // ("Groceries", 342.50), ("Transport", 118.20), ...
var body: some View {
Chart(categories) { category in
BarMark(
x: .value("Category", category.name),
y: .value("Spent", category.total)
)
}
}
}
Each axis argument is a PlottableValue — .value(label, value) — and the label is not decoration: it becomes the axis title, the legend entry, and the VoiceOver description (the accessibility lesson's audit, honoured by default — charts are read out data point by data point).
The mark vocabulary: BarMark, LineMark, PointMark, AreaMark, RuleMark (a horizontal or vertical reference line), RectangleMark, and — iOS 17+ — SectorMark for pies and donuts. Because marks are content, they compose; a real price chart is three of them layered in one Chart:
Chart {
ForEach(prices) { point in
LineMark(x: .value("Day", point.date), y: .value("Close", point.close))
.interpolationMethod(.catmullRom) // smooth between points
AreaMark(x: .value("Day", point.date), y: .value("Close", point.close))
.opacity(0.12) // the tinted fill under the line
}
RuleMark(y: .value("Cost basis", holding.costBasis))
.foregroundStyle(.secondary)
.lineStyle(StrokeStyle(dash: [4])) // the dashed "you bought here" line
}
That's the chart every brokerage app ships, in fifteen lines — and the reason "how would you build the portfolio chart" stopped being a two-week estimate.
Series — multiple lines that need a legend — are one modifier, not a loop of configurations. Style by a data dimension and Charts splits, colours and labels each series:
Chart(balances) { point in
LineMark(x: .value("Month", point.month), y: .value("Balance", point.amount))
.foregroundStyle(by: .value("Account", point.accountName)) // one line per account
}
.chartForegroundStyleScale([ // brand colours, if the defaults won't do
"Current": Color.blue, "Savings": Color.green,
])The scale is the chart
Every mark goes through the same pipeline: a domain (the span of your data) maps to a range (the pixels available). Get the domain wrong and the pretest's flatline happens — watch it, in a chart rendered out of characters:
The first block renders seven near-identical bars — every close is ~99% of the way to 103.9 when measured from zero. The second stretches min...max across the full width and the week's shape appears (the empty line is 102.1 itself: the domain's floor maps to zero length — real charts pad the domain a little so the minimum still draws).
Swift Charts computes a padded, "nice" domain automatically, and you override it when the default is wrong for the meaning:
.chartYScale(domain: 100...110) // explicit domain
.chartXAxis(.hidden) // sparklines: no chrome at all
.chartYAxis(.hidden)
And this is where chart honesty enters, because the right domain depends on the mark. A line encodes value as position — a min...max domain is correct and truthful. A bar encodes value as length from zero — truncate the domain and a £380 bar towers four times taller than a £320 bar. In a fintech app that's not a style choice; a spending chart that manufactures drama is the kind of thing that ends up in a regulator's screenshot.
Design review: the growth team wants the monthly-spending bar chart to "show more movement" and proposes .chartYScale(domain: 300...400) since all months fall in that band. The senior objection?
The sparkline, hand-rolled
Watch-list cells want a 40-point sparkline — no axes, no labels, just shape. That's three concepts you already own: the scale from this lesson, a Shape from the drawing lesson, and the y-axis flip — screen y counts like line numbers on a page, 1 at the top growing downward, not like the graph paper the data thinks in. Translating between the two conventions is the flip: the highest price must produce the smallest y:
struct Sparkline: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
var path = Path()
guard values.count > 1 else { return path }
let low = values.min() ?? 0
let high = values.max() ?? 1
let span = high - low > 0 ? high - low : 1
for (index, value) in values.enumerated() {
let x = rect.width * Double(index) / Double(values.count - 1)
let y = rect.height * (1 - (value - low) / span) // the flip
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
return path
}
}
// Sparkline(values: closes).stroke(.green, lineWidth: 2).frame(height: 44)
In production you'd write this with Charts (a LineMark chart with both axes hidden is a sparkline) — but interviewers love this exact exercise precisely because it checks whether you know what the framework is doing: domain, range, and the flip. The (1 - unit) is the line everyone forgets, and the exercise below grades exactly it.
This runtime's renderer can't draw arbitrary paths (the Shape above is an Xcode task), but the scale driving layout it can show — the same normalisation, powering a week-of-closes bar chart:
Axes, donuts, interaction
Axes are declared like chart content, replacing the defaults only where you say so:
.chartXAxis {
AxisMarks(values: .stride(by: .month)) {
AxisGridLine()
AxisValueLabel(format: .dateTime.month(.narrow)) // J F M A M J...
}
}
.chartYAxis {
AxisMarks(format: .currency(code: "GBP")) // £-formatted ticks for free
}
That .currency axis is the localisation-correct way to label money ticks — the same FormatStyle family you'd use anywhere else, so separators and symbols follow the user's locale without hand-formatting.
Allocation donut — the other chart on every portfolio screen (iOS 17+):
Chart(allocation) { slice in
SectorMark(
angle: .value("Value", slice.value),
innerRadius: .ratio(0.62), // 0 = pie, larger = thinner ring
angularInset: 1.5
)
.foregroundStyle(by: .value("Asset", slice.assetClass))
}
Selection — the touch-and-hold scrubber on every price chart (iOS 17+) is a binding, not a gesture you write:
@State private var selectedDate: Date?
Chart { /* line marks */ }
.chartXSelection(value: $selectedDate) // Charts writes the touched x-value
// Inside the Chart, mark the selection and annotate it:
if let selectedDate, let point = prices.closest(to: selectedDate) {
RuleMark(x: .value("Selected", selectedDate))
.annotation(position: .top) {
Text(point.close, format: .currency(code: "USD")).font(.caption.bold())
}
}
Scrolling (iOS 17+): .chartScrollableAxes(.horizontal) plus .chartXVisibleDomain(length: 3600 * 24 * 30) gives you a month-wide window over a year of data — the pan-around-history interaction, without a gesture handler in sight.
When Charts is the wrong tool
Marks have per-point cost. For the charts apps actually ship — dozens to a few thousand points — it is comfortably fast. The places it breaks, and the interview-grade answers:
- Too many points? Aggregate first. An intraday chart doesn't need every tick — OHLC candles are downsampling with domain meaning. This is the right fix ~90% of the time.
- Still too many (dense sensor/price arrays)? iOS 18 added vectorized plots —
LinePlot(data, x:, y:)hands Charts whole collections instead of per-point marks, built for exactly this — and function plots (LinePlot(x:y:)over a closure) for drawing curves rather than data. - Genuinely real-time at high frequency (a full order-book heatmap repainting every frame)? Leave Charts:
Canvasor Metal, drawn by hand — the SwiftUI-performance lesson's tier for "the framework's convenience is now the bottleneck".
Saying those three tiers in order — aggregate, vectorize, leave the framework — is a complete senior answer to "our chart is slow".
Implement the y-scale, flip included
The function under every chart in this lesson: map a value to a screen-y position, where screen y grows downward — the highest value in the domain lands at 0, the lowest at height. Out-of-domain values clamp to the edges.
Bars grow from the baseline
Turn three identical rectangles into a bar chart: bottom-aligned, with each bar's height taken from its value. (This is what BarMark does for you — once, by hand, so you know.)
Design the charts for a portfolio screen: the header price chart (line + area, range picker for 1D/1W/1Y, selection scrubber), an allocation donut, and a sparkline per holding cell. For each: which marks, what the domain policy is, what gets aggregated before it reaches the chart (and where that code lives so charts never see raw ticks), which OS-version gates apply, and the redraw story when live prices tick. End with the one honesty rule you'd write into the team's chart guidelines.
You can now:
- Compose bar/line/area/rule/sector marks over data with
PlottableValue - Split series by a data dimension and format money axes with
.currency - Wire the iOS 17 selection scrubber and scrollable domains
- Explain domains honestly — and hand-roll the sparkline that proves you own the scale
- Give the three-tier answer when a chart chokes on data volume
Next up: the data feeding these charts arrives as a stream — AsyncAlgorithms, and taming it.