我发现我的第一个 SwiftUI 挑战是一个棘手的挑战。给定一组扑克牌,以一种允许用户在有效利用空间的同时查看完整牌组的方式显示它们。这是一个简化的示例:
在这种情况下,按照-View
的顺序呈现了52 张卡片。它们被动态打包到父视图中,以便它们之间有足够的间距以使数字可见。01
52
问题
如果我们改变窗口的形状,打包算法会将它们(正确)打包成不同数量的行和列。但是,当行数/列数发生变化时,卡片 Views 会出现乱序(有些是重复的):
在上图中,请注意第一行是正确的 ( 01
- 26
),但第二行从 开始12
并在 结束52
。我希望他是因为第二行最初包含12
-22
并且这些视图没有更新。
附加标准:卡的数量和这些卡的顺序可以在运行时改变。此外,此应用程序必须能够在 Mac 上运行,其中窗口大小可以动态调整为任何形状(在合理范围内)。
我知道在使用ForEach
索引时,必须使用常量,但我必须遍历一系列行和列,每个行和列都可以更改。我试过添加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)
}
}