SwiftUI - ForEach 的使用

311 阅读3分钟

前言

在使用 SwiftUI 构建动态列表或重复的 UI 组件时,你很可能已经接触过 ForEach 视图元素。它是 SwiftUI 中一个功能强大但时常被误解的构建块。

本文将带你深入了解 ForEach 的工作机制,并讲解在何时以及如何正确使用它。我们还将探讨基于索引的迭代方式,以及 Identifiable 协议在其中所扮演的重要角色。

ForEach

ForEachSwiftUI 中用于遍历集合并为每个元素生成视图的工具。无论是在 ListLazyVStack,还是其他可以包含多个子视图的容器中,ForEach 都是构建重复界面的常用方式。

struct ContentView: View {
    let animals = ["🐱", "🐰", "🐂", "🐭"]
    var body: some View {
        ForEach(animals, id: \.self) {
            name in Text(name)
        }
    }
}

在上面的示例中,我们使用 \.self来遍历 animals 字符串数组。由于字符串类型本身符合 Hashable 协议,因此可以作为唯一标识符。但对于自定义数据类型,更推荐通过遵循 Identifiable 协议来显式提供一个唯一标识符,这样可以提升代码的可读性和稳定性。

自定义类型 + Identifiable

通常在开发过程中,我们会抽象自己的自定义类型,比如上面的字符串数组可能会改写为下面的自定义类型:

struct Pet: Identifiable {
    let id = UUID()
    let name: String
    let price: Int
}

通过自定义结构体 Pet 表示数据结构,包含宠物的名字价格和用于表示唯一数据的 id。

接着可以通过 ForEach 用来渲染我们需要展示的数据:

struct ContentView: View {
    let pets = [Pet(name: "🐱", price: 10), Pet(name: "🐰", price: 20),
    Pet(name: "🐂", price: 50),
    Pet(name: "🐭", price: 5)]
    
    var body: some View {
        ForEach(pets) {
            pet in
            HStack {
                Text(pet.name)
                Text("价格:\(pet.price)")
            }
        }
    }
}

上述代码声明了一个包含 Pet 类型的数组,然后通过 ForEach 来进行渲染,每个单元格通过 HStack 来进行展示,效果图如下:

截屏2025-05-11 16.34.12.png

使用 Identifiable 协议有助于 SwiftUI 在状态更新时跟踪视图的身份,避免不必要的重绘。

在 List 中使用 ForEach

在使用场景上,对于 ForEach 来说,最常见的使用场景就是在列表视图中使用。示例代码如下:

struct ContentView: View {
    let pets = [Pet(name: "🐱", price: 10), Pet(name: "🐰", price: 20),
    Pet(name: "🐂", price: 50),
    Pet(name: "🐭", price: 5)]
    
    var body: some View {
        List {
            ForEach(pets) {
                pet in
                HStack {
                    Text(pet.name)
                    Text("价格:\(pet.price)")
                }
            }
        }
    }
}

可以看到,代码的改动很小,只是在 ForEach 外层包裹了一个 List 即可,效果图如下:

截屏2025-05-11 16.38.07.png

如果在遍历过程中,我们需要访问到元素的索引,我们可以通过 enumerated() 来实现:

struct ContentView: View {
    var pets = ...
    
    var body: some View {
        List {
            ForEach(Array(pets.enumerated()), id: \.offset) {
                index, pet in
                HStack {
                    Text("\(index)")
                    Text(pet.name)
                    Text("价格:\(pet.price)")
                }
            }
        }
    }
}

因为 enumerated() 返回的是 (offset: Int, element: Element) 的元组。不是标准的 RandomAccessCollection 类型,所以我们需要 Array() 将其转换类型。并且需要 \.offset 指定用索引作为唯一标识符。效果图如下:

截屏2025-05-11 16.51.04.png

如果我们获取元素索引的同时,还有有删除功能的话,我们就不能使用 \.offset 当做单元格的唯一标识了,因为它是不稳定的 id,会造成页面抖动或者乱跳的问题。比如下面的代码:

let items = ["A", "B", "C"]

ForEach(Array(items.enumerated()), id: \.offset) { index, item in
    Text(item)
}

此时,SwiftUI 使用的是:

  • A → id = 0
  • B → id = 1
  • C → id = 2

现在,如果你删除了第一个元素 "A",剩下的是:

  • B → 现在是 index 0
  • C → 现在是 index 1

从 SwiftUI 的视角来看:

  • "B" 从 id 1 → 变成 id 0(被视为不同元素)
  • "C" 从 id 2 → 变成 id 1(也是不同元素)

结果就会导致 SwiftUI 会销毁原有视图并创建两个新的视图。解决办法就是将我们类型本身的 id 传进去即可:

ForEach(Array(pets.enumerated()), id: \.element.id) {}