You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 21:48:48