swiftui-layout-components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftUI Layout & Components

SwiftUI 布局与组件

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.
针对iOS 26+、使用Swift 6.2开发的SwiftUI应用的布局与组件模式。涵盖栈布局、网格布局、列表模式、滚动视图、表单、控件、搜索和覆盖层。除非特别说明,所有模式均向下兼容至iOS 17。

Contents

目录

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)
}
针对小型固定尺寸内容使用
VStack
HStack
ZStack
,它们会立即渲染所有子元素。
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
ScrollView
内部针对大型或动态集合使用
LazyVStack
LazyHStack
,它们会在元素滚动进入可视区域时按需创建子视图。
swift
ScrollView {
    LazyVStack(spacing: 12) {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
    .padding(.horizontal)
}
适用场景区分:
  • 非懒加载栈: 小型固定内容(头部、工具栏、字段较少的表单)
  • 懒加载栈: 大型或尺寸未知的集合、信息流、聊天消息

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.
针对图标选择器、媒体画廊和密集视觉选择场景使用
LazyVGrid
。使用
.adaptive
列实现适配不同设备尺寸的布局,或使用
.flexible
列实现固定列数的布局。
swift
// 自适应网格 -- 列数自动调整适配空间
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
// 固定3列网格
let columns = Array(repeating: GridItem(.flexible(minimum: 100), spacing: 4), count: 3)

LazyVGrid(columns: columns, spacing: 4) {
    ForEach(items) { item in
        ThumbnailView(item: item)
    }
}
使用
.aspectRatio
设置单元格尺寸。绝对不要在懒加载容器内部放置
GeometryReader
——它会强制提前测量,破坏懒加载机制。如果需要读取尺寸,请使用
.onGeometryChange
(iOS 18+)。
完整网格模式和设计选择请查看
references/grids.md

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.
针对信息流风格内容和设置行使用
List
,这类场景需要内置的行复用、选择能力和无障碍支持。
swift
List {
    Section("通用") {
        NavigationLink("显示") { DisplaySettingsView() }
        NavigationLink("触感反馈") { HapticsSettingsView() }
    }
    Section("账户") {
        Button("退出登录", role: .destructive) { }
    }
}
.listStyle(.insetGrouped)
核心模式:
  • 信息流布局使用
    .listStyle(.plain)
    ,设置页面使用
    .insetGrouped
  • 自定义主题背景时搭配
    .scrollContentBackground(.hidden)
    使用
  • 使用
    .listRowInsets(...)
    .listRowSeparator(.hidden)
    控制间距和分割线
  • 配合
    ScrollViewReader
    实现滚动到顶部或跳转到指定ID的功能
  • 使用
    .refreshable { }
    实现信息流下拉刷新
  • 需要整行可点击时为行元素添加
    .contentShape(Rectangle())
iOS 26特性: 添加
.scrollEdgeEffectStyle(.soft, for: .top)
实现现代化滚动边缘效果。
完整列表模式(包括支持滚动到顶部的信息流列表)请查看
references/list.md

ScrollView

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.
当你需要自定义布局、混合内容或横向滚动时,配合懒加载栈使用
ScrollView
swift
ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack(spacing: 8) {
        ForEach(chips) { chip in
            ChipView(chip: chip)
        }
    }
}
ScrollViewReader: 支持编程式滚动到指定元素。
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:)
可以将内容(输入栏、工具栏)固定在键盘上方,不会影响滚动布局。
iOS 26新增特性:
  • .scrollEdgeEffectStyle(.soft, for: .top)
    -- 边缘渐变效果
  • .backgroundExtensionEffect()
    -- 安全区域边缘的镜像/模糊效果(谨慎使用,每个页面最多一个)
  • .safeAreaBar(edge:)
    -- 附加可与滚动效果联动的栏视图
