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
а не строки заголовка карты. Это избавит от необходимости воссоздавать полный набор (в моем случае сложных)
CardView
s в коде просмотра.
Окончательный конечный результат теперь выглядит так: