- SimpleGraph.swift
- SwiftUI-examples
- Created by Bastiaan Nelissen on 17/03/2026.
Concepten in dit bestand:
- GeometryReader – geeft de beschikbare breedte en hoogte door aan de inhoud
- Path – teken vrije vormen met lijnen en curves
- Shape – protocol om herbruikbare vormen te maken
- .stroke – tekent de rand van een vorm
- .fill – vult een vorm met een kleur of gradient
- LinearGradient – verloopkleur van boven naar beneden (of andere richting)
- ZStack – stapelt views over elkaar voor het laag-effect
- @State – lokale toestand die hertekening triggert bij wijziging
- Picker – laat de gebruiker kiezen uit meerdere opties
- ForEach – herhaalt een view voor elk item in een collectie
- .overlay – legt een view over een andere heen
- .position – plaatst een view op een exacte x/y positie
- .frame – geeft een view een vaste breedte of hoogte
- CGPoint – een punt met x- en y-coordinaat (CoreGraphics)
- CGSize – een breedte en hoogte (CoreGraphics)
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()
}