完整滚动模式和iOS 26边缘效果请查看
references/scrollview.md

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.
针对结构化设置和输入页面使用
Form
,将相关控件分组到
Section
块中。
swift
Form {
    Section("通知") {
        Toggle("@提醒", isOn: $prefs.mentions)
        Toggle("关注动态", isOn: $prefs.follows)
    }
    Section("外观") {
        Picker("主题", 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)
在输入密集的表单中使用
@FocusState
管理键盘焦点。仅当独立展示或在弹窗中展示时才用
NavigationStack
包裹。

Controls

控件

ControlUsage
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.
控件用法
Toggle
布尔类型偏好设置
Picker
离散选择;2-4个选项时使用
.segmented
样式
Slider
数值范围选择,需搭配可见的数值标签
DatePicker
日期/时间选择
TextField
文本输入,搭配
.keyboardType
.textInputAutocapitalization
使用
将控件直接绑定到
@State
@Binding
@AppStorage
。将相关控件分组到
Form
的分区中。使用
.disabled(...)
标识锁定或继承的设置。如果需要提升清晰度,可以在Toggle中使用
Label
组合图标+文本。
swift
// Toggle分区
Form {
  Section("通知") {
    Toggle("@提醒", isOn: $preferences.notificationsMentionsEnabled)
    Toggle("关注动态", isOn: $preferences.notificationsFollowsEnabled)
  }
}

// 带数值显示的Slider
Section("字体大小") {
  Slider(value: $fontSizeScale, in: 0.5...1.5, step: 0.1)
  Text("缩放比例: \(String(format: "%.1f", fontSizeScale))")
}

// 枚举类型Picker
Picker("默认可见性", selection: $visibility) {
  ForEach(Visibility.allCases, id: \.self) { option in
    Text(option.title).tag(option)
  }
}
选项较多时避免使用
.pickerStyle(.segmented)
,改用菜单或行内样式。不要隐藏Slider的标签,始终展示上下文说明。
完整表单示例请查看
references/form.md

Searchable

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.
使用
.searchable
添加原生搜索UI。使用
.searchScopes
实现多模式搜索,使用
.task(id:)
实现防抖异步请求。
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("搜索")
    )
    .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)
  }
}
搜索结果为空时展示占位符。对输入做防抖处理避免过度请求。将搜索状态保存在视图本地。避免为空字符串执行搜索请求。

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).
使用
.overlay(alignment:)
实现临时UI(toast提示、横幅),不会影响原有布局。
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 }
              }
            }
        }
      }
  }
}
临时UI优先使用覆盖层实现,不要嵌入布局栈中。使用转场动画和短时间自动消失计时器。将覆盖层对齐到清晰的边缘(
.top
.bottom
)。除非明确需要,避免使用阻断所有交互的覆盖层。不要堆叠多个覆盖层,使用队列或者替换当前toast。
fullScreenCover: 使用
.fullScreenCover(item:)
实现覆盖整个屏幕的沉浸式弹窗(媒体查看器、新用户引导流程)。

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
  1. 对大型集合使用非懒加载栈——会导致所有子元素立即渲染
  2. 在懒加载容器内部放置
    GeometryReader
    ——破坏懒加载机制
  3. 使用数组索引作为
    ForEach
    的ID——导致差异计算错误和UI bug
  4. 嵌套同轴向的滚动视图——导致手势冲突
  5. List
    行内部使用复杂自定义布局——改用
    ScrollView
    +
    LazyVStack
  6. 可点击行未添加
    .contentShape(Rectangle())
    ——只有文本区域可点击
  7. 为弹窗硬编码尺寸——改用
    .presentationSizing
  8. 为空字符串执行搜索请求——始终做空查询拦截
  9. 在同一层级混合使用
    List
    ScrollView
    ——导致手势冲突
  10. 选项较多时使用
    .pickerStyle(.segmented)
    ——改用菜单或行内样式

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
  • 大型或动态集合使用了
    LazyVStack
    /
    LazyHStack
  • 所有
    ForEach
    元素使用了稳定的
    Identifiable
    ID(不是数组索引)
  • 懒加载容器内部没有
    GeometryReader
  • List
    样式匹配场景(信息流用
    .plain
    ,设置页用
    .insetGrouped
  • 结构化输入页面使用了
    Form
    (不是自定义栈)
  • .searchable
    通过
    .task(id:)
    实现了输入防抖
  • 数据源支持下拉刷新的场景添加了
    .refreshable
  • 覆盖层使用了转场动画和自动消失计时器
  • 可点击行添加了
    .contentShape(Rectangle())
  • 表单中使用
    @FocusState
    管理键盘焦点

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
  • 网格模式:
    references/grids.md
  • 列表与分区模式:
    references/list.md
  • ScrollView与懒加载栈:
    references/scrollview.md
  • 表单模式:
    references/form.md
  • 架构与状态管理: 查看
    swiftui-patterns
    技能
  • 导航模式: 查看
    swiftui-navigation
    技能