ViewModel e estado no Compose

1. Antes de começar

Nos codelabs anteriores, você aprendeu sobre o ciclo de vida das atividades e os problemas relacionados com as mudanças de configuração. Quando ocorre uma mudança de configuração, é possível salvar os dados de um app de maneiras diferentes, como usar rememberSaveable ou salvar o estado da instância. No entanto, essas opções podem criar problemas. Na maioria das vezes, é possível usar rememberSaveable, mas isso pode significar manter a lógica dentro ou perto de elementos de composição. Quando os apps crescerem, afaste os dados e a lógica dos elementos de composição. Neste codelab, você vai aprender a criar um app com eficiência e preservar os dados dele durante mudanças de configuração usando as diretrizes da biblioteca Android Jetpack, do ViewModel e da arquitetura de apps Android.

As bibliotecas do Android Jetpack são uma coleção que facilita o desenvolvimento de excelentes apps Android. Essas bibliotecas ajudam a seguir as práticas recomendadas e eliminar o código boilerplate, além de simplificar tarefas complexas para que seja possível se concentrar na parte importante do código, como a lógica do app.

A arquitetura de apps é um conjunto de regras de design para um app. Assim como a planta de uma casa, a arquitetura fornece a estrutura. Uma boa arquitetura pode deixar seu código otimizado, flexível, escalonável, testável e sustentável por anos. O Guia para a arquitetura de apps fornece recomendações e práticas recomendadas.

Neste codelab, você vai aprender a usar o ViewModel, um dos componentes de arquitetura das bibliotecas do Android Jetpack que podem armazenar os dados do app. Os dados armazenados não serão perdidos se o framework destruir e recriar as atividades durante uma mudança de configuração ou outros eventos. No entanto, os dados serão perdidos se a atividade for destruída devido à interrupção do processo. O ViewModel só armazena dados em cache usando recriações rápidas de atividades.

Pré-requisitos

  • Conhecimento sobre Kotlin, incluindo funções, lambdas e elementos de composição sem estado.
  • Conhecimento básico sobre como criar layouts no Jetpack Compose.
  • Conhecimento básico do Material Design.

O que você vai aprender

O que você vai criar

  • Um app de jogo Unscramble, em que o usuário consegue adivinhar as palavras embaralhadas

O que é necessário

  • A versão mais recente do Android Studio.
  • Conexão de Internet para fazer o download do código inicial

2. Visão geral do app

Visão geral do jogo

O app Unscramble é um jogo de palavras embaralhadas para um só jogador. O app exibe uma palavra embaralhada, e o jogador precisa adivinhá-la usando todas as letras mostradas. O jogador marca pontos se a palavra está correta. Caso contrário, o jogador pode tentar adivinhar a palavra quantas vezes quiser. O app também tem a opção de pular a palavra atual. No canto superior esquerdo, o app exibe a contagem de palavras, que é o número de palavras embaralhadas jogadas na sessão atual. Cada partida tem 10 palavras embaralhadas.

Acessar o código inicial

Para começar, faça o download do código inicial:

Outra opção é clonar o repositório do GitHub:

$ git clone
https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout starter

Procure o código inicial no repositório do GitHub do Unscramble (link em inglês).

3. Visão geral do app inicial

Para se familiarizar com o código inicial, siga estas etapas:

  1. Abra o projeto com o código inicial no Android Studio.
  2. Execute o app em um dispositivo Android ou em um emulador.
  3. Toque nos botões Submit e Skip para testar o app.

Você vai notar alguns bugs no app. A palavra embaralhada não é mostrada, mas está fixada no código como "scrambleun", e nada acontece quando você toca nos botões.

Neste codelab, você vai implementar as funcionalidades do jogo usando a arquitetura de apps Android.

Código inicial etapa por etapa

O código inicial tem o layout predefinido da tela de jogo. Neste módulo, você vai implementar a lógica do jogo. Você vai usar componentes da arquitetura para implementar a arquitetura recomendada do app e resolver os problemas mencionados acima. Veja a seguir um breve tutorial sobre alguns dos arquivos para começar.

WordsData.kt

Esse arquivo contém uma lista das palavras usadas no jogo, constantes para o número máximo de palavras por partida e o número de pontos que o jogador marca para cada palavra correta.

package com.example.android.unscramble.data

const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20

// Set with all the words for the Game
val allWords: Set<String> =
   setOf(
       "animal",
       "auto",
       "anecdote",
       "alphabet",
       "all",
       "awesome",
       "arise",
       "balloon",
       "basket",
       "bench",
      // ...
       "zoology",
       "zone",
       "zeal"
)

MainActivity.kt

Esse arquivo contém principalmente o código gerado pelo modelo. Você mostra o elemento de composição GameScreen no bloco setContent{}.

GameScreen.kt

Todos os elementos de composição de IU estão definidos no arquivo GameScreen.kt. As seções abaixo oferecem um tutorial de algumas funções de composição.

GameStatus

GameStatus é uma função de composição que mostra o status do jogo na parte de cima da tela, incluindo a contagem de palavras e a pontuação. A função de composição contém dois elementos de composição de texto em uma linha. Por enquanto, a pontuação e a contagem de palavras estão fixadas no código como 0.

24e4e954bb8f788c.png

// No need to copy over, this is included in the starter code.

