WWDC Quick Look 💓 By SwiftGGTeam
Demystify SwiftUI containers

Demystify SwiftUI containers

观看原视频

Highlight

SwiftUI 新增了 ForEach(subviewOf:)Group(subviewsOf:)ForEach(sectionOf:)ContainerValues API,让开发者能够构建支持任意内容组合、分组和自定义修饰符的容器视图。

核心内容

SwiftUI 的 List 已经支持灵活的内容组合——可以混用静态视图和 ForEach 动态生成的视图,还能用 Section 进行分组,甚至通过 listRowSeparator() 这样的修饰符自定义行样式。但如果你想构建自己的容器视图(比如卡片网格、看板布局),过去只能依赖数据驱动的初始化方式,灵活性受限。

苹果在今年推出了三个新 API 来解决这个问题。ForEach(subviewOf:) 可以遍历任意视图的「已解析子视图」——无论这些子视图是静态声明的还是通过 ForEach 动态生成的。Group(subviewsOf:) 则一次性获取所有已解析子视图的集合,适合需要统计数量或批量处理的场景。ForEach(sectionOf:) 用于检测和遍历视图中的 Section,让自定义容器也能像 List 一样支持分组。配合 ContainerValues,容器可以读取子视图上设置的自定义值,实现类似 listRowSeparator() 的容器特定修饰符。

这套 API 的关键在于理解「声明视图」和「已解析视图」的区别。声明视图是你写在代码里的 ViewBuilder 内容,已解析视图是 SwiftUI 在运行时实际渲染的视图——ForEach 会根据数据展开成多个视图,if 语句会根据条件决定是否渲染。新 API 操作的都是已解析视图,这让容器实现更加简洁。

详细内容

基础容器实现

一个数据驱动的 DisplayBoard 容器(05:27):

struct DisplayBoard<Data: Collection, Content: View>: View {
  var data: Data
  @ViewBuilder var content: (Data.Element) -> Content

  var body: some View {
    DisplayBoardCardLayout {
      ForEach(data) { item in
        CardView {
          content(item)
        }
      }
    }
    .background { BoardBackgroundView() }
  }
}

关键点:

  • data 集合通过 @ViewBuilder 闭包映射为视图
  • ForEach 迭代数据并生成 CardView
  • 容器只支持这种单一的数据驱动模式

支持任意内容组合

重构为使用 ViewBuilder 和 ForEach(subviewOf:)05:27):

struct DisplayBoard<Content: View>: View {
  @ViewBuilder var content: Content

  var body: some View {
    DisplayBoardCardLayout {
      ForEach(subviewOf: content) { subview in
        CardView {
          subview
        }
      }
    }
    .background { BoardBackgroundView() }
  }
}

// 使用示例
DisplayBoard {
  Text("Scrolling in the Deep")
  Text("Born to Build & Run")
  Text("Some Body like View")

  ForEach(songsFromSam) { song in
    Text(song.title)
  }
}

关键点:

  • 移除数据驱动参数,改为单一的 @ViewBuilder content
  • ForEach(subviewOf:) 遍历「已解析子视图」,无论静态还是动态生成
  • 容器现在可以混用静态视图和 ForEach,无需修改实现

声明视图 vs 已解析视图

理解 SwiftUI 的视图解析过程(08:00):

// 1 个声明视图
ForEach(songsFromSam) { song in
  Text(song.title)
}

// 9 个已解析子视图
Text("I Container Multitudes")
...
Text("Love Stack")

关键点:

  • ForEach 是声明视图,没有视觉外观
  • 它的作用是根据数据生成多个已解析子视图
  • Group 会直接展开其内容为已解析子视图
  • EmptyView 解析为零个子视图
  • if 语句根据条件决定解析结果

批量处理子视图

使用 Group(subviewsOf:) 统计子视图数量(10:00):

var body: some View {
  DisplayBoardCardLayout {
    Group(subviewsOf: content) { subviews in
      ForEach(subviews) { subview in
        CardView(
          scale: subviews.count > 15 ? .small : .normal
        ) {
          subview
        }
      }
    }
  }
  .background { BoardBackgroundView() }
}

