- TabelView.swift
- SwiftUI-examples
- Created by Bastiaan Nelissen on 17/03/2026.
OVERZICHT VAN SWIFTUI CONCEPTEN IN DIT BESTAND
PROPERTY WRAPPERS (speciale voorvoegsels voor variabelen)
- @State – lokale toestand van een view
- @StateObject – maakt een ViewModel aan en beheert die
- @ObservedObject – kijkt naar een bestaand ObservableObject
- @Published – variabele die views laat hertekenen bij wijziging
- @Environment – ingebouwde waarden van SwiftUI (bijv. dismiss)
- @ViewBuilder – maakt conditionele view-teruggave mogelijk
- @MainActor – garandeert uitvoering op de hoofdthread (UI)
- @ToolbarContentBuilder – builder voor toolbar-inhoud
PROTOCOLS (afspraken die een type moet nakomen)
- Identifiable – elk item heeft een unieke id
- Hashable – items mogen in een Set of als dict-sleutel
- ObservableObject – klasse waarvan SwiftUI wijzigingen bijhoudt
- CaseIterable – enum-waarden zijn optelbaar via .allCases
LAYOUT
- VStack / HStack / ZStack – stapelen verticaal / horizontaal / over elkaar
- Spacer – neemt alle vrije ruimte in
- Divider – horizontale scheidingslijn
- ScrollView – maakt inhoud scrollbaar
- LazyVGrid – raster dat rijen pas aanmaakt als ze zichtbaar zijn
- GridItem – beschrijft een kolom in een raster
NAVIGATIE & PRESENTATIE
- NavigationStack – beheert een stapel schermen
- .sheet – zweefscherm dat van onderaf omhoog schuift
- .popover – klein zweefvenster (iPad: ballon, iPhone: sheet)
- .overlay – legt een view bovenop een andere
- .toolbar / ToolbarItem – knoppen in de navigatiebalk
LIJSTEN & COLLECTIES
- List – scrollbare lijst met rijen
- ForEach – herhaalt een view voor elk item
- Section – groepeert rijen met header en footer
- .swipeActions – acties bij vegen op een rij
- .refreshable – pull-to-refresh
- .contextMenu – menu bij lang indrukken
- .searchable – zoekbalk bovenaan de NavigationStack
CONTROLS
- Toggle – aan/uit schakelaar
- Stepper – verhoog of verlaag een getal
- DatePicker – kies een datum
- Picker – kies een optie
- ProgressView – voortgangsbalk of draaiende indicator
- Menu – dropdown menu met knoppen
- Button – knop die een actie uitvoert
OPMAAK
- .clipShape / Capsule / RoundedRectangle – knip een vorm uit
- .shadow – slagschaduw
- .overlay met strokeBorder – rand tekenen om een vorm
- .monospacedDigit() – cijfers even breed (voor uitlijning)
- .lineLimit – maximaal aantal regels tekst
- .textCase – automatisch hoofdletters of kleine letters
- .tint – kleur voor interactieve elementen
- .foregroundColor / .foregroundStyle – tekstkleur
- .background – achtergrondkleur of -materiaal
- .padding – witruimte rondom een view
OVERIG
- Binding ($) – tweerichtingsverbinding naar een waarde
- UUID() – wereldwijd uniek ID genereren
- .tag – koppelt een view aan een selectiewaarde
- .contentShape – vergroot het tik-gebied
- LabeledContent – label links, waarde rechts (in Form)
- GroupBox – gegroepeerd kader met optionele titel
- Form – formulier-layout voor instellingen
import SwiftUI
Combine is het framework achter ObservableObject en @Published. SwiftUI importeert het niet automatisch, dus we doen het hier zelf.
import Combine
Hulpkleuren (platform-onafhankelijk)
Color.grijs5 gebruikt UIKit en werkt alleen op iOS. Deze extension biedt dezelfde kleuren voor iOS en macOS via #if canImport. '#if canImport(UIKit)' is een compilatieconditie: de code daarbinnen wordt alleen gecompileerd als UIKit beschikbaar is (iOS/iPadOS), anders de #else-tak.
private extension Color {
#if canImport(UIKit)
static let systeemAchtergrond = Color(uiColor: .systemBackground)
static let achtergrondGroepen = Color(uiColor: .systemGroupedBackground)
static let grijs5 = Color(uiColor: .systemGray5)
static let grijs6 = Color(uiColor: .systemGray6)
#else
static let systeemAchtergrond = Color(nsColor: .windowBackgroundColor)
static let achtergrondGroepen = Color(nsColor: .controlBackgroundColor)
static let grijs5 = Color(nsColor: .quaternaryLabelColor)
static let grijs6 = Color(nsColor: .controlColor)
#endif
}
Data Model
'struct' is een waardetypen: elke kopie is onafhankelijk. 'class' is een referentietype: meerdere variabelen wijzen naar hetzelfde object. Voor data-modellen in SwiftUI gebruik je vrijwel altijd struct.
'Identifiable' is een protocol (een soort contract): het verplicht ons een 'id' te hebben zodat SwiftUI elk item in een lijst uniek kan herkennen en efficient kan hertekenen als iets verandert.
'Hashable' maakt het mogelijk items op te slaan in een Set<> of als sleutel in een Dictionary. Het wordt hier gebruikt voor multi-selectie (Set<TabelItem.ID>).
struct TabelItem: Identifiable, Hashable {
UUID() maakt een wereldwijd uniek ID aan (bijv. "550e8400-e29b-41d4..."). 'let' betekent dat de waarde nooit meer verandert na aanmaken.
let id = UUID()
let nummer: Int
let naam: String
let categorie: String
let waarde: Double
let status: Status
let datum: Date
'var' in tegenstelling tot 'let': deze waarde mag achteraf worden gewijzigd.
var isFavoriet: Bool
'enum' (opsomming) definieert een vaste verzameling mogelijke waarden. 'String' as raw value betekent dat elke case ook een tekst-waarde heeft die je opvraagt met .rawValue (bijv. Status.actief.rawValue == "Actief"). 'CaseIterable' voegt .allCases toe zodat je over alle gevallen kunt loopen.
enum Status: String, CaseIterable, Hashable {
case actief = "Actief"
case inactief = "Inactief"
case wachten = "Wachten"
case afgerond = "Afgerond"
Een 'computed property': geen opgeslagen waarde, maar elke keer berekend. Dit koppelt een kleur aan elke status zonder die apart op te slaan.
var kleur: Color {
switch self {
case .actief: return .green
case .inactief: return .red
case .wachten: return .orange
case .afgerond: return .blue
}
}
}
}
ViewModel
Het ViewModel-patroon scheidt de logica (wat er met data gebeurt) van de weergave (hoe het er uit ziet). De View vraagt de data op bij het ViewModel en stuurt acties terug. Het ViewModel weet niets van de View.
'ObservableObject' is een protocol voor klassen waarvan SwiftUI de wijzigingen kan bijhouden. Zodra een @Published variabele verandert, hertekent SwiftUI alle views die dat ViewModel gebruiken.
'@MainActor' zorgt dat alle code in deze klasse op de hoofdthread (UI-thread) wordt uitgevoerd. SwiftUI-updates mogen alleen op de hoofdthread plaatsvinden.
@MainActor
class TabelViewModel: ObservableObject {
'@Published' is de sleutel: zodra een van deze variabelen verandert, worden alle views die dit ViewModel observeren automatisch opnieuw getekend. Vergelijk het met een nieuwsbrief: views zijn abonnees, @Published is het bericht.
@Published var items: [TabelItem] = []
@Published var isLaden: Bool = false
@Published var zoekTekst: String = ""
@Published var geselecteerdeStatus: TabelItem.Status? = nil // nil = geen filter actief
@Published var sorteerVeld: SorteerVeld = .nummer
@Published var sorteerOplopend: Bool = true
@Published var geselecteerdeItems: Set<TabelItem.ID> = [] // Set = geen duplicaten
@Published var toonAlleenFavorieten: Bool = false
@Published var paginaGrootte: Int = 20
enum SorteerVeld: String, CaseIterable {
case nummer = "Nr."
case naam = "Naam"
case waarde = "Waarde"
case datum = "Datum"
}
'private' betekent dat deze variabelen alleen binnen de klasse zichtbaar zijn. De View heeft ze niet nodig; ze zijn puur intern hulpmateriaal.
private let categorien = ["Categorie A", "Categorie B", "Categorie C", "Categorie D"]
private let namen = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta", "Iota", "Kappa",
"Lambda", "Mu", "Nu", "Xi", "Omicron", "Pi", "Rho", "Sigma", "Tau", "Upsilon"]
'init()' is de initialisatiefunctie: wordt aangeroepen zodra het object aangemaakt wordt.
init() {
laadData()
}
func laadData() {
isLaden = true
let kalender = Calendar.current
let basisDatum = Date()
.map transformeert een collectie: voor elk getal in 1...120 maak je een TabelItem. De uitkomst is een array van 120 TabelItem objecten.
items = (1...120).map { index in
let status = TabelItem.Status.allCases.randomElement()!
let dagOffset = Int.random(in: -365...0)
let datum = kalender.date(byAdding: .day, value: dagOffset, to: basisDatum)!
return TabelItem(
nummer: index,
naam: "\(namen.randomElement()!) \(index)",
categorie: categorien.randomElement()!,
waarde: Double.random(in: 1.0...9999.99),
status: status,
datum: datum,
isFavoriet: Bool.random()
)
}
isLaden = false
}
func herlaad() {
isLaden = true
DispatchQueue.main.asyncAfter voert code uit na een vertraging. Dit simuleert een netwerkverzoek. In een echte app zou je hier 'async/await' gebruiken met een URLSession-aanroep.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
self.laadData()
}
}
Een 'computed var' zonder setter: elke keer dat je gefilterdeItems opvraagt wordt de filtering en sortering opnieuw uitgevoerd op basis van de huidige staat. SwiftUI roept dit automatisch opnieuw aan als een @Published variabele wijzigt.
var gefilterdeItems: [TabelItem] {
var resultaat = items
'isEmpty' controleert of de tekst leeg is. Het uitroepteken (!) voor de voorwaarde maakt de conditie om: "als de tekst NIET leeg is".
if !zoekTekst.isEmpty {
.filter behoudt alleen items waarvoor de closure 'true' teruggeeft. localizedCaseInsensitiveContains houdt rekening met taal en negeert hoofd/kleine letters.
resultaat = resultaat.filter {
$0.naam.localizedCaseInsensitiveContains(zoekTekst) ||
$0.categorie.localizedCaseInsensitiveContains(zoekTekst)
}
}
'if let' pakt een Optional uit: als geselecteerdeStatus niet nil is, wordt de waarde beschikbaar als 'status' binnen het if-blok.
if let status = geselecteerdeStatus {
resultaat = resultaat.filter { $0.status == status }
}
if toonAlleenFavorieten {
resultaat = resultaat.filter { $0.isFavoriet }
}
resultaat.sort { a, b in
let oplopend: Bool
switch sorteerVeld {
case .nummer: oplopend = a.nummer < b.nummer
case .naam: oplopend = a.naam < b.naam
case .waarde: oplopend = a.waarde < b.waarde
case .datum: oplopend = a.datum < b.datum
}
De ternaire operator (? :) werkt als een inline if-else. sorteerOplopend ? oplopend : !oplopend betekent: "als oplopend sorteren, geef oplopend terug, anders het omgekeerde"
return sorteerOplopend ? oplopend : !oplopend
}
return resultaat
}
func toggleFavoriet(_ item: TabelItem) {
firstIndex(where:) zoekt de positie van een item in de array. We zoeken op id omdat TabelItem een struct is (kopie), niet een referentie.
if let index = items.firstIndex(where: { $0.id == item.id }) {
.toggle() wisselt een Bool van true naar false of andersom.
items[index].isFavoriet.toggle()
}
}
func verwijder(_ item: TabelItem) {
removeAll(where:) verwijdert alle items waarvoor de closure 'true' geeft.
items.removeAll { $0.id == item.id }
}
func verwijderGeselecteerde() {
Set.contains() is veel sneller dan Array.contains() voor grote collecties.
items.removeAll { geselecteerdeItems.contains($0.id) }
geselecteerdeItems.removeAll()
}
}
TabelView (Hoofdview)
Elke view in SwiftUI is een struct die het View-protocol naleeft. SwiftUI bouwt de interface op basis van wat 'body' teruggeeft. Zodra een @State of @StateObject verandert, roept SwiftUI 'body' opnieuw aan.
struct TabelView: View {
'@StateObject' maakt het ViewModel aan bij de eerste render en bewaakt het. SwiftUI zorgt ervoor dat het ViewModel blijft bestaan zolang deze view bestaat. Gebruik @StateObject alleen in de view die het object aanmaakt (de eigenaar).
@StateObject private var viewModel = TabelViewModel()
'@State' slaat lokale toestand op binnen een view. De waarde leeft in SwiftUI (niet in de struct zelf), zodat hertekenen de waarde niet kwijtraakt. Elke wijziging triggert een hertekening.
@State private var weergaveModus: WeergaveModus = .lijst
Een optionele @State: als toonDetail nil is, is er geen sheet open. Als het een TabelItem heeft, opent de sheet voor dat item. .sheet(item:) reageert automatisch hierop.
@State private var toonDetail: TabelItem? = nil
@State private var geselecteerdeDatum: Date = Date()
@State private var toonDatumKiezer: Bool = false
enum WeergaveModus: String, CaseIterable {
case lijst = "Lijst"
case raster = "Raster"
case compact = "Compact"
var systeemAfbeelding: String {
switch self {
case .lijst: return "list.bullet"
case .raster: return "square.grid.2x2"
case .compact: return "list.bullet.indent"
}
}
}
Een DateFormatter wordt eenmalig aangemaakt via een 'lazy closure' ({...}()). De haakjes aan het einde roepen de closure direct aan zodat het resultaat wordt opgeslagen in datumFormatter, niet de closure zelf.
private let datumFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
f.locale = Locale(identifier: "nl_NL")
return f
}()
'body' is de vereiste eigenschap van het View-protocol. SwiftUI roept dit aan om te weten wat het moet tekenen. 'some View' betekent: "geeft een concreet View-type terug, maar ik specificeer het exacte type niet". SwiftUI bepaalt het zelf (opaque return type).
var body: some View {
NavigationStack beheert een stapel schermen (push/pop navigatie). Alles binnen de stack krijgt automatisch een navigatiebalk.
NavigationStack {
VStack(spacing: 0) {
statistiekenBanner
Divider() // Horizontale scheidingslijn
filterBar
Divider()
Conditionele view: kolomkoppen alleen tonen in lijstmodus. SwiftUI zet de view neer of haalt hem weg op basis van de waarde.
if weergaveModus == .lijst {
kolomKoppen
Divider()
}
inhoudView
}
.navigationTitle toont de grote titel bovenaan
.navigationTitle("Overzichtstabel")
.navigationSubtitle toont een kleinere ondertitel (macOS/iPadOS)
.navigationSubtitle("Gegenereerd op \(datumFormatter.string(from: Date()))")
.navigationBarTitleDisplayMode bestaat alleen op iOS. Op macOS heeft NavigationStack geen scrollende grote titel.
#if os(iOS)
.navigationBarTitleDisplayMode(.large)
#endif
.searchable voegt een zoekbalk toe aan de NavigationStack. Het $-teken maakt van viewModel.zoekTekst een Binding: de zoekbalk kan de waarde lezen EN schrijven.
.searchable(text: $viewModel.zoekTekst, prompt: "Zoek op naam of categorie")
.toolbar verwacht een @ToolbarContentBuilder closure
.toolbar { toolbarInhoud }
.sheet(item:) toont een sheet als 'toonDetail' een waarde heeft (niet nil). Zodra je toonDetail = nil zet, sluit de sheet automatisch.
.sheet(item: $toonDetail) { item in
DetailSheet(item: item, viewModel: viewModel)
}
.overlay legt een view bovenop de huidige view, zonder de layout te beinvloeden. Hier gebruiken we het voor het laad-scherm dat alles afdekt.
.overlay {
if viewModel.isLaden { laadOverlay }
}
}
}
Statistieken Banner
Door grote views op te splitsen in losse 'private var' properties blijft 'body' leesbaar en overzichtelijk. Dit is geen aparte View-struct maar gewoon een stuk body dat we apart benoemen.
private var statistiekenBanner: some View {
HStack plaatst kinderen naast elkaar (horizontaal)
HStack(spacing: 10) {
StatistiekKaart(
titel: "Totaal",
waarde: "\(viewModel.items.count)",
kleur: .blue
)
StatistiekKaart(
titel: "Gefilterd",
waarde: "\(viewModel.gefilterdeItems.count)",
kleur: .purple
)
StatistiekKaart(
titel: "Favorieten",
waarde: "\(viewModel.items.filter { $0.isFavoriet }.count)",
kleur: .orange
)
StatistiekKaart(
titel: "Geselecteerd",
waarde: "\(viewModel.geselecteerdeItems.count)",
kleur: .green
)
}
.padding(.horizontal)
.padding(.vertical, 8)
Color(.systemGroupedBackground) is een adaptieve systeemkleur die automatisch licht of donker is afhankelijk van de apparaatinstellingen.
.background(Color.achtergrondGroepen)
}
Filter Bar
private var filterBar: some View {
ScrollView(.horizontal) maakt de inhoud horizontaal scrollbaar. showsIndicators: false verbergt de scrollbalk.
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Menu toont een dropdown-menu bij aantikken. Het heeft twee closures: de inhoud (knoppen) en het label (wat je ziet).
Menu {
Button("Alle statussen") {
viewModel.geselecteerdeStatus = nil
}
Divider()
ForEach herhaalt een view voor elk element. id: \.self betekent: gebruik het element zelf als identifier (werkt omdat Status Hashable is).
ForEach(TabelItem.Status.allCases, id: \.self) { status in
Button(status.rawValue) {
viewModel.geselecteerdeStatus = status
}
}
} label: {
'??' is de nil-coalescerende operator: geef de waarde terug als die niet nil is, anders het alternatief na '??'.
Label(
viewModel.geselecteerdeStatus?.rawValue ?? "Status",
systemImage: "line.3.horizontal.decrease.circle"
)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
Ternaire operator voor de achtergrondkleur: als er een status geselecteerd is, lichtblauw; anders grijs.
.background(
viewModel.geselecteerdeStatus != nil
? Color.accentColor.opacity(0.15)
: Color.grijs5
)
.foregroundColor(
viewModel.geselecteerdeStatus != nil ? .accentColor : .primary
)
.clipShape knipt de view bij tot de opgegeven vorm. Capsule() is een pilvorm (afgeronde rechthoek).
.clipShape(Capsule())
}
Toggle is een aan/uit schakelaar. isOn: verwacht een Binding<Bool>. Het $-teken maakt er een Binding van: de Toggle kan de waarde lezen EN schrijven. .toggleStyle(.button) geeft de toggle een knopuiterlijk in plaats van een schuifregelaar.
Toggle(isOn: $viewModel.toonAlleenFavorieten) {
Label("Favorieten", systemImage: "star.fill")
.font(.caption)
}
.toggleStyle(.button)
.tint(.orange) // .tint past de accentkleur aan voor interactieve elementen
Sorteer menu
Menu {
ForEach(TabelViewModel.SorteerVeld.allCases, id: \.self) { veld in
Button {
if viewModel.sorteerVeld == veld {
viewModel.sorteerOplopend.toggle()
} else {
viewModel.sorteerVeld = veld
viewModel.sorteerOplopend = true
}
} label: {
Conditioneel ander label tonen als dit veld actief is. SwiftUI's if/else in een @ViewBuilder geeft verschillende views terug.
if viewModel.sorteerVeld == veld {
Label(
veld.rawValue,
systemImage: viewModel.sorteerOplopend ? "arrow.up" : "arrow.down"
)
} else {
Text(veld.rawValue)
}
}
}
} label: {
Label("Sortering: \(viewModel.sorteerVeld.rawValue)", systemImage: "arrow.up.arrow.down")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.grijs5)
.clipShape(Capsule())
}
Datum filter via Popover Een popover is een zweefvenster. Op iPad verschijnt het als een ballon, op iPhone als een sheet. isPresented: verwacht een Binding<Bool>.
Button {
toonDatumKiezer.toggle()
} label: {
Label(datumFormatter.string(from: geselecteerdeDatum), systemImage: "calendar")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.grijs5)
.clipShape(Capsule())
}
.popover(isPresented: $toonDatumKiezer) {
VStack {
DatePicker laat de gebruiker een datum kiezen. selection: verwacht een Binding<Date> zodat de gekozen datum teruggeschreven wordt naar geselecteerdeDatum. displayedComponents: bepaalt wat je ziet: alleen datum, of ook tijd.
DatePicker(
"Datum",
selection: $geselecteerdeDatum,
displayedComponents: .date
)
.graphical toont een volledige kalender. Andere stijlen: .compact (tekstveld), .wheel (draaiwielen)
.datePickerStyle(.graphical)
.padding()
}
presentationCompactAdaptation bepaalt hoe de popover eruitziet op compacte schermen
.presentationCompactAdaptation(.popover)
}
Stepper verhoogt of verlaagt een getal met plus/min knoppen. value: is een Binding, in: bepaalt het bereik, step: de stapgrootte.
HStack(spacing: 6) {
Text("Per pagina:")
.font(.caption)
.foregroundColor(.secondary)
Stepper(
"\(viewModel.paginaGrootte)",
value: $viewModel.paginaGrootte,
in: 10...50,
step: 10
)
.labelsHidden() verbergt het automatisch gegenereerde label van de Stepper. We tonen de waarde zelf handmatig.
.labelsHidden()
Text("\(viewModel.paginaGrootte)")
.monospacedDigit() zorgt dat alle cijfers even breed zijn. Daardoor verspringt de tekst niet bij wijziging van de waarde.
.font(.caption.monospacedDigit())
.frame(width: 28)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.grijs5)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
Kolom Koppen
private var kolomKoppen: some View {
HStack(spacing: 0) {
'Alles selecteren' knop via een afbeelding met onTapGesture. .contentShape(Rectangle()) vergroot het tik-gebied tot de volledige frame, ook als de afbeelding kleiner is dan 44x44 punten.
Image(
systemName: viewModel.geselecteerdeItems.count == viewModel.gefilterdeItems.count
? "checkmark.square.fill"
: "square"
)
.foregroundColor(.accentColor)
.frame(width: 44)
.contentShape(Rectangle())
.onTapGesture {
if viewModel.geselecteerdeItems.count == viewModel.gefilterdeItems.count {
viewModel.geselecteerdeItems.removeAll()
} else {
.map transformeert elk item naar zijn id, Set() verwijdert duplicaten
viewModel.geselecteerdeItems = Set(viewModel.gefilterdeItems.map { $0.id })
}
}
KolomKop(titel: "Nr.", breedte: 50, sorteerVeld: .nummer, viewModel: viewModel)
KolomKop(titel: "Naam", breedte: nil, sorteerVeld: .naam, viewModel: viewModel)
KolomKop(titel: "Categorie", breedte: 110, sorteerVeld: nil, viewModel: viewModel)
KolomKop(titel: "Status", breedte: 90, sorteerVeld: nil, viewModel: viewModel)
KolomKop(titel: "Waarde", breedte: 90, sorteerVeld: .waarde, viewModel: viewModel)
KolomKop(titel: "Datum", breedte: 100, sorteerVeld: .datum, viewModel: viewModel)
}
.padding(.vertical, 8)
.background(Color.grijs6)
}
Inhoud (switch op weergavemodus)
'@ViewBuilder' maakt het mogelijk om meerdere views conditioneel terug te geven. Normaal mag een functie maar een view teruggeven; @ViewBuilder heft dat op. Het wordt automatisch toegepast op 'body', maar hier zetten we het expliciet.
@ViewBuilder
private var inhoudView: some View {
switch weergaveModus {
case .lijst: lijstView
case .raster: rasterView
case .compact: compactView
}
}
Lijst weergave
private var lijstView: some View {
List met 'selection:' parameter ondersteunt multi-selectie. geselecteerdeItems is een Set<TabelItem.ID> die bijhoudt welke rijen geselecteerd zijn. Elke rij die is getagd met .tag(item.id) kan geselecteerd worden.
List(selection: $viewModel.geselecteerdeItems) {
ForEach(viewModel.gefilterdeItems) { item in
TabelRij(item: item, viewModel: viewModel)
.tag koppelt een waarde aan een view voor selectie. List vergelijkt .tag-waarden met de selection Binding.
.tag(item.id)
.listRowInsets past de inspringing van een rij aan
.listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4))
.swipeActions voegt acties toe die verschijnen als je veegt. allowsFullSwipe: false voorkomt dat de eerste actie meteen bij volledig vegen wordt uitgevoerd.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
role: .destructive kleurt de knop rood en vraagt bevestiging
Button(role: .destructive) {
viewModel.verwijder(item)
} label: {
Label("Verwijder", systemImage: "trash")
}
Button {
viewModel.toggleFavoriet(item)
} label: {
Label(
item.isFavoriet ? "Verwijder favoriet" : "Favoriet",
systemImage: item.isFavoriet ? "star.slash" : "star"
)
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button {
toonDetail = item
} label: {
Label("Details", systemImage: "info.circle")
}
.tint(.blue)
}
.contextMenu verschijnt bij lang indrukken (of rechter muisknop op Mac)
.contextMenu { contextMenuInhoud(item: item) }
.onTapGesture { toonDetail = item }
}
}
.plain geeft een lijst zonder groepen of achtergrond; rijen lopen door tot de rand
.listStyle(.plain)
.refreshable voegt pull-to-refresh toe: naar beneden trekken roept de closure aan. Het wacht tot de closure klaar is (async/await-compatibel).
.refreshable { viewModel.herlaad() }
}
Raster weergave - LazyVGrid met 4 kolommen
private var rasterView: some View {
GridItem beschrijft een kolom. .flexible() betekent: neem gelijke breedte. Array(repeating:count:) maakt een array met 4 identieke GridItems.
let kolommen = Array(repeating: GridItem(.flexible(), spacing: 12), count: 4)
return ScrollView {
LazyVGrid plaatst items in een raster met de opgegeven kolommen. 'Lazy' betekent: items worden pas aangemaakt als ze zichtbaar worden. Dit is veel efficieneter dan alle 120 items tegelijk aanmaken.
LazyVGrid(columns: kolommen, spacing: 12) {
ForEach(viewModel.gefilterdeItems) { item in
RasterKaart(item: item)
.onTapGesture { toonDetail = item }
.contextMenu { contextMenuInhoud(item: item) }
}
}
.padding()
}
.refreshable { viewModel.herlaad() }
}
Compact gegroepeerde weergave per categorie
private var compactView: some View {
Dictionary(grouping:by:) groepeert een array in een woordenboek. De sleutel is de categorie, de waarde is een array van items in die categorie. .sorted zorgt dat de categorieen alfabetisch staan (Dictionary heeft geen volgorde).
let gegroepeerd = Dictionary(grouping: viewModel.gefilterdeItems, by: { $0.categorie })
.sorted(by: { $0.key < $1.key })
return List {
ForEach over een array van (key, value) tuples. id: \.key gebruikt de categorie-naam als identifier.
ForEach(gegroepeerd, id: \.key) { categorie, groepItems in
Section groepeert rijen met een optionele header en footer.
Section {
ForEach(groepItems) { item in
CompactRij(item: item)
.onTapGesture { toonDetail = item }
}
} header: {
HStack {
Text(categorie)
.font(.headline)
Spacer() // Spacer neemt alle beschikbare ruimte in, duwt de badge naar rechts
Text("\(groepItems.count) items")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.grijs5)
.clipShape(Capsule())
}
} footer: {
.reduce(0.0) { $0 + $1.waarde } telt alle waarden op. $0 is de lopende totaal, $1 is het huidige item.
let totaal = groepItems.reduce(0.0) { $0 + $1.waarde }
format: .currency is een ingebouwde Swift-opmaak voor valuta
Text("Subtotaal: \(totaal, format: .currency(code: "EUR"))")
.font(.caption)
}
}
}
.insetGrouped geeft afgeronde secties met inspringing (Apple-instellingenstijl) .insetGrouped bestaat alleen op iOS; op macOS valt het terug op .inset
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.inset)
#endif
.refreshable { viewModel.herlaad() }
}
Laad Overlay
private var laadOverlay: some View {
ZStack stapelt views over elkaar heen (Z = diepte-as). De laatste view in ZStack ligt bovenop.
ZStack {
Een zwarte overlay die de achtergrond verduistert. .ignoresSafeArea() zorgt dat de kleur ook achter de statusbalk loopt.
Color.black.opacity(0.25).ignoresSafeArea()
VStack(spacing: 16) {
ProgressView zonder waarde toont een draaiende indicator. Met waarde (bijv. ProgressView(value: 0.5)) toont het een voortgangsbalk.
ProgressView()
.controlSize(.large) // .large maakt de indicator groter
.tint(.white)
Text("Laden...")
.foregroundColor(.white)
.font(.headline)
}
.padding(28)
.ultraThinMaterial geeft een glas-effect (blur) als achtergrond. Andere materiaalniveaus: .thin, .regular, .thick, .ultraThick
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
Toolbar
@ToolbarContentBuilder werkt zoals @ViewBuilder maar dan voor toolbar-items. ToolbarItem en ToolbarItemGroup plaatsen knoppen op specifieke posities in de navigatiebalk via de 'placement' parameter.
@ToolbarContentBuilder
private var toolbarInhoud: some ToolbarContent {
ToolbarItemGroup(placement: .automatic) {
Picker laat een keuze maken uit een lijst opties. selection: is een Binding naar de huidige keuze. .pickerStyle(.segmented) toont alle opties als knoppen naast elkaar.
Picker("Weergave", selection: $weergaveModus) {
ForEach(WeergaveModus.allCases, id: \.self) { modus in
.tag koppelt elke optie aan de bijbehorende enum-waarde. Picker gebruikt .tag om te bepalen welke optie geselecteerd is.
Label(modus.rawValue, systemImage: modus.systeemAfbeelding).tag(modus)
}
}
.pickerStyle(.segmented)
.frame(width: 120)
Herlaad knop
Button {
viewModel.herlaad()
} label: {
Label combineert een tekst en een SF Symbol-icoontje. In een toolbar toont het standaard alleen het icoontje.
Label("Herlaad", systemImage: "arrow.clockwise")
}
Menu met secties (Section) voor visuele groepering van knoppen
Menu {
Section("Selectie") {
Button {
viewModel.geselecteerdeItems = Set(viewModel.gefilterdeItems.map { $0.id })
} label: {
Label("Alles selecteren", systemImage: "checkmark.square")
}
Button {
viewModel.geselecteerdeItems.removeAll()
} label: {
Label("Selectie wissen", systemImage: "square")
}
}
Section("Exporteren") {
Button {
Exporteer CSV (voorbeeld - nog niet geimplementeerd)
} label: {
Label("Exporteer CSV", systemImage: "square.and.arrow.up")
}
Button {
Afdrukken (voorbeeld - nog niet geimplementeerd)
} label: {
Label("Afdrukken", systemImage: "printer")
}
}
Section {
role: .destructive kleurt de knop rood als waarschuwing
Button(role: .destructive) {
viewModel.verwijderGeselecteerde()
} label: {
Label("Verwijder geselecteerde", systemImage: "trash")
}
}
} label: {
Label("Meer", systemImage: "ellipsis.circle")
}
}
}
Context Menu
@ViewBuilder hier omdat we meerdere views teruggeven (knoppen en een Divider). Zonder @ViewBuilder zou Swift niet weten hoe hij die moet samenvoegen.
@ViewBuilder
private func contextMenuInhoud(item: TabelItem) -> some View {
Button {
toonDetail = item
} label: {
Label("Details bekijken", systemImage: "info.circle")
}
Button {
viewModel.toggleFavoriet(item)
} label: {
Label(
item.isFavoriet ? "Verwijder uit favorieten" : "Voeg toe aan favorieten",
systemImage: item.isFavoriet ? "star.slash" : "star"
)
}
Divider()
Button(role: .destructive) {
viewModel.verwijder(item)
} label: {
Label("Verwijder", systemImage: "trash")
}
}
}
TabelRij
Een aparte View-struct voor de rij-layout. Door dit los te trekken uit TabelView blijft de code overzichtelijk en kan SwiftUI de rij efficient hertekenen zonder de hele lijst opnieuw te hoeven bouwen.
struct TabelRij: View {
'let' properties worden meegegeven bij initialisatie en veranderen nooit.
let item: TabelItem
'@ObservedObject' observeert een bestaand ObservableObject dat van buiten is meegegeven. Gebruik dit als de View het object niet aanmaakt. (De eigenaar is TabelView met @StateObject; TabelRij is slechts een observeerder.)
@ObservedObject var viewModel: TabelViewModel
private let datumFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "dd-MM-yy"
return f
}()
var body: some View {
HStack(spacing: 0) {
Selectie kolom: tik op het icoontje om dit item te selecteren/deselecteren. .contains() controleert of het item in de Set zit.
Image(
systemName: viewModel.geselecteerdeItems.contains(item.id)
? "checkmark.square.fill"
: "square"
)
.foregroundColor(.accentColor)
.frame(width: 44)
.contentShape(Rectangle()) // Tik-gebied uitbreiden tot de volledige 44pt breedte
.onTapGesture {
if viewModel.geselecteerdeItems.contains(item.id) {
viewModel.geselecteerdeItems.remove(item.id)
} else {
viewModel.geselecteerdeItems.insert(item.id)
}
}
Kolom 1: Nr. .frame(width: 50, alignment: .trailing) geeft een vaste breedte en lijnt rechts uit.
Text("\(item.nummer)")
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 50, alignment: .trailing)
.padding(.trailing, 8)
Kolom 2: Naam + favoriet indicator .frame(maxWidth: .infinity) laat deze kolom alle resterende breedte innemen.
HStack(spacing: 4) {
if item.isFavoriet {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.orange)
}
Text(item.naam)
.font(.body)
.lineLimit(1) // .lineLimit(1) knipt af na 1 regel met "..."
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Kolom 3: Categorie
Text(item.categorie)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 110, alignment: .leading)
.lineLimit(1)
Kolom 4: Status badge
StatusBadge(status: item.status)
.frame(width: 90)
Kolom 5: Waarde format: .currency is een Swift-ingebouwde opmaakspecificatie. code: "EUR" geeft het valutasymbool op basis van de instelling van het apparaat.
Text(item.waarde, format: .currency(code: "EUR"))
.font(.body.monospacedDigit())
.frame(width: 90, alignment: .trailing)
.padding(.trailing, 8)
Kolom 6: Datum
Text(datumFormatter.string(from: item.datum))
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 100, alignment: .trailing)
.padding(.trailing, 8)
}
.padding(.vertical, 8)
Achtergrondkleur op basis van selectiestatus. .opacity() maakt een kleur semi-transparant (0 = onzichtbaar, 1 = volledig zichtbaar).
.background(
viewModel.geselecteerdeItems.contains(item.id)
? Color.accentColor.opacity(0.08)
: Color.clear
)
}
}
RasterKaart
Een kaartje voor de rasterweergave. Elk kaartje is een zelfstandige View die alleen zijn eigen item nodig heeft - geen verwijzing naar het ViewModel. Dit maakt de component herbruikbaar en gemakkelijk te testen.
struct RasterKaart: View {
let item: TabelItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("#\(item.nummer)")
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
Spacer()
if item.isFavoriet {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.orange)
}
}
Text(item.naam)
.font(.subheadline.bold())
.lineLimit(2)
Text(item.categorie)
.font(.caption)
.foregroundColor(.secondary)
Divider()
StatusBadge(status: item.status)
Text(item.waarde, format: .currency(code: "EUR"))
.font(.callout.monospacedDigit().bold())
}
.padding(10)
Color(.systemBackground) is de hoofdachtergrondkleur van het apparaat (wit in lichte modus, zwart/donkergrijs in donkere modus).
.background(Color.systeemAchtergrond)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow voegt een slagschaduw toe. radius bepaalt de vervaging, x en y de verschuiving. Een kleine schaduw geeft een kaart-effect.
.shadow(color: .black.opacity(0.07), radius: 3, x: 0, y: 1)
.overlay legt een extra view bovenop. Hier tekenen we een rand. strokeBorder tekent de rand BINNEN de vorm (zodat hij niet afgekapt wordt).
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(item.status.kleur.opacity(0.3), lineWidth: 1)
)
}
}
CompactRij
struct CompactRij: View {
let item: TabelItem
var body: some View {
HStack {
Een smalle gekleurde balk als visuele statusindicator
RoundedRectangle(cornerRadius: 3)
.fill(item.status.kleur)
.frame(width: 4, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(item.naam)
.font(.subheadline)
Text(item.categorie)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(item.waarde, format: .currency(code: "EUR"))
.font(.subheadline.monospacedDigit())
Text(item.status.rawValue)
.font(.caption)
.foregroundColor(item.status.kleur)
}
}
.padding(.vertical, 4)
}
}
StatusBadge
Een kleine herbruikbare component. Door dit als aparte struct te definieren hoeven we de badge-opmaak maar op een plek te onderhouden.
struct StatusBadge: View {
let status: TabelItem.Status
var body: some View {
Text(status.rawValue)
.font(.caption2.bold())
.foregroundColor(status.kleur)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.opacity() op een kleur maakt die semi-transparant voor een subtiele achtergrond
.background(status.kleur.opacity(0.15))
.clipShape(Capsule())
}
}
KolomKop
struct KolomKop: View {
let titel: String
CGFloat? is een optionele decimale waarde. nil = geen vaste breedte opgegeven.
let breedte: CGFloat?
let sorteerVeld: TabelViewModel.SorteerVeld?
@ObservedObject var viewModel: TabelViewModel
var body: some View {
Button {
'guard let' is een vroege uitstap: als sorteerVeld nil is, stopt de functie. Het is leesbaarder dan een geneste if-let.
guard let veld = sorteerVeld else { return }
if viewModel.sorteerVeld == veld {
viewModel.sorteerOplopend.toggle()
} else {
viewModel.sorteerVeld = veld
viewModel.sorteerOplopend = true
}
} label: {
HStack(spacing: 2) {
Text(titel)
.font(.caption.bold())
.foregroundColor(.primary)
Sorteerpijl alleen tonen als dit het actieve sorteerveld is. 'if let' pakt de optionele waarde uit; als die nil is, wordt dit blok overgeslagen.
if let veld = sorteerVeld, viewModel.sorteerVeld == veld {
Image(systemName: viewModel.sorteerOplopend ? "arrow.up" : "arrow.down")
.font(.caption2)
.foregroundColor(.accentColor)
}
}
breedte ?? .infinity: gebruik de opgegeven breedte, of neem alle ruimte als die nil is.
.frame(maxWidth: breedte ?? .infinity, alignment: .leading)
}
.plain verwijdert de standaard knopstijl (blauw, kaders, etc.)
.buttonStyle(.plain)
.padding(.horizontal, 4)
.disabled() maakt een view niet interactief als de voorwaarde true is.
.disabled(sorteerVeld == nil)
}
}
StatistiekKaart
struct StatistiekKaart: View {
let titel: String
let waarde: String
let kleur: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(titel)
.font(.caption2)
.foregroundColor(.secondary)
.textCase(.uppercase) toont de tekst automatisch in hoofdletters, zonder de onderliggende string te wijzigen.
.textCase(.uppercase)
Text(waarde)
.font(.title3.bold().monospacedDigit())
.foregroundColor(kleur)
}
.frame(maxWidth: .infinity) zorgt dat alle kaartjes even breed zijn en de beschikbare HStack-breedte gelijkmatig verdelen.
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(kleur.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(kleur.opacity(0.2), lineWidth: 1)
)
}
}
DetailSheet
DetailSheet is een zelfstandige view die als sheet wordt gepresenteerd. Het ontvangt een item en het ViewModel als parameters.
struct DetailSheet: View {
let item: TabelItem
@ObservedObject var viewModel: TabelViewModel
'@Environment(\.dismiss)' leest een ingebouwde SwiftUI-omgevingswaarde. 'dismiss' is een functie die de huidige presentatie (sheet, fullscreen, etc.) sluit. Zo hoeft de sheet niet zelf bij te houden hoe hij gepresenteerd werd.
@Environment(\.dismiss) private var dismiss
private let datumFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .long
f.timeStyle = .none
f.locale = Locale(identifier: "nl_NL")
return f
}()
var body: some View {
NavigationStack {
Form geeft een gestyled formulier-layout (groepen met afgeronde hoeken). Ideaal voor instellingen en detail-informatie.
Form {
Section("Identificatie") {
LabeledContent toont een label links en inhoud rechts. In een Form worden label en inhoud automatisch uitgelijnd.
LabeledContent("Nummer") {
Text("\(item.nummer)").monospacedDigit()
}
LabeledContent("Naam") {
Text(item.naam)
}
LabeledContent("ID") {
Text(item.id.uuidString)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode bepaalt waar de tekst wordt afgekapt met "..." .middle kapt in het midden af: "550e8400...b41d4"
.truncationMode(.middle)
}
}
Section("Classificatie") {
LabeledContent("Categorie") {
Text(item.categorie)
}
LabeledContent("Status") {
StatusBadge(status: item.status)
}
}
Section("Financieel") {
LabeledContent("Waarde") {
Text(item.waarde, format: .currency(code: "EUR"))
.monospacedDigit()
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 4) {
Text("Relatieve waarde (max EUR 9.999)")
.font(.caption)
.foregroundColor(.secondary)
ProgressView(value:total:) toont een voortgangsbalk. value is de huidige positie, total is het maximum.
ProgressView(value: item.waarde, total: 9999)
.tint(item.status.kleur)
}
}
Section("Datum") {
LabeledContent("Aangemaakt op") {
Text(datumFormatter.string(from: item.datum))
}
}
Section("Overig") {
Toggle met een handmatige Binding. Binding(get:set:) maakt een Binding zonder een @State variabele: - get: wordt aangeroepen als de Toggle de waarde wil lezen - set: wordt aangeroepen als de Toggle de waarde wil schrijven Zo koppelen we de Toggle aan een functie in het ViewModel in plaats van direct aan een @State variabele.
Toggle(
"Favoriet",
isOn: Binding(
get: { item.isFavoriet },
set: { _ in viewModel.toggleFavoriet(item) }
)
)
.tint(.orange)
}
Section {
GroupBox groepeert gerelateerde inhoud in een visueel kader. De string is de optionele titel van het kader.
GroupBox("Samenvatting") {
VStack(alignment: .leading, spacing: 8) {
Text(item.naam)
.font(.headline)
Text(
"Dit item valt in \(item.categorie) met status \(item.status.rawValue.lowercased()) en een waarde van \(item.waarde, format: .currency(code: "EUR"))."
)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Item \(item.nummer)")
.navigationBarTitleDisplayMode bestaat alleen op iOS.
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .automatic) {
dismiss() sluit de sheet. @Environment(\.dismiss) levert deze functie.
Button("Sluiten") { dismiss() }
}
}
}
}
}
Preview
#Preview is een macro die een live-preview aanmaakt in Xcode. Tijdens het ontwikkelen zie je het resultaat direct zonder de app te starten. Je kunt meerdere previews definieren, bijv. voor licht/donker of verschillende schermgroottes.
#Preview {
TabelView()
}