SwiftUI:如何管理动态 rows/columns 的视图?
SwiftUI: How to manage dynamic rows/columns of Views?
我发现我的第一个 SwiftUI 挑战很棘手。给定一组扑克牌,以一种允许用户在有效使用 space 的同时看到整副牌的方式显示它们。这是一个简化的例子:
在这种情况下,出现了 52 张牌(View
s),顺序为 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
更有效的方法。参见 indexed
:https://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
的需要。
现在的最终结果是这样的:
我发现我的第一个 SwiftUI 挑战很棘手。给定一组扑克牌,以一种允许用户在有效使用 space 的同时看到整副牌的方式显示它们。这是一个简化的例子:
在这种情况下,出现了 52 张牌(View
s),顺序为 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
更有效的方法。参见 indexed
:https://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md
@jnpdx 在其方法中提供了一个直接的有效答案,这有助于在不增加额外复杂性的情况下理解问题。
我还偶然发现了一种替代方法,该方法需要对代码结构进行更彻底的更改,但性能更高,同时也会导致更多 production-ready 代码。
首先,我创建了一个实现 ObservableObject
协议的 CardData
结构。这包括根据给定的 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)
}
}
}
可以通过在 CardData
中存储 rows/columns 个 CardView
来进一步改进,而不是存储卡片标题字符串。这将消除在视图代码中重新创建一整套(在我的例子中是复杂的)CardView
的需要。
现在的最终结果是这样的: