Skip to content

Line Chart Draw In

A line chart whose line draws in from left to right while a dot follows its tip.

Line Chart Draw In, playing
Download .swift
Origin
Original work
Found by
line chart · draw animation · trim · path · animated chart · graph · gráfico de líneas · animación de trazo · trazado · gráfica animada · línea

A line chart whose line draws in from left to right while a dot follows its tip.

It draws again whenever the values change. With Reduce Motion on, the finished chart appears.

struct LineChartDrawInDemo: View {
@State private var isAlternate = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var values: [Double] {
isAlternate
? [62, 58, 70, 64, 80, 76, 66, 72, 88, 84, 96, 90]
: [20, 34, 28, 46, 40, 58, 52, 74, 66, 82, 78, 96]
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Active users")
.font(.largeTitle.bold())
Text(isAlternate ? "Europe" : "Americas")
.font(.title3)
.foregroundStyle(.secondary)
LineChartDrawIn(values: values)
.padding(.vertical, 24)
}
.padding(24)
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2.8))
guard !Task.isCancelled else { return }
isAlternate.toggle()
}
}
}
}
ParameterTypeDefault
values[Double]Required
tintColor.indigo
durationDouble1.6

Copy this code into your project, or download the file.

LineChartDrawIn.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A line chart whose line draws in from left to right while a dot follows its tip.
///
/// It draws again whenever the values change. With Reduce Motion on, the finished chart appears.
struct LineChartDrawIn: View {
let values: [Double]
var tint: Color = .indigo
var duration: Double = 1.6
@State private var progress = 0.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let inset = 12.0
private var shownProgress: Double { reduceMotion ? 1 : progress }
var body: some View {
ZStack {
LineChartDrawInArea(values: values, inset: inset, progress: shownProgress)
.fill(.linearGradient(colors: [tint.opacity(0.28), tint.opacity(0)], startPoint: .top, endPoint: .bottom))
LineChartDrawInLine(values: values, inset: inset)
.trim(from: 0, to: shownProgress)
.stroke(tint, style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round))
LineChartDrawInDot(values: values, inset: inset, radius: 12, progress: shownProgress)
.fill(tint.opacity(0.25))
LineChartDrawInDot(values: values, inset: inset, radius: 6, progress: shownProgress)
.fill(tint)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Line chart")
.accessibilityValue(LineChartDrawInLogic.summary(of: values))
.task(id: values) {
guard !reduceMotion else {
progress = 1
return
}
var reset = Transaction()
reset.disablesAnimations = true
withTransaction(reset) { progress = 0 }
try? await Task.sleep(for: .milliseconds(50))
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: max(duration, 0))) { progress = 1 }
}
}
}
nonisolated private struct LineChartDrawInLine: Shape {
let values: [Double]
let inset: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.addLines(LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset)))
return path
}
}
/// The fill under the line, up to its tip, so it never runs ahead of the line being drawn.
nonisolated private struct LineChartDrawInArea: Shape {
let values: [Double]
let inset: CGFloat
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
let points = LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset))
guard let first = points.first, let last = LineChartDrawInLogic.point(at: progress, along: points) else { return Path() }
var path = Path()
path.addLines(points.filter { $0.x < last.x } + [last])
path.addLine(to: CGPoint(x: last.x, y: rect.maxY))
path.addLine(to: CGPoint(x: first.x, y: rect.maxY))
path.closeSubpath()
return path
}
}
/// A circle that re-computes its position for every animation frame, so it stays on the tip.
nonisolated private struct LineChartDrawInDot: Shape {
let values: [Double]
let inset: CGFloat
let radius: CGFloat
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
let points = LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset))
guard let center = LineChartDrawInLogic.point(at: progress, along: points) else { return Path() }
return Path(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
}
}
/// The geometry of the chart, apart from the view so it can be tested.
nonisolated enum LineChartDrawInLogic {
static func points(for values: [Double], in rect: CGRect) -> [CGPoint] {
let values = values.filter(\.isFinite)
guard !rect.isNull, rect.width > 0, rect.height > 0,
let low = values.min(), let high = values.max() else { return [] }
let span = high - low
let step = values.count > 1 ? rect.width / CGFloat(values.count - 1) : 0
return values.enumerated().map { index, value in
let ratio = span > 0 && span.isFinite ? (value - low) / span : 0.5
return CGPoint(x: rect.minX + CGFloat(index) * step, y: rect.maxY - CGFloat(ratio) * rect.height)
}
}
/// The point at a fraction of the line's length, which is how `trim` measures it too.
static func point(at progress: Double, along points: [CGPoint]) -> CGPoint? {
guard let first = points.first else { return nil }
let lengths = zip(points, points.dropFirst()).map { hypot($1.x - $0.x, $1.y - $0.y) }
let total = lengths.reduce(0, +)
guard total > 0 else { return first }
var remaining = total * CGFloat(min(max(progress, 0), 1))
for (index, length) in lengths.enumerated() where length > 0 {
if remaining <= length {
let fraction = remaining / length
let start = points[index]
let end = points[index + 1]
return CGPoint(x: start.x + (end.x - start.x) * fraction, y: start.y + (end.y - start.y) * fraction)
}
remaining -= length
}
return points.last
}
static func summary(of values: [Double]) -> String {
let values = values.filter(\.isFinite)
guard let first = values.first, let last = values.last,
let low = values.min(), let high = values.max() else { return "No data" }
return "From \(first.formatted()) to \(last.formatted()). Lowest \(low.formatted()), highest \(high.formatted())."
}
}
// MARK: - Demo
struct LineChartDrawInDemo: View {
@State private var isAlternate = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var values: [Double] {
isAlternate
? [62, 58, 70, 64, 80, 76, 66, 72, 88, 84, 96, 90]
: [20, 34, 28, 46, 40, 58, 52, 74, 66, 82, 78, 96]
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Active users")
.font(.largeTitle.bold())
Text(isAlternate ? "Europe" : "Americas")
.font(.title3)
.foregroundStyle(.secondary)
LineChartDrawIn(values: values)
.padding(.vertical, 24)
}
.padding(24)
.task(id: reduceMotion) {
guard !reduceMotion else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2.8))
guard !Task.isCancelled else { return }
isAlternate.toggle()
}
}
}
}
#Preview {
LineChartDrawInDemo()
}