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

- Charts
- iOS 18.0+
- intermediate
- 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
Description
Section titled “Description”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() } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
values | [Double] | Required |
tint | Color | .indigo |
duration | Double | 1.6 |
Copy this code into your project, or download the file.
// 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()}