Plot Math Functions With Swift Charts LinePlot

The first version of many Swift Charts line charts starts with an array. Build a model for each point, sample the thing you care about, and draw one LineMark per element.

That is still the right approach for measured data. If the data came from HealthKit, a server response, a sensor, or a database, you have points and you should plot those points. But if the thing you want to show is a mathematical function, creating an array just so a chart can turn it back into a line is extra ceremony.

Starting in iOS 18, Swift Charts can plot functions directly with LinePlot and AreaPlot. For a y = f(x) curve, you provide a closure from Double to Double, and Swift Charts handles the sampling needed to draw it. The same release also added vectorized plot APIs for collections, which are useful when a whole dataset shares the same styling.

LinePlot, AreaPlot, and the other vectorized plot types are available on iOS 18, iPadOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2. The broader Swift Charts framework is older, so put these plot APIs behind availability checks if your app still supports iOS 17, macOS 14, or earlier.

The function

Let’s plot a normal distribution. The probability density function is a nice example because it is smooth, familiar, and annoying enough to sample by hand that the new API earns its place.

import Charts
import Foundation
import SwiftUI

func normalDensity(
    x: Double,
    mean: Double,
    standardDeviation: Double
) -> Double {
    guard standardDeviation > 0 else {
        return .nan
    }

    let variance = standardDeviation * standardDeviation
    let scale = 1 / (standardDeviation * sqrt(2 * Double.pi))
    let exponent = -pow(x - mean, 2) / (2 * variance)

    return scale * exp(exponent)
}

Returning .nan is deliberate. Apple’s function plot docs expect Double.nan for undefined inputs, and Double.infinity for infinite results. That is better than trapping or inventing a fake density for invalid input.

Plot the curve

Here is a complete chart for a standard normal distribution:

struct NormalDistributionChart: View {
    private let mean = 0.0
    private let standardDeviation = 1.0
    private let domain = -3.0 ... 3.0

    var body: some View {
        Chart {
            AreaPlot(
                x: "z score",
                y: "Density",
                domain: domain
            ) { x in
                normalDensity(
                    x: x,
                    mean: mean,
                    standardDeviation: standardDeviation
                )
            }
            .foregroundStyle(.blue.opacity(0.18))

            LinePlot(
                x: "z score",
                y: "Density",
                domain: domain
            ) { x in
                normalDensity(
                    x: x,
                    mean: mean,
                    standardDeviation: standardDeviation
                )
            }
            .foregroundStyle(.blue)
            .lineStyle(StrokeStyle(lineWidth: 3))
        }
        .chartXScale(domain: domain)
        .chartYScale(domain: 0 ... 0.45)
        .chartXAxisLabel("z score")
        .chartYAxisLabel("Density")
        .frame(height: 280)
    }
}

LinePlot is the curve. AreaPlot fills the area under the same function so the distribution reads as a shape instead of only a stroke. The function body is the same in both plots: take an x, return a y.

There are two domains in that example, and they do related but different jobs. The domain: passed to LinePlot and AreaPlot limits the part of the function that the plot samples. If you leave it as nil, Charts uses the chart’s x-scale domain. The .chartXScale(domain:) modifier controls the visible x-axis range for the chart. For a simple function chart, keeping them the same is usually the least surprising choice.

Highlight part of the distribution

AreaPlot can also fill the area between two y values returned for each x. For a distribution chart, that gives you an easy way to shade one interval while keeping the full curve visible.

struct HighlightedNormalDistributionChart: View {
    private let domain = -3.0 ... 3.0

    var body: some View {
        Chart {
            AreaPlot(
                x: "z score",
                yStart: "Baseline",
                yEnd: "Density",
                domain: -1.0 ... 1.0
            ) { x in
                (
                    yStart: 0,
                    yEnd: normalDensity(
                        x: x,
                        mean: 0,
                        standardDeviation: 1
                    )
                )
            }
            .foregroundStyle(.green.opacity(0.28))

            LinePlot(x: "z score", y: "Density", domain: domain) { x in
                normalDensity(x: x, mean: 0, standardDeviation: 1)
            }
            .foregroundStyle(.blue)
            .lineStyle(StrokeStyle(lineWidth: 3))
        }
        .chartXScale(domain: domain)
        .chartYScale(domain: 0 ... 0.45)
    }
}

The shaded region uses the yStart and yEnd function initializer. For each x in the highlighted domain, the lower bound is zero and the upper bound is the normal density. You can use the same tuple-label pattern for confidence bands, min/max envelopes, or the difference between two equations.

Compare it with sampled LineMark data

Before function plots, you would usually sample the function yourself:

struct FunctionSample: Identifiable {
    let x: Double
    let y: Double

    var id: Double { x }
}

func samples(
    from lowerBound: Double,
    through upperBound: Double,
    step: Double,
    mean: Double,
    standardDeviation: Double
) -> [FunctionSample] {
    stride(from: lowerBound, through: upperBound, by: step).map { x in
        FunctionSample(
            x: x,
            y: normalDensity(
                x: x,
                mean: mean,
                standardDeviation: standardDeviation
            )
        )
    }
}

Then you would draw those values with LineMark:

struct SampledNormalChart: View {
    private let points = samples(
        from: -3,
        through: 3,
        step: 0.5,
        mean: 0,
        standardDeviation: 1
    )

    var body: some View {
        Chart {
            ForEach(points) { point in
                LineMark(
                    x: .value("z score", point.x),
                    y: .value("Density", point.y)
                )
            }
            .interpolationMethod(.catmullRom)
            .foregroundStyle(.orange)
        }
        .chartXScale(domain: -3 ... 3)
        .chartYScale(domain: 0 ... 0.45)
    }
}

That approach gives you complete control over the inputs. It is also the right mental model when your “function” is really a set of observations. The cost is that chart quality now depends on the sampling step. A coarse step may hide detail between sampled x values. A tiny step creates more data for your view to manage.

For a mathematical curve, LinePlot moves that responsibility into Swift Charts:

Chart {
    LinePlot(x: "z score", y: "Density", domain: -3 ... 3) { x in
        normalDensity(x: x, mean: 0, standardDeviation: 1)
    }
}

The code is shorter, but the bigger win is intent. This chart is not a list of measurements. It is a graph of y = f(x).

When to use vectorized plots

LinePlot is not only for functions. It can also take a whole RandomAccessCollection and plot it as one vectorized chart content value:

Chart {
    LinePlot(
        points,
        x: .value("z score", \.x),
        y: .value("Density", \.y)
    )
    .foregroundStyle(.orange)
}

Use this collection initializer when you already have data points and the series can be expressed as one plot. You can still style vectorized plots with constants or key paths, but if each element needs different mark types, conditional annotations, or one-off styling, individual marks inside ForEach are still the more flexible tool.

That gives you a useful split:

  • Use function LinePlot or AreaPlot for equations.
  • Use vectorized plots for large, consistently styled collections.
  • Use LineMark, PointMark, and friends when individual marks need individual decisions.

Most app charts do not need all three approaches at once. Pick the one that matches the source of the values. Measured data wants marks or vectorized plots. Math wants a function plot.

Further reading: