SwiftUI 为ForEach元素自动添加Divider 兼容非Hashable元素
问题背景
我在使用ForEach展示数组内容时,最初通过判断元素索引的方式手动在相邻元素之间添加分割线,实现代码如下:
struct ContentView: View { let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"] var body: some View { VStack { /// 包含元素索引和元素本身的元组数组 let enumerated = Array(zip(animals.indices, animals)) ForEach(enumerated, id: \.1) { index, animal in Text(animal) /// 非最后一个元素时添加分割线 if index != enumerated.count - 1 { Divider() .background(.blue) } } } } }
运行效果如下:
这个实现可以正常运行,但每次编写Array(zip(animals.indices, animals))属于重复样板代码,因此我封装了自定义视图来实现自动添加分割线的通用逻辑:
struct ForEachDividerView<Data, Content>: View where Data: RandomAccessCollection, Data.Element: Hashable, Content: View { var data: Data var content: (Data.Element) -> Content var body: some View { let enumerated = Array(zip(data.indices, data)) ForEach(enumerated, id: \.1) { index, data in /// 生成元素视图 content(data) /// 非最后一个元素时添加分割线 if let index = index as? Int, index != enumerated.count - 1 { Divider() .background(.blue) } } } } /// 调用方式 ForEachDividerView(data: animals) { animal in Text(animal) }
这个封装可以抽离zip相关的样板代码,运行效果和原实现完全一致,但存在限制:仅支持元素遵循Hashable协议的集合,比如示例中的String类型数组。如果数组元素不遵循Hashable协议就会触发编译错误,示例如下:
struct Person { var name: String } struct ContentView: View { let people: [Person] = [ .init(name: "Anna"), .init(name: "Bob"), .init(name: "Chris") ] var body: some View { VStack { /// 编译报错:Generic struct 'ForEachDividerView' requires that 'Person' conform to 'Hashable' ForEachDividerView(data: people) { person in Text(person.name) } } } }
SwiftUI原生ForEach支持传入自定义键路径提取元素ID,我希望在自定义的ForEachDividerView中兼容该能力,但尝试时出现问题,代码如下:
struct ForEachDividerView<Data, Content, ID>: View where Data: RandomAccessCollection, ID: Hashable, Content: View { var data: Data var id: KeyPath<Data.Element, ID> var content: (Data.Element) -> Content var body: some View { let enumerated = Array(zip(data.indices, data)) /// 编译报错:Invalid component of Swift key path ForEach(enumerated, id: \.1.appending(path: id)) { index, data in content(data) if let index = index as? Int, index != enumerated.count - 1 { Divider() .background(.blue) } } } } /// 调用侧写法预期是可行的 ForEachDividerView(data: people, id: \.name) { person in Text(person.name) }
我尝试用appending(path:)方法拼接两个键路径:从元组提取元素的键路径、从元素提取Hashable ID的键路径,但触发了Invalid component of Swift key path编译错误。
实现方案
错误原因
键路径字面量(\.1这类语法)是编译时确定的,不能在字面量内部动态拼接运行时传入的KeyPath实例,因此\.1.appending(path: id)的写法不符合语法规则。
通用实现代码
不需要做键路径拼接,直接通过keyPath下标动态从元素中取ID即可,同时优化索引判断逻辑,兼容非Int类型索引的集合,还可以扩展便捷初始化方法适配Hashable元素的简化调用:
struct ForEachDividerView<Data, Content, ID>: View where Data: RandomAccessCollection, ID: Hashable, Content: View { private let data: Data private let idKeyPath: KeyPath<Data.Element, ID> private let content: (Data.Element) -> Content private let divider: () -> Divider /// 通用初始化方法,支持传入自定义ID键路径 init( _ data: Data, id: KeyPath<Data.Element, ID>, @ViewBuilder content: @escaping (Data.Element) -> Content, @ViewBuilder divider: @escaping () -> Divider = { Divider().background(.blue) } ) { self.data = data self.idKeyPath = id self.content = content self.divider = divider } var body: some View { let enumerated = Array(zip(data.indices, data)) ForEach(enumerated, id: \.1[keyPath: idKeyPath]) { index, item in content(item) // 直接和枚举数组的最后一个索引对比,不需要强转Int,兼容所有RandomAccessCollection if index != enumerated.last?.0 { divider() } } } } /// 便捷初始化:当元素本身遵循Hashable时,不需要手动传id extension ForEachDividerView where Data.Element: Hashable, ID == Data.Element { init( _ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content, @ViewBuilder divider: @escaping () -> Divider = { Divider().background(.blue) } ) { self.init(data, id: \.self, content: content, divider: divider) } }
调用示例
- 非Hashable元素调用,传入自定义ID键路径即可正常运行:
struct ContentView: View { let people: [Person] = [ .init(name: "Anna"), .init(name: "Bob"), .init(name: "Chris") ] var body: some View { VStack { ForEachDividerView(people, id: \.name) { person in Text(person.name) } } } }
- Hashable元素可以直接简化调用,和原有使用方式一致:
let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"] VStack { ForEachDividerView(animals) { animal in Text(animal) } }
- 支持自定义分割线样式:
ForEachDividerView(animals) { animal in Text(animal) } divider: { Divider().background(.red).frame(height: 1) }
内容的提问来源于stack exchange,提问作者aheze
相关产品推荐
相关产品推荐

