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:
In this case 52 cards (View
s) 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):
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)
}
}
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