TabelView.swift

SwiftUI concepten uitgelegd — gegenereerd uit broncode met commentaar

OVERZICHT VAN SWIFTUI CONCEPTEN IN DIT BESTAND

PROPERTY WRAPPERS (speciale voorvoegsels voor variabelen)

PROTOCOLS (afspraken die een type moet nakomen)

LAYOUT

NAVIGATIE & PRESENTATIE

LIJSTEN & COLLECTIES

CONTROLS

OPMAAK

OVERIG

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()
}