Lektion 4.8 · Stufe 4 – Profi

Canvas

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du mit Canvas viele Elemente schnell zeichnen: Formen, Text, Bilder und SF Symbols über einen GraphicsContext. Du animierst Zeichnungen mit TimelineView, baust einen Konfetti-Effekt aus Hunderten Partikeln und machst Canvas-Grafiken für VoiceOver zugänglich.

Das Problem: Tausend Views sind zu viele

Stell dir einen Konfetti-Effekt vor, wenn der Nutzer ein Ziel erreicht: 300 bunte Schnipsel fliegen über den Bildschirm. Oder ein Sternenhimmel mit 1.000 funkelnden Punkten. Oder ein Diagramm mit 10.000 Messwerten.

Mit dem, was du bisher kennst, würdest du für jeden Schnipsel eine View bauen:

Swift
ForEach(particles) { particle in
    Rectangle()
        .fill(particle.color)
        .frame(width: 8, height: 4)
        .rotationEffect(particle.angle)
        .position(particle.position)
}

Das funktioniert, aber jede dieser Views hat Identität, Layout, Abhängigkeiten und einen Platz im Attribut-Graphen (Lektion 4.3). Bei 300 Elementen, die sich 60-mal pro Sekunde bewegen, ist das eine Menge Buchhaltung für etwas, das am Ende nur bunte Pixel sind. Das Ergebnis: Ruckeln.

Für solche Fälle gibt es Canvas: eine einzige View, in die du direkt zeichnest wie auf ein Blatt Papier.

Views gegenüber Zeichnen

Der Unterschied ist grundlegend:

ViewsCanvas
Jedes Element ist …eine eigene View mit Identitätnur Pixel in einer Zeichnung
Tippen, Fokus, VoiceOverpro Elementnur für den ganzen Canvas
AnimationSwiftUI interpoliertdu berechnest jedes Bild selbst
Gut fürBedienelemente, Inhalte, Listenviele grafische Elemente, Effekte, Diagramme

Faustregel: Muss man es antippen oder lesen können, ist es eine View. Ist es Dekoration oder Datenvisualisierung mit sehr vielen Elementen, ist es ein Canvas.

Der erste Canvas

Swift
struct SimpleCanvas: View {
    var body: some View {
        Canvas { context, size in
            let rect = CGRect(x: 20, y: 20, width: size.width - 40, height: 100)
            context.fill(Path(roundedRect: rect, cornerRadius: 16), with: .color(.orange))

            let circle = Path(ellipseIn: CGRect(x: size.width / 2 - 40, y: 150, width: 80, height: 80))
            context.stroke(circle, with: .color(.blue), lineWidth: 6)

            context.draw(Text("Hallo Canvas").font(.title.bold()), at: CGPoint(x: size.width / 2, y: 280))
        }
        .frame(height: 320)
    }
}
  • Canvas { context, size in ... } bekommt einen GraphicsContext und die Größe der Zeichenfläche.
  • context.fill(path, with:) füllt einen Path (Lektion 4.7).
  • context.stroke(path, with:, lineWidth:) zeichnet den Umriss.
  • context.draw(Text(...), at:) zeichnet Text, zentriert auf dem Punkt.
  • .color(.orange) ist eine Schattierung (GraphicsContext.Shading). Es gibt auch .linearGradient, .radialGradient und andere.

Die Closure ist kein @ViewBuilder: Hier stehen ganz normale Befehle, Schleifen und Berechnungen, die nacheinander ausgeführt werden.

Der Zeichenkontext

Viele Elemente in einer Schleife

Swift
Canvas { context, size in
    for _ in 0..<1000 {
        let x = Double.random(in: 0...size.width)
        let y = Double.random(in: 0...size.height)
        let star = Path(ellipseIn: CGRect(x: x, y: y, width: 2, height: 2))
        context.fill(star, with: .color(.white.opacity(Double.random(in: 0.3...1))))
    }
}
.background(.black)

