--- name: swiftui-layout-components description: "Build SwiftUI layouts using stacks, grids, lists, scroll views, forms, and controls. Covers VStack/HStack/ZStack, LazyVGrid/LazyHGrid, List with sections and swipe actions, ScrollView with ScrollViewReader, Form with validation, Toggle/Picker/Slider, .searchable, and overlay patterns. Use when building data-driven layouts, collection views, settings screens, search interfaces, or transient overlay UI." --- # SwiftUI Layout & Components Layout and component patterns for SwiftUI apps targeting iOS 26+ with Swift 6.2. Covers stack and grid layouts, list patterns, scroll views, forms, controls, search, and overlays. Patterns are backward-compatible to iOS 17 unless noted. ## Contents - [Layout Fundamentals](#layout-fundamentals) - [Grid Layouts](#grid-layouts) - [List Patterns](#list-patterns) - [ScrollView](#scrollview) - [Form and Controls](#form-and-controls) - [Searchable](#searchable) - [Overlay and Presentation](#overlay-and-presentation) - [Common Mistakes](#common-mistakes) - [Review Checklist](#review-checklist) - [References](#references) ## Layout Fundamentals ### Standard Stacks Use `VStack`, `HStack`, and `ZStack` for small, fixed-size content. They render all children immediately. ```swift VStack(alignment: .leading, spacing: 8) { Text(title).font(.headline) Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } ``` ### Lazy Stacks Use `LazyVStack` and `LazyHStack` inside `ScrollView` for large or dynamic collections. They create child views on demand as they scroll into view. ```swift ScrollView { LazyVStack(spacing: 12) { ForEach(items) { item in ItemRow(item: item) } } .padding(.horizontal) } ``` **When to use which:** - **Non-lazy stacks:** Small, fixed content (headers, toolbars, forms with few fields) - **Lazy stacks:** Large or unknown-size collections, feeds, chat messages ## Grid Layouts Use `LazyVGrid` for icon pickers, media galleries, and dense visual selections. Use `.adaptive` columns for layouts that scale across device sizes, or `.flexible` columns for a fixed column count. ```swift // Adaptive grid -- columns adjust to fit let columns = [GridItem(.adaptive(minimum: 120, maximum: 1024))] LazyVGrid(columns: columns, spacing: 6) { ForEach(items) { item in ThumbnailView(item: item) .aspectRatio(1, contentMode: .fit) } } ``` ```swift // Fixed 3-column grid let columns = Array(repeating: GridItem(.flexible(minimum: 100), spacing: 4), count: 3) LazyVGrid(columns: columns, spacing: 4) { ForEach(items) { item in ThumbnailView(item: item) } } ``` Use `.aspectRatio` for cell sizing. Never place `GeometryReader` inside lazy containers -- it forces eager measurement and defeats lazy loading. Use `.onGeometryChange` (iOS 18+) if you need to read dimensions. See `references/grids.md` for full grid patterns and design choices. ## List Patterns Use `List` for feed-style content and settings rows where built-in row reuse, selection, and accessibility matter. ```swift List { Section("General") { NavigationLink("Display") { DisplaySettingsView() } NavigationLink("Haptics") { HapticsSettingsView() } } Section("Account") { Button("Sign Out", role: .destructive) { } } } .listStyle(.insetGrouped) ``` **Key patterns:** - `.listStyle(.plain)` for feed layouts, `.insetGrouped` for settings - `.scrollContentBackground(.hidden)` + custom background for themed surfaces - `.listRowInsets(...)` and `.listRowSeparator(.hidden)` for spacing and separator control - Pair with `ScrollViewReader` for scroll-to-top or jump-to-id - Use `.refreshable { }` for pull-to-refresh feeds - Use `.contentShape(Rectangle())` on rows that should be tappable end-to-end **iOS 26:** Apply `.scrollEdgeEffectStyle(.soft, for: .top)` for modern scroll edge effects. See `references/list.md` for full list patterns including feed lists with scroll-to-top. ## ScrollView Use `ScrollView` with lazy stacks when you need custom layout, mixed content, or horizontal scrolling. ```swift ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 8) { ForEach(chips) { chip in ChipView(chip: chip) } } } ``` **ScrollViewReader:** Enables programmatic scrolling to specific items. ```swift ScrollViewReader { proxy in ScrollView { LazyVStack { ForEach(messages) { message in MessageRow(message: message).id(message.id) } } } .onChange(of: messages.last?.id) { _, newValue in if let id = newValue { withAnimation { proxy.scrollTo(id, anchor: .bottom) } } } } ``` **`safeAreaInset(edge:)`** pins content (input bars, toolbars) above the keyboard without affecting scroll layout. **iOS 26 additions:** - `.scrollEdgeEffectStyle(.soft, for: .top)` -- fading edge effect - `.backgroundExtensionEffect()` -- mirror/blur at safe area edges (use sparingly, one per screen) - `.safeAreaBar(edge:)` -- attach bar views that integrate with scroll effects See `references/scrollview.md` for full scroll patterns and iOS 26 edge effects. ## Form and Controls ### Form Use `Form` for structured settings and input screens. Group related controls into `Section` blocks. ```swift Form { Section("Notifications") { Toggle("Mentions", isOn: $prefs.mentions) Toggle("Follows", isOn: $prefs.follows) } Section("Appearance") { Picker("Theme", selection: $theme) { ForEach(Theme.allCases, id: \.self) { Text($0.title).tag($0) } } Slider(value: $fontScale, in: 0.5...1.5, step: 0.1) } } .formStyle(.grouped) .scrollContentBackground(.hidden) ``` Use `@FocusState` to manage keyboard focus in input-heavy forms. Wrap in `NavigationStack` only when presented standalone or in a sheet. ### Controls | Control | Usage | |---------|-------| | `Toggle` | Boolean preferences | | `Picker` | Discrete choices; `.segmented` for 2-4 options | | `Slider` | Numeric ranges with visible value label | | `DatePicker` | Date/time selection | | `TextField` | Text input with `.keyboardType`, `.textInputAutocapitalization` | Bind controls directly to `@State`, `@Binding`, or `@AppStorage`. Group related controls in `Form` sections. Use `.disabled(...)` to reflect locked or inherited settings. Use `Label` inside toggles to combine icon + text when it adds clarity. ```swift // Toggle sections Form { Section("Notifications") { Toggle("Mentions", isOn: $preferences.notificationsMentionsEnabled) Toggle("Follows", isOn: $preferences.notificationsFollowsEnabled) } } // Slider with value text Section("Font Size") { Slider(value: $fontSizeScale, in: 0.5...1.5, step: 0.1) Text("Scale: \(String(format: "%.1f", fontSizeScale))") } // Picker for enums Picker("Default Visibility", selection: $visibility) { ForEach(Visibility.allCases, id: \.self) { option in Text(option.title).tag(option) } } ``` Avoid `.pickerStyle(.segmented)` for large sets; use menu or inline styles. Don't hide labels for sliders; always show context. See `references/form.md` for full form examples. ## Searchable Add native search UI with `.searchable`. Use `.searchScopes` for multiple modes and `.task(id:)` for debounced async results. ```swift @MainActor struct ExploreView: View { @State private var searchQuery = "" @State private var searchScope: SearchScope = .all @State private var isSearching = false @State private var results: [SearchResult] = [] var body: some View { List { if isSearching { ProgressView() } else { ForEach(results) { result in SearchRow(result: result) } } } .searchable( text: $searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Search") ) .searchScopes($searchScope) { ForEach(SearchScope.allCases, id: \.self) { scope in Text(scope.title) } } .task(id: searchQuery) { await runSearch() } } private func runSearch() async { guard !searchQuery.isEmpty else { results = [] return } isSearching = true defer { isSearching = false } try? await Task.sleep(for: .milliseconds(250)) results = await fetchResults(query: searchQuery, scope: searchScope) } } ``` Show a placeholder when search is empty. Debounce input to avoid overfetching. Keep search state local to the view. Avoid running searches for empty strings. ## Overlay and Presentation Use `.overlay(alignment:)` for transient UI (toasts, banners) without affecting layout. ```swift struct AppRootView: View { @State private var toast: Toast? var body: some View { content .overlay(alignment: .top) { if let toast { ToastView(toast: toast) .transition(.move(edge: .top).combined(with: .opacity)) .onAppear { Task { try? await Task.sleep(for: .seconds(2)) withAnimation { self.toast = nil } } } } } } } ``` Prefer overlays for transient UI rather than embedding in layout stacks. Use transitions and short auto-dismiss timers. Keep overlays aligned to a clear edge (`.top` or `.bottom`). Avoid overlays that block all interaction unless explicitly needed. Don't stack many overlays; use a queue or replace the current toast. **fullScreenCover:** Use `.fullScreenCover(item:)` for immersive presentations that cover the entire screen (media viewers, onboarding flows). ## Common Mistakes 1. Using non-lazy stacks for large collections -- causes all children to render immediately 2. Placing `GeometryReader` inside lazy containers -- defeats lazy loading 3. Using array indices as `ForEach` IDs -- causes incorrect diffing and UI bugs 4. Nesting scroll views of the same axis -- causes gesture conflicts 5. Heavy custom layouts inside `List` rows -- use `ScrollView` + `LazyVStack` instead 6. Missing `.contentShape(Rectangle())` on tappable rows -- tap area is text-only 7. Hard-coding frame dimensions for sheets -- use `.presentationSizing` instead 8. Running searches on empty strings -- always guard against empty queries 9. Mixing `List` and `ScrollView` in the same hierarchy -- gesture conflicts 10. Using `.pickerStyle(.segmented)` for large option sets -- use menu or inline styles ## Review Checklist - [ ] `LazyVStack`/`LazyHStack` used for large or dynamic collections - [ ] Stable `Identifiable` IDs on all `ForEach` items (not array indices) - [ ] No `GeometryReader` inside lazy containers - [ ] `List` style matches context (`.plain` for feeds, `.insetGrouped` for settings) - [ ] `Form` used for structured input screens (not custom stacks) - [ ] `.searchable` debounces input with `.task(id:)` - [ ] `.refreshable` added where data source supports pull-to-refresh - [ ] Overlays use transitions and auto-dismiss timers - [ ] `.contentShape(Rectangle())` on tappable rows - [ ] `@FocusState` manages keyboard focus in forms ## References - Grid patterns: `references/grids.md` - List and section patterns: `references/list.md` - ScrollView and lazy stacks: `references/scrollview.md` - Form patterns: `references/form.md` - Architecture and state management: see `swiftui-patterns` skill - Navigation patterns: see `swiftui-navigation` skill