SimpleGraph.swift

SwiftUI lijngraafiek uitgelegd — gegenereerd uit broncode met commentaar

Concepten in dit bestand:

import SwiftUI

Data Model

Een enkel datapunt: een label en een waarde. 'Identifiable' zodat ForEach elk punt uniek kan herkennen.

struct Datapunt: Identifiable {
    let id = UUID()
    let label: String
    let waarde: Double
}

Een dataset heeft een naam en een reeks datapunten.

struct Dataset: Identifiable {
    let id = UUID()
    let naam: String
    let kleur: Color
    let punten: [Datapunt]

De minimale en maximale waarde in deze dataset. Handig voor het berekenen van de schaal van de grafiek.

    var minimum: Double { punten.map(\.waarde).min() ?? 0 }
    var maximum: Double { punten.map(\.waarde).max() ?? 1 }
}

Lijn Vorm

Door het 'Shape'-protocol te implementeren kunnen we een herbruikbare, schaalbare vorm maken. SwiftUI geeft ons via 'rect' de beschikbare ruimte. Wij berekenen dan de coordinaten van de lijn op basis van die ruimte.

struct LijnVorm: Shape {
    let punten: [Datapunt]
    let minimum: Double
    let maximum: Double

'path(in:)' is de vereiste methode van het Shape-protocol. rect is de beschikbare rechthoek (afkomstig van de parent-view).

    func path(in rect: CGRect) -> Path {
        guard punten.count > 1 else { return Path() }

Path is een verzameling van lijnen en curves. We bouwen hem stap voor stap op met 'move' en 'addLine'.

        var pad = Path()

Hulpfunctie: zet een datapunt om naar een CGPoint op het scherm. De x-positie verdeelt de punten gelijkmatig over de breedte. De y-positie schaalt de waarde van 0 (onderkant) naar 1 (bovenkant).

        func coordinaat(index: Int, waarde: Double) -> CGPoint {
            let bereik = maximum - minimum == 0 ? 1 : maximum - minimum
            let x = rect.width * CGFloat(index) / CGFloat(punten.count - 1)
            let y = rect.height * CGFloat(1 - (waarde - minimum) / bereik)
            return CGPoint(x: x, y: y)
        }

Zet het startpunt neer met 'move'. Zonder move begint Path altijd op (0, 0).

        pad.move(to: coordinaat(index: 0, waarde: punten[0].waarde))

Verbind elk volgend punt met een rechte lijn via 'addLine'.

        for (index, punt) in punten.enumerated().dropFirst() {
            pad.addLine(to: coordinaat(index: index, waarde: punt.waarde))
        }
        return pad
    }
}

Opvulvorm (gradiënt onder de lijn)

Dezelfde berekening als LijnVorm, maar dan gesloten naar de onderkant. Zo kunnen we de ruimte onder de lijn vullen met een verloopkleur.

struct OpvulVorm: Shape {
    let punten: [Datapunt]
    let minimum: Double
    let maximum: Double
    func path(in rect: CGRect) -> Path {
        guard punten.count > 1 else { return Path() }
        var pad = Path()
        func coordinaat(index: Int, waarde: Double) -> CGPoint {
            let bereik = maximum - minimum == 0 ? 1 : maximum - minimum
            let x = rect.width * CGFloat(index) / CGFloat(punten.count - 1)
            let y = rect.height * CGFloat(1 - (waarde - minimum) / bereik)
            return CGPoint(x: x, y: y)
        }
        pad.move(to: coordinaat(index: 0, waarde: punten[0].waarde))
        for (index, punt) in punten.enumerated().dropFirst() {
            pad.addLine(to: coordinaat(index: index, waarde: punt.waarde))
        }

Sluit de vorm door naar de rechteronderkant en linkeronderkant te gaan. Zo ontstaat een gesloten vlak dat we kunnen vullen.

        pad.addLine(to: CGPoint(x: rect.width, y: rect.height))
        pad.addLine(to: CGPoint(x: 0, y: rect.height))
        pad.closeSubpath()
        return pad
    }
}

Grafiek View

struct GrafiekView: View {
    let dataset: Dataset

@State: als de gebruiker op een punt tikt, slaan we de index op. nil betekent: geen punt geselecteerd.

    @State private var geselecteerdIndex: Int? = nil

Vaste marges rondom de grafiek zodat labels niet afgekapt worden.

