Modern SwiftUI · core

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.

12 min read12 min practice0/2 exercises5 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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 contentmarks — 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:

Same data, two domains. The domain IS the story.

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.

Check yourself

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:

The scale as layout: normalised closes driving bar heights, baseline-aligned. The red bar is the week's low.
Run to see the preview.

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 plotsLinePlot(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: Canvas or 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

Write the code

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.

Solution unlocks after 3 attempts

Bars grow from the baseline

Build the view

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.)

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.