@Composable
fun GameStatus(modifier: Modifier = Modifier) {
   Row(
       modifier = modifier
           .fillMaxWidth()
           .padding(16.dp)
           .size(48.dp),
   ) {
       Text(
           text = stringResource(R.string.word_count, 0),
           fontSize = 18.sp,
       )
       Text(
           modifier = Modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           text = stringResource(R.string.score, 0),
           fontSize = 18.sp,
       )
   }
}

GameLayout

GameLayout é uma função de composição que exibe a funcionalidade principal do jogo, incluindo a palavra embaralhada, as instruções do jogo e um campo de texto que aceita os palpites do usuário.

ce20d47835deed87.png

Perceba que o código de GameLayout abaixo contém uma coluna com três elementos filhos: o texto da palavra embaralhada, o texto das instruções e o campo de texto do OutlinedTextField da palavra do usuário. Por enquanto, a palavra embaralhada está fixada no código para ser scrambleun. Mais adiante no codelab, você vai implementar uma funcionalidade para mostrar uma palavra do arquivo WordsData.kt.

// No need to copy over, this is included in the starter code.

@Composable
fun GameLayout(modifier: Modifier = Modifier) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = "",
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
       Text(
           text = stringResource(R.string.instructions),
           fontSize = 17.sp,
           modifier = Modifier.align(Alignment.CenterHorizontally)
       )
       OutlinedTextField(
           value = "",
           singleLine = true,
           modifier = Modifier.fillMaxWidth(),
           onValueChange = { },
           label = { Text(stringResource(R.string.enter_your_word)) },
           isError = false,
           keyboardOptions = KeyboardOptions.Default.copy(
               imeAction = ImeAction.Done
           ),
           keyboardActions = KeyboardActions(
               onDone = { }
           ),
       )
   }
}

O elemento de composição OutlinedTextField é semelhante ao elemento de composição TextField de apps em codelabs anteriores.

Há dois tipos de campos de texto:

  • Campos de texto preenchidos
  • Campos de texto contornados

3e6c39034b80dad4.png

1. Campos de texto preenchidos

2. Campos de texto contornados

Os campos de texto contornados têm menos ênfase visual do que os campos de texto preenchidos. Quando eles aparecem em lugares como formulários, onde muitos campos de texto são colocados juntos, a ênfase reduzida deles ajuda a simplificar o layout.

No código inicial, o OutlinedTextField não é atualizado quando o usuário digita um palpite. Você vai atualizar esse recurso no codelab.

GameScreen

O elemento combinável GameScreen contém as funções GameStatus e GameLayout, além dos elementos combináveis dos botões Submit e Skip.

1ec5cfbe7e5fa830.png

@Composable
fun GameScreen(modifier: Modifier = Modifier) {
   Column(
       modifier = modifier
           .verticalScroll(rememberScrollState())
           .padding(16.dp),
       verticalArrangement = Arrangement.spacedBy(8.dp)
   ) {
       GameStatus()
       GameLayout()
       Row(
           modifier = modifier
               .fillMaxWidth()
               .padding(top = 16.dp),
           horizontalArrangement = Arrangement.SpaceAround
       ) {
           OutlinedButton(
               onClick = { },
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           ) {
               Text(stringResource(R.string.skip))
           }
           Button(
               modifier = modifier
                   .fillMaxWidth()
                   .weight(1f)
                   .padding(start = 8.dp),
               onClick = { }
           ) {
               Text(stringResource(R.string.submit))
           }
       }
   }
}

Os eventos de clique do botão não são implementados no código inicial. Você vai implementar esses eventos como parte do codelab.

FinalScoreDialog

O elemento combinável FinalScoreDialog mostra uma caixa de diálogo, ou seja, uma janela pequena que dá ao usuário opções para Play Again (Jogar novamente) ou Exit (Sair) do jogo. Mais adiante neste codelab, você vai implementar uma lógica para exibir essa caixa de diálogo no final do jogo.

5bc66c590984ab28.png

// No need to copy over, this is included in the starter code.

@Composable
private fun FinalScoreDialog(
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
   val activity = (LocalContext.current as Activity)

   AlertDialog(
       onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onCloseRequest.
       },
       title = { Text(stringResource(R.string.congratulations)) },
       text = { Text(stringResource(R.string.you_scored, 0)) },
       modifier = modifier,
       dismissButton = {
           TextButton(
               onClick = {
                   activity.finish()
               }
           ) {
               Text(text = stringResource(R.string.exit))
           }
       },
       confirmButton = {
           TextButton(
               onClick = {
                   onPlayAgain()
               }
           ) {
               Text(text = stringResource(R.string.play_again))
           }
       }
   )
}

4. Saiba mais sobre a arquitetura de apps

A arquitetura de um app fornece diretrizes para ajudar a alocar as responsabilidades do app entre as classes. Uma arquitetura de app bem projetada ajuda a escalonar o app e a ampliá-lo com outros recursos. A arquitetura também pode simplificar a colaboração em equipe.

Os princípios de arquitetura mais comuns são a separação de conceitos e o modelo de base da IU.

Separação de conceitos

O princípio de separação de conceitos diz que o app é dividido em classes de funções, cada uma com responsabilidades separadas.

Basear a IU em um modelo

A IU de base de um princípio de modelo determina que você precisa basear sua IU em um modelo, de preferência um que seja persistente. Os modelos são responsáveis por processar os dados de um app e são independentes dos elementos da IU e dos componentes do app. Portanto, eles não são afetados pelo ciclo de vida e por preocupações relacionadas ao app.