关键点:

  • Group(subviewsOf:) 获取已解析子视图的集合
  • 可以通过 count 等属性访问集合信息
  • 适合需要根据子视图数量动态调整布局的场景

支持 Section 分组

使用 ForEach(sectionOf:) 遍历 Section(11:42):

var body: some View {
  HStack(spacing: 80) {
    ForEach(sectionOf: content) { section in
      VStack(spacing: 20) {
        if !section.header.isEmpty {
          DisplayBoardSectionHeaderCard { section.header }
        }
        DisplayBoardSectionContent {
          section.content
        }
        .background { BoardSectionBackgroundView() }
      }
    }
  }
  .background { BoardBackgroundView() }
}

关键点:

  • ForEach(sectionOf:) 检测并遍历内容中的 Section
  • 每个 section 有 headercontent 属性
  • header.isEmpty 检查是否有头部内容
  • 容器可以为每个 Section 创建独立的布局

自定义容器修饰符

使用 ContainerValues 定义容器特定修饰符(14:46):

extension ContainerValues {
  @Entry var isDisplayBoardCardRejected: Bool = false
}

extension View {
  func displayBoardCardRejected(_ isRejected: Bool) -> some View {
    containerValue(\.isDisplayBoardCardRejected, isRejected)
  }
}

关键点:

  • ContainerValues 是新的键值存储类型
  • @Entry 宏用于声明新的容器值条目
  • containerValue() 修饰符用于设置值

在容器中读取容器值(15:42):

struct DisplayBoardSectionContent<Content: View>: View {
  @ViewBuilder var content: Content

  var body: some View {
    DisplayBoardCardLayout {
      Group(subviewsOf: content) { subviews in
        ForEach(subviews) { subview in
          let values = subview.containerValues
          CardView(
            scale: (subviews.count > 15) ? .small : .normal,
            isRejected: values.isDisplayBoardCardRejected
          ) {
            subview
          }
        }
      }
    }
  }
}

关键点:

  • 子视图有 containerValues 属性可读取
  • Section 也有 containerValues
  • 应用于 Section 的修饰符会影响其所有子视图

使用自定义修饰符(16:15):

DisplayBoard {
  Section("Matt's Favorites") {
    Text("Scrolling in the Deep")
      .displayBoardCardRejected(true)
    Text("Born to Build & Run")
    Text("Some Body like View")
  }
  Section("Sam's Favorites") {
    ForEach(songsFromSam) { song in
      Text(song.title)
        .displayBoardCardRejected(song.samHasDibs)
    }
  }
}

关键点:

  • 修饰符可以应用于单个视图
  • 也可以应用于 ForEach 中的每个视图
  • 应用于 Section 会影响所有子视图

核心启发

  • 构建卡片网格容器:使用 ForEach(subviewOf:) 构建类似 DisplayBoard 的卡片网格容器,支持静态卡片和动态卡片混合排列。这比手动解析 ViewBuilder 输出更可靠,也能更好地适应 SwiftUI 的声明式更新机制。先从简单的网格布局开始,验证子视图遍历逻辑正确后再扩展功能。

  • 实现看板布局的列分组:使用 ForEach(sectionOf:) 为看板布局(Kanban board)添加列分组支持,每个 Section 对应一列,Section header 作为列标题。这种方式比自己定义数据模型来表示列更直观,用户可以直接用 SwiftUI 的 Section API 来组织内容,降低使用成本。

  • 为容器添加装饰性修饰符:使用 ContainerValues 为容器视图添加特定的装饰选项,比如网格项的跨列设置、卡片的强调样式、或者是否禁用某些交互。这类修饰符与容器强相关,不适合用 Environment 或 Preference 实现,而 ContainerValues 正是为这种场景设计的——值只对直接容器可见,不会泄漏到视图层级中。

关联 Session

评论

GitHub Issues · utterances