Swift Charts Deep Dive: Timelines, Gestures, and Annotations
Today I want to take you on a little journey into Swift Charts — one of the most surprisingly powerful tools inside SwiftUI. Once you start feeding it real data, you suddenly see how naturally it belongs in health apps, habit trackers, weather forecasts, financial dashboards — anywhere numbers are part of the story.
This whole exploration started when I added medications to Viatza.
At first everything lived in a simple list:
you take a dose → a row appears.
Great for tracking.
Not great for understanding.
Because the moment you ask deeper questions —
- When do I usually take this?
- Do I skip more on certain days?
- Is my routine improving?
…the list format collapses. It’s just rows. No shape. No pattern. No insight.
A timeline changes everything.
Put the same data into bars, dots, or lanes, and suddenly the behaviour becomes visible. It’s the same reason the Health app leans so heavily on charts: time adds meaning.
But there was one thing I wanted that even Health doesn’t do:
selecting a custom range directly on the chart.
So I built it.
The result became two interactive timelines:
- Medicine timeline — stacked bars for taken vs skipped doses
- Symptom timeline — intensity evolving over time
And they’re not static — they respond to touch:
- switch between Day / Week / Month / 6M / Year
- tap to inspect a single day
- long-press + drag to select any interval
- a floating summary bubble depending on selection
And later, near the end, we’ll touch on a tiny Swift Charts detail that cost me a whole day…
and ended up being a one-liner. A classic SwiftUI moment.


What we’ll go through
- Medicine timeline — building stacked
BarMarks for taken vs skipped doses - Symptom timeline — visualising intensity as lanes using
RectangleMarkandPointMark - Custom chart interactions — handling taps, long-presses, and drags with
chartGestureandChartProxy - Visual feedback — highlighting selected days and intervals using
RuleMarkandPlot - Conclusion — how all these pieces come together and what I learned along the way
1. Medicine timeline — stacked BarMarks
When you start visualising medication logs, grouping entries into daily / weekly / monthly buckets reveals patterns instantly.
Each bucket carries its interval, midpoint date, and counts of taken vs skipped units — perfect for a clear, lightweight stacked bar chart.


Code Example (Stacked BarMarks)
import SwiftUI
import Charts
Chart(state.buckets) { bucket in
// 1. Taken bar (base segment)
BarMark(
x: .value("Date", bucket.mid),
yStart: .value("TakenStart", 0),
yEnd: .value("TakenEnd", bucket.takenUnits)
)
.foregroundStyle(.tint)
// 2. Skipped bar (stacked on top)
BarMark(
x: .value("Date", bucket.mid),
yStart: .value("SkippedStart", bucket.takenUnits),
yEnd: .value("SkippedEnd", bucket.totalUnits)
)
.foregroundStyle(.tint.opacity(0.4))
}
.chartYAxis {
// 3. Custom Y-axis: show only even numbers
AxisMarks(values: Array(0...state.maxUnits)) { value in
if let raw = value.as(Int.self),
(raw % 2 == 0 || raw == state.maxUnits) {
AxisGridLine()
AxisValueLabel(String(raw))
}
}
}
// 4. Full scrollable domain
.chartXScale(domain: state.domain)
// 5. Window for chosen range
.chartXVisibleDomain(length: state.range.visibleLength)
// 6. Scroll like Health
.chartScrollableAxes(.horizontal)
🔍 Detailed Breakdown
1. Taken bar
The solid base segment.
Represents confirmed doses from 0 → takenUnits.
2. Skipped bar
Starts right where the taken bar ends.
Lower opacity keeps it secondary but still part of the total picture.
3. Y-axis formatting
Shows only even values (plus the max) to keep the chart clean and reduce noise.
4. Full scrollable domain
The chart understands its entire timeline — months, even years — so users can freely move across their full history.
5. Window for the chosen range
Day, Week, Month, 6M, and Year views simply define how much of that timeline is visible.
It creates a natural zoom effect without changing the underlying data.
6. Scroll like Health
Horizontal scrolling behaves just like the Health app’s charts — smooth, intuitive, and familiar.
2. Symptom timeline — intensity lanes
For symptoms, what matters most is how strong the symptom was — and for how long.
So instead of units, each event in Viatza carries:
- interval — the period when the symptom was present
- intensity — one of five levels: Not Present, Present, Mild, Moderate, Severe
How to show this visually turned out to be simple:
- short intervals → dots
- longer intervals → thin lanes stretched across the time they lasted
The result is a clean, readable pattern very close to what the Health app does for cycles and symptoms — but tailored to your own data.


Code example — dots for short events, lanes for long ones
import SwiftUI
import Charts
Chart {
ForEach(state.events) { event in
let minDuration = state.range.barMinimumDuration
let isSelected = isSelected(event: event)
if event.interval.duration < minDuration {
// 1. Short event → dot on its intensity row
PointMark(
x: .value("Start", event.interval.end),
y: .value("Intensity", event.intensity.rawValue)
)
.symbol(Circle())
.symbolSize(isSelected ? 90 : 45)
} else {
// 2. Longer event → thin lane centered on the intensity level
let center = Double(event.intensity.rawValue)
let thickness = isSelected ? 0.3 : 0.2
let half = thickness / 2
RectangleMark(
xStart: .value("Start", event.interval.start),
xEnd: .value("End", event.interval.end),
yStart: .value("Bottom", center - half),
yEnd: .value("Top", center + half)
)
.cornerRadius(99)
}
}
}
.chartYAxis {
// 3. Named intensity lanes
AxisMarks(values: Intensity.allCases.map(\.rawValue)) { value in
if let raw = value.as(Int.self),
let intensity = Intensity(rawValue: raw) {
AxisGridLine()
AxisValueLabel(intensity.displayText)
}
}
}
// 4. Always show full intensity range
.chartYScale(domain: 0...Intensity.severe.rawValue)
Detailed Breakdown
1. Dots for short events
Short flares don’t need a full bar. A small dot sits neatly on the intensity row and keeps the timeline uncluttered.
2. Lanes for longer events
Sustained symptoms deserve more visual weight.
A thin band centered on the intensity level shows both duration and severity at once, forming a natural “track” the eye can follow.
3. Labeled intensity rows
Instead of raw numbers (0–4), the Y-axis shows the names of the intensity levels.
It instantly reads as a diagnostic overview rather than a numeric graph.
4. Stable vertical domain
The full intensity range is always visible.
Switching between Day / Week / Month / Year only changes the timeline — never the vertical scale — which makes comparing patterns much easier.
Why this works
- Dots vs lanes clearly separate short flares from longer stretches.
- Centering lanes on intensity creates five clean tracks, mirroring the visual clarity of Health.
- Labeled Y-axis turns abstract severity values into something human and readable.
- At a glance, you can see calm periods, escalations, and how long each flare-up lasted — and let the pattern speak for itself.
3. Custom interaction — tap vs long-press + drag
At first, I tried the “standard” approach:
- placing an overlay over the chart
- relying on the built-in X-axis selection
- letting SwiftUI decide what a “selection” should mean
It worked… right until I wanted more control:
- Tap → select a single day
- Long-press + drag → select an interval
- Horizontal scrolling still smooth
- Selection snapping to my own calendar buckets
At that point, a custom chart gesture driven by ChartProxy became the clearer path — simpler, predictable, and perfectly tailored to what Viatza needed.


Code example — single-day tap and interval drag
import SwiftUI
import Charts
func selectionChartGesture(proxy: ChartProxy) -> some Gesture {
SpatialTapGesture(count: 1)
.onEnded { value in
// 1. Handle simple tap → convert tap location into a Date
// and select a single-day interval.
guard let date: Date = proxy.value(atX: value.location.x) else {
state.interval = nil
return
}
state.interval = DateInterval(start: date, duration: 0)
}
.exclusively(before:
LongPressGesture(minimumDuration: 0.3)
.sequenced(before: DragGesture(minimumDistance: 0))
.onChanged { value in
switch value {
// 2. Long press detected → enter range-selection mode.
case .first(true):
state.isDragging = true
// 3. User is dragging after long press → update interval.
case .second(true, let drag):
// Convert the starting drag point into a Date.
guard let startDate: Date = proxy.value(atX: drag.startLocation.x) else { return }
// Convert current drag location into a Date if possible.
if let endDate: Date = proxy.value(atX: drag.location.x),
state.interval != nil {
// 3a. Extend interval as the finger moves.
state.interval = DateInterval(start: startDate, end: endDate)
} else {
// 3b. Drag hasn’t moved enough yet → keep as anchor day.
state.interval = DateInterval(start: startDate, duration: 0)
}
default:
break
}
}
)
.onEnded { _ in
// 4. Gesture finished → reset dragging state and clear selection.
state.isDragging = false
state.interval = nil
}
}
// Usage in the chart
Chart {
// marks…
}
// 5. Wire the gesture into the chart using ChartProxy for screen→date conversion.
.chartGesture { proxy in
selectionChartGesture(proxy: proxy)
}
Detailed Breakdown
1. SpatialTapGesture — detecting simple tapsSpatialTapGesture gives you the exact tap location inside the chart.
From there, proxy.value(atX:) transforms the point into a Date.
If the conversion succeeds, a zero-length DateInterval marks that day as selected.
If not, the selection resets.
This cleanly handles “tap → inspect one day” without fighting the chart’s scrolling behaviour.
2. exclusively(before:) — cleanly separating tap vs range selectionexclusively(before:) tells SwiftUI:
“Try the tap gesture first. If it doesn’t win, treat it as a long-press gesture.”
This prevents gesture conflicts:
- taps never mistakenly trigger range selection
- long-presses never get swallowed as taps
Both behaviours stay predictable and independent.
3. LongPressGesture — entering range-selection mode
A 0.3s long press signals that the user wants more than a single-day highlight.
When .first(true) fires, the UI enters range selection mode:state.isDragging = true.
Nothing is selected yet — this is just the handshake before the drag begins.
4. sequenced(before: DragGesture) — from hold → drag → intervalLongPressGesture().sequenced(before: DragGesture()) gives a natural two-phase flow:
- Phase 1: Long press
.first(true)fires — intent established. - Phase 2: Drag updates
.second(true, drag)fires continuously as the finger moves. It provides two points: drag.startLocation→ anchor of the selectiondrag.location→ current endpoint Both are converted to dates through theChartProxy.
The interval begins as a single point and grows as the drag continues.
This sequence is what makes the gesture feel fluid and intuitive, mirroring interactions from the Health app.
5. onEnded — resetting interaction state
When the gesture ends, everything resets:
state.isDragging = falsestate.interval = nil(or you can keep it, depending on your UX choice)
6. Wiring the gesture into the chart
Inside .chartGesture, the chart gives you a ChartProxy.
You pass it directly into selectionChartGesture(proxy:).
This keeps gesture logic fully isolated, while the chart remains focused on rendering marks, rules, and annotations.
4. Drawing selection with lines and a shaded band
Once the gesture updates state.interval: DateInterval?, the chart’s job is simply to visualise that choice:
- if the duration is
0→ highlight a single day - if the duration is
> 0→ highlight a range with boundaries and a soft band in between

