Lektion 3.18 · Stufe 3 – Fortgeschritten

ScrollView fortgeschritten

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du ScrollView gezielt einsetzen: mit LazyVStack für lange Inhalte, waagrechten Karussells mit Einrasten, Abständen über contentMargins, Springen zu Positionen mit scrollPosition und Effekten beim Scrollen mit scrollTransition.

Das Problem: List ist nicht immer die Lösung

List (Lektion 2.15) ist großartig für Listen mit Zeilen und Trennlinien. Aber viele moderne Bildschirme sehen anders aus:

  • ein Feed mit großen Karten, wie in Social-Media-Apps,
  • ein Karussell, das man seitwärts wischt und das auf jeder Karte einrastet,
  • ein Chat, der beim Öffnen ganz unten steht,
  • Effekte, bei denen Karten beim Hereinscrollen einblenden.

Dafür ist die ScrollView da. Du kennst sie seit Projekt B. In dieser Lektion lernst du, was sie alles kann.

Die Grundlagen

Swift
ScrollView {
    VStack(spacing: 16) {
        ForEach(1...50, id: \.self) { number in
            Text("Eintrag \(number)")
                .frame(maxWidth: .infinity)
                .padding()
                .background(.blue.opacity(0.1))
                .clipShape(.rect(cornerRadius: 12))
        }
    }
    .padding()
}

Eine ScrollView macht ihren Inhalt scrollbar, sobald er größer ist als der verfügbare Platz. Standardmäßig vertikal, mit ScrollView(.horizontal) waagrecht.

VStack oder LazyVStack?

Im Beispiel oben erzeugt der VStack alle 50 Einträge sofort, auch die, die noch weit unten liegen. Bei 50 Texten ist das egal. Bei 1.000 Karten mit Bildern nicht.

Swift
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(posts) { post in
            PostCard(post: post)
        }
    }
    .padding()
}

LazyVStack erzeugt Views erst, wenn sie gebraucht werden, wie LazyVGrid (Lektion 3.17) und List.

Faustregel

VStack für wenige, feste Inhalte, etwa ein Profil oder eine Detailseite. LazyVStack für viele oder dynamische Inhalte, etwa einen Feed. Im Zweifel bei Daten mit ForEach: LazyVStack.

Waagrechte Karussells

Swift
ScrollView(.horizontal) {
    LazyHStack(spacing: 12) {
        ForEach(colors, id: \.self) { color in
            RoundedRectangle(cornerRadius: 16)
                .fill(color.gradient)
                .frame(width: 280, height: 180)
        }
    }
    .padding(.horizontal)
}
.scrollIndicators(.hidden)

.scrollIndicators(.hidden) blendet den Scrollbalken aus. Bei Karussells stört er meist.

Einrasten

Wischt man durch dieses Karussell, bleibt es irgendwo zwischen zwei Karten stehen. Schöner ist es, wenn es auf einer Karte einrastet:

Swift
ScrollView(.horizontal) {
    LazyHStack(spacing: 12) {
        ForEach(colors, id: \.self) { color in
            RoundedRectangle(cornerRadius: 16)
                .fill(color.gradient)
                .frame(width: 280, height: 180)
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 20, for: .scrollContent)
.scrollIndicators(.hidden)
  • .scrollTargetLayout() am Stack sagt: „Jedes Kind ist ein mögliches Ziel zum Einrasten.“
  • .scrollTargetBehavior(.viewAligned) an der ScrollView lässt sie an diesen Zielen einrasten.
  • .contentMargins(.horizontal, 20, for: .scrollContent) setzt einen Abstand zum Rand innerhalb des Scrollbereichs. Die erste Karte beginnt eingerückt, aber beim Scrollen dürfen die Karten bis an den Rand gleiten.

Erwartetes Ergebnis

Ein Karussell mit farbigen Karten. Wischst du, gleitet es zur nächsten Karte und richtet sie am linken Rand aus. Rechts lugt die nächste Karte hervor, ein Hinweis, dass es weitergeht.

Seitenweise blättern

Soll jede Karte die ganze Breite einnehmen und das Blättern wie in einem Fotoalbum funktionieren:

Swift
ScrollView(.horizontal) {
    LazyHStack(spacing: 0) {
        ForEach(photos) { photo in
            PhotoView(photo: photo)
                .containerRelativeFrame(.horizontal)
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
  • .containerRelativeFrame(.horizontal) macht jede Seite genau so breit wie die ScrollView (Lektion 2.8).
  • .scrollTargetBehavior(.paging) blättert immer genau eine Seite weiter.

Mit .containerRelativeFrame(.horizontal, count: 2, spacing: 12) passen genau zwei Karten nebeneinander, unabhängig von der Bildschirmbreite.

Abstände: contentMargins und safeAreaPadding

Ein häufiges Problem: Ein .padding() an der ScrollView schneidet den Inhalt beim Scrollen am Rand ab. Ein .padding() am Inhalt dagegen bewegt sich mit. Für Scrollbereiche gibt es deshalb eigene Werkzeuge:

Swift
ScrollView {
    LazyVStack { ... }
}
.contentMargins(16, for: .scrollContent)

Der Inhalt hat 16 Punkte Abstand zum Rand, wenn er ganz oben bzw. unten steht, kann aber beim Scrollen bis an den Rand gleiten. Mit for: .scrollIndicators verschiebst du stattdessen den Scrollbalken.

Zu einer Position springen

Ein Chat soll unten beginnen. Ein „Nach oben“-Button soll an den Anfang springen. Dafür gibt es scrollPosition:

Swift
struct ChatView: View {
    let messages: [Message]
    @State private var position = ScrollPosition(edge: .bottom)

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 8) {
                ForEach(messages) { message in
                    Text(message.text)
                        .padding(10)
                        .background(.blue.opacity(0.15))
                        .clipShape(.rect(cornerRadius: 12))
                }
            }
            .padding()
        }
        .scrollPosition($position)
        .toolbar {
            Button("Nach oben", systemImage: "arrow.up") {
                withAnimation {
                    position.scrollTo(edge: .top)
                }
            }
        }
    }
}
  • ScrollPosition(edge: .bottom) beginnt ganz unten.
  • .scrollPosition($position) verbindet die ScrollView mit diesem Zustand.
  • position.scrollTo(edge: .top) springt an den Anfang. In withAnimation gleitet es sanft (Lektion 3.23).

Du kannst auch zu einem bestimmten Element springen, über seine id:

Swift
position.scrollTo(id: message.id)

Damit SwiftUI die Elemente kennt, braucht der Stack .scrollTargetLayout(), wie beim Einrasten.

Tipp

In älterem Code siehst du für denselben Zweck oft ScrollViewReader mit proxy.scrollTo(id). Das funktioniert weiterhin. ScrollPosition ist die modernere, zustandsbasierte Variante.

Welches Element ist gerade sichtbar?

Umgekehrt kannst du auch lesen, wo der Nutzer gerade ist, etwa für Seitenpunkte unter einem Karussell:

Swift
@State private var currentID: Photo.ID?

ScrollView(.horizontal) {
    LazyHStack(spacing: 0) {
        ForEach(photos) { photo in
            PhotoView(photo: photo)
                .containerRelativeFrame(.horizontal)
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollPosition(id: $currentID)

.scrollPosition(id:) schreibt die id des aktuell sichtbaren Elements in currentID. Darunter kannst du dann Punkte anzeigen, von denen der passende hervorgehoben ist.

Effekte beim Scrollen

Mit .scrollTransition verändert sich ein Element, während es in den sichtbaren Bereich hinein- oder herausscrollt:

Swift
LazyVStack(spacing: 16) {
    ForEach(1...30, id: \.self) { number in
        RoundedRectangle(cornerRadius: 16)
            .fill(.indigo.gradient)
            .frame(height: 120)
            .scrollTransition { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0.3)
                    .scaleEffect(phase.isIdentity ? 1 : 0.85)
            }
    }
}
  • Die Closure bekommt den Inhalt und eine Phase.
  • phase.isIdentity ist true, wenn das Element vollständig sichtbar ist.
  • Am Rand wird es durchsichtiger und kleiner, beim Hereinscrollen wächst es auf volle Größe.

Erwartetes Ergebnis

Beim Scrollen wachsen die Karten am unteren Rand sanft heran und schrumpfen am oberen wieder. Ein lebendiger Effekt mit vier Zeilen Code.

Mit phase.value bekommst du einen Wert zwischen −1 (oben bzw. links draußen) und 1 (unten bzw. rechts draußen) und kannst damit etwa Drehungen oder Verschiebungen berechnen.

Achtung

Scroll-Effekte sind schön, aber sparsam einsetzen. Zu viel Bewegung lenkt ab und kann manchen Menschen sogar Übelkeit bereiten. Berücksichtige die Systemeinstellung „Bewegung reduzieren“ mit @Environment(\.accessibilityReduceMotion) (Lektion 4.16).

Typische Fehler

Inhalt wird am Rand abgeschnitten

.padding() hängt an der ScrollView statt am Inhalt. Verwende .contentMargins oder setz das Padding an den Stack innerhalb.

Karussell rastet nicht ein

Entweder fehlt .scrollTargetLayout() am Stack oder .scrollTargetBehavior(.viewAligned) an der ScrollView. Beides wird gebraucht.

Langsames Scrollen bei vielen Elementen

Ein normaler VStack erzeugt alles auf einmal. Nimm LazyVStack.

List in ScrollView

Eine List scrollt selbst und kann in einer ScrollView auf null Höhe schrumpfen (Lektion 2.15). Entscheide dich für eins von beiden.

FAQ

Anfänger

Was bedeutet das überhaupt?

Wann List, wann ScrollView? List für klassische Zeilen mit Trennlinien, Wischgesten und Bearbeitungsmodus. ScrollView mit LazyVStack für frei gestaltete Inhalte wie Karten und Feeds. Beide sind lazy und schnell.

Kann eine ScrollView in beide Richtungen scrollen? Ja, mit ScrollView([.horizontal, .vertical]). Das brauchst du etwa für große Karten oder Tabellen.

Fortgeschritten

Warum verhält es sich so?

Wie reagiere ich auf die Scrollposition, etwa um eine Leiste auszublenden? Mit .onScrollGeometryChange(for:of:action:) kannst du einen Wert aus der Scrollgeometrie, etwa den Versatz, beobachten und bei Änderungen reagieren. So blendest du zum Beispiel einen „Nach oben“-Button erst ein, wenn der Nutzer weit genug gescrollt hat.

Wie lade ich mehr Daten, wenn das Ende erreicht ist? Mit .onAppear am letzten Element: Wird es sichtbar, lädst du die nächste Seite nach. Weil LazyVStack Elemente erst beim Erscheinen erzeugt, funktioniert das zuverlässig. Dieses Muster heißt Pagination oder „Infinite Scrolling“.

Profi

Was passiert unter der Haube?

Wie entscheidet .viewAligned, wo eingerastet wird? Die ScrollView berechnet beim Loslassen die Zielposition, an der die Bewegung natürlicherweise enden würde, und sucht dann das Ziel aus scrollTargetLayout, dessen Anfang dieser Position am nächsten liegt. Mit einem eigenen ScrollTargetBehavior kannst du diese Logik selbst schreiben, etwa um immer nur ein Element weiterzuspringen.

Ist scrollTransition teuer? Nein. Die Closure beschreibt nur visuelle Effekte, die SwiftUI direkt auf die Darstellung anwendet, ohne body der Views neu zu berechnen. Deshalb sind dort nur bestimmte Effekte erlaubt, etwa Deckkraft, Skalierung, Drehung und Verschiebung, aber keine Layoutänderungen.

Übungen

Anfänger

Übung 1: Feed

Baue einen Feed mit 30 Karten in einer ScrollView mit LazyVStack. Jede Karte hat einen Titel und einen kurzen Text und füllt die Breite mit 16 Punkten Abstand zum Rand. Verwende .contentMargins.

Fortgeschritten

Übung 2: Fotoalbum mit Punkten

Baue ein seitenweise blätterndes Album mit fünf farbigen Seiten. Darunter stehen fünf Punkte, von denen der zur aktuellen Seite gehörende hervorgehoben ist. Ein Tipp auf einen Punkt springt zur passenden Seite.

Lösung Übung 2Erst selbst probieren!
Swift
struct Page: Identifiable {
    let id: Int
    let color: Color
}

struct AlbumView: View {
    let pages = [Color.red, .orange, .green, .blue, .purple].enumerated().map { Page(id: $0.offset, color: $0.element) }
    @State private var current: Int?

    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                LazyHStack(spacing: 0) {
                    ForEach(pages) { page in
                        page.color.gradient
                            .containerRelativeFrame(.horizontal)
                    }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.paging)
            .scrollPosition(id: $current)
            .scrollIndicators(.hidden)
            .frame(height: 300)

            HStack {
                ForEach(pages) { page in
                    Circle()
                        .fill(page.id == (current ?? 0) ? Color.primary : Color.secondary.opacity(0.3))
                        .frame(width: 8, height: 8)
                        .onTapGesture {
                            withAnimation { current = page.id }
                        }
                }
            }
        }
    }
}

.scrollPosition(id:) funktioniert in beide Richtungen: Es liest die aktuelle Seite und springt, wenn du current setzt.

Profi

Übung 3: Coverflow

Baue ein waagrechtes Karussell mit .viewAligned, in dem die Karten am Rand mit .scrollTransition kleiner werden und sich mit .rotation3DEffect leicht wegdrehen, abhängig von phase.value. Tipp: .rotation3DEffect(.degrees(phase.value * 30), axis: (x: 0, y: 1, z: 0)).

Zusammenfassung

  • ScrollView macht frei gestaltete Inhalte scrollbar, vertikal oder waagrecht.
  • LazyVStack und LazyHStack erzeugen nur sichtbare Elemente.
  • .scrollTargetLayout() und .scrollTargetBehavior(.viewAligned / .paging) lassen Karussells einrasten.
  • .containerRelativeFrame macht Elemente so breit wie den Scrollbereich oder einen Teil davon.
  • .contentMargins setzt Abstände, die beim Scrollen nicht abschneiden.
  • ScrollPosition und .scrollPosition springen zu Positionen und lesen die aktuelle aus.
  • .scrollTransition erzeugt Effekte beim Hinein- und Herausscrollen.

Spickzettel

Swift
ScrollView(.horizontal) {
    LazyHStack(spacing: 12) {
        ForEach(items) { Card(item: $0).containerRelativeFrame(.horizontal, count: 2, spacing: 12) }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 16, for: .scrollContent)
.scrollIndicators(.hidden)

@State private var position = ScrollPosition(edge: .bottom)
.scrollPosition($position)
position.scrollTo(edge: .top)

.scrollTransition { content, phase in
    content.opacity(phase.isIdentity ? 1 : 0.3)
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.