Search code examples
dynamicforeachswiftuiidentifiable

SwiftUI: How to manage dynamic rows/columns of Views?


I am finding my first SwiftUI challenge to be a tricky one. Given a set of playing cards, display them in a way that allows the user to see the full deck while using space efficiently. Here's a simplified example:

enter image description here

In this case 52 cards (Views) are presented, in order of 01 - 52. They are dynamically packed into the parent view such that there is enough spacing between them to allow the numbers to be visible.

The problem

If we change the shape of the window, the packing algorithm will pack them (correctly) into a different number of rows & columns. However, when the number of rows/columns change, the card Views are out of order (some are duplicated):

enter image description here

In the image above, notice how the top row is correct (01 - 26) but the second row starts at 12 and ends at 52. I expect his is because the second row originally contained 12 - 22 and those views were not updated.

Additional criteria: The number of cards and the order of those cards can change at runtime. Also, this app must be able to be run on Mac, where the window size can be dynamically adjusted to any shape (within reason.)

I understand that when using ForEach for indexing, one must use a constant but I must loop through a series of rows and columns, each of which can change. I have tried adding id: \.self, but this did not solve the problem. I ended up looping through the maximum possible number of rows/columns (to keep the loop constant) and simply skipped the indices that I didn't want. This is clearly wrong.

The other alternative would be to use arrays of Identifiable structures. I tried this, but wasn't able to figure out how to organize the data flow. Also, since the packing is dependent on the size of the parent View it would seem that the packing must be done inside the parent. How can the parent generate the data needed to fulfill the deterministic requirements of SwiftUI?

I'm willing to do the work to get this working, any help understanding how I should proceed would be greatly appreciated.

The code below is a fully working, simplified version. Sorry if it's still a bit large. I'm guessing the problem revolves around the use of the two ForEach loops (which are, admittedly, a bit janky.)

import SwiftUI

// This is a hacked together simplfied view of a card that meets all requirements for demonstration purposes
struct CardView: View {
    public static let kVerticalCornerExposureRatio: CGFloat = 0.237
    public static let kPhysicalAspect: CGFloat = 63.5 / 88.9

    @State var faceCode: String

    func bgColor(_ faceCode: String) -> Color {
        let ascii = Character(String(faceCode.suffix(1))).asciiValue!
        let r = (CGFloat(ascii) / 3).truncatingRemainder(dividingBy: 0.7)
        let g = (CGFloat(ascii) / 17).truncatingRemainder(dividingBy: 0.9)
        let b = (CGFloat(ascii) / 23).truncatingRemainder(dividingBy: 0.6)
        return Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
    }

    var body: some View {
        GeometryReader { geometry in
            RoundedRectangle(cornerRadius: 10)
                .fill(bgColor(faceCode))
                .cornerRadius(8)
                .frame(width: geometry.size.height * CardView.kPhysicalAspect, height: geometry.size.height)
                .aspectRatio(CardView.kPhysicalAspect, contentMode: .fit)
                .overlay(Text(faceCode)
                        .font(.system(size: geometry.size.height * 0.1))
                        .padding(5)
                         , alignment: .topLeading)
                .overlay(RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 2))
        }
    }
}

// A single rows of our fanned out cards
struct RowView: View {
    var cards: [String]
    var width: CGFloat
    var height: CGFloat
    var start: Int
    var columns: Int

    var cardWidth: CGFloat {
        return height * CardView.kPhysicalAspect
    }

    var cardSpacing: CGFloat {
        return (width - cardWidth) / CGFloat(columns - 1)
    }

    var body: some View {
        HStack(spacing: 0) {
            // Visit all cards, but only add the ones that are within the range defined by start/columns
            ForEach(0 ..< cards.count) { index in
                if index < columns && start + index < cards.count {
                    HStack(spacing: 0) {
                        CardView(faceCode: cards[start + index])
                            .frame(width: cardWidth, height: height)
                    }
                    .frame(width: cardSpacing, alignment: .leading)
                }
            }
        }
    }
}

struct ContentView: View {
    @State var cards: [String]
    @State var fanned: Bool = true

    // Generates the number of rows/columns that meets our rectangle-packing criteria
    func pack(area: CGSize, count: Int) -> (rows: Int, cols: Int) {
        let areaAspect = area.width / area.height
        let exposureAspect = 1 - CardView.kVerticalCornerExposureRatio
        let aspect = areaAspect / CardView.kPhysicalAspect * exposureAspect
        var rows = Int(ceil(sqrt(Double(count)) / aspect))
        let cols = count / rows + (count % rows > 0 ? 1 : 0)
        while cols * (rows - 1) >= count { rows -= 1 }
        return (rows, cols)
    }