    private let margeLinks:   CGFloat = 48
    private let margeOnder:   CGFloat = 32
    private let margeBoven:   CGFloat = 16
    var body: some View {

GeometryReader geeft de beschikbare breedte en hoogte door als 'geo'. Zonder GeometryReader weten we niet hoe groot de view is. We gebruiken dit om de grafiek exact passend te tekenen.

        GeometryReader { geo in
            let grafiekBreedte = geo.size.width - margeLinks
            let grafiekHoogte  = geo.size.height - margeOnder - margeBoven
            ZStack(alignment: .topLeading) {

Laag 1: rasterlijnen en y-labels

                rasterLagen(hoogte: grafiekHoogte, breedte: grafiekBreedte)
                    .offset(x: margeLinks, y: margeBoven)

Laag 2: opvulling onder de lijn LinearGradient maakt een verloopkleur. 'startPoint' en 'endPoint' bepalen de richting van het verloop.

                OpvulVorm(
                    punten: dataset.punten,
                    minimum: dataset.minimum,
                    maximum: dataset.maximum
                )

.fill vult de gesloten vorm met de opgegeven kleur of gradient.

                .fill(
                    LinearGradient(
                        colors: [dataset.kleur.opacity(0.3), dataset.kleur.opacity(0.0)],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                )
                .frame(width: grafiekBreedte, height: grafiekHoogte)
                .offset(x: margeLinks, y: margeBoven)

Laag 3: de lijn zelf

                LijnVorm(
                    punten: dataset.punten,
                    minimum: dataset.minimum,
                    maximum: dataset.maximum
                )

.stroke tekent alleen de rand van de vorm, niet de vulling. lineWidth bepaalt de dikte.

                .stroke(dataset.kleur, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
                .frame(width: grafiekBreedte, height: grafiekHoogte)
                .offset(x: margeLinks, y: margeBoven)

Laag 4: datapunten (cirkels) en x-labels

                puntLagen(breedte: grafiekBreedte, hoogte: grafiekHoogte)
                    .offset(x: margeLinks, y: margeBoven)

Laag 5: tooltip bij geselecteerd punt

                if let index = geselecteerdIndex {
                    tooltipView(index: index, breedte: grafiekBreedte, hoogte: grafiekHoogte)
                        .offset(x: margeLinks, y: margeBoven)
                }
            }

onTapGesture met coordinaat: we gebruiken de x-positie van de tik om te berekenen op welk datapunt de gebruiker tikte.

            .contentShape(Rectangle())
            .onTapGesture { locatie in
                let relatieveX = locatie.x - margeLinks
                let stap = grafiekBreedte / CGFloat(dataset.punten.count - 1)
                let index = Int((relatieveX / stap).rounded())
                let geldig = max(0, min(dataset.punten.count - 1, index))
                geselecteerdIndex = geselecteerdIndex == geldig ? nil : geldig
            }
        }
    }

Rasterlijnen

@ViewBuilder zodat we meerdere views kunnen teruggeven vanuit een functie.

    @ViewBuilder
    private func rasterLagen(hoogte: CGFloat, breedte: CGFloat) -> some View {
        let aantalLijnen = 4
        let bereik = dataset.maximum - dataset.minimum
        ForEach(0...aantalLijnen, id: \.self) { i in
            let y = hoogte * CGFloat(i) / CGFloat(aantalLijnen)
            let waarde = dataset.maximum - bereik * Double(i) / Double(aantalLijnen)

Horizontale rasterlijn Rectangle() met een vaste hoogte van 1 punt = dunne lijn

            Rectangle()
                .fill(Color.white.opacity(0.08))
                .frame(width: breedte, height: 1)
                .position(x: breedte / 2, y: y)

Y-as label links van de lijn .position() plaatst het middelpunt van de view op die coordinaat.

            Text(waarde, format: .number.precision(.fractionLength(0)))
                .font(.system(size: 10).monospacedDigit())
                .foregroundColor(.secondary)
                .frame(width: margeLinks - 8, alignment: .trailing)
                .position(x: -(margeLinks / 2), y: y)
        }
    }

Datapunten en x-labels

    @ViewBuilder
    private func puntLagen(breedte: CGFloat, hoogte: CGFloat) -> some View {
        let bereik = dataset.maximum - dataset.minimum == 0 ? 1.0 : dataset.maximum - dataset.minimum
        ForEach(Array(dataset.punten.enumerated()), id: \.offset) { index, punt in
            let x = breedte * CGFloat(index) / CGFloat(dataset.punten.count - 1)
            let y = hoogte * CGFloat(1 - (punt.waarde - dataset.minimum) / bereik)
            let isGeselecteerd = geselecteerdIndex == index

Cirkel op het datapunt Een Circle() met .frame geeft een cirkel met vaste afmetingen.

            Circle()
                .fill(isGeselecteerd ? dataset.kleur : Color.white)
                .frame(width: isGeselecteerd ? 10 : 6, height: isGeselecteerd ? 10 : 6)
                .overlay(
                    Circle().stroke(dataset.kleur, lineWidth: isGeselecteerd ? 0 : 2)
                )

.position plaatst het middelpunt van de view op (x, y)

                .position(x: x, y: y)

X-as label (niet elk label tonen als er veel punten zijn)

            if dataset.punten.count <= 12 || index % 2 == 0 {
                Text(punt.label)
                    .font(.system(size: 9))
                    .foregroundColor(.secondary)
                    .position(x: x, y: hoogte + 16)
            }
        }
    }

Tooltip

    @ViewBuilder
    private func tooltipView(index: Int, breedte: CGFloat, hoogte: CGFloat) -> some View {
        let punt = dataset.punten[index]
        let bereik = dataset.maximum - dataset.minimum == 0 ? 1.0 : dataset.maximum - dataset.minimum
        let x = breedte * CGFloat(index) / CGFloat(dataset.punten.count - 1)
        let y = hoogte * CGFloat(1 - (punt.waarde - dataset.minimum) / bereik)

Verticale lijn van punt naar x-as

        Rectangle()
            .fill(dataset.kleur.opacity(0.4))
            .frame(width: 1, height: hoogte - y)
            .position(x: x, y: y + (hoogte - y) / 2)

Tooltip kaartje GroupBox geeft een ingebouwde kaart-stijl met achtergrond

        VStack(alignment: .leading, spacing: 2) {
            Text(punt.label)
                .font(.caption2)
                .foregroundColor(.secondary)
            Text(punt.waarde, format: .number.precision(.fractionLength(1)))
                .font(.caption.bold().monospacedDigit())
                .foregroundColor(dataset.kleur)
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 6)
        .background(.ultraThinMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 6))
        .overlay(
            RoundedRectangle(cornerRadius: 6)
                .strokeBorder(dataset.kleur.opacity(0.4), lineWidth: 1)
        )

Verschuif de tooltip zodat hij niet buiten het scherm valt

        .position(
            x: min(max(x, 60), breedte - 60),
            y: max(y - 40, 30)
        )
    }
}

Hoofdview

struct SimpleGraph: View {

De beschikbare datasets om uit te kiezen

    private let datasets: [Dataset] = [
        Dataset(
            naam: "Temperatuur",
            kleur: .orange,
            punten: [
                Datapunt(label: "jan", waarde: 4.1),
                Datapunt(label: "feb", waarde: 5.8),
                Datapunt(label: "mrt", waarde: 9.2),
                Datapunt(label: "apr", waarde: 13.4),
                Datapunt(label: "mei", waarde: 17.1),
                Datapunt(label: "jun", waarde: 19.8),
                Datapunt(label: "jul", waarde: 21.5),
                Datapunt(label: "aug", waarde: 21.2),
                Datapunt(label: "sep", waarde: 17.6),
                Datapunt(label: "okt", waarde: 12.9),
                Datapunt(label: "nov", waarde: 7.8),
                Datapunt(label: "dec", waarde: 4.3),
            ]
        ),
        Dataset(
            naam: "Neerslag",
            kleur: .cyan,
            punten: [
                Datapunt(label: "jan", waarde: 68),
                Datapunt(label: "feb", waarde: 47),
                Datapunt(label: "mrt", waarde: 62),
                Datapunt(label: "apr", waarde: 43),
                Datapunt(label: "mei", waarde: 55),
                Datapunt(label: "jun", waarde: 67),
                Datapunt(label: "jul", waarde: 78),
                Datapunt(label: "aug", waarde: 71),
                Datapunt(label: "sep", waarde: 80),
                Datapunt(label: "okt", waarde: 88),
                Datapunt(label: "nov", waarde: 82),
                Datapunt(label: "dec", waarde: 74),
            ]
        ),
        Dataset(
            naam: "Zonuren",
            kleur: .yellow,
            punten: [
                Datapunt(label: "jan", waarde: 62),
                Datapunt(label: "feb", waarde: 89),
                Datapunt(label: "mrt", waarde: 133),
                Datapunt(label: "apr", waarde: 175),
                Datapunt(label: "mei", waarde: 220),
                Datapunt(label: "jun", waarde: 212),
                Datapunt(label: "jul", waarde: 218),
                Datapunt(label: "aug", waarde: 196),
                Datapunt(label: "sep", waarde: 148),
                Datapunt(label: "okt", waarde: 105),
                Datapunt(label: "nov", waarde: 60),
                Datapunt(label: "dec", waarde: 47),
            ]
        ),
    ]

@State: houdt bij welke dataset geselecteerd is via de Picker. Elke keer dat dit verandert, wordt de grafiek opnieuw getekend.