Considerando os princípios de arquitetura comuns mencionados na seção anterior, cada app precisa ter pelo menos duas camadas:

  • Camada de IU: uma camada que exibe os dados do app na tela, mas é independente dos dados.
  • Camada de dados: uma camada que armazena, recupera e expõe os dados do app.

É possível adicionar uma camada extra, conhecida como camada de domínios, para simplificar e reutilizar as interações entre a IU e as camadas de dados. Essa camada é opcional e está fora do escopo deste curso.

df6ce8b662eb792b.png

Camada de IU

A função da camada de IU, ou camada de apresentação, é exibir os dados do aplicativo na tela. Sempre que os dados mudam devido a uma interação do usuário, como o pressionamento de um botão, a IU precisa ser atualizada para refletir as mudanças.

A camada de IU é composta pelos seguintes componentes:

  • Elementos da IU: componentes que renderizam os dados na tela. Crie esses elementos usando o Jetpack Compose.
  • Detentores de estado: componentes que armazenam os dados, os expõem à IU e processam a lógica do app. Um exemplo de detentor de estado é o ViewModel.

76f1a8e160d64184.png

ViewModel

O componente ViewModel armazena e expõe o estado que a IU consome. O estado da IU são os dados do aplicativo transformados por ViewModel. O ViewModel permite que o app siga o princípio da arquitetura para orientar a IU do modelo.

O ViewModel armazena os dados relacionados ao app que não são destruídos quando a atividade é destruída e recriada pelo framework do Android. Ao contrário da instância de atividade, os objetos ViewModel não são destruídos. O app retém automaticamente objetos ViewModel durante mudanças de configuração para que os dados retidos fiquem imediatamente disponíveis após a recomposição.

Para implementar o ViewModel no seu app, estenda a classe ViewModel, que é da biblioteca de componentes da arquitetura, e armazene os dados do app nessa classe.

Estado da IU

A IU é o que o usuário vê, e o estado dela é o que o app diz que precisa ser mostrado. A IU é a representação visual do estado da IU. Todas as mudanças no estado são imediatamente refletidas na IU.

ce7ffbb6e5a6bffe.png

A IU é resultado da vinculação de elementos da IU na tela com o estado dela.

// Example of UI state definition, do not copy over

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Imutabilidade

A definição do estado da IU no exemplo acima é imutável. Os objetos imutáveis fornecem garantias de que várias fontes não alteram o estado do app instantaneamente. Essa proteção libera a IU para se concentrar em uma única tarefa: ler o estado e atualizar os elementos da IU de acordo com ele. Portanto, nunca modifique o estado diretamente na IU, a menos que ela seja a única fonte dos dados. A violação desse princípio resulta em várias fontes da verdade para a mesma informação, levando a inconsistências de dados e bugs sutis.

5. Adicionar um ViewModel

Nesta tarefa, você vai adicionar um ViewModel ao app para armazenar o estado da IU do jogo (palavra embaralhada, contagem de palavras e pontuação). Para resolver o problema do código inicial que você percebeu na seção anterior, salve os dados do jogo no ViewModel.

  1. Abra build.gradle(Module: Unscramble), no bloco dependencies, e adicione a seguinte dependência para ViewModel:
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
  1. No pacote ui, crie uma classe/arquivo Kotlin com o nome GameViewModel. Estenda-a da classe ViewModel.
import androidx.lifecycle.ViewModel

class GameViewModel : ViewModel() {
}
  1. No pacote ui, adicione uma classe de modelo para a IU do estado chamada GameUiState. Transforme-a em uma classe de dados e adicione uma variável para a palavra embaralhada atual.
data class GameUiState(
   val currentScrambledWord: String = ""
)

StateFlow

StateFlow é um fluxo observável detentor de dados que emite as atualizações de estado novas e atuais. A propriedade value reflete o valor do estado atual. Para atualizar o estado e enviá-lo ao fluxo, atribua um novo valor à propriedade da classe MutableStateFlow.

No Android, StateFlow funciona bem com classes que precisam manter um estado imutável observável.

Um StateFlow pode ser exposto no GameUiState para que os elementos de composição possam detectar atualizações de estado da IU e fazer com que o estado da tela sobreviva às mudanças de configuração.

Na classe GameViewModel, adicione a seguinte propriedade _uiState:

import kotlinx.coroutines.flow.MutableStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())

Propriedade de apoio

Uma propriedade de apoio permite que você retorne algo de um getter diferente do objeto exato.

Para cada propriedade var, o framework Kotlin gera getters e setters.

Para os métodos getter e setter, é possível substituir um deles, ou ambos, e fornecer um comportamento personalizado próprio. Para implementar uma propriedade de apoio, você substitui o método getter para retornar uma versão somente leitura dos dados. O exemplo a seguir mostra uma propriedade de apoio.

//Example code, no need to copy over

// Declare private mutable variable that can only be modified
// within the class it is declared.
private var _count = 0

// Declare another public immutable field and override its getter method.
// Return the private property's value in the getter method.
// When count is accessed, the get() function is called and
// the value of _count is returned.
val count: Int
   get() = _count

Como outro exemplo, digamos que você queira que os dados do app sejam particulares para o ViewModel:

Na classe ViewModel:

  • A propriedade _count é private e mutável. Portanto, ela só pode ser acessada e editada na classe ViewModel.

Fora da classe ViewModel:

  • O modificador de visibilidade padrão no Kotlin é public. Portanto, a classe count é pública e pode ser acessada por outras classes, como controladores de IU. Como apenas o método get() está sendo substituído, essa propriedade é imutável e somente leitura. Quando uma classe externa acessa essa propriedade, ela retorna o valor de _count, que não pode ser modificado. Essa propriedade de apoio protege os dados do app dentro do ViewModel contra mudanças indesejadas e não seguras por classes externas, mas permite que autores de chamadas externos acessem o valor com segurança.
  1. No arquivo GameViewModel, adicione uma propriedade de apoio a _uiState. Nomeie a propriedade como uiState, com o tipo StateFlow<GameUiState>.

Agora, _uiState só pode ser acessado e editado no GameViewModel. A IU pode ler o valor dela usando a propriedade somente leitura, uiState. É possível corrigir o erro de inicialização na próxima etapa.

import kotlinx.coroutines.flow.StateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
// Backing property to avoid state updates from other classes
val uiState: StateFlow<GameUiState>
  1. Defina uiState como _uiState.asStateFlow().

O asStateFlow() transforma esse fluxo de estado mutável em um fluxo de estado somente leitura.

import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

// Game UI state
private val _uiState = MutableStateFlow(GameUiState())
val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

Exibir palavra embaralhada aleatória

Nesta tarefa, você vai adicionar métodos auxiliares para escolher uma palavra aleatória do WordsData.kt e criar a palavra embaralhada.

  1. No GameViewModel, adicione uma propriedade com o nome currentWord do tipo String para salvar a palavra embaralhada atual.
private lateinit var currentWord: String
  1. Adicione um método auxiliar para escolher uma palavra aleatória na lista e a embaralhar. Chame o arquivo de pickRandomWordAndShuffle() sem parâmetros de entrada e faça com que ele retorne um String.
import com.example.android.unscramble.data.allWords

private fun pickRandomWordAndShuffle(): String {
   // Continue picking up a new random word until you get one that hasn't been used before
   currentWord = allWords.random()
   if (usedWords.contains(currentWord)) {
       return pickRandomWordAndShuffle()
   } else {
       usedWords.add(currentWord)
       return shuffleCurrentWord(currentWord)
   }
}

O Android Studio sinaliza um erro para a função indefinida.

  1. No GameViewModel, adicione a seguinte propriedade após a propriedade currentWord para servir como um conjunto mutável para armazenar palavras usadas no jogo:
// Set of words used in the game
private var usedWords: MutableSet<String> = mutableSetOf()
  1. Adicione outro método auxiliar para embaralhar a palavra atual com o nome shuffleCurrentWord(), que usa uma String e retorna a String embaralhada.
private fun shuffleCurrentWord(word: String): String {
   val tempWord = word.toCharArray()
   // Scramble the word
   tempWord.shuffle()
   while (String(tempWord).equals(word)) {
       tempWord.shuffle()
   }
   return String(tempWord)
}
  1. Adicione uma função auxiliar para inicializar o jogo com o nome resetGame(). Você vai usar essa função mais tarde para iniciar e reiniciar o jogo. Nessa função, limpe todas as palavras do conjunto usedWords e inicie o _uiState. Escolha uma nova palavra para currentScrambledWord usando pickRandomWordAndShuffle().
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Adicione um bloco init ao GameViewModel e chame o resetGame() a partir dele.
init {
   resetGame()
}

Ao criar o app agora, você ainda não vai ver nenhuma mudança na IU. Você não transmite os dados do ViewModel aos elementos de composição na GameScreen.

6. Como arquitetar a IU do Compose

No Compose, a única maneira de atualizar a IU é mudando o estado do app. O que pode ser controlado é o estado da IU. Cada vez que o estado da IU muda, o Compose recria as partes da árvore da IU que mudaram. Os elementos de composição podem aceitar estado e expor eventos. Por exemplo, um TextField/OutlinedTextField aceita um valor e expõe um callback onValueChange que solicita que o gerenciador de callback mude o valor.

//Example code no need to copy over

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Como as funções de composição aceitam estados e expõem eventos, o padrão de fluxo de dados unidirecional é adequado para o Jetpack Compose. Esta seção se concentra em como implementar o padrão de fluxo de dados unidirecional no Compose, como implementar detentores de estados e eventos e como trabalhar com ViewModels no Compose.

Fluxo de dados unidirecional

Um fluxo de dados unidirecional (UDF, na sigla em inglês) é um padrão de design em que os estados fluem para baixo, e os eventos para cima. Ao seguir o fluxo de dados unidirecional, você pode dissociar os elementos que exibem o estado na IU das partes do app que armazenam e mudam o estado.

O loop de atualização da IU para um app usando o fluxo de dados unidirecional é semelhante a este:

  • Evento: parte da IU gera um evento e o transmite para cima, como um clique de botão transmitido ao ViewModel para ser processado, ou um evento transmitido de outras camadas do app, como indicação de que a sessão do usuário expirou.
  • Estado de atualização: um manipulador de eventos pode mudar o estado.
  • Estado de exibição: o detentor do estado transmite esse estado, e a IU o exibe.

63eff819a2c3c3f4.png

O uso do padrão UDF para a arquitetura de apps tem estas implicações:

  • O ViewModel armazena e expõe o estado que a IU consome.
  • O estado da IU são os dados do app transformados pelo ViewModel.
  • A IU notifica o ViewModel sobre eventos do usuário.
  • O ViewModel processa as ações do usuário e atualiza o estado.
  • O estado atualizado é retornado à IU para renderização.
  • Esse processo se repete para qualquer evento que cause uma mutação de estado.

