
Scrubbing Line
From syxUI — written for both platforms, not translated between them.
InteractiveA line chart with a draggable scrub head that snaps to data points and lifts a callout.
Charts · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- chart
- line
- scrub
- drag
- callout
- crosshair
- data
- animated
The actual source
ScrubLineCallout.swift
// Scrubbing Line · syxUI · https://syxui.dev/components/scrub-line-callout
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A line chart with a draggable scrub head that snaps to data points and
/// lifts a callout.
///
/// Two things about the entry are deliberate. The stroke draws in with a trim,
/// which walks the path's arc length; the gradient fill is revealed by a
/// **growing clip rect** instead, because trimming a closed path walks its
/// perimeter and produces a wedge rather than a partial area. The clip trails
/// the pen by 70ms, which reads as ink settling in behind it.
///
/// The scrub is one spring. `slotState` is the head's position in sample units;
/// the head, the vertical rule and the callout are all positioned from it, so
/// the marker and the readout arrive as one object rather than two springs that
/// happen to agree. Selection snaps to a sample and never interpolates — the
/// callout prints a real datum, and an interpolated one is a number that is not
/// in the series.
struct ScrubLineCallout: View {
/// The series. Injected, but baked with a real default so the component
/// compiles standalone and renders the same picture on every run — nothing
/// here is random and nothing is fetched. Not a param: only `colors` can
/// bind a collection.
var values: [Double] = [6, 12, 9, 18, 23, 16, 21, 26, 22, 31, 35, 30, 33]
/// The reading the chart opens on. Deliberately not `nil` — a chart whose
/// default state is "nothing selected" is a squiggle until someone touches it.
var selectedIndex: Int = 7
var lineWidth: CGFloat = 2.6
/// Seconds for the stroke to draw in. The fill's clip follows 70ms behind.
var revealDuration: Double = 0.7
var accent: Color = Color(red: 0.184, green: 0.42, blue: 1.0)
var grid: Color = Color(red: 0.91, green: 0.91, blue: 0.925)
var showFill: Bool = true
/// Preview override. When set, the component renders that point of one
/// rehearsal instead of driving its own clock: `0…0.26` trims the line in,
/// `0.26…0.34` lifts the readout, `0.34…0.90` walks the head from index 2
/// to the last sample, and the tail holds it there.
var progress: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var strokeReveal: Double = 0
@State private var fillReveal: Double = 0
@State private var readoutState: Double = 0
/// The head's position in sample units, driven by one spring. `-1` is
/// "not started"; it is the only way a `@State` can defer to a property of
/// `self` until `onAppear` seeds it.
@State private var slotState: Double = -1
@State private var picked: Int? = nil
var body: some View {
GeometryReader { geometry in
let plot = plotRect(in: geometry.size)
let points = samplePoints(in: plot)
ZStack(alignment: .topLeading) {
Color.white
scale(plot: plot)
// Clipped to the plot, not merely to the card, so the stroke's
// round cap and the gradient can never cross a rounded corner.
plotLayers(plot: plot, points: points)
.clipShape(ScrubRectClip(rect: plot))
readout(plot: plot)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { select(at: $0.location.x, in: plot) }
.onEnded { release($0, in: plot) }
)
// The cursor scrubs the same state a finger does, so a Mac reads
// the chart without dragging. Leaving the plot restores the default.
.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case .active(let location): select(at: location.x, in: plot)
case .ended: resetSelection()
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color(white: 0.91), lineWidth: 1)
)
.onAppear(perform: start)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Visits per hour")
.accessibilityValue(spokenReading)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: step(by: 1)
case .decrement: step(by: -1)
default: break
}
}
#if os(iOS)
// On an index change only. A haptic per frame of a drag is a buzz;
// one per crossing is what turns the plot into a detent rail.
.sensoryFeedback(.selection, trigger: index)
#endif
}
// MARK: - Layers
/// Gridlines and both axes, drawn in one `Canvas` pass. These never
/// animate, so they cost nothing to resolve here rather than as views.
private func scale(plot: CGRect) -> some View {
Canvas { context, _ in
let fade = min(1, revealed * 4)
for tick in ticks {
let ty = y(tick, in: plot)
var rule = Path()
rule.move(to: CGPoint(x: plot.minX, y: ty))
rule.addLine(to: CGPoint(x: plot.maxX, y: ty))
context.stroke(
rule,
with: .color(grid.opacity(fade * (tick == 0 ? 1 : 0.75))),
lineWidth: 1
)
let label = context.resolve(
Text(tickText(tick))
.font(.system(size: 9, weight: .medium))
.foregroundStyle(Color(white: 0.62).opacity(fade))
)
// The bottom tick rides just above the axis rather than centred
// on its rule, so "0" cannot touch the first hour label.
context.draw(
label,
at: CGPoint(x: plot.minX - 9, y: min(ty, plot.maxY - 7)),
anchor: .trailing
)
}
// Every third sample only. Thirteen samples put a label on both
// ends: five ~18pt labels at a 79.5pt pitch, so 61pt of air
// separates them at any line width.
for index in stride(from: 0, to: values.count, by: 3) {
let label = context.resolve(
Text(hourText(index))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(Color(white: 0.55).opacity(fade))
)
context.draw(
label,
at: CGPoint(x: x(Double(index), in: plot), y: plot.maxY + 13),
anchor: .center
)
}
}
.allowsHitTesting(false)
}
private func plotLayers(plot: CGRect, points: [CGPoint]) -> some View {
ZStack(alignment: .topLeading) {
if showFill {
ScrubAreaShape(points: points, baseline: plot.maxY)
.fill(
LinearGradient(
colors: [accent.opacity(0.26), accent.opacity(0.02)],
startPoint: .top,
endPoint: .bottom
)
)
// A growing rect, never a trim.
.mask(alignment: .leading) {
Rectangle()
.frame(width: plot.minX + plot.width * CGFloat(filled))
}
}
ScrubLineShape(points: points)
.trim(from: 0, to: revealed)
.stroke(
accent,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
)
// The pen tip rides the trim's leading edge, then hands the frame
// over to the scrub head as the readout rises.
Circle()
.fill(accent)
.frame(width: 5, height: 5)
.position(penTip(at: revealed, points: points))
.opacity(1 - readoutIn)
}
}
private func readout(plot: CGRect) -> some View {
let head = headPoint(in: plot)
let flip = head.y - Metrics.calloutClearance < plot.minY
let centreX = min(
max(head.x, plot.minX + Metrics.calloutWidth / 2),
plot.maxX - Metrics.calloutWidth / 2
)
let notchLimit = Metrics.calloutWidth / 2 - Metrics.notchInset
let notchOffset = min(max(head.x - centreX, -notchLimit), notchLimit)
let boxHeight = Metrics.calloutHeight + Metrics.notchHeight
let boxCentreY = flip
? head.y + Metrics.headGap + boxHeight / 2
: head.y - Metrics.headGap - boxHeight / 2
return ZStack(alignment: .topLeading) {
// Ties the number to the x axis. Without it the callout floats.
Rectangle()
.fill(accent.opacity(0.40))
.frame(width: 1, height: max(plot.maxY - head.y, 0))
.position(x: head.x, y: (head.y + plot.maxY) / 2)
ScrubCallout(
value: Int(currentValue.rounded()),
clock: clockText(index),
accent: accent,
notchOffset: notchOffset,
flipped: flip
)
.frame(width: Metrics.calloutWidth, height: boxHeight)
.scaleEffect(
calloutScale,
anchor: UnitPoint(
x: 0.5 + notchOffset / Metrics.calloutWidth,
y: flip ? 0 : 1
)
)
.position(x: centreX, y: boxCentreY)
ZStack {
Circle().fill(accent.opacity(0.16)).frame(width: 20, height: 20)
Circle().fill(Color.white).frame(width: 11, height: 11)
Circle().fill(accent).frame(width: 7, height: 7)
}
.scaleEffect(0.5 + 0.5 * readoutIn)
.position(head)
}
.opacity(readoutIn)
.allowsHitTesting(false)
}
// MARK: - Drive
private var count: Int { values.count }
private var lastIndex: Int { max(count - 1, 0) }
private var defaultIndex: Int { min(max(selectedIndex, 0), lastIndex) }
/// The index the callout is printing.
private var index: Int {
if let walked = rehearsalSlot {
return min(max(Int(walked.rounded()), 0), lastIndex)
}
return min(max(picked ?? defaultIndex, 0), lastIndex)
}
private var currentValue: Double { values.isEmpty ? 0 : values[index] }
/// Continuous sample position during the scripted scrub, `nil` when a real
/// finger (or nothing at all) is in charge.
private var rehearsalSlot: Double? {
guard let p = progress, count > 1 else { return nil }
let t = clamp01((p - Metrics.scrubStart) / Metrics.scrubSpan)
let from = Double(min(Metrics.scrubFrom, lastIndex))
return from + (Double(lastIndex) - from) * t
}
/// The head's position in sample units. Fractional only while the spring
/// is in flight; the rehearsal reports the snapped sample, because snapping
/// is the behaviour, not a shortcut.
private var slot: Double {
if rehearsalSlot != nil { return Double(index) }
return slotState < 0 ? Double(index) : slotState
}
private var revealed: Double {
guard let p = progress else { return strokeReveal }
return easeOut(clamp01(p / Metrics.entrySpan))
}
private var filled: Double {
guard let p = progress else { return fillReveal }
// The same 70ms, expressed in drive units so the rehearsal shows the
// lag at whatever `revealDuration` is set to.
let lag = Metrics.entrySpan * min(0.5, Metrics.fillLag / max(revealDuration, 0.05))
return easeOut(clamp01((p - lag) / (Metrics.entrySpan - lag)))
}
private var readoutIn: Double {
guard let p = progress else { return readoutState }
return smoothstep(clamp01((p - Metrics.readoutStart) / Metrics.readoutSpan))
}
/// How far the readout still has to travel, in samples. The callout is not
/// a second spring — it reads the distance left on the head's own, so a
/// long throw compresses it more than a nudge does.
private var calloutScale: Double {
let travel: Double
if let walked = rehearsalSlot {
// Fraction of the dwell elapsed since the last snap stands in for
// the spring's remaining travel, so a filmstrip shows the pop.
let arrival = (walked - walked.rounded()) + 0.5
travel = max(0, 1 - arrival / 0.4)
} else {
travel = abs(slot - Double(index))
}
return (0.90 + 0.10 * readoutIn) * (1 - 0.10 * min(1, travel / 1.6))
}
private func start() {
guard progress == nil else { return }
slotState = Double(defaultIndex)
guard !reduceMotion else {
// The settled frame, not a frozen midpoint: path drawn, fill in,
// reading up at the default index.
strokeReveal = 1
fillReveal = 1
readoutState = 1
return
}
withAnimation(.easeOut(duration: revealDuration)) { strokeReveal = 1 }
withAnimation(.easeOut(duration: revealDuration).delay(Metrics.fillLag)) {
fillReveal = 1
}
withAnimation(
.spring(response: 0.34, dampingFraction: 0.84)
.delay(revealDuration * 0.86)
) {
readoutState = 1
}
}
// MARK: - Scrub
/// Nearest index, so the head jumps at the midpoint between two samples.
private func nearestIndex(to x: CGFloat, in plot: CGRect) -> Int {
guard count > 1 else { return 0 }
let raw = (x - plot.minX) / pitch(plot)
return min(max(Int(raw.rounded()), 0), lastIndex)
}
private func select(at x: CGFloat, in plot: CGRect) {
guard progress == nil, count > 1 else { return }
let target = nearestIndex(to: x, in: plot)
// Only an index *change* does anything, so a pointer moving inside one
// cell does not relaunch the spring on every event.
guard target != index else { return }
// A second touch retargets this spring rather than queueing a new one,
// so tap-tap-tap across the plot is one continuous motion.
move(to: target)
}
private func release(_ value: DragGesture.Value, in plot: CGRect) {
guard progress == nil, count > 1 else { return }
let velocity = value.velocity.width
guard abs(velocity) > Metrics.coastVelocity else {
// Below the threshold the reading stays exactly where the finger
// left it. A chart that erases its number on lift is unreadable.
return
}
let coasted = value.location.x + CGFloat(velocity * Metrics.coastFactor)
select(at: min(max(coasted, plot.minX), plot.maxX), in: plot)
}
private func resetSelection() {
guard progress == nil, picked != nil else { return }
withAnimation(.spring(response: 0.30, dampingFraction: 0.90)) {
picked = nil
slotState = Double(defaultIndex)
}
}
private func step(by delta: Int) {
guard progress == nil, count > 1 else { return }
let next = min(max(index + delta, 0), lastIndex)
guard next != index else { return }
move(to: next)
}
private func move(to target: Int) {
let spring = Animation.spring(
response: Metrics.selectionResponse,
dampingFraction: Metrics.selectionDamping
)
// The first move after an appear that never ran has nowhere to spring
// from, so it lands instantly rather than flying in from off-canvas.
let animated = slotState >= 0 && !reduceMotion
withAnimation(animated ? spring : nil) {
picked = target
slotState = Double(target)
}
}
// MARK: - Geometry
private func plotRect(in size: CGSize) -> CGRect {
CGRect(
x: Metrics.gutter,
y: Metrics.topInset,
width: max(size.width - Metrics.gutter - Metrics.rightInset, 1),
height: max(size.height - Metrics.topInset - Metrics.axisRow, 1)
)
}
/// Rounded up to the next ten above the peak, so three ticks always land on
/// readable numbers whatever series is injected.
private var domainTop: Double {
let peak = values.max() ?? 10
return max(10, (peak / 10).rounded(.up) * 10)
}
private var ticks: [Double] { [0, domainTop / 2, domainTop] }
private func pitch(_ plot: CGRect) -> CGFloat {
count > 1 ? plot.width / CGFloat(lastIndex) : plot.width
}
private func x(_ sample: Double, in plot: CGRect) -> CGFloat {
plot.minX + CGFloat(sample) * pitch(plot)
}
private func y(_ value: Double, in plot: CGRect) -> CGFloat {
plot.maxY - CGFloat(value / domainTop) * plot.height
}
private func samplePoints(in plot: CGRect) -> [CGPoint] {
values.indices.map {
CGPoint(x: x(Double($0), in: plot), y: y(values[$0], in: plot))
}
}
private func headPoint(in plot: CGRect) -> CGPoint {
guard count > 0 else { return CGPoint(x: plot.minX, y: plot.maxY) }
let s = min(max(slot, 0), Double(lastIndex))
let low = Int(s)
let high = min(low + 1, lastIndex)
let fraction = s - Double(low)
let value = values[low] + (values[high] - values[low]) * fraction
return CGPoint(x: x(s, in: plot), y: y(value, in: plot))
}
/// The point at `fraction` of the polyline's arc length — the same measure
/// `Shape.trim` uses, so the pen tip sits exactly on the stroke's end.
private func penTip(at fraction: Double, points: [CGPoint]) -> CGPoint {
guard points.count > 1 else { return points.first ?? .zero }
var segments: [CGFloat] = []
var total: CGFloat = 0
for i in 1..<points.count {
let length = hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)
segments.append(length)
total += length
}
let target = total * CGFloat(clamp01(fraction))
var walked: CGFloat = 0
for i in segments.indices {
if walked + segments[i] >= target {
let f = segments[i] > 0 ? (target - walked) / segments[i] : 0
return CGPoint(
x: points[i].x + (points[i + 1].x - points[i].x) * f,
y: points[i].y + (points[i + 1].y - points[i].y) * f
)
}
walked += segments[i]
}
return points[points.count - 1]
}
// MARK: - Labels
private func tickText(_ value: Double) -> String { "\(Int(value.rounded()))" }
/// Axis form: compact, 9pt, no space. The series starts at 9am.
private func hourText(_ index: Int) -> String {
let hour = (9 + index) % 24
if hour == 0 { return "12a" }
if hour < 12 { return "\(hour)a" }
if hour == 12 { return "12p" }
return "\(hour - 12)p"
}
/// Callout form: spoken out, because the readout has room for it.
private func clockText(_ index: Int) -> String {
let hour = (9 + index) % 24
let display = hour % 12 == 0 ? 12 : hour % 12
return "\(display) \(hour < 12 ? "AM" : "PM")"
}
private var spokenReading: String {
"\(Int(currentValue.rounded())) visits at \(clockText(index))"
}
private enum Metrics {
/// Left gutter for the y ticks; the x labels get the bottom row.
static let gutter: CGFloat = 34
static let rightInset: CGFloat = 20
static let topInset: CGFloat = 16
static let axisRow: CGFloat = 24
static let calloutWidth: CGFloat = 84
static let calloutHeight: CGFloat = 36
static let notchHeight: CGFloat = 7
/// Keeps the notch's base clear of the callout's rounded corners:
/// half the notch plus the corner radius, so the outline stays one
/// continuous curve however far the notch slides.
static let notchInset: CGFloat = 16
static let headGap: CGFloat = 11
/// Room the callout needs above the head, notch and gap included.
static var calloutClearance: CGFloat { calloutHeight + notchHeight + headGap }
static let selectionResponse: Double = 0.22
static let selectionDamping: Double = 0.86
/// Points per second above which a lift becomes a flick.
static let coastVelocity: Double = 300
static let coastFactor: Double = 0.09
static let fillLag: Double = 0.07
static let entrySpan: Double = 0.26
static let readoutStart: Double = 0.22
static let readoutSpan: Double = 0.12
static let scrubStart: Double = 0.34
static let scrubSpan: Double = 0.56
static let scrubFrom: Int = 2
}
}
// MARK: - Shapes
private struct ScrubLineShape: Shape {
let points: [CGPoint]
func path(in _: CGRect) -> Path {
var path = Path()
guard let first = points.first else { return path }
path.move(to: first)
for point in points.dropFirst() { path.addLine(to: point) }
return path
}
}
private struct ScrubAreaShape: Shape {
let points: [CGPoint]
let baseline: CGFloat
func path(in _: CGRect) -> Path {
var path = Path()
guard let first = points.first, let last = points.last else { return path }
path.move(to: CGPoint(x: first.x, y: baseline))
path.addLine(to: first)
for point in points.dropFirst() { path.addLine(to: point) }
path.addLine(to: CGPoint(x: last.x, y: baseline))
path.closeSubpath()
return path
}
}
/// Clips to an absolute rect in the parent's coordinate space.
private struct ScrubRectClip: Shape {
let rect: CGRect
func path(in _: CGRect) -> Path { Path(rect) }
}
/// The floating readout: a rounded box with a notch that slides within it, so
/// a clamped callout still points at the head.
private struct ScrubCallout: View {
let value: Int
let clock: String
let accent: Color
let notchOffset: CGFloat
let flipped: Bool
var body: some View {
ScrubCalloutShape(notchOffset: notchOffset, flipped: flipped)
.fill(Color.white)
.overlay(
ScrubCalloutShape(notchOffset: notchOffset, flipped: flipped)
.stroke(Color(white: 0.90), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.10), radius: 7, x: 0, y: 3)
.overlay {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(value)")
.font(.system(size: 15, weight: .semibold).monospacedDigit())
.foregroundStyle(Color(white: 0.12))
// A proportional digit makes a changing value twitch
// sideways; this swaps it in place instead.
.contentTransition(.numericText(value: Double(value)))
VStack(alignment: .leading, spacing: 0) {
Text(clock)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(accent)
Text("visits")
.font(.system(size: 9))
.foregroundStyle(Color(white: 0.58))
}
}
.padding(.top, flipped ? ScrubCalloutShape.notch : 0)
.padding(.bottom, flipped ? 0 : ScrubCalloutShape.notch)
}
}
}
private struct ScrubCalloutShape: Shape {
static let notch: CGFloat = 7
static let radius: CGFloat = 8
let notchOffset: CGFloat
let flipped: Bool
/// One continuous outline rather than a rounded rect plus a triangle. Two
/// subpaths would stroke the notch's base straight across the box's edge
/// and cast a hairline shadow with it; this walks the edge and detours into
/// the notch on the way past.
func path(in rect: CGRect) -> Path {
let notch = Self.notch
let radius = Self.radius
let width = rect.width
let height = rect.height
let tip = width / 2 + notchOffset
var path = Path()
func line(_ x: CGFloat, _ y: CGFloat) {
path.addLine(to: CGPoint(x: x, y: y))
}
// `addArc(tangent1End:tangent2End:radius:)` is the classic rounded
// corner: it lines to the arc's start, so the straight edges are free.
func corner(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat) {
path.addArc(
tangent1End: CGPoint(x: x1, y: y1),
tangent2End: CGPoint(x: x2, y: y2),
radius: radius
)
}
if flipped {
// The notch sits on the top edge, pointing up at a head below it.
let edge = notch
path.move(to: CGPoint(x: radius, y: edge))
line(tip - notch, edge)
line(tip, 0)
line(tip + notch, edge)
line(width - radius, edge)
corner(width, edge, width, height)
line(width, height - radius)
corner(width, height, 0, height)
line(radius, height)
corner(0, height, 0, edge)
line(0, edge + radius)
corner(0, edge, width, edge)
} else {
let edge = height - notch
path.move(to: CGPoint(x: radius, y: 0))
line(width - radius, 0)
corner(width, 0, width, edge)
line(width, edge - radius)
corner(width, edge, 0, edge)
line(tip + notch, edge)
line(tip, height)
line(tip - notch, edge)
line(radius, edge)
corner(0, edge, 0, 0)
line(0, radius)
corner(0, 0, width, 0)
}
path.closeSubpath()
return path
}
}
// MARK: - Curves
private func clamp01(_ value: Double) -> Double { min(max(value, 0), 1) }
/// Close enough to SwiftUI's `.easeOut` that the rehearsal and the real clock
/// draw the same picture.
private func easeOut(_ t: Double) -> Double { 1 - pow(1 - clamp01(t), 2.2) }
private func smoothstep(_ t: Double) -> Double {
let x = clamp01(t)
return x * x * (3 - 2 * x)
}
#Preview {
ScrubLineCallout()
.frame(width: 372, height: 212)
.padding(24)
}
iOS 17 · No dependencies
SwiftUI note. The plot is clipped to `plotRect`, not to the card, so neither the gradient nor the stroke's round cap can cross a rounded corner. Swap `values` for your own series and the y ticks re-scale; set `progress` to preview the entry and a scripted scrub with no finger present, and leave it nil in production so the drag takes over.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