1.000 Sterne, ohne eine einzige zusätzliche View.

Achtung

Die Sterne wechseln bei jedem Neuzeichnen ihre Position, weil random jedes Mal neue Werte liefert. Für eine ruhige Grafik erzeugst du die Positionen einmal und speicherst sie, etwa in @State oder einer let-Konstante.

Verschieben, drehen, durchsichtig machen

Der Kontext hat einen Zustand, den du verändern kannst. Jede Änderung gilt für alles, was danach gezeichnet wird:

Swift
Canvas { context, size in
    context.translateBy(x: size.width / 2, y: size.height / 2)   // Ursprung in die Mitte

    for i in 0..<12 {
        var copy = context                       // Kopie für diesen Schritt
        copy.rotate(by: .degrees(Double(i) * 30))
        copy.opacity = Double(i + 1) / 12
        copy.fill(Path(CGRect(x: -4, y: -80, width: 8, height: 30)), with: .color(.blue))
    }
}

Ein „Ladekranz“ aus 12 Strichen, jeder etwas weiter gedreht und etwas kräftiger.

Der Trick ist var copy = context: GraphicsContext ist ein Werttyp (Lektion 1.13). Eine Kopie hat ihre eigenen Einstellungen. Drehung und Deckkraft gelten nur für die Kopie und „verschmutzen“ nicht den Original-Kontext für den nächsten Schleifendurchlauf.

Weitere Einstellungen:

  • context.scaleBy(x:y:): skalieren
  • context.blendMode = .screen: wie Farben sich mischen, etwa für leuchtende Effekte
  • context.addFilter(.blur(radius: 4)): Unschärfe, Schatten und andere Filter

Bilder und SF Symbols

Swift
Canvas { context, size in
    let heart = context.resolve(Image(systemName: "heart.fill"))
    for i in 0..<20 {
        context.draw(heart, at: CGPoint(x: Double(i) * 20 + 10, y: size.height / 2))
    }
}
.foregroundStyle(.red)

context.resolve(_:) bereitet ein Bild (oder einen Text) einmal vor. Das vorbereitete Ergebnis lässt sich dann beliebig oft und schnell zeichnen. Bei vielen gleichen Elementen ist das deutlich schneller, als jedes Mal ein neues Image zu übergeben.

Ganze Views als Symbole

Willst du eine kleine, mit SwiftUI gestaltete View vielfach zeichnen, übergibst du sie als Symbol:

Swift
Canvas { context, size in
    guard let badge = context.resolveSymbol(id: "badge") else { return }
    for i in 0..<10 {
        context.draw(badge, at: CGPoint(x: 40 + Double(i % 5) * 60, y: 40 + Double(i / 5) * 60))
    }
} symbols: {
    Text("★")
        .font(.title)
        .padding(8)
        .background(.yellow, in: .circle)
        .tag("badge")
}

Die Views im symbols-Block werden nicht angezeigt, sondern nur vorbereitet. Mit .tag bekommen sie eine Kennung, über die du sie im Canvas abrufst.

Animieren mit TimelineView

Ein Canvas zeichnet nur neu, wenn sich etwas ändert, wovon er abhängt. Für flüssige Animationen brauchst du einen Taktgeber, der ihn jedes Bild neu zeichnen lässt. Das ist TimelineView:

Swift
struct PulsingDots: View {
    var body: some View {
        TimelineView(.animation) { timeline in
            let time = timeline.date.timeIntervalSinceReferenceDate

            Canvas { context, size in
                for i in 0..<5 {
                    let phase = time * 3 + Double(i) * 0.6
                    let radius = 10 + sin(phase) * 6
                    let x = size.width / 2 + Double(i - 2) * 40
                    let rect = CGRect(x: x - radius, y: size.height / 2 - radius,
                                      width: radius * 2, height: radius * 2)
                    context.fill(Path(ellipseIn: rect), with: .color(.indigo))
                }
            }
        }
        .frame(height: 80)
    }
}
  • TimelineView(.animation) erzeugt seinen Inhalt für jedes Bild neu, 60- oder 120-mal pro Sekunde.
  • timeline.date ist der Zeitpunkt des aktuellen Bilds. Daraus berechnest du alles: Positionen, Größen, Farben.
  • Die Kreise pulsieren, jeder mit etwas Verzögerung, über eine Sinuswelle.