Transmitir os dados

Transmita a instância do modelo de visualização para a IU, ou seja, do GameViewModel para GameScreen() no arquivo GameScreen.kt. Na GameScreen(), use a instância de modelo de visualização para acessar o uiState usando collectAsState().

A função collectAsState() coleta valores desse StateFlow e representa o valor mais recente dela usando State. O StateFlow.value é usado como um valor inicial. Sempre que há um novo valor postado no StateFlow (link em inglês), o valor retornado de State é atualizado, causando a recomposição de cada uso de State.value.

  1. Na função GameScreen, transmita um segundo argumento do tipo GameViewModel com um valor padrão de viewModel().
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GameScreen(
   modifier: Modifier = Modifier,
   gameViewModel: GameViewModel = viewModel()
) {
   // ...
}

6e0fbdfb44482d13.png

  1. Na função GameScreen(), adicione uma nova variável com o nome gameUiState. Use o delegado by e chame collectAsState() no uiState.

Essa abordagem garante que, sempre que haja uma mudança no valor do uiState, a recomposição ocorra para os elementos de composição usando o valor gameUiState.

import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

@Composable
fun GameScreen(
   // ...
) {
   val gameUiState by gameViewModel.uiState.collectAsState()
   // ...
}
  1. Transmita gameUiState.currentScrambledWord para o elemento de composição GameLayout(). Você vai adicionar o argumento em uma etapa posterior, então ignore o erro por enquanto.
GameLayout(currentScrambledWord = gameUiState.currentScrambledWord)
  1. Adicione currentScrambledWord como outro parâmetro à função de composição GameLayout().
@Composable
fun GameLayout(
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
) {
}
  1. Atualize a função de composição GameLayout() para mostrar currentScrambledWord. Defina o parâmetro text do primeiro campo de texto na coluna como currentScrambledWord.
@Composable
fun GameLayout(
   // ...
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       Text(
           text = currentScrambledWord,
           fontSize = 45.sp,
           modifier = modifier.align(Alignment.CenterHorizontally)
       )
    //...
    }
}
  1. Execute e crie o app. A palavra embaralhada vai ser mostrada.

ca2f4d2e759ea047.png

Mostrar a palavra adivinhada

No elemento de composição GameLayout(), a atualização do palpite do usuário é um dos callbacks de evento que fluem de GameScreen para ViewModel. Os dados gameViewModel.userGuess fluem do ViewModel para a GameScreen.

o teclado de callbacks do evento depois de pressionar a tecla e as mudanças de palpite do usuário são transmitidas da IU para o modelo de visualização.

  1. No arquivo GameScreen.kt, no elemento de composição GameLayout(), defina onUserGuessChanged como onValueChange e onKeyboardDone() como ação do teclado onDone. Você vai corrigir os erros na próxima etapa.
OutlinedTextField(
   value = "",
   singleLine = true,
   modifier = Modifier.fillMaxWidth(),
   onValueChange = onUserGuessChanged,
   label = { Text(stringResource(R.string.enter_your_word)) },
   isError = false,
   keyboardOptions = KeyboardOptions.Default.copy(
       imeAction = ImeAction.Done
   ),
   keyboardActions = KeyboardActions(
       onDone = { onKeyboardDone() }
   ),
  1. Na função de composição GameLayout(), adicione mais dois argumentos: o lambda onUserGuessChanged recebe um argumento String e não retorna nada, e onKeyboardDone não recebe nada e não retorna nada.
@Composable
fun GameLayout(
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   currentScrambledWord: String,
   modifier: Modifier = Modifier,
   ) {
}
  1. Na função de composição GameLayout(), adicione parâmetros lambda para onUserGuessChanged e onKeyboardDone.
GameLayout(
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },
   currentScrambledWord = gameUiState.currentScrambledWord,
)

Você vai definir o método updateUserGuess em GameViewModel em breve.

  1. No arquivo GameViewModel.kt, adicione um método com o nome updateUserGuess() que usa um argumento String, a palavra adivinhada pelo usuário. Dentro da função, atualize a userGuess usando a guessedWord transmitida.
  fun updateUserGuess(guessedWord: String){
     userGuess = guessedWord
  }

Em seguida, adicione userGuess ao modelo de visualização.

  1. No arquivo GameViewModel.kt, adicione uma propriedade var chamada userGuess. Use mutableStateOf() para que o Compose observe esse valor e defina o valor inicial como "".
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

var userGuess by mutableStateOf("")
   private set
  1. No arquivo GameScreen, em GameLayout(), adicione outro parâmetro String para userGuess. Defina o parâmetro value do OutlinedTextField como userGuess.
fun GameLayout(
   currentScrambledWord: String,
   userGuess: String,
   onUserGuessChanged: (String) -> Unit,
   onKeyboardDone: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       verticalArrangement = Arrangement.spacedBy(24.dp)
   ) {
       //...
       OutlinedTextField(
           value = userGuess,
           //..
       )
   }
}
  1. Na função GameScreen, atualize a chamada de função GameLayout() para incluir o parâmetro userGuess.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { },)
  1. Compile e execute o app.
  2. Tente adivinhar e insira uma palavra. O campo de texto pode mostrar o palpite do usuário.

c04b2959c403cdd8.png

7. Verificar a palavra adivinhada e atualizar a pontuação

