Skip to content
Scrubbing Line preview
An animated render of the SwiftUI source on this page.

Scrubbing Line

From syxUI — written for both platforms, not translated between them.

Interactive

A 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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27