Zeit statt Zustand

Bei Canvas-Animationen berechnest du jedes Bild aus der Zeit, statt bei jedem Bild einen Zustand fortzuschreiben. Das ist einfacher, robuster und läuft auch dann korrekt, wenn ein Bild ausfällt: Das nächste Bild ist dann einfach etwas weiter.

TimelineView hat noch andere Takte: .periodic(from: .now, by: 1) für einmal pro Sekunde, etwa für eine Uhr, und .everyMinute. Wähle immer den langsamsten Takt, der reicht. Er spart Akku.

Ein Konfetti-Effekt

Jetzt kommt alles zusammen. Ein Tipp lässt 200 Konfetti-Schnipsel aus der Mitte explodieren und unter Schwerkraft herabfallen.

Swift
struct Particle {
    let start: CGPoint
    let velocity: CGVector
    let spin: Double
    let color: Color
    let birth: Date
}

struct ConfettiView: View {
    @State private var particles: [Particle] = []
    let lifetime = 3.0
    let gravity = 600.0

    var body: some View {
        TimelineView(.animation(paused: particles.isEmpty)) { timeline in
            Canvas { context, size in
                let now = timeline.date
                for particle in particles {
                    let t = now.timeIntervalSince(particle.birth)
                    guard t < lifetime else { continue }

                    let x = particle.start.x + particle.velocity.dx * t
                    let y = particle.start.y + particle.velocity.dy * t + 0.5 * gravity * t * t

                    var copy = context
                    copy.opacity = 1 - t / lifetime
                    copy.translateBy(x: x, y: y)
                    copy.rotate(by: .degrees(particle.spin * t))
                    copy.fill(Path(CGRect(x: -5, y: -2.5, width: 10, height: 5)),
                              with: .color(particle.color))
                }
            }
        }
        .contentShape(.rect)
        .onTapGesture { location in
            burst(at: location)
        }
        .accessibilityLabel("Konfetti")
        .accessibilityAddTraits(.isButton)
    }

    private func burst(at point: CGPoint) {
        let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple, .pink]
        let now = Date.now
        particles.removeAll { now.timeIntervalSince($0.birth) > lifetime }

        for _ in 0..<200 {
            let angle = Double.random(in: 0..<(2 * .pi))
            let speed = Double.random(in: 150...500)
            particles.append(Particle(
                start: point,
                velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed - 250),
                spin: Double.random(in: -720...720),
                color: colors.randomElement()!,
                birth: now
            ))
        }
    }
}

So funktioniert es:

  • Jeder Partikel speichert nur seine Startwerte: Ort, Geschwindigkeit, Drehung, Farbe und Geburtszeit.
  • Die aktuelle Position wird für jedes Bild aus der Zeit berechnet, mit der Formel für den schrägen Wurf: Weg = Geschwindigkeit × Zeit, plus ½ × Schwerkraft × Zeit² nach unten.
  • Mit der Zeit wird jeder Schnipsel durchsichtiger und ist nach drei Sekunden weg.
  • .animation(paused: particles.isEmpty) hält die TimelineView an, solange es nichts zu zeichnen gibt. Sonst würde sie ohne Grund 120-mal pro Sekunde neu zeichnen und Akku verbrauchen.
  • .onTapGesture { location in ... } liefert die Position des Tipps, damit das Konfetti dort entsteht.
  • Vor jedem neuen Ausbruch werden abgelaufene Partikel entfernt, damit das Array nicht endlos wächst.

Erwartetes Ergebnis