Nesta tarefa, você vai implementar um método para verificar a palavra que o usuário imagina e, em seguida, atualizar a pontuação do jogo ou exibir um erro. Você atualizará a IU do estado de jogo com a nova pontuação e a nova palavra mais tarde.

  1. No arquivo GameViewModel, adicione outro método com o nome checkUserGuess().
  2. Na função checkUserGuess(), adicione um bloco if else para verificar se o palpite do usuário é igual à currentWord. Redefina a userGuess como uma string vazia.
fun checkUserGuess() {

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
   }
   // Reset user guess
   updateUserGuess("")
}
  1. Se o palpite do usuário estiver errado, defina isGuessedWordWrong como true. MutableStateFlow<T>. update() atualiza o MutableStateFlow.value usando o valor especificado (links em inglês).
import kotlinx.coroutines.flow.update

   if (userGuess.equals(currentWord, ignoreCase = true)) {
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
  1. Na classe GameUiState, adicione um Boolean chamado isGuessedWordWrong e inicialize-o como false.
// no need to copy
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
)

Em seguida, você transmite o callback do evento checkUserGuess() de GameScreen para ViewModel quando o usuário clica no botão Enviar ou na tecla concluído do teclado. Transmita os dados, gameUiState.isGuessedWordWrong do ViewModel para o GameScreen, para definir o erro no campo de texto.

3961ff7d479ec25a.png

  1. No arquivo GameScreen, no final da função de composição GameScreen(), chame gameViewModel.checkUserGuess() dentro da expressão lambda onClick do botão Enviar.
Button(
   modifier = modifier
       .fillMaxWidth()
       .weight(1f)
       .padding(start = 8.dp),
   onClick = { gameViewModel.checkUserGuess() }
) {
   Text(stringResource(R.string.submit))
}
  1. Na função de composição GameScreen(), atualize a chamada de função GameLayout() para transmitir gameViewModel.checkUserGuess() na expressão lambda onKeyboardDone.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() }
)
  1. Na função de composição GameLayout(), adicione um parâmetro de função para Boolean, isGuessWrong. Defina o parâmetro isError do OutlinedTextField como isGuessWrong para mostrar o erro no campo de texto quando o palpite do usuário estiver errado.
fun GameLayout(currentScrambledWord: String,
              isGuessWrong: Boolean,
              userGuess: String,
              onUserGuessChanged: (String) -> Unit,
              onKeyboardDone: () -> Unit,
              modifier: Modifier = Modifier) {
   Column(
       // ,...
       OutlinedTextField(
           // ...
           isError = isGuessWrong,
           keyboardOptions = KeyboardOptions.Default.copy(
               imeAction = ImeAction.Done
           ),
           keyboardActions = KeyboardActions(
               onDone = { onKeyboardDone() }
           ),
       )
   }
}
  1. Na função combinável GameScreen(), atualize a chamada de função GameLayout() para transmitir isGuessWrong.
GameLayout(
   currentScrambledWord = gameUiState.currentScrambledWord,
   userGuess = gameViewModel.userGuess,
   onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
   onKeyboardDone = { gameViewModel.checkUserGuess() },
   isGuessWrong = gameUiState.isGuessedWordWrong
)
  1. Compile e execute o app.
  2. Insira um palpite errado e clique em Enviar. Observe que o campo de texto fica vermelho, indicando o erro.

e186e8436aab4fa2.png

O campo ainda mostra a mensagem "Enter your word". Para facilitar o uso, adicione uma mensagem de erro informando que a palavra está errada.

  1. No arquivo GameLayout, atualize o parâmetro de rótulo do campo de texto, dependendo de isGuessWrong da seguinte maneira:
OutlinedTextField(
   // ...
   label = {
       if (isGuessWrong) {
           Text(stringResource(R.string.wrong_guess))
       } else {
           Text(stringResource(R.string.enter_your_word))
       }
   },
   // ...
)
  1. No arquivo strings.xml, adicione uma string ao rótulo de erro.
<string name="wrong_guess">Wrong Guess!</string>
  1. Compile e execute seu aplicativo novamente.
  2. Insira um palpite errado e clique em Enviar. Observe que o rótulo de erro e o campo de texto foram apagados.

1c1f4d6a0acbe5c8.png

8. Atualizar pontuação e contagem de palavras

Nesta tarefa, você vai atualizar a pontuação e a contagem de palavras enquanto o usuário joga. A pontuação precisa fazer parte de _uiState.

  1. No GameUiState, adicione uma variável score e a inicialize em zero.
data class GameUiState(
   val currentScrambledWord: String = "",
   val isGuessedWordWrong: Boolean = false,
   val score: Int = 0
)
  1. Para atualizar o valor da pontuação, aumente o valor de score na função checkUserGuess(), em GameViewModel, dentro da condição if para quando o palpite do usuário estiver correto.
fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
   } else {
       //...
   }
}
  1. No arquivo GameViewModel, adicione outro método com o nome updateGameState para atualizar a pontuação, incrementar a contagem de palavras atual e escolher uma nova palavra no arquivo WordsData.kt. Adicione uma Int chamada updatedScore como parâmetro. Atualize as variáveis da IU do estado do jogo desta forma:
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           isGuessedWordWrong = false,
           currentScrambledWord = pickRandomWordAndShuffle(),
           score = updatedScore
       )
   }
}
  1. Na função checkUserGuess(), se o palpite do usuário estiver correto, faça uma chamada para updateGameState com a pontuação atualizada para preparar o jogo para a próxima rodada.
import com.example.android.unscramble.data.SCORE_INCREASE

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       //...
   }
}

