androidimagekotlinandroid-jetpack-composemutablestateof

Images are not updated despite a changed MutableState


I'm using images (.png) to show the "X" and "O" in the game. But it appears that they don't show up until the game is over.

The game logic:

enum class Player {
    X, O
}

enum class GameState {
    ONGOING, DRAW, X_WON, O_WON
}


class TicTacToe {
    var board by mutableStateOf(Array(3) { CharArray(3) { ' ' } })
    var currentPlayer by mutableStateOf(Player.X)
    var gameState by mutableStateOf(GameState.ONGOING)

    fun makeMove(
        row: Int,
        col: Int,
        board: Array<CharArray>,
    ) {
        if(board[row][col] != ' ') return

        board[row][col] = currentPlayer.name.first()

        currentPlayer = if(currentPlayer == Player.X) {
            Player.O
        } else {
            Player.X
        }

        gameState = getGameState(board)
    }

    fun getGameState(
        board: Array<CharArray>,
    ): GameState {
        var winner = ' '

        //rows and columns
        for (i in 0..2) {
            if (board[i][0] != ' ' && board[i][0] == board[i][1] && board[i][1] == board[i][2]) {
                winner = board[i][0]
                break
            }
            if (board[0][i] != ' ' && board[0][i] == board[1][i] && board[1][i] == board[2][i]) {
                winner = board[0][i]
                break
            }
        }

        // Diagonals
        if (winner == ' ' && board[0][0] != ' ' && board[0][0] == board[1][1] && board[1][1] == board[2][2]) {
            winner = board[0][0]
        }
        if (winner == ' ' && board[0][2] != ' ' && board[0][2] == board[1][1] && board[1][1] == board[2][0]) {
            winner = board[0][2]
        }

        return when (winner) {
            'X' -> GameState.X_WON
            'O' -> GameState.O_WON
            else -> {
                val isDraw = board.all { row -> row.all { cell -> cell != ' ' } }
                if (isDraw) GameState.DRAW else GameState.ONGOING
            }
        }
    }

    fun reset(
        board: Array<CharArray>,
    ) {
        for (i in 0..2) {
            for (j in 0..2) {
                board[i][j] = ' '
            }
        }
        gameState = GameState.ONGOING
    }
}

The game screen:

@Composable
fun GameScreen(
    modifier: Modifier = Modifier,
) {
    val recomposeTrigger = remember { mutableIntStateOf(0) }
    val game = remember { mutableStateOf(TicTacToe()) }
    if(game.value.gameState == GameState.DRAW || game.value.gameState == GameState.O_WON || game.value.gameState == GameState.X_WON) {
        GameEndModal(
            gameState = game.value.gameState,
            onPlayAgain = {
                game.value.reset(game.value.board)
            }
        )
    }
    Box(
        modifier = modifier
            .size(300.dp)
            .background(Color(0xFFBA00FF))
            .padding(16.dp),
        contentAlignment = Alignment.Center
    ) {
        TicTacToeGrid()
        Column(
            modifier = Modifier.size(300.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            for(row in 0..2) {
                Row(
                    modifier = Modifier
                        .weight(1f)
                ) {
                    for(col in 0..2) {
                        val imageRes: Painter? = when(game.value.board[row][col]) {
                            'X' -> {
                                painterResource(R.drawable.x)
                            }
                            'O' -> {
                                painterResource(R.drawable.o)
                            }
                            else -> {
                                null
                            }
                        }
                        Box(
                            modifier = Modifier
                                .weight(1f)
                                .fillMaxSize()
                                .clickable {
                                    game.value.makeMove(row, col, board = game.value.board)
                                    recomposeTrigger.value++
                                },
                            contentAlignment = Alignment.Center
                        ) {
                            if (imageRes != null) {
                                Image(
                                    painter = imageRes,
                                    contentDescription = null,
                                    modifier = Modifier
                                        .fillMaxSize()
                                        .padding(4.dp)
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

Image mid-play: Clicking the boxes is not recomposing the GameScreen composable

Image end: At the end the images are loaded properly

I made a clickable box for the user to interact with.

And I even tried to force recompose the composable by defining a variable and incrementing it at the box click (which I know is not a good practice), but still it didn't work.


Solution

  • The problem is that, although the board is backed by a MutableState, the content itself is mutable. This assignment does not change the MutableState:

    board[row][col] = currentPlayer.name.first()
    

    Instead, it only modifies the array inside the MutableState, which does not trigger a recomposition of your UI.

    A simple solution would be to only work on a copy of the array and, once done, assign this new array to board:

    fun makeMove(
        row: Int,
        col: Int,
    ) {
        val newBoard = board.copyOf()
    
        if(newBoard[row][col] != ' ') return
    
        newBoard[row][col] = currentPlayer.name.first()
    
        currentPlayer = if(currentPlayer == Player.X) {
            Player.O
        } else {
            Player.X
        }
    
        board = newBoard
        gameState = getGameState(newBoard)
    }
    

    Please not that I also removed the board parameter as that seems to be always the same as the already existing board property of the same name.

    Just omit the last parameter when called:

    game.value.makeMove(row, col)