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))
                    }
                }
            }
        }
    }
}

Horizontaler Grid mit Sections und AsyncImage

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) }
)

Collapsible List Sections mit expandierten Regionen

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.

VarianteState-TypGeeignet für
Einfach@State BoolFeste 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)
                }
            }
        )
}

Sterne-Rating mit partieller Füllung

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)

Star Rating mit Slider-Steuerung

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)

Tap-Gesture Feedback Animation

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.

Vergleich mit und ohne Tap-Feedback

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.