user3067435
user3067435

Reputation: 51

UI doesn't show character in OutlinedTextField onValueChange (Wordle game)

I'm new to Android and I was making my hands dirty with Jetpack Compose. My first assignment is to develop a Wordle-like game where a user has to find out a hidden work in N number of guesses.

  1. I'm using OutlinedTextField to represent the grid that displays a letter of the word being guessed. My understanding was to maintain the state for each of the OutlinedTextField using a two-dimensional array (of String or Char) that when updated will recompose the UI with a new value. I failed to produce desired behaviour with either String or Char. Please guide me if I'm on the right path?
  2. I'm not able to identify bug in the code. My state object, when printed using println, shows that state is updated with the desired new value but OutlinedTextField sometimes how this data and not most other time.
  3. I also want to build a custom keyboard that displays the alphabet. When a user clicks on the custom keyboard component, the Char associated with that UI composable component should appear at the currently focused OutlinedTextField in this grid. Please help me understand which UI component would be helpful in this situation. Or may be totally different approach that makes more sense.

Following is the data class that holds all the states of the game.

// state of the Wordle Game
data class WordleState(
    val currentGuess: ArrayList<CharacterToGuess>, //holds the word being guessed, on value change, UI is supposed to get recompose
    var gameState: GameState = GameState.ONGOING,
    val wordToGuess: String = getWordFromWordBank(),
    var remainingAttempts: Int = MAX_GUESSES_ALLOWED,
    /*var currentGuess: MutableList<MutableList<String>> = MutableList(MAX_GUESSES_ALLOWED) {
        MutableList(MAX_LETTERS_LENGTH) {""}
    },*/
    var selectedRowIndex: Int = 0
) {
    companion object {
        fun getWordFromWordBank(): String { //TODO: move to the WordFactory
            val wordBank: List<String> = mutableListOf("AUDIO", "VIDEO", "KITES" )
            return wordBank[Random.nextInt(0, wordBank.size-1)]
        }

        var MAX_GUESSES_ALLOWED: Int = 1
        var MAX_LETTERS_LENGTH: Int = 5
    }

    override fun equals(other: Any?): Boolean { //Android studio suggested this override
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as WordleState

        if (gameState != other.gameState) return false
        if (wordToGuess != other.wordToGuess) return false
        if (remainingAttempts != other.remainingAttempts) return false
        if (!currentGuess.contentEquals(other.currentGuess)) return false
        if (selectedRowIndex != other.selectedRowIndex) return false

        return true
    }

    override fun hashCode(): Int { //Android studio suggested this override
        var result = gameState.hashCode()
        result = 31 * result + wordToGuess.hashCode()
        result = 31 * result + remainingAttempts
        result = 31 * result + currentGuess.contentHashCode()
        result = 31 * result + selectedRowIndex
        return result
    }

}

ViewModel code that gets called on OutlinedTextField valueChange as follows,

class WordleViewModel: ViewModel() {
    private val _wordleState = MutableLiveData(WordleState())
    var state: LiveData<WordleState> = _wordleState

...

    fun updateCurrentGuessLetter(it: String, col: Int) {
        val newGuessLetter = it.first()
        println("onValueChange before: " +
                "Letter:`$it`" +
                " currentGuess:${state.value!!.currentGuess.joinToString(separator = "")}," +
                " states:${state.value}")

        val updatedGuess = _wordleState.value!!.copy().currentGuess
        updatedGuess[col] = newGuessLetter
        _wordleState.value = _wordleState.value!!.copy(
            currentGuess = updatedGuess
        )

        println("onValueChange after: " +
                "Letter:`$it`" +
                " currentGuess:${state.value!!.currentGuess.joinToString(separator = "")}," +
                " states:${state.value}")
    }


...

}

MainActivity.kt code as follows,

fun WordleGrid(viewModel: WordleViewModel, state: WordleState) {
    val context = LocalContext.current.applicationContext
    val focusManager = LocalFocusManager.current

    Column(modifier = Modifier.fillMaxWidth()) {
        for (row in 0 until WordleState.MAX_GUESSES_ALLOWED) {
            Row(modifier = Modifier
                .fillMaxWidth()
                .height(50.dp)) {
                for (col in 0 until WordleState.MAX_LETTERS_LENGTH) {
                    MyOutlinedTextField(
                        modifier = Modifier.weight(1f),
                        viewModel = viewModel,
                        state = state,
                        row = row, col = col
                    )
                }
            }
        }

        CheckButton(
            onClick = { viewModel.onSubmitWord() }
        )
    }
}

