Vier SwiftUI Layout-Patterns, die in jedes Projekt gehören
Ursprünglich erschienen auf Medium: LazyHGrid (Juli 2024), Collapsible Sections (Dezember 2024), TabGesture (Juli 2024).
SwiftUI hat mehr Layout-Power als die meisten Tutorials zeigen. Hier sind vier Patterns, die ich regelmäßig verwende — jedes löst ein konkretes Problem.
1. LazyHGrid mit Pinned Section Headers
Horizontales Scrollen mit fixierten Kategorie-Headern eignet sich für Bildergalerien, Mediatheken oder horizontale Produktkataloge.
Das Problem dabei: Section-Header in einer LazyHGrid werden standardmäßig horizontal gerendert, der Text läuft also von links nach rechts statt von oben nach unten. Die Lösung ist eine 270-Grad-Rotation mit negativem Padding, um den Header korrekt zu positionieren:
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(
rows: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 10,
pinnedViews: .sectionHeaders
) {
Section(header:
Text("Kategorie A")
.rotationEffect(Angle(degrees: 270))
.padding(.trailing, -125)
.padding(.leading, -130)
) {
ForEach(0..<10) { index in
AsyncImage(url: imageUrl) { phase in
if case .success(let image) = phase {
image.resizable()
.frame(width: 145, height: 145)
.clipShape(.rect(cornerRadius: 10))
}
}
}
}
}
}

Die Padding-Werte (-125, -130) sind empirisch und hängen von der Header-Breite ab. Im Code sieht das nicht elegant aus, aber im UI passt es exakt.
Custom Navigation Bar mit SafeAreaInset
Die Standard-Toolbar passt optisch selten zum eigenen Design. Der .safeAreaInset-Modifier ersetzt sie durch eine frei gestaltbare View mit Blur-Effekt:
.toolbar(.hidden, for: .navigationBar)
.safeAreaInset(edge: .top) {
SafeAreaView(screenTitle: "Gallery",
screenSubtitle: "Browse your pictures")
}
2. Collapsible List Sections (iOS 17+)
Seit iOS 17 unterstützt Section den Parameter isExpanded als Binding<Bool>. Zusammen mit .listStyle(.sidebar) entstehen ein- und ausklappbare Sektionen ohne zusätzliche Bibliotheken.
Einfache Variante mit einem Bool
@State private var isExpanded = true
List {
Section(isExpanded: $isExpanded) {
ForEach(items) { item in
Text(item.name)
}
} header: {
Text("Section Title")
}
}
.listStyle(.sidebar)
Dynamische Variante mit Set
Bei einer variablen Anzahl an Sections reicht ein einzelner Bool nicht mehr aus. Stattdessen verwaltet ein Set<String> den Zustand aller Sections gleichzeitig:
@State private var expanded: Set<String>
init() {
_expanded = State(initialValue: Set(regions.map { $0.name }))
}
Section(
isExpanded: Binding<Bool>(
get: { expanded.contains(region.name) },
set: { isExpanding in
if isExpanding { expanded.insert(region.name) }
else { expanded.remove(region.name) }
}
),
content: { /* ... */ },
header: { Text(region.name) }
)

Das manuelle Binding<Bool> sieht auf den ersten Blick umständlich aus, ist aber der saubere Weg — kein Array von Bools, kein Index-Matching und keine Off-by-one-Fehler.
| Variante | State-Typ | Geeignet für |
|---|---|---|
| Einfach | @State Bool | Feste Anzahl Sections |
| Dynamisch | @State Set<String> | Variable Section-Anzahl |
3. Star Rating mit Mask-Modifier
Der .mask()-Modifier schneidet eine View in die Form eines Bildes. Damit lässt sich eine Sternebewertung mit partieller Füllung bauen, ganz ohne Custom Drawing oder Canvas.
Die Technik funktioniert über zwei übereinanderliegende Rectangles — grau als Hintergrund, gelb als Füllung — die gemeinsam mit SF-Symbol-Sternen maskiert werden:
ZStack {
Rectangle()
.fill(.gray)
.frame(width: 136, height: 20)
.overlay(
HStack {
Rectangle()
.fill(.yellow)
.frame(width: CGFloat(starValue), height: 20)
Spacer(minLength: 0)
}
)
.mask(
HStack {
ForEach(0..<5) { _ in
Image(systemName: "star.fill")
.resizable()
.frame(width: 20, height: 20)
}
}
)
}

Die gelbe Fläche wächst proportional zum Slider-Wert, und die Maske schneidet beide Rectangles in Sternform. Ein Slider steuert den Wert:
Slider(value: $starValue, in: 0...136, step: 4.25)

Kein Path-Drawing, kein Rechenaufwand — fünf SF Symbols als Maske reichen völlig aus.
4. Tap-Gesture mit Button-Feedback
onTapGesture hat im Gegensatz zu Button kein visuelles Feedback. Es gibt keinen Press-State und keine Opacity-Änderung, was dazu führt, dass der User tippt und visuell nichts passiert, obwohl die Aktion im Hintergrund ausgeführt wird.
Der Fix ist eine manuelle Opacity-Animation mit DispatchQueue.main.asyncAfter:
@State private var addOpacity = false
Text("Action")
.frame(height: 55)
.frame(maxWidth: .infinity)
.background(.pink)
.foregroundColor(.white)
.cornerRadius(20)
.onTapGesture {
action()
addOpacity.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
addOpacity.toggle()
}
}
.opacity(!addOpacity ? 1.0 : 0.1)
.animation(.easeInOut(duration: 0.1), value: addOpacity)

Der Ablauf ist einfach: Beim Tap sinkt die Opacity auf 0.1, nach 100 Millisekunden springt sie zurück auf 1.0, und die .animation(.easeInOut) glättet den Übergang. Das Ergebnis fühlt sich an wie ein nativer Button-Press.

Das Pattern lohnt sich immer dann, wenn das Button-Styling nicht passt, wenn man Text oder Image direkt als interaktives Element verwenden will oder wenn man bereits in einer onTapGesture-Kette arbeitet.
Vier Patterns, vier konkrete Probleme gelöst.