Lektion 4.7 · Stufe 4 – Profi

Shapes und Paths

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du eigene Formen zeichnen: mit dem Shape-Protokoll und Path aus Linien, Bögen und Kurven. Du füllst und umrandest Formen, zeichnest sie mit trim Stück für Stück, animierst ihre Gestalt über animatableData und baust daraus Fortschrittsringe, Wellen und eigene Rahmen.

Das Problem: Formen, die es nicht fertig gibt

SwiftUI bringt einige Formen mit: Rectangle, RoundedRectangle, Circle, Ellipse, Capsule. Damit kommst du weit. Aber früher oder später brauchst du etwas anderes:

  • eine Sprechblase mit kleiner Spitze für einen Chat,
  • eine Welle als unteren Rand eines Headers,
  • einen Fortschrittsring, der sich füllt,
  • ein Diagramm, das sich Linie für Linie zeichnet,
  • einen Stern für eine Bewertung.

All das sind eigene Formen. In SwiftUI baust du sie mit dem Shape-Protokoll. Das Schöne: Eigene Formen verhalten sich genau wie die eingebauten. Sie passen sich ihrer Größe an, lassen sich füllen, umranden, als Hintergrund oder Maske verwenden und animieren.

Das Koordinatensystem

Formen zeichnest du in einem Rechteck. Wie überall auf Apple-Plattformen gilt:

  • Der Ursprung (0, 0) ist oben links.
  • x wächst nach rechts, y wächst nach unten.
Text
(0,0) ───────────── x
  │
  │      (rect.midX, rect.midY)
  │              •
  │
  y                     (rect.maxX, rect.maxY)

Statt fester Zahlen verwendest du die Eigenschaften des Rechtecks: rect.minX, rect.midX, rect.maxX und dasselbe für y, rect.width, rect.height. So passt sich deine Form jeder Größe an.

Die erste eigene Form: ein Dreieck

Swift
struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}
  • Eine Form ist ein Struct, das Shape erfüllt, mit einer einzigen Methode: path(in:). Sie bekommt das Rechteck, in dem gezeichnet wird, und gibt einen Path zurück.
  • Ein Path ist wie ein Stift, den du über Papier führst:
    • move(to:) setzt den Stift ab, ohne zu zeichnen.
    • addLine(to:) zieht eine gerade Linie zum Punkt.
    • closeSubpath() schließt die Form mit einer Linie zurück zum Startpunkt.

Verwenden kannst du sie wie jede andere Form:

Swift
VStack(spacing: 30) {
    Triangle()
        .fill(.orange)
        .frame(width: 120, height: 100)

    Triangle()
        .stroke(.blue, lineWidth: 4)
        .frame(width: 200, height: 60)
}

Erwartetes Ergebnis

Ein gefülltes oranges Dreieck und darunter ein flaches, blau umrandetes Dreieck. Dieselbe Form passt sich an beide Rahmen an, weil sie nur relative Punkte verwendet.

Füllen und Umranden

Swift
Triangle().fill(.orange)                           // füllen
Triangle().fill(.orange.gradient)                  // mit Verlauf
Triangle().stroke(.blue, lineWidth: 4)             // Umriss
Triangle().strokeBorder(.blue, lineWidth: 4)       // Umriss nach innen (nur InsettableShape)

Für gestrichelte oder abgerundete Linien gibt es StrokeStyle:

Swift
Triangle()
    .stroke(.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round, dash: [12, 8]))
  • lineCap: .round: abgerundete Linienenden.
  • lineJoin: .round: abgerundete Ecken, wo Linien zusammentreffen.
  • dash: [12, 8]: 12 Punkte Linie, 8 Punkte Lücke, immer wieder.

Füllen und umranden geht in einem Schritt:

Swift
Triangle()
    .fill(.orange)
    .stroke(.brown, lineWidth: 3)

Tipp

.stroke zeichnet die Linie mittig auf dem Pfad: Die Hälfte liegt außerhalb der Form und kann am Rand abgeschnitten werden. Bei eingebauten Formen wie Circle und RoundedRectangle gibt es deshalb .strokeBorder, das die Linie ganz nach innen legt.

Bögen und Kurven

Gerade Linien sind nur der Anfang. Drei weitere Werkzeuge machen fast jede Form möglich.

Kreisbögen

Swift
path.addArc(
    center: CGPoint(x: rect.midX, y: rect.midY),
    radius: rect.width / 2,
    startAngle: .degrees(0),
    endAngle: .degrees(180),
    clockwise: false
)