@Composable
fun MyOutlinedTextField(
    modifier: Modifier,
    viewModel: WordleViewModel,
    state: WordleState,
    row: Int,
    col: Int,
) {
    val isEnabled = (row == state.selectedRowIndex) //state.currentGuess[col].toString(),
    val focusManager = LocalFocusManager.current

    OutlinedTextField(
        //value = state.currentGuess[row][col], //for state as 2D string array
        value = state.currentGuess[col].toString(),
        enabled = isEnabled,
        onValueChange = {
            if (it.isNotBlank() && it.isNotEmpty()) {
                //viewModel.updateCurrentGuessLetter(newLetter.first().toString(), row, col)
                viewModel.updateCurrentGuessLetter(it, col)

                if(col == WordleState.MAX_LETTERS_LENGTH - 1) {
                    viewModel.onSubmitWord()
                } else {
                    focusManager.moveFocus(FocusDirection.Right)
                }
            } else {
                println("onValueChange: Ignoring blank/empty letter")
            }
        },
        singleLine = true,
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .then(modifier),
        colors = TextFieldDefaults.outlinedTextFieldColors(
            focusedBorderColor = Color.Blue,
            unfocusedBorderColor = Color.DarkGray
        ),
        textStyle = TextStyle(
            color = Color.Red,
            textAlign = TextAlign.Center,
            //fontSize = 28.sp,
        ),
        keyboardOptions = KeyboardOptions.Default.copy(
            capitalization = KeyboardCapitalization.Characters,
            autoCorrect = false,
            keyboardType = KeyboardType.Text,
            /*imeAction = (
                    if (col == WordleState.MAX_LETTERS_LENGTH - 1) ImeAction.Done
                    else ImeAction.Next
            )*/
        ),
        keyboardActions = KeyboardActions(
            onNext = {
                // Move focus to the next outlined text field
                focusManager.moveFocus(FocusDirection.Right)
            },
            onDone = {
                // Submit the word when the last outlined text field is completed
                viewModel.onSubmitWord()
            }
        ),
    )
}

Let me know if any more details need to be added. Appreciate your time and efforts for this help.

Upvotes: 1

Views: 88

Answers (2)

user3067435
user3067435

Reputation: 51

As @Tenfour04 mentioned the issue was

  1. using var property in the class which I updated to val
  2. Modifying mutable property of the class doesn't change the instance of the class and Compose considering the state unchanged.

Here is the modified code for the references -

fun WordleGrid(viewModel: WordleViewModel, state: WordleState) {
    //val context = LocalContext.current.applicationContext
    val focusManager = LocalFocusManager.current

    Column(modifier = Modifier.fillMaxWidth()) {
        for (row in 0 until WordleState.MAX_GUESSES_ALLOWED) {
            Row(modifier = Modifier
                .fillMaxWidth()
                .height(75.dp)
            ) {
                for (col in 0 until WordleState.MAX_LETTERS_LENGTH) {
                    val fieldValue = state.currentGuess[row][col]
                    val backgroundColorValue = state.backgroundColor[row][col]
                    val fontColorValue = state.fontColor[row][col]
                    val isEnabled = (row == state.selectedRowIndex)
                    println("Row:$row, Column:$col, isEnabled:$isEnabled")
                    val modifier = Modifier
                        .weight(1f)
                        .fillMaxSize()
                        .padding(2.dp)
                        .background(color = backgroundColorValue /*if(state.selectedRowIndex<=row) {Color.White} else {Color.DarkGray}*/)
                    val onValueChange = { it: String ->
                        println("MyOutlinedTextField before: " +
                                "old letter:`${viewModel.state.value!!.currentGuess[row][col]}`, " +
                                "new letter: `$it`, row:`$row`, col:`$col`, isEnabled:$isEnabled`")

                        if (it.isNotBlank() && it.isNotEmpty()) {
                            viewModel.onValueChange(it, row, col)
                            if(col != WordleState.MAX_LETTERS_LENGTH - 1) {
                                focusManager.moveFocus(FocusDirection.Right)
                            }
                        } else {
                            println("MyOutlinedTextField-onValueChange: Ignoring blank/empty letter")
                        }
                    }

    .
    .
    .
    .
}

MyOutlinedTextField(
                        value = fieldValue,
                        onValueChange = onValueChange,
                        isEnabled = isEnabled,
                        modifier = modifier,
                        colors = colors,
                        textStyle = textStyle,
                        keyboardOptions = keyboardOptions,
                        keyboardActions = keyboardActions,
                        viewModel = viewModel
                    )

ViewModel Code

    fun onValueChange(newGuessLetter: String, row: Int, col: Int) {
        //create new copy of the MutableList and then update the content
        val tempCurrentGuess = _wordleState.value!!.currentGuess
            .map {
                    outerIt -> outerIt.map { it }.toMutableList()
            }.toMutableList()
        tempCurrentGuess[row][col] = newGuessLetter
        _wordleState.value = _wordleState.value!!.copy(
            currentGuess = tempCurrentGuess
        )
    }

Upvotes: 0

Tenfour04
Tenfour04

Reputation: 93834

  1. There's no reason to override equals or hashcode in your data class. They are already automatically going to include all the properties in your constructor. By overriding them, you are only inviting the possibility of getting them out of sync with your properties design, thereby breaking the class. You didn't show your code for CharacterToGuess, but the same is true for that.

  2. Having any var properties in your class that represents State will break your functionality. Compose State has to be able to compare old and new instances of a class to determine if state has changed. But if you modify a mutable property, the old and new state are the same instance and Compose will consider the state unchanged. This prevents re-composition from happening when it should.

Other than those problems, just a comment on your design of MyOutlinedTextField(). It's kind of a mess of spaghetti. It has a view model parameter, then a WorldState parameter which is something it could also retrieve from that view model, then row and column, which is something that from the point of view of a specific UI element should be irrelevant. You can hoist all of that stuff out of there and just pass it a Char and a Color. The parent can determine the Char and Color. That would be a cleaner and more robust design.

I didn't check the logic of how you game works.

Upvotes: 2

Related Questions