    // Calculate the height of a card such that a series of rows overlap without covering the corner pips
    func cardHeight(frameHeight: CGFloat, rows: Int) -> CGFloat {
        let partials = CGFloat(rows - 1) * CardView.kVerticalCornerExposureRatio + 1
        return frameHeight / partials
    }

    var body: some View {
        VStack {
            GeometryReader { geometry in
                let w = geometry.size.width
                let h = geometry.size.height
                if w > 0 && h > 0 { // using `geometry.size != .zero` crashes the preview :(
                    let (rows, cols) = pack(area: geometry.size, count: cards.count)
                    let cardHeight = cardHeight(frameHeight: h, rows: rows)
                    let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio

                    VStack(spacing: 0) {
                        // Visit all cards as if the layout is one row per card and simply skip the rows
                        // we're not interested in. If I make this `0 ..< rows` - it doesn't work at all
                        ForEach(0 ..< cards.count) { row in
                            if row < rows {
                                RowView(cards: cards, width: w, height: cardHeight, start: row * cols, columns: cols)
                                    .frame(width: w, height: rowSpacing, alignment: .topLeading)
                            }
                        }
                    }
                    .frame(width: w, height: 100, alignment: .topLeading)
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(cards: ["01", "02", "03", "04", "05", "06", "07", "08", "09",
                            "10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
                            "20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
                            "30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
                            "40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
                            "50", "51", "52"])
            .background(Color.white)
            .preferredColorScheme(.light)
    }
}

Solution

  • I think you're on the right track that you need to use an Identifiable to prevent the system from making assumptions about what can be recycled in the ForEach. To that end, I've created a Card:

    struct Card : Identifiable {
        let id = UUID()
        var title : String
    }
    

    Within the RowView, this is trivial to use:

    struct RowView: View {
        var cards: [Card]
        var width: CGFloat
        var height: CGFloat
        var columns: Int
    
        var cardWidth: CGFloat {
            return height * CardView.kPhysicalAspect
        }
    
        var cardSpacing: CGFloat {
            return (width - cardWidth) / CGFloat(columns - 1)
        }
    
        var body: some View {
            HStack(spacing: 0) {
                // Visit all cards, but only add the ones that are within the range defined by start/columns
                ForEach(cards) { card in
                        HStack(spacing: 0) {
                            CardView(faceCode: card.title)
                                .frame(width: cardWidth, height: height)
                        }
                        .frame(width: cardSpacing, alignment: .leading)
                }
            }
        }
    }
    

    In the ContentView, things get a little more complicated because of the dynamic rows:

    struct ContentView: View {
        @State var cards: [Card] = (1..<53).map { Card(title: "\($0)") }
        @State var fanned: Bool = true
    
        // Generates the number of rows/columns that meets our rectangle-packing criteria
        func pack(area: CGSize, count: Int) -> (rows: Int, cols: Int) {
            let areaAspect = area.width / area.height
            let exposureAspect = 1 - CardView.kVerticalCornerExposureRatio
            let aspect = areaAspect / CardView.kPhysicalAspect * exposureAspect
            var rows = Int(ceil(sqrt(Double(count)) / aspect))
            let cols = count / rows + (count % rows > 0 ? 1 : 0)
            while cols * (rows - 1) >= count { rows -= 1 }
            return (rows, cols)
        }
    
        // Calculate the height of a card such that a series of rows overlap without covering the corner pips
        func cardHeight(frameHeight: CGFloat, rows: Int) -> CGFloat {
            let partials = CGFloat(rows - 1) * CardView.kVerticalCornerExposureRatio + 1
            return frameHeight / partials
        }
    
        var body: some View {
            VStack {
                GeometryReader { geometry in
                    let w = geometry.size.width
                    let h = geometry.size.height
                    if w > 0 && h > 0 { // using `geometry.size != .zero` crashes the preview :(
                        let (rows, cols) = pack(area: geometry.size, count: cards.count)
                        let cardHeight = cardHeight(frameHeight: h, rows: rows)
                        let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio
    
                        VStack(spacing: 0) {
                            ForEach(Array(cards.enumerated()), id: \.1.id) { (index, card) in
                                let row = index / cols
                                if index % cols == 0 {
                                    let rangeMin = min(cards.count, row * cols)
                                    let rangeMax = min(cards.count, rangeMin + cols)
                                    RowView(cards: Array(cards[rangeMin..<rangeMax]), width: w, height: cardHeight, columns: cols)
                                        .frame(width: w, height: rowSpacing, alignment: .topLeading)
                                }
                            }
                        }
                        .frame(width: w, height: 100, alignment: .topLeading)
                    }
                }
            }
        }
    }
    

    This loops through all of the cards and uses the unique IDs. Then, there's some logic to use the index to determine what row the loop is on and if it is the beginning of the loop (and thus should render the row). Finally, it sends just a subset of the cards to the RowView.

    Note: you can look at Swift Algorithms for a more efficient method than enumerated. See indexed: https://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md