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
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.
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
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:
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 derScrollViewlä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:
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 dieScrollView(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:
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:
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 dieScrollViewmit diesem Zustand.position.scrollTo(edge: .top)springt an den Anfang. InwithAnimationgleitet es sanft (Lektion 3.23).
Du kannst auch zu einem bestimmten Element springen, über seine id:
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:
@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:
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.isIdentityisttrue, 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
Ü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.
Ü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!
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.
Ü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
ScrollViewmacht frei gestaltete Inhalte scrollbar, vertikal oder waagrecht.LazyVStackundLazyHStackerzeugen nur sichtbare Elemente..scrollTargetLayout()und.scrollTargetBehavior(.viewAligned / .paging)lassen Karussells einrasten..containerRelativeFramemacht Elemente so breit wie den Scrollbereich oder einen Teil davon..contentMarginssetzt Abstände, die beim Scrollen nicht abschneiden.ScrollPositionund.scrollPositionspringen zu Positionen und lesen die aktuelle aus..scrollTransitionerzeugt Effekte beim Hinein- und Herausscrollen.
Spickzettel
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)
}