6  H6: Animaties

Een statische klok is saai. In dit hoofdstuk geven we de wijzerplaat leven: een wijzer die soepel beweegt met de huidige tijd, en een laadanimatie waarbij de segmenten één voor één oplichten als de app start. Je leert hoe je withAnimation gebruikt en hoe je herhalende taken uitvoert met Task.

6.1 Wat gaan we bouwen?

Twee animaties:

  1. De tijdwijzer — beweegt elke seconde een klein stukje, vloeiend dankzij een lineaire animatie.
  2. De reveal-animatie — bij het laden verschijnen de 24 segmenten één voor één in een halve seconde.

De wijzerplaat met een bewegende wijzer die de huidige tijd aangeeft

De wijzer beweegt soepel rond de klok

6.2 Playground-voorbeeld

import SwiftUI
import PlaygroundSupport

struct AnimatieView: View {
    @State private var hoek: Double = 0

    var body: some View {
        VStack(spacing: 20) {
            // Een rechthoek die draait
            Rectangle()
                .fill(.blue)
                .frame(width: 20, height: 80)
                .rotationEffect(.degrees(hoek))

            Button("Draai!") {
                withAnimation(.linear(duration: 1)) {
                    hoek += 45
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .frame(width: 200, height: 200)
    }
}

PlaygroundPage.current.setLiveView(AnimatieView())

Klik op “Draai!” — de rechthoek roteert vloeiend 45 graden. Dat is withAnimation in één zin: verander een waarde, en SwiftUI animeert de overgang.

6.3 Concept uitgelegd

6.3.1 withAnimation: vertel SwiftUI dat het mooi mag zijn

Normaal verandert SwiftUI dingen direct: druk op knop, teller wordt 10. Met withAnimation zeg je: “doe het wel, maar neem de tijd”:

// Zonder animatie: direct
score = 10

// Met animatie: vloeiend in 0.5 seconde
withAnimation(.easeInOut(duration: 0.5)) {
    score = 10
}

SwiftUI berekent zelf alle tussenposities. Jij geeft alleen de begin- en eindwaarde op.

6.3.2 Task: werk op de achtergrond

Een wijzer die elke seconde beweegt, mag de rest van de app niet vertragen. Daarvoor gebruik je Task — een stuk werk dat op de achtergrond uitgevoerd wordt.

.task {
    // Dit loopt op de achtergrond
    while !Task.isCancelled {
        // Wacht 1 seconde
        try? await Task.sleep(for: .seconds(1))
        // Doe iets
        updateTijd()
    }
}

De while !Task.isCancelled-lus stopt automatisch als de view verdwijnt.

6.3.3 Hoeken berekenen

Om de wijzer op de goede positie te zetten, rekenen we de huidige tijd om naar graden:

  • 24 uur = 360 graden
  • 1 uur = 15 graden
  • 12 uur 30 minuten = 12,5 × 15 = 187,5 graden

En omdat we 0 graden bovenaan willen, trekken we 90 graden af.

6.4 Code schrijven

Stap 1: de tijdwijzer

Voeg aan KlokView een @State toe voor de hoek, en een task om die bij te houden:

// Bron: EnergyClock/EnergyClockView.swift
struct KlokView: View {
    let colorMapper: EnergyColorMapper
    @State private var wijzerHoek: Double = 0

    var body: some View {
        Canvas { context, size in
            let middelpunt = CGPoint(x: size.width / 2, y: size.height / 2)
            let straal = (min(size.width, size.height) / 2) - 20

            tekenSegmenten(context: context, middelpunt: middelpunt, straal: straal)
            tekenWijzer(context: context, middelpunt: middelpunt, straal: straal - 30, hoek: wijzerHoek)
        }
        .frame(width: 300, height: 300)
        .task {
            // Start direct
            updateTijd()
            // Herhaal elke seconde
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                updateTijd()
            }
        }
    }

    private func updateTijd() {
        let kalender = Calendar.current
        let nu = Date.now
        let uur     = kalender.component(.hour,   from: nu)
        let minuut  = kalender.component(.minute, from: nu)
        let seconde = kalender.component(.second, from: nu)

        // Bereken exacte hoek inclusief minuten en seconden
        let uurMetMinuten = Double(uur) + Double(minuut) / 60.0 + Double(seconde) / 3600.0
        let doelHoek = (uurMetMinuten * 360.0 / 24.0) - 90

        withAnimation(.linear(duration: 1)) {
            wijzerHoek = doelHoek
        }
    }

    private func tekenWijzer(context: GraphicsContext, middelpunt: CGPoint, straal: CGFloat, hoek: Double) {
        let boogsHoek = CGFloat(hoek * .pi / 180)

        // Punt van de wijzer
        let tip = CGPoint(
            x: middelpunt.x + cos(boogsHoek) * straal,
            y: middelpunt.y + sin(boogsHoek) * straal
        )

        // Contragewicht (tegenovergestelde kant)
        let contra = CGPoint(
            x: middelpunt.x - cos(boogsHoek) * 20,
            y: middelpunt.y - sin(boogsHoek) * 20
        )

        var pad = Path()
        pad.move(to: contra)
        pad.addLine(to: tip)
        context.stroke(pad, with: .color(.primary), lineWidth: 3)
    }
}

Stap 2: de reveal-animatie

Bij het laden willen we de segmenten één voor één laten verschijnen. Voeg een @State toe die bijhoudt hoeveel segmenten al zichtbaar zijn:

// Bron: EnergyClock/EnergyClockView.swift
@State private var zichtbareUren: Int = 24

// Voeg toe aan de body, na de bestaande .task:
.onAppear {
    Task {
        await laadAnimatie()
    }
}

En de animatiefunctie:

// Bron: EnergyClock/EnergyClockView.swift – loadAnimationClock()
private func laadAnimatie() async {
    let duur: Double = 0.5       // totale duur in seconden
    zichtbareUren = 0
    let vertraging = duur / 24.0  // vertraging per uur

    for uur in 1...24 {
        try? await Task.sleep(for: .seconds(vertraging))
        zichtbareUren = uur
    }
}

Pas tekenSegmenten aan om zichtbareUren te gebruiken:

for uur in 0..<min(zichtbareUren, 24) {
    // ... rest van de code
}
NoteVerdieping: animatiekrommen

withAnimation accepteert een animatiecurve die beschrijft hoe snel de animatie gaat op verschillende momenten:

  • .linear — constante snelheid van begin tot eind
  • .easeIn — begint langzaam, eindigt snel
  • .easeOut — begint snel, eindigt langzaam
  • .easeInOut — langzaam start, versnelt in het midden, vertraagt aan het einde
  • .spring — overshoots het doel lichtjes, zoals een veer

Voor de wijzer gebruiken we .linear zodat hij in een constante snelheid beweegt — precies zoals een echte secondewijzer.

Achter de schermen interpoleert SwiftUI de waarden met behulp van deze easing functions, ook wel Bézier-curves genoemd. De .spring-animatie gebruikt een differentiaalvergelijking die de beweging van een massa aan een veer simuleert.

6.5 Apple documentatie

Meer over animaties in SwiftUI:

developer.apple.com/documentation/swiftui/animation

Meer over Task en async/await:

developer.apple.com/documentation/swift/task

Zoek in de SwiftUI animatiedocumentatie naar .linear, .easeIn en .spring. Probeer elk van de drie uit in het playground-voorbeeld bovenaan dit hoofdstuk.

6.6 Samenvatting

Begrip Betekenis
withAnimation Animeert een verandering in @State
.linear(duration:) Animatie met constante snelheid
.easeInOut Animatie die versnelt en vertraagt
Task Voert werk uit op de achtergrond
Task.sleep(for:) Wacht een bepaalde tijd
Task.isCancelled Controleert of de taak gestopt moet worden
.task { } Start een Task als de view verschijnt; stopt automatisch als de view verdwijnt
.onAppear { } Voert code uit als de view voor het eerst zichtbaar wordt
Radialen Wiskundige hoekeenheid: = 360 graden

6.7 Opdracht

Pas de reveal-animatie aan:

  1. Maak de animatie sneller: gebruik 0.3 seconden in plaats van 0.5.
  2. Voeg een klein geluid toe als de animatie klaar is (hint: kijk naar AudioServicesPlaySystemSound uit het AudioToolbox-framework — maar kijk eerst of je dit kunt vinden in de Apple documentatie).
  3. Moeilijker: zorg dat de reveal-animatie opnieuw afspeelt als je op een knop drukt.