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.
- 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?
- 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. - 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.