ForEach 视图

ForEach 视图允许在循环中创建其他视图。例如想要遍历一组名称,将每个名称转化成文本视图;或遍历一组菜单项,将每个名称显示为图像。

// 例子:foreach 可以遍历一个半闭范围。后面闭包中的唯一参数 number ,代表前面范围中提取的一个个值
ForEach(0..<100) { number in 
		Text("Row \\(number)") 
}

// 例子:foreach 可以直接遍历数组
let agents = ["Cyril", "Lana", "Pam", "Sterling"]

ForEach(agents, id: \\.self) { agent in 
		Text(agent) 
}

ForEach(emojis[0..<emojiCount], id: \\.self){ emoji in 
		CardView(content: emoji) 
}

// 省略闭包参数名:因为传入的是闭包,所以可以使用闭包参数的简写语法,如下所示:
ForEach(0..<agents.count) { Text(agents[$0]) }

参数:半闭范围

ForEach 接受的数据类型是: Range<Int> ,这是一个整数范围,还有第二种相似的类型称为 ClosedRange<Int> ,这就是导致出错的原因。当我们写 0..<5 时,我们得到一个 Range<Int> ,但是当我们写 0...5 时,我们得到一个 ClosedRange<Int> 尽管它看起来与我们相似,但 Swift 认为这两种范围类型是不同的,因此我们不能将封闭范围与 ForEach 一起使用。(希望未来可以改变这个)

// 以下代码正确,半闭范围
ForEach( 0..<5 ) {
    Text("Row \\($0)")
}

// 以下代码报错,因为是全闭范围
ForEach( 0...5 ) {
   Text("Row \\($0)")
}

参数:ID

ForEach 的 ID 属性是必须填写的,因为 SwiftUI 需要能够唯一地识别屏幕上的每个视图,以便它可以检测到事物何时发生变化。

当在 SwiftUI 中创建静态视图时(我们硬编码 VStackTextFieldButton 等),SwiftUI 可以准确地看到拥有哪些视图,并且能够控制它们、赋予它们动画等。但是当使用 ListForEach 制作动态视图时,SwiftUI 需要知道它如何唯一地标识每个项目,否则它将很难比较视图层次结构以找出发生了什么变化。

1. 使用UUID,遵循 Identifiable【最推荐】

真正解决 ID 唯一性问题的做法是:生成 UUID 的唯一识别码,并遵循 Identifiable 协议。

// UUID 是长十六进制字符串,例如:08B15DB4-2F02-4AB8-A965-67A9C90D8A44。所以,那就是八位,四位,四位,四位,然后十二位,其中唯一的要求是第三个块的第一个数字是4。如果我们减去固定的 4,我们最终会得到 31 个数字,每个数字都可以是 16 个值之。如果在 10 亿年里每秒生成 1 个 UUID,才可能有机会生成重复项。

// 1: 给每个实例自动生成UUID,用UUID函数随机产生通用的识别码,作为通用的唯一标识
struct Restaurant{
	var id = UUID()
	var name: String
	var image: String
}

// 2. 使用 Restaurant 的 id 属性作为唯一标识
ForEach(restaurants, id: \\.id){
	restaurant in 
	Image(restaurant.image)
	Text(restaurant.name)
}

还应该让结构遵循 Identifiable 协议,该协议只有一个要求,即该类型必须具备某种 ID 作为唯一识别码。

// 为什么要遵循这个协议,不遵循不是也有效吗?因为遵循 Identifiable 协议,只有一个要求,即必须有一个名为 id 的属性,其中包含唯一标识符。所以当结构体遵循了该协议,就不再需要告诉 ForEach 使用哪个属性作为标识符了,它知道将会有一个 id 属性,并且它将是唯一的。
// 所以遵循 Identifiable 协议,可以把 ForEach 中的 ID 参数部分省略掉。

// 因此,代码可以修改为如下所示:
struct Restaurant: Identifiable {
	var id = UUID()
	var name: String
	var image: String
}

//id部分的参数已经被省略了。
ForEach(restaurants){
	restaurant in 
	Image(restaurant.image)
	Text(restaurant.name)
}

2. 复杂数据用属性作 ID

当遍历复杂数据时,如果不用 UUID ,也可以用对象里面的属性值作为ID,写作 \\.name 。但如何确保这些属性值是唯一的是个难题。

struct ExpenseItem {
    let name: String
    let type: String
    let amount: Double
}

ForEach(expenses.items, id: \\.name) { 
		item in
    Text(item.name)
}

3. 用值本身作 ID

当 ForEach 遍历的是一个简单的数组时(例如:字符串数组、整数数组),由于无法使它们符合 Identifiable 协议,也就没有 ID 属性。于是我们告诉 Swift,字符串本身就是每个元素的唯一标识符。于是我们传入 \\.self 作为其 id 参数。

ForEach(students, id:\\.self) { Text($0) }

参见:结合 \.self 用作 ForEach ID


单个列表行设置

<aside> 💡 该功能仅适用于实现 DynamicViewContent 协议的视图。 目前,唯一符合 DynamicViewContent 协议的视图是 ForEach 视图。 因此,这些功能仅在 ForEach 视图上可用,而不能在 List 列表视图上使用。

</aside>

禁用“点击整行触发”

当【表单Form】或【列表List】中有行时,SwiftUI 喜欢假设【整行】本身是可点击的。这使得用户可以更轻松地进行选择,因为他们可以点击行中的任意位置来触发其中的按钮。在下面例子中,我们有多个按钮,因此 SwiftUI 按顺序点击所有按钮 - rating 设置为 1,然后是 2,然后是 3、4 和 5,这就是为什么它最终都是 5 。