A checkUserGuess() concluída vai ficar assim:

fun checkUserGuess() {
   if (userGuess.equals(currentWord, ignoreCase = true)) {
       // User's guess is correct, increase the score
       // and call updateGameState() to prepare the game for next round
       val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
       updateGameState(updatedScore)
   } else {
       // User's guess is wrong, show an error
       _uiState.update { currentState ->
           currentState.copy(isGuessedWordWrong = true)
       }
   }
}

Em seguida, assim como as atualizações da pontuação, vai ser necessário atualizar a contagem de palavras.

  1. Adicione outra variável para a contagem em GameUiState. Chame-a de currentWordCount e inicialize em 1.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 0,
   val score: Int = 1,
   val isGuessedWordWrong: Boolean = false,
)
  1. No arquivo GameViewModel.kt, na função updateGameState(), aumente a contagem de palavras, como mostrado abaixo. A função updateGameState é chamada para preparar o jogo para a próxima rodada.
private fun updateGameState(updatedScore: Int) {
   _uiState.update { currentState ->
       currentState.copy(
           //...
           currentWordCount = currentState.currentWordCount.inc(),
           )
   }
}

Pontuação de aprovação e contagem de palavras

Conclua as etapas a seguir para transmitir dados de pontuação e contagem de palavras de ViewModel para GameScreen.

1ba26a176275e349.png

  1. No arquivo GameScreen.kt, na função GameStatus, adicione argumentos de pontuação e contagem de palavras e transmita os argumentos de formato wordCount e score aos elementos de texto.
   fun GameStatus(wordCount: Int, score: Int, modifier: Modifier = Modifier
) {
   Row(
       //...
   ) {
       Text(
           text = stringResource(R.string.word_count, wordCount),
           //...
       )
       Text(
           //...
           text = stringResource(R.string.score, score),
       )
   }
}
  1. Na função de composição GameScreen(), atualize a chamada de função GameStatus() para incluir os parâmetros wordCount e score. Transmita a pontuação e a contagem de palavras do gameUiState.
GameStatus(
   wordCount = gameUiState.currentWordCount,
   score = gameUiState.score
)
  1. Compile e execute o aplicativo.
  2. Insira o palpite para a palavra e clique em Submit. A pontuação e a contagem de palavras são atualizadas.
  3. Clique em Skip e observe que nada acontece.

Para implementar a funcionalidade de pular, transmita o callback do evento de pulo para o GameViewModel.

  1. No arquivo GameScreen.kt, na função de composição GameScreen(), faça uma chamada para gameViewModel.skipWord() na expressão lambda onClick.

O Android Studio mostra um erro porque você ainda não implementou a função. Para corrigir esse erro, adicione o método skipWord() na próxima etapa. Quando o usuário pula uma palavra, é necessário atualizar as variáveis e preparar o jogo para a próxima rodada.

OutlinedButton(
   onClick = { gameViewModel.skipWord() },
   modifier = Modifier
       .weight(1f)
       .padding(end = 8.dp)
) {
   Text(stringResource(R.string.skip))
}
  1. Em GameViewModel, adicione o método skipWord().
  2. Na função skipWord(), faça uma chamada para updateGameState(), transmitindo a pontuação e redefina o palpite do usuário.
fun skipWord() {
   updateGameState(_uiState.value.score)
   // Reset user guess
   updateUserGuess("")
}
  1. Execute o app e jogue uma partida. Agora você pode pular palavras.

Você ainda pode jogar com mais de 10 palavras. Na próxima tarefa, você vai lidar com a última rodada do jogo.

9. Processar a última rodada do jogo

Na implementação atual, os usuários podem pular ou reproduzir mais de 10 palavras. Nesta tarefa, você vai adicionar lógica para encerrar o jogo.

922cfa6749b986ea.png

Para implementar a lógica de fim de jogo, primeiro é preciso verificar se o usuário atinge o número máximo de palavras.

  1. No GameViewModel, adicione um bloco if-else e mova o corpo da função existente para o bloco else.
  2. Adicione uma condição if para verificar se o tamanho das usedWords é igual a MAX_NO_OF_WORDS.
import com.example.android.unscramble.data.MAX_NO_OF_WORDS

private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. No bloco if, adicione a sinalização Boolean isGameOver e a defina como true para indicar o fim do jogo.
  2. Atualize a score e redefina isGuessedWordWrong dentro do bloco if. O código abaixo mostra como a função vai ficar.
private fun updateGameState(updatedScore: Int) {
   if (usedWords.size == MAX_NO_OF_WORDS){
       //Last round in the game, update isGameOver to true, don't pick a new word
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               score = updatedScore,
               isGameOver = true
           )
       }
   } else{
       // Normal round in the game
       _uiState.update { currentState ->
           currentState.copy(
               isGuessedWordWrong = false,
               currentScrambledWord = pickRandomWordAndShuffle(),
               currentWordCount = currentState.currentWordCount.inc(),
               score = updatedScore
           )
       }
   }
}
  1. No GameUiState, adicione a variável Boolean isGameOver e a defina como false.
data class GameUiState(
   val currentScrambledWord: String = "",
   val currentWordCount: Int = 1,
   val score: Int = 0,
   val isGuessedWordWrong: Boolean = false,
   val isGameOver: Boolean = false
)
  1. Execute o app e jogue uma partida. Só é possível jogar com até 10 palavras.

ca9553a515b0e4f0.png

