skip to Main Content

I’m using a ForEach to display the contents of an array, then manually showing a divider between each element by checking the element index. Here’s my code:

struct ContentView: View {
    let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"]

    var body: some View {
        VStack {
            /// array of tuples containing each element's index and the element itself
            let enumerated = Array(zip(animals.indices, animals))
            ForEach(enumerated, id: .1) { index, animal in
                Text(animal)

                /// add a divider if the element isn't the last
                if index != enumerated.count - 1 {
                    Divider()
                        .background(.blue)
                }
            }
        }
    }
}

Result:

Stack of text with dividers in between

This works, but I’d like a way to automatically add dividers everywhere without writing the Array(zip(animals.indices, animals)) every time. Here’s what I have so far:

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

            /// generate the view
            content(data)

            /// add a divider if the element isn't the last
            if let index = index as? Int, index != enumerated.count - 1 {
                Divider()
                    .background(.blue)
            }
        }
    }
}

/// usage
ForEachDividerView(data: animals) { animal in
    Text(animal)
}

This works great, isolating all the boilerplate zip code and still getting the same result. However, this is only because animals is an array of Strings, which conform to Hashable — if the elements in my array didn’t conform to Hashable, it wouldn’t work:

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 {

            /// Error! Generic struct 'ForEachDividerView' requires that 'Person' conform to 'Hashable'
            ForEachDividerView(data: people) { person in
                Text(person.name)
            }
        }
    }
}

That’s why SwiftUI’s ForEach comes with an additional initializer, init(_:id:content:), that takes in a custom key path for extracting the ID. I’d like to take advantage of this initializer in my ForEachDividerView, but I can’t figure it out. Here’s what I tried:

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))

        /// Error! 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)
            }
        }
    }
}


/// at least this part works...
ForEachDividerView(data: people, id: .name) { person in
    Text(person.name)
}

I tried using appending(path:) to combine the first key path (which extracts the element from enumerated) with the second key path (which gets the Hashable property from the element), but I got Invalid component of Swift key path.

How can I automatically add a divider in between the elements of a ForEach, even when the element doesn’t conform to Hashable?

4

Answers


  1. Chosen as BEST ANSWER

    Found a solution!

    1. appending(path:) seems to only work on key paths erased to AnyKeyPath.
    2. Then, appending(path:) returns an optional AnyKeyPath? — this needs to get cast down to KeyPath<(Data.Index, Data.Element), ID> to satisfy the id parameter.
    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))
    
            /// first create a `AnyKeyPath` that extracts the element from `enumerated`
            let elementKeyPath: AnyKeyPath = (Data.Index, Data.Element).1
    
            /// then, append the `id` key path to `elementKeyPath` to extract the `Hashable` property
            if let fullKeyPath = elementKeyPath.appending(path: id) as? KeyPath<(Data.Index, Data.Element), ID> {
                ForEach(enumerated, id: fullKeyPath) { index, data in
    
                    content(data)
    
                    if let index = index as? Int, index != enumerated.count - 1 {
                        Divider()
                            .background(.blue)
                    }
                }
            }
        }
    }
    

    Usage:

    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 {
                ForEachDividerView(data: people, id: .name) { person in
                    Text(person.name)
                }
            }
        }
    }
    

    Result:

    Person names stacked vertically, with blue divider in between


  2. Simple way

    struct ContentView: View {
    let animals = ["Apple", "Bear", "Cat", "Dog", "Elephant"]
    
    var body: some View {
        VStack {
    
            ForEach(animals, id: .self) { animal in
                Text(animal)
    
                if animals.last != animal  {
                    Divider()
                        .background(.blue)
                }
            }
        }
    }
    }
    

    Typically the type inside animals must be Identifiable. In which case the code will be modified as.

              if animals.last.id != animal.id {...}
    

    This will avoid any equatable requirements/ implementations

    Login or Signup to reply.
  3. Does everything need to be in a ForEach? If not, you can consider not using indices at all:

    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 {
            if let first = data.first {
                content(first)
                
                ForEach(data.dropFirst(), id: id) { element in
                    Divider()
                        .background(.blue)
                    content(element)
                }
            }
        }
    }
    
    Login or Signup to reply.
  4. Using the article mentioned in a comment I built the following. It takes the set of views and places a divider between them.

    This is also useful when the views are not being generated by a ForEach, especially when one or more of the views is removed conditionally (e.g. using an if statement).

    struct Divided<Content: View>: View {
        var content: Content
    
        init(@ViewBuilder content: () -> Content) {
            self.content = content()
        }
    
        var body: some View {
            _VariadicView.Tree(DividedLayout()) {
                content
            }
        }
    
        struct DividedLayout: _VariadicView_MultiViewRoot {
            @ViewBuilder
            func body(children: _VariadicView.Children) -> some View {
                let last = children.last?.id
    
                ForEach(children) { child in
                    child
    
                    if child.id != last {
                        Divider()
                    }
                }
            }
        }
    }
    
    struct Divided_Previews: PreviewProvider {
        static var previews: some View {
            VStack {
                Divided {
                    Text("Alpha")
                    Text("Beta")
                    Text("Gamma")
                }
            }
            .previewDisplayName("Vertical")
    
            HStack {
                Divided {
                    Text("Alpha")
                    Text("Beta")
                    Text("Gamma")
                }
            }
            .previewDisplayName("Horizontal")
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search