'SwiftUI @Published var inconsistently updating views

I am working on a simple Reversi app built using SwiftUI. Recently I was updating my app and noticed a bug with the UI not updating to match the game state.

Here is a screenshot of an in-progress game:

Here is a screenshot of the board after I press the undo button:

In the second screenshot, the middle-rightmost white piece should be black. I verified that the game state was correct by printing the board in a didSet block. For the first screenshot, this was the output (the array of pieces shown below corresponds to the pieces on the board, going left to right, then bottom to top):

[..., Optional(Dueling_Disks.Piece(isLight: false, id: 434BCE7B-D1B6-45E8-81AB-15F76007F76A)), Optional(Dueling_Disks.Piece(isLight: true, id: 63070399-84AB-43EE-9D03-BA21D64E0399)), ...,
Optional(Dueling_Disks.Piece(isLight: false, id: 9BBD7C56-5DE4-4249-8EC3-870AD8F9FBB1)), Optional(Dueling_Disks.Piece(isLight: false, id: D870C5F2-AF0F-4667-8CF1-0F7D81A82D17)), Optional(Dueling_Disks.Piece(isLight: true, id: 2C92104A-12F9-487F-A0BF-60ECEF8BF4B7)), ..., 
Optional(Dueling_Disks.Piece(isLight: true, id: 4F120B09-C0BE-4993-8324-43013D95083B)), Optional(Dueling_Disks.Piece(isLight: true, id: DD3316A7-FAF9-4AB3-853A-F1750856E430)), Optional(Dueling_Disks.Piece(isLight: true, id: 8796F231-BDEA-4930-AA1C-D3084E6D6C98)), ...]

This means that this output would be: [black, white, (next row up) black, black, white, (next row up) white, white, white], which is correct and matches the screenshot. After pressing undo, here was the output:

[..., Optional(Dueling_Disks.Piece(isLight: false, id: 434BCE7B-D1B6-45E8-81AB-15F76007F76A)), Optional(Dueling_Disks.Piece(isLight: true, id: 63070399-84AB-43EE-9D03-BA21D64E0399)), ...,
Optional(Dueling_Disks.Piece(isLight: false, id: 9BBD7C56-5DE4-4249-8EC3-870AD8F9FBB1)), Optional(Dueling_Disks.Piece(isLight: false, id: D870C5F2-AF0F-4667-8CF1-0F7D81A82D17)), Optional(Dueling_Disks.Piece(isLight: false, id: 28FB5783-F0C2-426C-A03F-C5FCF3D342BA)), ...,
Optional(Dueling_Disks.Piece(isLight: true, id: 4F120B09-C0BE-4993-8324-43013D95083B)), Optional(Dueling_Disks.Piece(isLight: false, id: 22190EEE-D0C3-4E3F-AE18-F6577917CFD6)), ...]

This mean the output would be: [black, white, (next row up) black, black, black, (next row up) white, black].

Clearly, the SwiftUI views do not fully match the change (with the middle-rightmost staying white instead of being black as the state dictates).

For background, I have an ObservableObject that stores the game state and publishes changes to the views:

class Game: ObservableObject {
    @Published var board = Board()
    @Published var times = GameState.times
    var pieces: [Piece] {
        board.pieces.filter({ $0 != nil}) as! [Piece]
    }
}

Both board and times are Structs (and are thus value-types).

I then initialize it in my GamesView:

struct GamesView: View {
    @StateObject private var game = Game()
    
    var body: some View {
        NavigationView {
            GameView()
        }
        .environmentObject(game)
    }
}

My GamView just contains a BoardView (and some other UI views I omitted in this post):

struct GameView: View {
    var body: some View {
        BoardView()
    }
}

And the BoardView just contains a grid of 64 SquareViews:

// SizeRange is 1...8
struct BoardView: View {    
    var body: some View {
        VStack(alignment: .center, spacing: 0) {
            ForEach(SizeRange.reversed(), id: \.self) { row in
                HStack(spacing: 0) {
                    ForEach(SizeRange, id: \.self) { column in
                        SquareView(column: column, row: row)
                    }
                }
            }
        }
    }
}

Finally, the SquareView will display the correctly colored piece (or none if there is no piece on the board):

struct SquareView: View {
    @EnvironmentObject private var game: Game
    @State var column: Int
    @State var row: Int
    
    var body: some View {
        let square = Square(column: column, row: row)
        let animation: Animation = .linear(duration: 0.3)
        ZStack {
            Button {
            } label: {
                Text("")
            }
            .disabled(game.board.gameOver)
            
            if let piece = game.board.pieces[square] {
                PieceView(isLight: piece.isLight)
                    .frame(width: boardLength / 16, height: boardLength / 16)
                    .transition(.opacity.animation(animation))
                    .animation(animation, value: game.board)
                    .zIndex(1)
            }
        }
//      .onChange(of: game.board.pieces) { _ in
//          print("PIECE", row, column)
//      }
    }
}

I also used the above commented out .onChange function to see if views were getting the update. What was really odd is when I placed a piece, all squares would give output but when I pressed undo, only some of the squares would give output (and it appears to be random for each launch of the app).

I was able to fix the issue by changing the code in my Game object to this:

@Published var board = Board() {
    willSet {
        objectWillChange.send()
    }
}

Which from my understanding should do the same thing as just having an @Published variable.

Any help is very appreciated, this is really stumping me since I can't think of what might be causing the views to not reflect the data. Thanks so much for any help, and I am happy to give any other info if needed!



Solution 1:[1]

First thing I notice is you have @StateObject in a View that does not do anything with any properties of the game in the body func when it changes so there is no reason to have it in that View because the body is being called pointlessly every time game is updated. Easy fix is to make it a let above the struct or if globals aren't your thing you could put in a singleton struct.

Second thing I notice is misuse of ForEach. You shouldn't compute reversed because that will slow down body and you shouldn't use id:\.self because that will break SwiftUI's structural identity (See Demystify SwiftUI WWDC 2021). You can however use ForEach on constant data and the syntax for that is ForEach(0..<10). Note you can not dynamically add a row to the board game this way or it'll crash.

Third, the use of @State in SquareView is incorrect. @State is declaring a state of truth so it must have a default value. I think those properties should simply be lets. When a View is init with different lets from last time, body is called, which is part of SwiftUI’s magic.

I think it would be a better architecture to the board places 2D array in your data model and doing a ForEach over it. Rather than doing a ForEach over indices and then looking up the data.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1