Ein Tipp irgendwo auf die Fläche lässt 200 bunte Schnipsel nach allen Seiten fliegen. Sie drehen sich, fallen in einem Bogen nach unten und verblassen. Mehrere schnelle Tipps hintereinander erzeugen mehrere Ausbrüche gleichzeitig, flüssig auch mit über tausend Schnipseln.

Barrierefreiheit

Für VoiceOver ist ein Canvas ein einziges Bild ohne Inhalt. Was darin gezeichnet ist, kann es nicht erkennen. Deshalb:

  • Rein dekorative Canvas, wie Konfetti oder ein Hintergrund, bekommen .accessibilityHidden(true) oder eine kurze Beschreibung.
  • Informative Canvas, etwa ein Diagramm, brauchen eine Beschreibung des Inhalts: .accessibilityLabel("Umsatz der letzten 12 Monate, steigend von 2.000 auf 5.000 Euro").
  • Für Diagramme, in denen man einzelne Werte erkunden soll, kannst du mit .accessibilityChildren { ... } unsichtbare Ersatz-Views angeben, die VoiceOver stattdessen vorliest. Mehr dazu in Lektion 4.16.

Typische Fehler

Zeichnung flackert oder springt

Zufallswerte werden in der Canvas-Closure erzeugt und ändern sich bei jedem Neuzeichnen. Erzeuge sie einmal und speichere sie.

Einstellungen „wandern“ in der Schleife

rotate und translateBy wirken auf den Original-Kontext und summieren sich mit jedem Durchlauf. Arbeite in der Schleife mit einer Kopie: var copy = context.

Animation läuft nicht

Ein Canvas zeichnet nur bei Änderungen neu. Für fortlaufende Animationen gehört er in eine TimelineView(.animation).

Akku leert sich

Die TimelineView läuft dauerhaft mit voller Bildrate, auch wenn nichts passiert. Pausiere sie mit .animation(paused:) oder verwende einen langsameren Takt.

FAQ

Anfänger

Was bedeutet das überhaupt?

Ist Canvas immer schneller als Views? Für viele grafische Elemente ja. Für normale Oberflächen mit Text, Buttons und Listen nicht: Dort sind Views richtig, weil sie Tippen, Fokus, VoiceOver und Dynamic Type mitbringen.

Kann ich auf einzelne Elemente im Canvas tippen? Nicht direkt. Du kannst aber mit .onTapGesture { location in ... } die Position des Tipps bekommen und selbst berechnen, welches Element getroffen wurde.

Fortgeschritten

Warum verhält es sich so?

Wie zeichne ich ein Diagramm mit 10.000 Punkten? Erst prüfen, ob Swift Charts mit PointPlot oder LinePlot (Vectorized Plots, Lektion 3.31) reicht. Wenn nicht: einen einzigen Path mit allen Punkten erzeugen und einmal zeichnen ist viel schneller als 10.000 einzelne fill-Aufrufe.

Was macht drawLayer? context.drawLayer { layerContext in ... } zeichnet eine Gruppe in eine eigene Ebene, die dann als Ganzes weiterverarbeitet wird, etwa mit einem gemeinsamen Filter oder einer Deckkraft. So kannst du mehrere Elemente zusammen verwischen, ohne jedes einzeln zu bearbeiten.

Profi

Was passiert unter der Haube?

Wie wird ein Canvas intern gezeichnet? Die Zeichenbefehle werden aufgezeichnet und von SwiftUIs Render-Pipeline über die Grafikkarte ausgegeben. Im Gegensatz zu Views gibt es keine Attribute pro Element und kein Layout pro Element. Das macht ihn schnell. Mit Canvas(rendersAsynchronously: true) kann die Darstellung zusätzlich außerhalb des Main Actors vorbereitet werden, was bei sehr aufwendigen Zeichnungen hilft.