Quando o jogo terminar, avise o usuário e pergunte se ele quer jogar de novo. Você vai implementar esse recurso na próxima tarefa.

Caixa de diálogo de exibição de encerramento do jogo

Nesta tarefa, você transmite dados isGameOver do modelo de visualização para GameScreen e os usa para exibir uma caixa de diálogo de alerta com opções de encerrar ou reiniciar o jogo.

As caixas de diálogo são pequenas janelas que levam o usuário a tomar uma decisão ou inserir informações adicionais. Normalmente, uma caixa de diálogo não preenche toda a tela e exige que os usuários realizem uma ação antes de continuar. O Android oferece diferentes tipos de caixas de diálogo. Neste codelab, você vai aprender sobre caixas de diálogo de alerta.

Anatomia de uma caixa de diálogo de alerta

f8650ca15e854fe4.png

  1. Caixa de diálogo de alerta
  2. Título (opcional)
  3. Mensagem
  4. Botões de texto

O arquivo GameScreen.kt no código inicial já fornece uma função que exibe uma caixa de diálogo de alerta com opções para sair ou reiniciar o jogo.

c16aec2499c015e2.png

@Composable
private fun FinalScoreDialog(
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
   val activity = (LocalContext.current as Activity)

   AlertDialog(
       onDismissRequest = {
           // Dismiss the dialog when the user clicks outside the dialog or on the back
           // button. If you want to disable that functionality, simply use an empty
           // onCloseRequest.
       },
       title = { Text(stringResource(R.string.congratulations)) },
       text = { Text(stringResource(R.string.you_scored, 0)) },
       modifier = modifier,
       dismissButton = {
           TextButton(
               onClick = {
                   activity.finish()
               }
           ) {
               Text(text = stringResource(R.string.exit))
           }
       },
       confirmButton = {
           TextButton(
               onClick = {
                   onPlayAgain()
               }
           ) {
               Text(text = stringResource(R.string.play_again))
           }
       }
   )
}

Nessa função, os parâmetros title e text mostram o título e a mensagem na caixa de diálogo de alerta. dismissButton e confirmButton são os botões de texto. No parâmetro dismissButton, você exibe o texto Sair e conclui a atividade para encerrar o app. No parâmetro confirmButton, você reinicia o jogo e mostra o texto Play Again (Jogar novamente).

dfa3316b4e8aa417.png

  1. No arquivo GameScreen.kt, na função FinalScoreDialog, adicione um novo parâmetro para mostrar a pontuação do jogo na caixa de diálogo de alerta.
@Composable
private fun FinalScoreDialog(
   score: Int,
   onPlayAgain: () -> Unit,
   modifier: Modifier = Modifier
) {
  1. Na função FinalScoreDialog(), atualize a expressão lambda do parâmetro text para usar score como argumento de formato para o texto da caixa de diálogo.
text = { Text(stringResource(R.string.you_scored, score))
  1. No arquivo GameScreen, no final da função de composição GameScreen(), depois do bloco Row, adicione uma condição if para conferir gameUiState.isGameOver.
  2. No bloco if, exiba a caixa de diálogo de alerta. Faça uma chamada para FinalScoreDialog() transmitindo score e gameViewModel.resetGame() para o callback de evento onPlayAgain.
if (gameUiState.isGameOver) {
   FinalScoreDialog(
       score = gameUiState.score,
       onPlayAgain = { gameViewModel.resetGame() }
   )
}

O resetGame() é um callback de evento transmitido do GameScreen para o ViewModel.

  1. No arquivo GameViewModel.kt, chame a função resetGame(), inicialize _uiState e escolha uma nova palavra.
fun resetGame() {
   usedWords.clear()
   _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}
  1. Compile e execute o app.
  2. Jogue até o final e observe a caixa de diálogo de alerta com as opções Sair ou Jogar novamente. Tente as opções mostradas na caixa de diálogo de alerta.

546b07253acff8d3.png

10. Estado na rotação do dispositivo

Nos codelabs anteriores, você aprendeu sobre as mudanças de configuração no Android. Quando ocorre uma mudança de configuração, o Android reinicia a atividade do zero e executa todos os callbacks de inicialização do ciclo de vida.

O ViewModel armazena os dados relacionados ao app que não são destruídos quando o framework do Android destrói e recria a atividade. Os objetos ViewModel são retidos automaticamente e não são destruídos, como a instância de atividade, durante a mudança da configuração. Os dados retidos ficam imediatamente disponíveis após a recomposição.

Nesta tarefa, você confere se o app retém a IU do estado durante uma mudança de configuração.

  1. Execute o app e jogue algumas palavras. Mude a configuração do dispositivo de retrato para paisagem ou vice-versa.
  2. Os dados salvos na IU de estado do ViewModel são mantidos durante a mudança da configuração.

3cf63edcd9d719c1.png

c99bc2bb698344f2.png

11. Acessar o código da solução

Para fazer o download do código do codelab concluído, use estes comandos git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout viewmodel

Se preferir, você pode fazer o download do repositório como um arquivo ZIP, descompactar e abrir no Android Studio.

Você pode conferir o código da solução deste codelab no GitHub (link em inglês).

12. Conclusão

Parabéns! Você concluiu o codelab. Agora você entende como as diretrizes de arquitetura de apps Android recomendam separar classes com responsabilidades diferentes e usar um modelo para a IU.

Não se esqueça de compartilhar seu trabalho nas redes sociais com a hashtag #AndroidBasics.

Saiba mais