SwiftUI: как управлять динамическими строками / столбцами представлений?

Я считаю, что моя первая задача SwiftUI была сложной. Учитывая набор игральных карт, отобразите их таким образом, чтобы пользователь мог видеть всю колоду, при этом эффективно используя пространство. Вот упрощенный пример:

В этом случае отображаются 52 карты в порядке -. Они динамически упаковываются в родительский вид, так что между ними есть достаточный интервал, чтобы числа были видны.

Эта проблема

Если мы изменим форму окна, алгоритм упаковки упакует их (правильно) в другое количество строк и столбцов. Однако при изменении количества строк / столбцов просмотры карточек не работают (некоторые дублируются):

На изображении выше обратите внимание на правильность верхнего ряда ( 01 - 26), но вторая строка начинается и заканчивается в 52. Я ожидаю, что это потому, что вторая строка изначально содержала 12 - 22 и эти просмотры не обновлялись.

Дополнительные критерии: количество карт и их порядок могут меняться во время выполнения. Кроме того, это приложение должно быть запущено на Mac, где размер окна можно динамически регулировать до любой формы (в пределах разумного).

Я понимаю, что при использовании для индексации необходимо использовать константу, но я должен пройти через серию строк и столбцов, каждая из которых может изменяться. Я пробовал добавить id: \.self, но это не решило проблему. В итоге я перебрал максимально возможное количество строк / столбцов (чтобы цикл оставался постоянным) и просто пропустил индексы, которые мне не нужны. Это явно неверно.

Другой альтернативой было бы использование массивов 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)
    }
}

2 ответа

Решение

Я думаю, вы на правильном пути, и вам нужно использовать Identifiable чтобы система не делала предположений о том, что может быть переработано в ForEach. С этой целью я создал Card:

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

В рамках это тривиально использовать:

      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: "\($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)
                }
            }
        }
    }
}

Это проходит через все cardsи использует уникальные идентификаторы. Тогда есть некоторая логика для использования indexчтобы определить, в какой строке находится цикл, и является ли это началом цикла (и, следовательно, следует отображать строку). Наконец, он отправляет только часть карт в RowView.

Примечание: вы можете посмотреть на алгоритмы Swift более эффективный метод, чем enumerated. Видеть indexed: https://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md

@jnpdx предоставил верный ответ, который является прямым по своему подходу, который помогает понять проблему без добавления дополнительной сложности.

Я также наткнулся на альтернативный подход, который требует более радикальных изменений в структуре кода, но является более производительным, а также приводит к более производственному коду.

Для начала я создал структуру, реализующую ObservableObjectпротокол. Это включает в себя код для упаковки набора карточек в строки / столбцы на основе заданного CGSize.

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

Дальнейшие улучшения можно получить, сохранив строки / столбцы s внутри CardDataа не строки заголовка карты. Это избавит от необходимости воссоздавать полный набор (в моем случае сложных) CardViews в коде просмотра.

Окончательный конечный результат теперь выглядит так:

Другие вопросы по тегам