Wann sind Shader die bessere Wahl? Wenn der Effekt pro Pixel berechnet wird, etwa Verzerrungen, Farbverläufe, Rauschen oder Lichteffekte. Ein Canvas zeichnet Formen, ein Shader verändert Pixel. Für Tausende einzelne Objekte ist der Canvas richtig, für Effekte über eine ganze Fläche ein Shader (Lektion 4.9).

Übungen

Anfänger

Übung 1: Sternenhimmel

Zeichne in einem Canvas 500 weiße Sterne auf schwarzem Grund. Die Positionen und Größen werden einmal beim Erscheinen erzeugt und in @State gespeichert, damit nichts flackert.

Lösung Übung 1Erst selbst probieren!
Swift
struct Star {
    let x: Double   // 0...1, relativ zur Breite
    let y: Double   // 0...1, relativ zur Höhe
    let size: Double
}

struct StarrySky: View {
    @State private var stars: [Star] = (0..<500).map { _ in
        Star(x: .random(in: 0...1), y: .random(in: 0...1), size: .random(in: 1...3))
    }

    var body: some View {
        Canvas { context, size in
            for star in stars {
                let rect = CGRect(x: star.x * size.width, y: star.y * size.height,
                                  width: star.size, height: star.size)
                context.fill(Path(ellipseIn: rect), with: .color(.white))
            }
        }
        .background(.black)
        .ignoresSafeArea()
        .accessibilityHidden(true)
    }
}

Die Positionen sind relativ gespeichert (0 bis 1). So passt sich der Himmel jeder Größe an, auch beim Drehen des Geräts.

Fortgeschritten

Übung 2: Funkelnde Sterne

Erweitere Übung 1: Jeder Stern bekommt eine eigene Phase und funkelt, indem seine Deckkraft mit einer Sinuswelle über die Zeit schwankt. Verwende TimelineView(.animation).

Profi

Übung 3: Konfetti zur Belohnung

Baue den Konfetti-Effekt als wiederverwendbaren Modifier .confetti(trigger:), der als Overlay über beliebigen Inhalt gelegt wird und bei jeder Änderung des Triggers von der oberen Bildschirmmitte aus explodiert. Das Overlay darf Tipps auf den Inhalt darunter nicht blockieren. Tipp: .allowsHitTesting(false). Setze ihn im Ausgaben-Tracker ein, wenn das Monatsbudget eingehalten wurde.

Zusammenfassung

  • Canvas ist eine einzige View, in die du direkt zeichnest: ideal für viele grafische Elemente.
  • Der GraphicsContext zeichnet mit fill, stroke und draw Formen, Text, Bilder und Symbole.
  • translateBy, rotate, opacity, blendMode und Filter verändern den Kontext. In Schleifen mit einer Kopie arbeiten.
  • resolve und symbols bereiten Bilder und Views einmal vor, um sie oft zu zeichnen.
  • TimelineView(.animation) zeichnet jedes Bild neu. Positionen aus der Zeit berechnen, nicht fortschreiben. Mit paused: anhalten, wenn nichts passiert.
  • Für VoiceOver ist ein Canvas ein Bild: dekorative ausblenden, informative beschreiben.

Spickzettel

Swift
Canvas { context, size in
    context.fill(Path(ellipseIn: rect), with: .color(.orange))
    context.stroke(path, with: .color(.blue), lineWidth: 3)
    context.draw(Text("Hi"), at: CGPoint(x: size.width / 2, y: 20))

    var copy = context
    copy.translateBy(x: 100, y: 100)
    copy.rotate(by: .degrees(45))
    copy.opacity = 0.5

    let img = context.resolve(Image(systemName: "star.fill"))
    context.draw(img, at: point)
}

TimelineView(.animation(paused: isIdle)) { timeline in
    let t = timeline.date.timeIntervalSinceReferenceDate
    Canvas { context, size in /* aus t berechnen */ }
}

.accessibilityHidden(true)            // dekorativ
.accessibilityLabel("Beschreibung")   // informativ

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.