SwiftUI + ForEach is hitting "index out of range" while redrawing matrix with new dimensions

115 Views Asked by At

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
    }
1

There are 1 best solutions below

9
On BEST ANSWER

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 Bindings to find and set a value by id.