Ein Bogen um einen Mittelpunkt. Achtung: Weil y nach unten wächst, ist „im Uhrzeigersinn“ auf dem Bildschirm gespiegelt. clockwise: false zeichnet auf dem Bildschirm im Uhrzeigersinn. Das verwirrt jeden einmal. Probier im Zweifel beide Werte aus.

Quadratische Kurven

Swift
path.addQuadCurve(to: endPoint, control: controlPoint)

Eine Kurve zum Endpunkt, die vom Kontrollpunkt „angezogen“ wird, ohne ihn zu berühren. Gut für einfache, gleichmäßige Bögen.

Kubische Kurven

Swift
path.addCurve(to: endPoint, control1: c1, control2: c2)

Wie die quadratische Kurve, aber mit zwei Kontrollpunkten. Damit lassen sich S-förmige Kurven und Wellen zeichnen. Das sind die „Bézierkurven“, die du aus Zeichenprogrammen kennst.

Ein Beispiel: die Welle

Swift
struct Wave: Shape {
    var amplitude: CGFloat = 20

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - amplitude))

        path.addCurve(
            to: CGPoint(x: rect.maxX, y: rect.maxY - amplitude),
            control1: CGPoint(x: rect.width * 0.35, y: rect.maxY + amplitude),
            control2: CGPoint(x: rect.width * 0.65, y: rect.maxY - amplitude * 3)
        )

        path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        path.closeSubpath()
        return path
    }
}

Die Form ist oben gerade und unten wellig. Als Header-Hintergrund:

Swift
VStack {
    Text("Rezepte")
        .font(.largeTitle.bold())
        .foregroundStyle(.white)
        .padding(.top, 60)
        .padding(.bottom, 50)
        .frame(maxWidth: .infinity)
        .background(Wave().fill(.orange.gradient))
    Spacer()
}
.ignoresSafeArea(edges: .top)

Formen mit Parametern

Formen dürfen Eigenschaften haben, wie amplitude bei der Welle. Damit wird eine Form flexibel. Hier eine Sprechblase mit einstellbarer Spitze:

Swift
struct Bubble: Shape {
    var cornerRadius: CGFloat = 16
    var tailSize: CGFloat = 10

    func path(in rect: CGRect) -> Path {
        let body = CGRect(x: rect.minX, y: rect.minY,
                          width: rect.width, height: rect.height - tailSize)
        var path = Path(roundedRect: body, cornerRadius: cornerRadius)

        path.move(to: CGPoint(x: body.minX + 24, y: body.maxY))
        path.addLine(to: CGPoint(x: body.minX + 24, y: rect.maxY))
        path.addLine(to: CGPoint(x: body.minX + 24 + tailSize * 1.5, y: body.maxY))
        path.closeSubpath()
        return path
    }
}

Path(roundedRect:cornerRadius:) erzeugt ein abgerundetes Rechteck in einer Zeile. Dazu kommt ein kleines Dreieck als Spitze. Beide Teilpfade werden zusammen gefüllt.

Swift
Text("Bin gleich da!")
    .padding(.horizontal, 14)
    .padding(.top, 10)
    .padding(.bottom, 20)
    .background(Bubble().fill(.blue))
    .foregroundStyle(.white)

Stück für Stück zeichnen: trim

Jede Form lässt sich teilweise zeichnen. .trim(from:to:) nimmt nur einen Abschnitt des Pfads, von 0 (Anfang) bis 1 (Ende):

Swift
Circle()
    .trim(from: 0, to: 0.75)
    .stroke(.green, style: StrokeStyle(lineWidth: 12, lineCap: .round))

Ein Dreiviertelkreis. Daraus wird ein Fortschrittsring:

Swift
struct ProgressRing: View {
    let progress: Double   // 0 bis 1

    var body: some View {
        ZStack {
            Circle()
                .stroke(.green.opacity(0.2), lineWidth: 14)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(.green, style: StrokeStyle(lineWidth: 14, lineCap: .round))
                .rotationEffect(.degrees(-90))
            Text(progress, format: .percent.precision(.fractionLength(0)))
                .font(.title.bold())
                .contentTransition(.numericText(value: progress))
        }
        .frame(width: 140, height: 140)
        .animation(.smooth, value: progress)
    }
}
  • Der Kreis beginnt bei trim standardmäßig rechts. .rotationEffect(.degrees(-90)) dreht den Anfang nach oben, wie bei Apples Aktivitätsringen.
  • Weil trim animierbar ist, wächst der Ring sanft, wenn sich progress ändert.

Erwartetes Ergebnis

Ein grüner Ring auf blassgrünem Grund. Erhöhst du den Fortschritt, wächst der Ring im Uhrzeigersinn, und die Prozentzahl in der Mitte rollt mit.