    @State private var geselecteerdeIndex: Int = 0

Tijdstip van de laatste herlaadactie. Wordt bijgewerkt via de toolbar-knop. De ondertitel toont dit tijdstip zodat de gebruiker weet wanneer de data ververst is.

    @State private var laatstBijgewerkt: Date = Date()
    var huidigDataset: Dataset { datasets[geselecteerdeIndex] }
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {

Dataset-kiezer

                Picker("Dataset", selection: $geselecteerdeIndex) {
                    ForEach(datasets.indices, id: \.self) { i in
                        Text(datasets[i].naam).tag(i)
                    }
                }
                .pickerStyle(.segmented)
                .padding()
                Divider()

Informatieregel: min, max en gemiddelde

                informatieRegel

De grafiek zelf — neemt alle beschikbare ruimte in

                GrafiekView(dataset: huidigDataset)
                    .padding(.horizontal, 16)
                    .padding(.vertical, 12)

.frame(maxWidth/maxHeight: .infinity) laat een view alle beschikbare ruimte innemen in die richting

                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                Divider()
                Text("Tik op de grafiek om een waarde te selecteren")
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .padding(.vertical, 10)
            }
            .navigationTitle(huidigDataset.naam)

.navigationSubtitle toont een kleinere tekst onder de hoofdtitel (macOS/iPadOS).

            .navigationSubtitle("Bijgewerkt: \(laatstBijgewerkt.formatted(date: .omitted, time: .shortened))")
            #if os(iOS)
            .navigationBarTitleDisplayMode(.large)
            #endif

.toolbar voegt knoppen toe aan de navigatiebalk. placement: .automatic kiest de juiste positie per platform.

            .toolbar {
                ToolbarItem(placement: .automatic) {
                    Button {

Herlaad: update het tijdstip zodat de ondertitel verandert. In een echte app zou je hier nieuwe data ophalen.

                        laatstBijgewerkt = Date()
                    } label: {
                        Label("Herlaad", systemImage: "arrow.clockwise")
                    }
                }
            }
            .background(Color(red: 0.1, green: 0.1, blue: 0.15))
            .foregroundColor(.white)
        }

ColorScheme forceren op donker zodat de grafiek goed zichtbaar is

        .preferredColorScheme(.dark)
    }

Informatie Regel

    private var informatieRegel: some View {
        let waarden = huidigDataset.punten.map(\.waarde)
        let gemiddelde = waarden.reduce(0, +) / Double(waarden.count)
        return HStack(spacing: 0) {
            StatKaartje(label: "Min", waarde: huidigDataset.minimum, kleur: .blue)
            Divider().frame(height: 36)
            StatKaartje(label: "Gem", waarde: gemiddelde, kleur: huidigDataset.kleur)
            Divider().frame(height: 36)
            StatKaartje(label: "Max", waarde: huidigDataset.maximum, kleur: .red)
        }
        .padding(.vertical, 8)
        .background(Color.white.opacity(0.04))
    }
}

Statistiek Kaartje

Een kleine herbruikbare view voor min/gem/max. 'let' properties: worden meegegeven bij initialisatie, veranderen nooit.

struct StatKaartje: View {
    let label: String
    let waarde: Double
    let kleur: Color
    var body: some View {
        VStack(spacing: 2) {
            Text(label)
                .font(.caption2)
                .foregroundColor(.secondary)
                .textCase(.uppercase)
            Text(waarde, format: .number.precision(.fractionLength(1)))
                .font(.callout.bold().monospacedDigit())
                .foregroundColor(kleur)
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 4)
    }
}

Preview

#Preview {
    SimpleGraph()
}