前言
在使用 SwiftUI 构建动态列表或重复的 UI 组件时,你很可能已经接触过 ForEach 视图元素。它是 SwiftUI 中一个功能强大但时常被误解的构建块。
本文将带你深入了解 ForEach 的工作机制,并讲解在何时以及如何正确使用它。我们还将探讨基于索引的迭代方式,以及 Identifiable 协议在其中所扮演的重要角色。
ForEach
ForEach 是 SwiftUI 中用于遍历集合并为每个元素生成视图的工具。无论是在 List、LazyVStack,还是其他可以包含多个子视图的容器中,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 来进行展示,效果图如下:
使用 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 即可,效果图如下:
如果在遍历过程中,我们需要访问到元素的索引,我们可以通过 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 指定用索引作为唯一标识符。效果图如下:
如果我们获取元素索引的同时,还有有删除功能的话,我们就不能使用 \.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) {}