SwiftUI:如何管理动态 rows/columns 的视图?

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

我发现我的第一个 SwiftUI 挑战很棘手。给定一组扑克牌,以一种允许用户在有效使用 space 的同时看到整副牌的方式显示它们。这是一个简化的例子:

在这种情况下,出现了 52 张牌(Views),顺序为 01 - 52。它们被动态 打包到父视图中,这样它们之间就有足够的间距以允许数字可见。

问题

如果我们改变 window 的形状,打包算法会将它们(正确地)打包成不同数量的行和列。但是,当rows/columns个数变化时,卡片Views乱序(有的重复):

在上图中,请注意第一行是正确的 (01 - 26),但第二行从 12 开始并在 52 结束。我希望他是因为第二行最初包含 12 - 22 并且这些视图未更新。

附加条件:卡片的数量和卡片的顺序可以在 运行 时更改。此外,此应用程序必须能够在 Mac 上 运行,其中 window 大小可以动态调整为任何形状(在合理范围内。)

我知道在使用 ForEach 进行索引时,必须使用一个常量,但我必须循环遍历一系列行和列,每个行和列都可以更改。我试过添加 id: \.self,但这并没有解决问题。我最终循环遍历了最大可能数量的 rows/columns (以保持循环不变)并简单地跳过了我不想要的索引。这显然是错误的。

另一种选择是使用 Identifiable 结构的数组。我试过了,但无法弄清楚如何组织数据流。此外,由于包装取决于父级 View 的大小,因此包装似乎必须在父级内部完成。父级如何生成满足 SwiftUI 确定性要求所需的数据?

我愿意为此工作,如果能帮助我理解应该如何进行,我将不胜感激。

下面的代码是一个完全有效的简化版本。对不起,如果它仍然有点大。我猜问题围绕着两个 ForEach 循环的使用而展开(诚然,这有点简陋。)

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

我认为您走在正确的轨道上,您需要使用 Identifiable 来防止系统对 ForEach 中可以回收的内容做出假设。为此,我创建了一个 Card:

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

RowView 中,这很容易使用:

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

ContentView 中,由于动态行,事情变得有点复杂:

struct ContentView: View {
    @State var cards: [Card] = (1..<53).map { Card(title: "\([=12=])") }
    @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)
                }
            }
        }
    }
}

这遍历所有 cards 并使用唯一 ID。然后,有一些逻辑使用 index 来确定循环所在的行以及它是否是循环的开始(因此应该呈现该行)。最后,它只将卡片的一个子集发送到 RowView.

注意:您可以查看 Swift 算法以获得比 enumerated 更有效的方法。参见 indexedhttps://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md

@jnpdx 在其方法中提供了一个直接的有效答案,这有助于在不增加额外复杂性的情况下理解问题。

我还偶然发现了一种替代方法,该方法需要对代码结构进行更彻底的更改,但性能更高,同时也会导致更多 production-ready 代码。

首先,我创建了一个实现 ObservableObject 协议的 CardData 结构。这包括根据给定的 CGSize.

将一组卡片打包成 rows/columns 的代码
class CardData: ObservableObject {
    var cards = [[String]]()

    var hasData: Bool {
        return cards.count > 0 && cards[0].count > 0
    }

    func layout(cards: [String], size: CGSize) -> CardData {

        // ...
        // Populate `cards` with packed rows/columns
        // ...

        return self
    }
}

这只有在布局代码可以知道它打包的帧尺寸的情况下才有效。为此,我使用 .onChange(of:perform:) 来跟踪几何本身的变化:

.onChange(of: geometry.size, perform: { size in
   cards.layout(cards: cardStrings, size: size)
})

这大大简化了 ContentView:

var body: some View {
    VStack {
        GeometryReader { geometry in
            let cardHeight = cardHeight(frameHeight: geometry.size.height, rows: cards.rows)
            let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio

            VStack(spacing: 0) {
                ForEach(cards.cards, id: \.self) { row in
                    RowView(cards: row, width: geometry.size.width, height: cardHeight)
                        .frame(width: geometry.size.width, height: rowSpacing, alignment: .topLeading)
                }
            }
            .frame(width: geometry.size.width, height: 100, alignment: .topLeading)
            .onChange(of: geometry.size, perform: { size in
                _ = cards.layout(cards: CardData.faceCodes, size: size)
            })
        }
    }
}

此外,它还简化了RowView:

var body: some View {
    HStack(spacing: 0) {
        ForEach(cards, id: \.self) { card in
            HStack(spacing: 0) {
                CardView(faceCode: card)
                    .frame(width: cardWidth, height: height)
            }
            .frame(width: cardSpacing, alignment: .leading)
        }
    }
}

可以通过在 CardData 中存储 rows/columns 个 CardView 来进一步改进,而不是存储卡片标题字符串。这将消除在视图代码中重新创建一整套(在我的例子中是复杂的)CardView 的需要。

现在的最终结果是这样的: