
Swiftui Layout Components
- 2.9k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swiftui-layout-components is a Swift iOS skill for stacks, grids, lists, scroll views, forms, controls, search, and overlay UI patterns on iOS 26+.
About
SwiftUI Layout and Components teaches stack, grid, list, scroll, form, control, search, and overlay patterns for iOS 26+ apps using Swift 6.3, with backward compatibility notes to iOS 17. It contrasts non-lazy VStack, HStack, and ZStack for small fixed content against LazyVStack and LazyHStack inside ScrollView for large collections. Grid guidance covers adaptive and flexible LazyVGrid columns, aspect ratio sizing, and avoiding GeometryReader inside lazy containers. List patterns include insetGrouped settings rows, scrollContentBackground hiding, refreshable feeds, ScrollPosition jump-to-id, and iOS 26 scroll edge effects. Form and control sections document Toggle, Picker, Slider, DatePicker, TextField binding, and FocusState keyboard management. Searchable examples show debounced async search with searchScopes and task id triggers. Overlay patterns cover transient toasts and fullScreenCover presentations. A common mistakes list and review checklist guard against index-based ForEach IDs, nested scroll views, and empty-query searches. Reference files expand grids, lists, scroll views, and forms.
- Lazy versus non-lazy stack selection for collection size.
- LazyVGrid adaptive and flexible column patterns with aspect ratio sizing.
- List feed and settings styles with ScrollPosition and refreshable.
- Form controls with FocusState and searchable debounced async search.
- Overlay toasts and iOS 26 scroll edge effect patterns.
Swiftui Layout Components by the numbers
- 2,875 all-time installs (skills.sh)
- +137 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #56 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
swiftui-layout-components capabilities & compatibility
- Capabilities
- stack and lazy stack selection guidance · lazyvgrid adaptive and flexible column setup · list feed and settings row patterns · form control binding with focusstate · searchable debounced async query flow · overlay toast and fullscreencover presentation
- Use cases
- frontend · ui design
- Platforms
- macOS
- Pricing
- Free
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-layout-componentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.9k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I choose lazy stacks, list styles, forms, and searchable patterns without common SwiftUI layout pitfalls?
Build SwiftUI layouts with stacks, grids, lists, scroll views, forms, controls, search, and overlays for iOS 26+ apps.
Who is it for?
iOS developers building feeds, settings screens, search interfaces, and transient overlay UI in SwiftUI.
Skip if: Skip for UIKit-only projects, navigation routing architecture, or server-side API work.
When should I use this skill?
User builds SwiftUI layouts, collection views, settings forms, searchable lists, or toast overlays.
What you get
Correct stack or grid choice, list and form patterns, debounced search setup, and overlay presentation aligned to Apple layout guidance.
- swiftui layout code
- list and grid implementations
- form and search ui components
By the numbers
- Targets iOS 26+ with Swift 6.3
- Covers VStack, HStack, ZStack, LazyVGrid, LazyHGrid, List, ScrollView, and Form patterns
Files
SwiftUI Layout & Components
Layout and component patterns for SwiftUI apps targeting iOS 26+ with Swift 6.3. 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
- Grid Layouts
- List Patterns
- ScrollView
- Form and Controls
- Searchable
- Overlay and Presentation
- Common Mistakes
- Review Checklist
- References
Layout Fundamentals
Standard Stacks
Use VStack, HStack, and ZStack for small, fixed-size content. They render all children immediately.
VStack(alignment: .leading) {
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.
ScrollView {
LazyVStack {
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.
// Adaptive grid -- columns adjust to fit
let columns = [GridItem(.adaptive(minimum: 120, maximum: 1024))]
LazyVGrid(columns: columns) {
ForEach(items) { item in
ThumbnailView(item: item)
.aspectRatio(1, contentMode: .fit)
}
}// 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 16+) 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.
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,.insetGroupedfor settings.scrollContentBackground(.hidden)+ custom background for themed surfaces.listRowInsets(...)and.listRowSeparator(.hidden)for spacing and separator control- Use
ScrollPositionwith.scrollPosition($scrollPosition)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.
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(chips) { chip in
ChipView(chip: chip)
}
}
}ScrollPosition: Enables declarative, bidirectional scroll position tracking and programmatic scrolling.
@State private var scrollPosition = ScrollPosition(edge: .bottom)
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition)
.onChange(of: messages.last?.id) {
withAnimation { scrollPosition.scrollTo(edge: .bottom) }
}See references/scrollview.md for full ScrollPosition patterns including scroll-to-id and user-scroll detection.
`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.
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.
// 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.
@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.
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 11. Hard-coding spacing: on stacks and grids by default -- omit to get platform-adaptive spacing; only specify for intentional tight (0–4pt) or wide gaps
Review Checklist
- [ ]
LazyVStack/LazyHStackused for large or dynamic collections - [ ] Stable
IdentifiableIDs on allForEachitems (not array indices) - [ ] No
GeometryReaderinside lazy containers - [ ]
Liststyle matches context (.plainfor feeds,.insetGroupedfor settings) - [ ]
Formused for structured input screens (not custom stacks) - [ ]
.searchabledebounces input with.task(id:) - [ ]
.refreshableadded where data source supports pull-to-refresh - [ ] Overlays use transitions and auto-dismiss timers
- [ ]
.contentShape(Rectangle())on tappable rows - [ ]
@FocusStatemanages keyboard focus in forms - [ ] Stack/grid
spacing:omitted unless a specific value is required
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-patternsskill - Navigation patterns: see
swiftui-navigationskill
Form
Intent
Use Form for structured settings, grouped inputs, and action rows. This pattern keeps layout, spacing, and accessibility consistent for data entry screens.
Core patterns
- Wrap the form in a
NavigationStackonly when it is presented in a sheet or standalone view without an existing navigation context. - Group related controls into
Sectionblocks. - Use
.scrollContentBackground(.hidden)plus a custom background color when you need design-system colors. - Apply
.formStyle(.grouped)for grouped styling when appropriate. - Use
@FocusStateto manage keyboard focus in input-heavy forms.
Example: settings-style form
@MainActor
struct SettingsView: View {
@Environment(Theme.self) private var theme
var body: some View {
NavigationStack {
Form {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Edit profile") { /* open sheet */ }
.buttonStyle(.plain)
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
}
}Example: modal form with validation
@MainActor
struct AddRemoteServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@State private var server: String = ""
@State private var isValid = false
@FocusState private var isServerFieldFocused: Bool
var body: some View {
NavigationStack {
Form {
TextField("Server URL", text: $server)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isServerFieldFocused)
.listRowBackground(theme.primaryBackgroundColor)
Button("Add") {
guard isValid else { return }
dismiss()
}
.disabled(!isValid)
.listRowBackground(theme.primaryBackgroundColor)
}
.formStyle(.grouped)
.navigationTitle("Add Server")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar { CancelToolbarItem() }
.onAppear { isServerFieldFocused = true }
}
}
}Design choices to keep
- Prefer
Formover custom stacks for settings and input screens. - Keep rows tappable by using
.contentShape(Rectangle())and.buttonStyle(.plain)on row buttons. - Use list row backgrounds to keep section styling consistent with your theme.
Pitfalls
- Avoid heavy custom layouts inside a
Form; it can lead to spacing issues. - If you need highly custom layouts, prefer
ScrollView+VStack. - Don’t mix multiple background strategies; pick either default Form styling or custom colors.
Grids
Contents
- Intent
- Choosing the right grid type
- Column strategies
- Cell sizing and aspect ratio
- Example: adaptive icon grid
- Example: fixed 3-column media grid
- Example: sectioned grid
- Selection and interaction patterns
- Performance guardrails
- Accessibility and polish
- Pitfalls
Intent
Use grids for dense visual collections where row-based layouts waste space or make scanning harder.
Good fits:
- icon pickers
- media galleries
- template choosers
- settings tiles
- dashboards with repeatable cards
Default to LazyVGrid for vertically scrolling collections on iOS. Reach for Grid when content is small and non-scrollable, or when you need explicit row composition rather than a large lazy container.
Choosing the right grid type
LazyVGrid
Use for the common iPhone/iPad case: many items, vertical scrolling, and a column definition that should adapt to width.
LazyHGrid
Use when horizontal scrolling is the dominant interaction and rows are easier to define than columns.
Grid
Use when:
- the item count is small
- the content is mostly static
- row/column relationships matter more than lazy loading
- you need
GridRowcomposition instead of a repeated collection layout
For large scrolling datasets, LazyVGrid is usually the safer default.
Column strategies
Adaptive columns
Use .adaptive when you want the number of columns to respond to available width.
let columns = [GridItem(.adaptive(minimum: 120, maximum: 240))]This is the best default for icon pickers, template choosers, and photo grids that should scale naturally across iPhone and iPad sizes.
Flexible columns
Use multiple .flexible columns when you want a predictable column count.
let columns = [
GridItem(.flexible(minimum: 100)),
GridItem(.flexible(minimum: 100)),
GridItem(.flexible(minimum: 100)),
]This works well when design requires a fixed 2-column or 3-column rhythm.
Fixed columns
Use .fixed only when the design truly requires exact widths. Fixed columns are less resilient across device sizes and dynamic type changes.
Cell sizing and aspect ratio
Grid cells should usually define their own shape without reading parent geometry.
Preferred sizing tools:
.aspectRatio(1, contentMode: .fit)for square thumbnails.frame(maxWidth: .infinity)when content should fill the available column- internal padding instead of outer geometry math
Avoid GeometryReader inside lazy containers. It defeats lazy layout and adds extra measurement work.
Example: adaptive icon grid
let columns = [GridItem(.adaptive(minimum: 120, maximum: 240))]
LazyVGrid(columns: columns) {
ForEach(icons) { icon in
Button {
select(icon)
} label: {
ZStack(alignment: .bottomTrailing) {
Image(icon.previewName)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(.rect(cornerRadius: 6))
if icon.isSelected {
Image(systemName: "checkmark.seal.fill")
.padding(4)
.tint(.green)
}
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
}Why this works:
- adaptive columns scale across width classes
- square-ish cells come from image aspect ratio, not geometry reads
- the whole tile remains tappable
Example: fixed 3-column media grid
LazyVGrid(
columns: [
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
],
spacing: 4
) {
ForEach(items) { item in
ThumbnailView(item: item)
.aspectRatio(1, contentMode: .fit)
.clipShape(.rect(cornerRadius: 8))
}
}Use this when the design language wants a consistent three-up gallery instead of an adaptive count.
Example: sectioned grid
Sectioned grids work well for grouped content like categories or recents.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))]) {
ForEach(sections) { section in
Section {
ForEach(section.items) { item in
Tile(item: item)
}
} header: {
Text(section.title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top)
}
}
}Keep headers visually lightweight. Dense grids lose scan efficiency when every section header is oversized or heavily decorated.
Selection and interaction patterns
Grid interactions should stay obvious and forgiving.
Useful defaults:
- make the entire cell tappable with
.contentShape(Rectangle()) - show selection state in one consistent corner or border treatment
- keep hover/focus/pressed states subtle but visible
- avoid stacking too many overlays on every item
For multi-select grids, prefer clear selection affordances instead of hidden state changes triggered only by long press.
Performance guardrails
- Use
LazyVGridfor large collections. - Keep overlay count low in every cell.
- Downsample large images before display.
- Avoid expensive formatting or filtering in cell bodies.
- Prefer stable item identity in
ForEach. - Precompute grouped or filtered collections before rendering.
If scrolling stutters, profile image decoding, per-cell overlays, and repeated state invalidation before changing the grid structure itself.
Accessibility and polish
- Maintain clear visual grouping and consistent spacing.
- Test with Dynamic Type even if cells are primarily visual.
- Ensure VoiceOver labels describe the item and selection state.
- Use meaningful focus order on iPad and keyboard-driven flows.
- Avoid tiny hit targets even when cells are visually dense.
A dense grid should still feel calm and legible, not like a wall of competing badges and borders.
Pitfalls
- Avoid heavy overlays in every grid cell; it can be expensive.
- Don’t nest grids inside other grids without a clear reason.
- Don’t put filtering logic inline in
ForEach. - Don’t use fixed columns where adaptive columns better match the product goal.
- Never place `GeometryReader` inside lazy containers (
LazyVGrid,
LazyHGrid, LazyVStack, LazyHStack). It forces eager measurement and defeats lazy loading. Use .aspectRatio for sizing, or .onGeometryChange (iOS 18+) if you need to read dimensions.
List and Section
Intent
Use List for feed-style content and settings-style rows where built-in row reuse, selection, and accessibility matter.
Core patterns
- Prefer
Listfor long, vertically scrolling content with repeated rows. - Use
Sectionheaders to group related rows. - Use
ScrollPositionwith.scrollPosition($scrollPosition)for scroll-to-top or jump-to-id. - Use
.listStyle(.plain)for modern feed layouts. - Use
.listStyle(.grouped)for multi-section discovery/search pages where section grouping helps. - Apply
.scrollContentBackground(.hidden)+ a custom background when you need a themed surface. - Use
.listRowInsets(...)and.listRowSeparator(.hidden)to tune row spacing and separators. - Use
.environment(\.defaultMinListRowHeight, ...)to control dense list layouts.
Example: feed list with scroll-to-top
@MainActor
struct TimelineListView: View {
@Environment(\.selectedTabScrollToTop) private var selectedTabScrollToTop
@State private var scrollPosition = ScrollPosition(idType: String.self)
var body: some View {
List {
ForEach(items) { item in
TimelineRow(item: item)
.id(item.id)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 1)
.scrollPosition($scrollPosition)
.onChange(of: selectedTabScrollToTop) {
withAnimation {
scrollPosition.scrollTo(edge: .top)
}
}
}
}Example: settings-style list
@MainActor
struct SettingsView: View {
var body: some View {
List {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Sign Out", role: .destructive) {}
}
}
.listStyle(.insetGrouped)
}
}Design choices to keep
- Use
Listfor dynamic feeds, settings, and any UI where row semantics help. - Use stable IDs for rows to keep animations and scroll positioning reliable.
- Prefer
.contentShape(Rectangle())on rows that should be tappable end-to-end. - Use
.refreshablefor pull-to-refresh feeds when the data source supports it.
iOS 26 Scroll Edge Effects
Apply edge effects to lists for modern scroll behavior:
List {
// rows
}
.scrollEdgeEffectStyle(.soft, for: .top)See scrollview.md for the full scroll edge effect and backgroundExtensionEffect() API reference.
Pitfalls
- Avoid heavy custom layouts inside a
Listrow; useScrollView+LazyVStackinstead. - Be careful mixing
Listand nestedScrollView; it can cause gesture conflicts.
ScrollView and Lazy stacks
Contents
- Intent
- Core patterns
- Example: vertical custom feed
- ScrollPosition capabilities
- Example: horizontal chips
- Example: adaptive grid
- Design choices to keep
- iOS 26 Scroll Edge Effects
- Pitfalls
Intent
Use ScrollView with LazyVStack, LazyHStack, or LazyVGrid when you need custom layout, mixed content, or horizontal/ grid-based scrolling.
Core patterns
- Prefer
ScrollView+LazyVStackfor chat-like or custom feed layouts. - Use
ScrollView(.horizontal)+LazyHStackfor chips, tags, avatars, and media strips. - Use
LazyVGridfor icon/media grids; prefer adaptive columns when possible. - Use
ScrollPositionfor programmatic scrolling: scroll-to-id, scroll-to-edge, and point-based offsets. - Use
safeAreaInset(edge:)for input bars that should stick above the keyboard.
Example: vertical custom feed
@MainActor
struct ConversationView: View {
@State private var scrollPosition = ScrollPosition(edge: .bottom)
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.scrollTargetLayout()
.padding(.horizontal, .layoutPadding)
}
.scrollPosition($scrollPosition)
.safeAreaInset(edge: .bottom) {
MessageInputBar()
}
.onChange(of: messages.last?.id) {
withAnimation { scrollPosition.scrollTo(edge: .bottom) }
}
}
}ScrollPosition capabilities
ScrollPosition (iOS 18+) replaces ScrollViewReader for programmatic scrolling. It is declarative, supports bidirectional position tracking, and does not require a closure wrapper.
Setup: Declare state and attach to the scroll view. Apply .scrollTargetLayout() to the inner layout container so SwiftUI can track individual view identities.
@State private var scrollPosition = ScrollPosition(idType: Message.ID.self)
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition)Scroll to a specific item:
scrollPosition.scrollTo(id: message.id, anchor: .top)Scroll to an edge:
scrollPosition.scrollTo(edge: .bottom)Read the current position:
if let currentID = scrollPosition.viewID(type: Message.ID.self) {
// The view with this ID is currently at the scroll anchor
}Detect user-initiated scrolls:
.onChange(of: scrollPosition.isPositionedByUser) { _, byUser in
if byUser {
// User scrolled manually -- show "scroll to bottom" button
}
}Example: horizontal chips
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(chips) { chip in
ChipView(chip: chip)
}
}
}Example: adaptive grid
let columns = [GridItem(.adaptive(minimum: 120))]
ScrollView {
LazyVGrid(columns: columns) {
ForEach(items) { item in
GridItemView(item: item)
}
}
.padding()
}Design choices to keep
- Use
Lazy*stacks when item counts are large or unknown. - Use non-lazy stacks for small, fixed-size content to avoid lazy overhead.
- Keep IDs stable for
ScrollPositiontracking; changing IDs causes position jumps. - Prefer explicit animations (
withAnimation) when scrolling to an ID.
iOS 26 Scroll Edge Effects
scrollEdgeEffectStyle
Configure the visual treatment at scroll view edges (iOS 26+):
ScrollView {
content
}
.scrollEdgeEffectStyle(.soft, for: .top) // Soft fading edge at top
.scrollEdgeEffectStyle(.hard, for: .bottom) // Hard cutoff at bottomScrollEdgeEffectStyle values:
.automatic-- platform default.soft-- soft fading edge effect.hard-- hard cutoff with dividing line
Use scrollEdgeEffectHidden(_:for:) to hide the edge effect entirely.
backgroundExtensionEffect
Duplicates, mirrors, and blurs the view to extend behind safe area edges (iOS 26+):
NavigationSplitView {
sidebar
} detail: {
BannerView()
.backgroundExtensionEffect()
}Use sparingly -- Apple recommends only a single instance for visual clarity and performance. The modifier clips the view to prevent mirror overlap.
safeAreaBar
Attach a bar view to the safe area edge, integrating with scroll edge effects (iOS 26+):
content
.safeAreaBar(edge: .top) {
FilterBar()
}Pitfalls
- Avoid nesting scroll views of the same axis; it causes gesture conflicts.
- Don’t combine
ListandScrollViewin the same hierarchy without a clear reason. - Overuse of
LazyVStackfor tiny content can add unnecessary complexity. - Apply
scrollEdgeEffectStyleon the ScrollView, not on inner content. - Use
backgroundExtensionEffect()on only one view per screen.
Related skills
How it compares
Use swiftui-layout-components for opinionated SwiftUI layout recipes instead of generic Swift snippets without iOS 26 ScrollPosition or List patterns.
FAQ
When should I use LazyVStack instead of VStack?
Use lazy stacks inside ScrollView for large or unknown-size collections; non-lazy stacks suit small fixed headers and toolbars.
Why avoid GeometryReader in lazy containers?
It forces eager measurement and defeats lazy loading performance benefits.
How should searchable queries debounce?
Use task id on the search query with a short sleep before fetching and guard against empty strings.
Is Swiftui Layout Components safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.