I'm using SwiftUI's ForEach to draw a Grid from a matrix in my view model that contains an equal number of rows and columns. I want to be able to tap a button and switch to a new matrix which has a different length of rows and columns (but still a square grid). When I tap the button and switch matrices, the ForEach loops out of range while it tries to draw the smaller grid with the previous grid's dimensions.
The matrix contains RewardCell
elements which I've conformed to Identifiable
and Hashable
:
struct RewardCell: Identifiable {
var isVisible: Bool = false
let id: String = UUID().uuidString
let coord: CGPoint
}
// Removed after some feedback...
//extension RewardCell: Hashable {
// func hash(into hasher: inout Hasher) {
// hasher.combine(isVisible)
// }
//
// static func == (lhs: RewardCell, rhs: RewardCell) -> Bool {
// lhs.coord == rhs.coord
// }
//}
The ForEach brute forces through the matrix while drawing it's view like so...
var rewardGrid: someView {
ZStack {
GiftView()
.environmentObject(rewards)
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
ForEach(rewards.grid, id: \.self) { row in
GridRow {
ForEach(row, id: \.self) { element in
RewardGridCell(coord: element.coord, isEditor: true)
.environmentObject(rewards)
}
}
}
}
}
}
The rewards
ivar used above is an ObservableObject
passed in as an EnvironmentObject
and it computes the grid property this way...
private lazy var muteableRewards: [Gift] = initialRewards
var currentReward: Gift {
get { muteableRewards[currentRewardIndex] }
set {
guard let index = muteableRewards.firstIndex(where: { $0 == newValue }) else { return }
currentRewardIndex = index
muteableRewards[index] = newValue
}
}
var grid: [[RewardCell]] { currentReward.unlockedTileMatrix }
And it changes the currentRewardIndex
like so...
func nextIndex() {
currentRewardIndex = currentRewardIndex == rewards.count - 1 ? 0 : currentRewardIndex + 1
}
Inside the constructor of the RewardGridCell
it checks if it should be drawn (isVisible
property) and throws the error in the guard statement below:
func isVisible(atCoord coord: CGPoint) throws -> Bool {
guard let value = element(atCoord: coord)?.isVisible else {
throw NullError(type: .optionalIsNil, subject: coord)
}
return value
}
func element(atCoord coord: CGPoint) -> RewardCell? {
for row in 0..<grid.count {
for column in 0..<grid[row].count {
if grid[row][column].coord == coord {
return grid[row][column]
}
}
}
return nil
}
The first grid drawn is 5x5 and the second grid is 3x3; which has no element when the ForEach tries the coords (4,1). I'm thinking that I screwed up the Identifiable conformance or maybe I'm not iterating through the multidimensional array correctly. Why is this happening, and what would be the correct way to do this with SwiftUI?
Okay, after some feedback I tried to ditch the matrix altogether and switch to a lazy grid by swapping out rewardGrid
for lazyRewardGrid
(also removed the Hashable extension that's now crossed out above):
var lazyRewardGrid: some View {
ZStack {
GiftView()
.environmentObject(rewards)
LazyVGrid(columns: rewards.gridColumns, spacing: 0) {
ForEach(rewards.gridAsArray) { element in
RewardGridCell(coord: element.coord, isEditor: true)
.environmentObject(rewards)
}
}
}
}
Now it's no longer a perfect square etc... but more importantly the app still crashes in the isVisible:atCoord
method when I change to the new grid size.
Other changes I made during this...
rewards.gridColumns:
var gridDimension: Int { /* Length/Width (e.g. 5 when grid is 5x5 */ }
var gridColumns: [GridItem] {
var columns = [GridItem]()
for _ in 0..<gridDimension {
let item = GridItem(.flexible())
columns.append(item)
}
return columns
}
rewards.gridAsArray:
var gridAsArray: [RewardCell] {
var array = [RewardCell]()
for row in grid { array += row }
return array
}
The important thing to know about ForEach
is it is not a loop and the item closure can be called multiple times and in any order. The behaviour is different depending on what container View
it is in, e.g. List
, Table
etc.
ForEach
requires a real id
(that is a unique identifier property of the struct that is unique within the array and stays the same between changes) or Identifiable
which you already have so remove all of the occurrences of id: \.self
and the entire bad Hashable
extension (which I'm guessing you added to make the \.self
mistake to compile). By the way you could improve your id
slightly like this let id = UUID()
, from then on you can use the type RewardCell.ID
for your functions.
Use the id
to find things (eg first(where:)
) instead of using indices because in Swift's world of value types the index is usually out of date, hence the out of range crashing. If you need a binding you can just do ForEach($items) { $item in
which is convenience for a computed array of Binding
s to find and set a value by id
.