// contentView
@State var rating: Int = 3
Form{
	Section("Write a review"){
		RatingView(rating: $rating)
	}
}

// subView
@Binding var rating: Int
HStack {
	ForEach( 1..<maximumRating + 1, id: \\.self) { number in
				Button {
					rating = number
					print(rating)
				} label: {
					image(for: number).foregroundStyle(number > rating ? offColor : onColor)
				}
	}
}

// 这时,可以通过在子视图中附加到整个 HStack 的额外修饰符,来禁用整个“点击行以触发其按钮”行为:
HStack { ... }
	.buttonStyle(.plain)

2. OnDelete 删除行

如果希望 onDelete 正常工作,需要创建一个能接收单个 IndexSet 类型参数的方法。这有点像一组整数,只不过它是经过排序的,它只是告诉我们 ForEach 中应删除的所有项目的具体位置。

struct ContentView: View {
    
		// 定义了一个空的整数数组,并加上了 @State
    @State private var numbers = [Int]()
    @State private var currentNumber = 1
    
    var body: some View {
        NavigationStack{
            VStack {
                List {
                    ForEach(numbers, id: \\.self) {
                        Text("Row \\($0)")
                    }
										// 在ForEach后添加onDelete修饰符,告诉SwiftUI当想要从ForEach中删除数据时调用该方法
                    .onDelete(perform: removeRows)
                }
                Button("Add Number") {
                    numbers.append(currentNumber)
                    currentNumber += 1
                }
                
            }
            .toolbar {
                EditButton()
								// EditButton() 是一个内置函数,返回一个视图(按钮)。该视图将自动切换列表中的编辑模式。 
								// 它的文本显示“编辑”,然后点击时,您将看到移动手柄出现在行上,按钮文本显示“完成”。
            }
        }
    }
    
    // 声明删除行的方法:接收单个 IndexSet 参数的方法
		// 例子中 ForEach 完全是从单个数组创建的,所以实际上可以将该索引集直接传递到 numbers 数组;
		// 它有一个特殊的 remove(atOffsets:) 方法接受索引集
    func removeRows(at offsets: IndexSet) {
		    numbers.remove(atOffsets: offsets)
		}
		// remove(atOffsets:) 方法是一个 mutating 方法,它用于从数组中移除元素。而你尝试在不可变的值上调用它
		// 为了解决这个问题,所以要使用 @State 修饰符来标记 numbers 属性为可变状态,以允许在视图中修改它
}
// 例子2
@State var data = ["Swipe to Delete", "Practice Coding", "Grocery shopping", "Get tickets"]

// 在 onDelete 的閉包,它會傳遞一個 indexSet,儲存被刪除的索引。
// 然後以 indexSet 來呼叫 remove 方法來刪除在 restaurants 陣列中的特定項目
func delete(at indexes: IndexSet) {
		 if let first = indexes.first {
				data.remove(at: first)
		 }
}

3. OnMove 移动行

onMove 修饰符后面有 sourcedestination 两个参数。

struct List_MoveRow : View {    

	@State var data = ["Hit the Edit button to reorder", "Practice Coding", "Grocery shopping", "Get tickets", "Clean house", "Do laundry", "Cook dinner", "Paint room"]        
	
	var body: some View {
		NavigationView {            
			List {                
				ForEach(data, id: \\.self) { 
					datum in                    
					Text(datum).font(Font.system(size: 24)).padding()                
				}
				//Move修饰器是加到ForEach上,不是加到List
				.onMove { source, destination in                    
					data.move(fromOffsets: source, toOffset: destination)                
				}            
			}            
			.navigationTitle("To Do")
			//工具栏上增加编辑按钮
			.toolbar {
				ToolbarItem { EditButton() }            
			}
		}
		// 改变工具栏上按钮的颜色
		.tint(.green) 
	}

}
// 例子2:
struct List_Delete : View {

	// 如果希望操作后更新UI,记得在数据参数前加上 @State 关键词
	@State var data = ["Swipe to Delete", "Practice Coding", "Grocery shopping", "Get tickets"]

	var body: some View {
			List {            
				Section(header: Text("To Do").padding()) {                
					ForEach(data, id: \\.self) { 
						datum in                    
						Text(datum).font(Font.system(size: 24)).padding()                
					}
					// 添加移动   
					.onMove(perform: moveRow)    
				}
			}
	}

	// 同理,排序的闭包
	func moveRow(from indexes: IndexSet, to destination: Int) {
			if let first = indexes.first {
				  data.insert(data.remove(at: first), at: destination)
			}
	}

}

4. 批量编辑行

如果您已将 SwiftUI 列表视图配置为支持删除或编辑其项目,则可以通过在某处添加 EditButton 来允许用户切换列表视图的编辑模式。

// 例如:在 toolbar 工具栏添加 EditButton() ,实现同时编辑多行数据
.toolbar {
    EditButton()
}

应用案例

1. 与 Picker 结合使用

struct ContentView: View {

		// students 数组不需要用 @State 标记,因为它是一个常量;它不会改变
    let students = ["Harry", "Hermione", "Ron"]

		// selectedStudent 属性以值“Harry”开头,但可以更改,这就是它标记为 @State 的原因
    @State private var selectedStudent = "Harry"

    var body: some View {
        NavigationStack {
            Form {
                Picker("Select your student", selection: $selectedStudent) {
                    ForEach(students, id: \\.self) {
                        Text($0)
                    }
                }
            }
        }
    }
}