Code example — single-day tap and interval drag
import SwiftUI
import Charts
if let interval = state.interval, interval.duration == .zero {
// 1. Single-day selection → one vertical line + summary bubble
RuleMark(
x: .value("Selected",
interval.start,
unit: state.range.calendarUnit)
)
.foregroundStyle(.gray.opacity(0.5))
.offset(yStart: -10)
.annotation(
position: .top,
spacing: 0,
overflowResolution: .init(
x: .fit(to: .chart),
y: .disabled
)
) {
/* annotation view */
}
} else if let interval = state.interval,
interval.duration > .zero {
Plot {
// 2. Left boundary of the selected range
RuleMark(
x: .value("Selected lower bound",
interval.start,
unit: state.range.calendarUnit)
)
// 3. Shaded band covering the whole interval
RectangleMark(
xStart: .value("Start",
interval.start,
unit: state.range.calendarUnit),
xEnd: .value("End",
interval.end,
unit: state.range.calendarUnit),
yStart: .value("Bottom", 0),
yEnd: .value("Top", state.maxUnits)
)
.foregroundStyle(.tint.opacity(0.02))
// 4. Right boundary of the selected range
RuleMark(
x: .value("Selected upper bound",
interval.end,
unit: state.range.calendarUnit)
)
}
.offset(yStart: -10)
.annotation(
position: .top,
spacing: 0,
overflowResolution: .init(
x: .fit(to: .chart),
y: .disabled
)
) {
/* annotation view */
}
}
`
Detailed Breakdown
1. Single-day selection line
When the interval has zero duration, it represents a single calendar day.
A thin vertical line marks that position — translucent enough to stay subtle — with an annotation above it showing a small summary, like “Today · 2 taken · 1 skipped”.
2. Left boundary of a range
For multi-day intervals, the first step is to anchor the start date with a vertical line.
It clearly defines where the selected range begins.
3. Shaded band across the selected period
A low-opacity rectangle spans from the start to the end date across the full Y-domain (e.g. 0...maxUnits).
It doesn’t obscure the underlying bars or points — it simply casts a soft highlight over the selected window.
4. Right boundary of a range
A second vertical line marks the end of the interval.
Together with the shaded band, the two lines form a clean “window” on the timeline: everything inside belongs to the selection, everything outside becomes context.
5. Shared annotation above the chart
Both cases — single day and interval — attach the same annotation.
Only the content changes:
- day: “Taken X · Skipped Y”
- range: “Last 7 days · Avg 1.5 units/day”
Because the annotation is driven entirely by state.interval, the same logic works for both medicine and symptom charts with no duplication.
6. Adapting it to symptoms
For symptoms, the shaded band spans the full intensity domain instead of numeric units.
Visually it communicates: “show me everything that happened — at any intensity — during this window.”
It fits seamlessly with the intensity lanes you built earlier.
5. Biggest lesson — scrollClipDisabled() (or: “why my annotation vanished”)
This one genuinely caught me off-guard.
In isolation, the chart behaved perfectly.
The annotation appeared. The gestures worked. Everything felt solid.
But as soon as I embedded the same chart inside a sheet, inside a Form, inside sections, the summary bubble began to randomly disappear.
- The bars were still there
- The gestures still fired
- The interval updated correctly
…but the annotation was nowhere to be seen.

I lost a full day suspecting everything else — the gesture logic, the annotation view, the layout.
In reality, the chart was simply being clipped by the scroll container above it.
The fix? A single line:
Chart {
// marks…
}
.scrollClipDisabled() // ← the hero
Because annotations always extend outside the chart’s bounds, any chart that scrolls will clip them unless you explicitly opt out.

The End
Thank you for reading — I hope you picked up something new, or at least felt that little spark of “hmm, I could use this in my app.”
If you’re experimenting with Swift Charts, or thinking about adding timelines, ranges, or custom interactions, I’d love to hear how you’re approaching it.
And if you have questions, I’m always happy to help in the comments. 🙌