Mit trim kannst du auch Formen „zeichnen lassen“: Animiere to von 0 auf 1, und der Umriss entsteht vor den Augen des Nutzers, etwa für ein Häkchen nach erfolgreichem Speichern.

Die Form selbst animieren: animatableData

trim, Farbe und Größe lassen sich animieren. Was aber, wenn sich die Gestalt einer eigenen Form ändern soll, etwa die Höhe der Welle?

Swift
Wave(amplitude: isCalm ? 5 : 30)
    .fill(.blue)
    .animation(.smooth, value: isCalm)

Ohne weiteres springt die Welle von einer Höhe zur anderen. SwiftUI weiß nicht, dass amplitude ein Wert ist, den man zwischen Start und Ziel interpolieren kann.

Das sagst du ihm mit animatableData:

Swift
struct Wave: Shape {
    var amplitude: CGFloat = 20

    var animatableData: CGFloat {
        get { amplitude }
        set { amplitude = newValue }
    }

    func path(in rect: CGRect) -> Path { ... }
}

Jetzt berechnet SwiftUI für jedes Bild der Animation einen Zwischenwert, setzt ihn über animatableData und ruft path(in:) erneut auf. Die Welle glättet sich fließend.

Sollen zwei Werte animiert werden, fasst du sie mit AnimatablePair zusammen:

Swift
var animatableData: AnimatablePair<CGFloat, CGFloat> {
    get { AnimatablePair(amplitude, phase) }
    set {
        amplitude = newValue.first
        phase = newValue.second
    }
}

Tipp

animatableData ist das Protokoll Animatable aus Lektion 3.23 (Profi-FAQ). Shape erfüllt es schon. Du musst nur sagen, welche Werte animiert werden.

Formen als Maske und Rahmen

Formen sind auch außerhalb des Zeichnens nützlich:

Swift
Image("food")
    .resizable()
    .scaledToFill()
    .frame(width: 200, height: 160)
    .clipShape(Bubble())                     // Bild in Form einer Sprechblase

Text("Neu")
    .padding(8)
    .background(Triangle().fill(.red))       // Form als Hintergrund

RoundedRectangle(cornerRadius: 20)
    .fill(.orange)
    .mask { Wave() }                         // Form als Maske

Mit .contentShape(Triangle()) bestimmst du sogar, wo eine View auf Tipps reagiert: nur innerhalb des Dreiecks.

Für unterschiedlich abgerundete Ecken brauchst du übrigens keine eigene Form: UnevenRoundedRectangle(topLeadingRadius: 20, bottomTrailingRadius: 20) oder kurz .rect(topLeadingRadius: 20, bottomTrailingRadius: 20).

Typische Fehler

Form klebt in der Ecke oder ist riesig

Die Form verwendet feste Zahlen statt rect. Rechne immer relativ zu rect.minX, rect.width usw.

Umriss ist am Rand abgeschnitten

.stroke liegt zur Hälfte außerhalb der Form. Verwende strokeBorder (bei eingebauten Formen), verkleinere die Form mit .inset(by:) oder gib ihr Padding.

Form springt statt zu animieren

animatableData fehlt oder gibt einen anderen Wert zurück als den, der sich ändert.

Bogen zeichnet in die falsche Richtung

Das Koordinatensystem ist gespiegelt, clockwise wirkt umgekehrt. Tausch den Wert oder Start- und Endwinkel.

FAQ

Anfänger

Was bedeutet das überhaupt?

Was ist der Unterschied zwischen Shape und Path? Path ist eine konkrete Zeichnung mit festen Koordinaten. Shape ist ein Bauplan, der für jede Größe einen passenden Path erzeugt. In Views verwendest du fast immer Shape. Path kannst du aber auch direkt als View benutzen: Path { p in ... }.stroke().

Kann ich SVG-Grafiken verwenden? Ja, aber nicht als Shape. Zieh die SVG-Datei in den Asset-Katalog und aktiviere „Preserve Vector Data“. Dann kannst du sie als Image verwenden, in jeder Größe gestochen scharf. Für eigene Symbole im Stil der SF Symbols gibt es die App „SF Symbols“, mit der du eigene Symbole erstellen kannst.

Fortgeschritten

Warum verhält es sich so?

Was ist InsettableShape? Ein Protokoll für Formen, die sich nach innen verkleinern lassen. Es ermöglicht strokeBorder. Dafür implementierst du inset(by:) und gibst eine verkleinerte Version deiner Form zurück, meist über eine Eigenschaft insetAmount, die in path(in:) von rect abgezogen wird.

Wie zeichne ich ein Liniendiagramm aus Daten? Normalerweise mit Swift Charts (Lektion 3.31). Brauchst du volle Kontrolle, rechne die Datenpunkte in eine Form um: x aus dem Index, y aus dem Wert relativ zu Minimum und Maximum, und verbinde sie mit addLine. Zusammen mit trim kannst du das Diagramm dann von links nach rechts zeichnen lassen.

Profi

Was passiert unter der Haube?

Wie oft wird path(in:) aufgerufen? Bei jedem Layout mit neuer Größe und bei Animationen von animatableData für jedes Bild. Halte die Methode deshalb schnell und frei von Nebenwirkungen. Für sehr viele Formen auf einmal, etwa Tausende Partikel, ist Canvas (Lektion 4.8) die bessere Wahl.

Kann ich Formen kombinieren, etwa ausschneiden? Ja. Seit iOS 17 hat Shape Mengenoperationen: .union(_:), .intersection(_:), .subtracting(_:) und .symmetricDifference(_:). Zum Beispiel Circle().subtracting(Circle().offset(x: 20)) für eine Mondsichel. Für Formen, die sich selbst überlappen, bestimmt FillStyle(eoFill: true), welche Bereiche als „innen“ gelten.

Übungen

Anfänger

Übung 1: Ein Rhombus

Zeichne eine Raute (vier Ecken: oben Mitte, rechts Mitte, unten Mitte, links Mitte). Zeig sie einmal gefüllt mit einem Verlauf und einmal gestrichelt umrandet mit abgerundeten Linienenden.

Fortgeschritten

Übung 2: Das gezeichnete Häkchen

Baue eine Checkmark-Form aus zwei Linien. Nach einem Tipp auf „Speichern“ soll das Häkchen in einem grünen Kreis mit trim von 0 auf 1 animiert gezeichnet werden.

Lösung Übung 2Erst selbst probieren!
Swift
struct Checkmark: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX + rect.width * 0.2, y: rect.midY))
        path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.42, y: rect.maxY - rect.height * 0.25))
        path.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.18, y: rect.minY + rect.height * 0.28))
        return path
    }
}

struct SaveDone: View {
    @State private var isDone = false

    var body: some View {
        VStack(spacing: 30) {
            ZStack {
                Circle().fill(.green.opacity(0.15))
                Checkmark()
                    .trim(from: 0, to: isDone ? 1 : 0)
                    .stroke(.green, style: StrokeStyle(lineWidth: 8, lineCap: .round, lineJoin: .round))
                    .padding(20)
            }
            .frame(width: 120, height: 120)

            Button(isDone ? "Zurücksetzen" : "Speichern") {
                withAnimation(.easeOut(duration: 0.5)) { isDone.toggle() }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

.easeOut passt hier besser als eine Feder: Das Zeichnen soll gleichmäßig beginnen und sanft auslaufen, nicht nachschwingen.

Profi

Übung 3: Bewegte Welle

Erweitere Wave um eine Eigenschaft phase, die die Welle seitlich verschiebt (Tipp: Verwende statt addCurve viele kleine addLine-Abschnitte und berechne y mit sin). Animiere phase endlos mit .linear(duration: 2).repeatForever(autoreverses: false), damit die Welle fließt. Verwende AnimatablePair, damit sich auch die Höhe animiert ändern lässt.

Zusammenfassung

  • Eigene Formen erfüllen Shape mit der Methode path(in:), die für jedes Rechteck einen Path liefert.
  • Ursprung oben links, y wächst nach unten. Immer relativ zu rect rechnen.
  • Path: move, addLine, addArc, addQuadCurve, addCurve, closeSubpath.
  • .fill, .stroke und StrokeStyle für Füllung und Linie.
  • .trim(from:to:) zeichnet Teile einer Form, ideal für Fortschrittsringe und „sich zeichnende“ Linien.
  • animatableData (mit AnimatablePair für zwei Werte) macht die Gestalt einer Form animierbar.
  • Formen dienen auch als Hintergrund, Maske, Zuschnitt und Tippfläche.

Spickzettel

Swift
struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var p = Path()
        p.move(to: CGPoint(x: rect.midX, y: rect.minY))
        p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        p.closeSubpath()
        return p
    }
}

Triangle().fill(.orange).stroke(.brown, lineWidth: 2)
.stroke(.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round, dash: [10, 6]))

Circle().trim(from: 0, to: progress).rotationEffect(.degrees(-90))

var animatableData: CGFloat {
    get { amplitude }
    set { amplitude = newValue }
}

.clipShape(Bubble())  .mask { Wave() }  .contentShape(Triangle())

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.