1. Introdução
Neste codelab, você vai aprender sobre o estado e como ele pode ser usado e manipulado pelo Jetpack Compose.
Antes de começar, é útil definir exatamente o que é o estado. Na essência, o estado de um aplicativo é qualquer valor que possa mudar com o tempo. Essa é uma definição muito ampla e abrange tudo, desde um banco de dados do Room até uma variável de classe.
Todos os apps Android exibem o estado ao usuário. Alguns exemplos de estado em apps Android:
- Um snackbar que mostra quando não é possível estabelecer uma conexão de rede
- Uma postagem de blog e comentários associados
- Animações de ondulação em botões quando um usuário clica neles
- Adesivos que podem ser mostrados em cima de uma imagem
Neste codelab, você vai aprender a usar e avaliar o estado ao usar o Jetpack Compose. Para fazer isso, criaremos um aplicativo "TODO" de lista de tarefas. Ao final deste codelab, você vai ter criado uma IU com estado que exibe uma lista de tarefas interativa e editável.
Na próxima seção, você vai aprender sobre o fluxo de dados unidirecional, um padrão de design básico para entender como exibir e gerenciar o estado ao usar o Compose.
O que você vai aprender
- O que é fluxo de dados unidirecional.
- Como considerar o estado e os eventos em uma IU.
- Como usar o
ViewModel
e oLiveData
do componente de arquitetura no Compose para gerenciar o estado. - Como o Compose usa o estado para mostrar uma tela.
- Quando mover o estado para um autor de chamada.
- Como usar o estado interno no Compose.
- Como usar o
State<T>
para integrar o estado ao Compose.
O que é necessário
- Android Studio Bumblebee
- Conhecimento sobre Kotlin.
- É recomendável fazer o codelab de Noções básicas do Jetpack Compose antes deste.
- Noções básicas do Compose (como a anotação
@Composable
). - Conhecimento básico dos layouts do Compose (por exemplo, linha e coluna).
- Conhecimento básico dos modificadores (por exemplo, Modifier.padding)
- Compreensão básica dos componentes
ViewModel
eLiveData
de arquitetura
O que você vai criar
- O app TODO de lista de tarefas interativa usando o fluxo de dados unidirecional no Compose
2. Etapas da configuração
Para fazer o download do app de exemplo, você pode:
Ou clonar o repositório do GitHub pela linha de comando usando o comando abaixo:
git clone https://github.com/googlecodelabs/android-compose-codelabs.git cd android-compose-codelabs/StateCodelab
Você pode executar qualquer um dos módulos no Android Studio a qualquer momento mudando a configuração de execução na barra de ferramentas.
Abrir o projeto no Android Studio
- Na janela "Welcome to Android Studio", selecione
Open an Existing Project.
- Selecione a pasta
[Download Location]/StateCodelab
. Dica: selecione o diretórioStateCodelab
que contémbuild.gradle
. - Depois que o Android Studio importar o projeto, teste se você pode executar os módulos
start
efinished
.
Conhecer o código inicial
O código inicial contém quatro pacotes:
examples
: exemplos de atividades para explorar os conceitos do fluxo de dados unidirecional. Não é necessário editar este pacote.ui
: contém temas gerados automaticamente pelo Android Studio ao iniciar um novo projeto no Compose. Não é necessário editar este pacote.util
: contém o código auxiliar do projeto. Não é necessário editar este pacote.todo
: o pacote que contém o código da tela da lista de tarefas que estamos criando. Você vai fazer modificações neste pacote.
Este codelab se concentra nos arquivos do pacote todo
. No módulo start
, há vários arquivos que você precisa conhecer.
Arquivos fornecidos no pacote todo
Data.kt
: estruturas de dados usadas para representar umTodoItem
.TodoComponents.kt
: elementos que podem ser compostos e reutilizados que vão ser usados para criar a tela do app. Não é necessário editar este arquivo.
Arquivos que você vai editar no pacote todo
TodoActivity.kt
: atividade do Android que vai usar o Compose para mostrar uma tela do app "Todo" após o codelab.TodoViewModel.kt
: umViewModel
que você vai integrar ao Compose para criar a tela do Todo. Você vai o conectar ao Compose e ampliá-lo para adicionar mais recursos durante este codelab.TodoScreen.kt
: implementação do Compose em uma tela do app "Todo" que você vai criar durante este codelab.
3. Noções básicas sobre o fluxo de dados unidirecional
Loop de atualização da IU
Antes de começar a usar nosso app "TODO", vamos conhecer os conceitos do fluxo de dados unidirecional usando o sistema de visualização do Android.
O que faz com que o estado seja atualizado? Na introdução, falamos sobre o estado como qualquer valor que muda ao longo do tempo. Isso faz parte apenas da história do estado em um app Android.
Nos apps Android, o estado é atualizado em resposta aos eventos. Eventos são entradas geradas fora do nosso app, como o toque do usuário em um botão que chama um OnClickListener
, um EditText
que chama um afterTextChanged
ou um acelerômetro que envia um novo valor.
Todos os apps Android têm um loop de atualização de IU principal que funciona assim:
- Evento: um evento é gerado pelo usuário ou por outra parte do programa.
- Estado de atualização: um manipulador de eventos muda o estado usado pela IU.
- Estado de exibição: a IU é atualizada para exibir o novo estado.
O gerenciamento de estado no Compose tem como objetivo entender como o estado e os eventos interagem entre si.
Estado não estruturado
Antes de começar a usar o Compose, vamos conhecer os eventos e o estado no sistema de visualização do Android. Como um estado "Hello, World", vamos criar uma Activity
"Hello World" que permite ao usuário inserir o nome dele.
Uma maneira de escrever isso é fazer com que o callback do evento defina diretamente o estado na TextView e o código, usando a ViewBinding
, pode ser assim:
HelloCodelabActivity**.kt**
class HelloCodelabActivity : AppCompatActivity() {
private lateinit var binding: ActivityHelloCodelabBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {text ->
name = text.toString()
updateHello()
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
Esse código cumpre o papel dele. Para um exemplo pequeno como esse, tudo bem. No entanto, isso se torna difícil de gerenciar à medida que a IU cresce.
À medida que você adicionar mais eventos e estados a uma atividade criada assim, podem surgir vários problemas:
- Testes: como o estado da IU está interligado às
Views
, pode ser difícil testar esse código. - Atualizações de estado parcial: quando a tela tem muitos outros eventos, é fácil esquecer que uma parte do estado vai ser atualizada em resposta a um evento. Como resultado, o usuário pode ver uma IU inconsistente ou incorreta.
- Atualizações parciais da IU: como estamos atualizando manualmente a IU após cada mudança de estado, às vezes é fácil esquecer isso. Como resultado, o usuário pode ver dados desatualizados na IU que são atualizados aleatoriamente.
- Complexidade do código: é difícil extrair parte da lógica ao codificar nesse padrão. Como resultado, o código tende a se tornar difícil de ler e entender.
Como usar o fluxo de dados unidirecional
Para ajudar a corrigir esses problemas com o estado não estruturado, lançamos os Componentes da arquitetura do Android, que contêm ViewModel
e LiveData
.
Um ViewModel
permite extrair o estado da IU e definir eventos que a IU pode chamar para atualizar esse estado. Vejamos a mesma atividade escrita usando um ViewModel
.
HelloCodelabActivity.kt
class HelloCodelabViewModel: ViewModel() {
// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChanged(newName: String) {
_name.value = newName
}
}
class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
private val helloViewModel by viewModels<HelloCodelabViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {
helloViewModel.onNameChanged(it.toString())
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
Neste exemplo, movemos o estado da Activity
para um ViewModel
. Em um ViewModel, o estado é representado por LiveData
. Um objeto LiveData
é um detentor de estado observável, o que significa que ele permite que qualquer pessoa observe mudanças no estado. Em seguida, usamos o método observe
para atualizar a IU sempre que o estado mudar.
O ViewModel
também expõe um evento: onNameChanged
. Esse evento é chamado pela IU em resposta a eventos do usuário, como o que acontece aqui sempre que o texto do EditText
muda.
Voltando ao loop de atualização da IU que abordamos anteriormente, podemos ver como esse ViewModel
se ajusta a eventos e estados.
- Evento:
onNameChanged
é chamado pela IU quando a entrada de texto é modificada. - Estado de atualização:
onNameChanged
faz o processamento e define o estado de_name
. - Estado de exibição: os observadores de
name
são chamados, o que notifica a IU das mudanças de estado.
Ao estruturar o código dessa maneira, podemos pensar em eventos que "fluem" para ViewModel
. Em seguida, em resposta a eventos, o ViewModel
vai fazer algum processamento e possivelmente atualizar o estado. Quando o estado é atualizado, ele "flui" para a Activity
.
Esse padrão é chamado de fluxo de dados unidirecional. O fluxo de dados unidirecional é um design em que os estados fluem para baixo e os eventos para cima. Ao estruturar o código dessa maneira, podemos oferecer algumas vantagens:
- Capacidade de teste: ao desacoplar o estado da IU que o exibe, fica mais fácil testar o ViewModel e a atividade.
- Encapsulamento de estado: como o estado só pode ser atualizado em um lugar (o
ViewModel
), é menos provável que você introduza um bug de atualização de estado parcial à medida que a IU cresce. - Consistência da IU: todas as atualizações de estado são refletidas imediatamente na IU pelo uso de detentores de estado observáveis.
Assim, embora essa abordagem adicione um pouco mais de código, ela tende a ser mais fácil e confiável para processar estados e eventos complexos usando o fluxo de dados unidirecional.
Na próxima seção, vamos ver como usar o fluxo de dados unidirecional com o Compose.
4. Compose e ViewModels
Na última seção, abordamos o fluxo de dados unidirecional no sistema de visualizações do Android usando ViewModel
e LiveData
. Agora vamos aprender a usar o fluxo de dados unidirecional no Compose com ViewModels
.
No final desta seção, você vai ter criado esta tela:
Conhecer os elementos que podem ser compostos da TodoScreen
O código transferido por download contém vários elementos que podem ser compostos que vão ser usados e editados ao longo deste codelab.
Abra TodoScreen.kt
e veja o elemento TodoScreen
que pode ser composto:
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
/* ... */
}
Para ver o que esse elemento que pode ser composto exibe, use o painel de visualização no Android Studio clicando no ícone de divisão no canto direito de cima .
Esse elemento que pode ser composto exibe uma lista de tarefas editável, mas não tem nenhum estado próprio. Lembre-se que o estado é qualquer valor que pode mudar, mas nenhum dos argumentos da TodoScreen pode ser modificado.
items
: uma lista imutável de itens a serem mostrados na tela.onAddItem
: um evento para quando o usuário solicita a adição de um item.onRemoveItem
: um evento para quando o usuário solicita a remoção de um item.
Na verdade, esse elemento que pode ser composto é sem estado. Ele exibe somente a lista de itens que foi transmitida e não tem maneiras de editar diretamente a lista. Em vez disso, ele recebe dois eventos onRemoveItem
e onAddItem
que podem solicitar mudanças.
Isso levanta a questão: se for sem estado, como ele pode exibir uma lista editável? Isso é feito com uma técnica chamada elevação de estado. A elevação de estado é o padrão para mover um estado para cima e tornar um componente em sem estado. Componentes sem estado são mais fáceis de testar, tendem a ter menos bugs e abrem mais oportunidades para reutilização.
Na verdade, a combinação desses parâmetros funciona para permitir que o autor da chamada eleve o estado desse elemento que pode ser composto. Para ver como isso funciona, veja o loop de atualização da IU desse elemento.
- Evento: quando o usuário solicita a adição ou remoção de um item, o
TodoScreen
chamaonAddItem
ouonRemoveItem
. - Estado de atualização: o autor da chamada de
TodoScreen
pode responder a esses eventos atualizando o estado. - Estado de exibição: quando o estado for atualizado, a
TodoScreen
vai ser chamada novamente com os novositems
e vai poder os exibir na tela.
O autor da chamada é responsável por descobrir onde e como manter esse estado. No entanto, ele pode armazenar items
na memória ou os ler de um banco de dados do Room. A TodoScreen
é completamente separada da forma como o estado é gerenciado.
Definir a composição da TodoActivityScreen
Abra TodoViewModel.kt
e encontre um ViewModel
que defina uma variável de estado e dois eventos.
TodoViewModel.kt
class TodoViewModel : ViewModel() {
// state: todoItems
private var _todoItems = MutableLiveData(listOf<TodoItem>())
val todoItems: LiveData<List<TodoItem>> = _todoItems
// event: addItem
fun addItem(item: TodoItem) {
/* ... */
}
// event: removeItem
fun removeItem(item: TodoItem) {
/* ... */
}
}
Queremos usar esse ViewModel
para elevar o estado da TodoScreen
. Quando terminarmos, teremos criado um design de fluxo de dados unidirecional que vai ter esta aparência:
Para começar a integrar a TodoScreen
na TodoActivity
, abra o arquivo TodoActivity.kt
e defina uma nova função @Composable
TodoActivityScreen(todoViewModel: TodoViewModel)
e a chame em setContent
no método onCreate
.
No restante desta seção, vamos criar a TodoActivityScreen
um passo por vez. Para começar, chame a TodoScreen
com um estado falso e eventos como este:
TodoActivity.kt
import androidx.compose.runtime.Composable
class TodoActivity : AppCompatActivity() {
private val todoViewModel by viewModels<TodoViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
StateCodelabTheme {
Surface {
TodoActivityScreen(todoViewModel)
}
}
}
}
}
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>() // in the next steps we'll complete this
TodoScreen(
items = items,
onAddItem = { }, // in the next steps we'll complete this
onRemoveItem = { } // in the next steps we'll complete this
)
}
Esse elemento que pode ser composto vai ser uma ponte entre o estado armazenado no ViewModel e o elemento TodoScreen
que já está definido no projeto. Você pode mudar a TodoScreen
para usar o ViewModel
diretamente, mas a TodoScreen
seria um pouco menos reutilizável. Ao dar preferência a parâmetros mais simples, como List<TodoItem>
, a TodoScreen
não está acoplada ao local específico em que o estado é elevado.
Se você executar o app agora, vai ver que ele exibe um botão, mas clicar nele não faz nada. Isso ocorre porque ainda não conectamos nosso ViewModel
à TodoScreen
.
Fluxo de eventos para cima
Agora que temos todos os componentes necessários, um ViewModel
, uma TodoActivityScreen
que pode ser composta como ponte e uma TodoScreen
, vamos conectar tudo para exibir uma lista dinâmica usando um fluxo de dados unidirecional.
Na TodoActivityScreen
, transmita addItem
e removeItem
do ViewModel
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>()
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
Quando a TodoScreen
chama onAddItem
ou onRemoveItem
, podemos transmitir a chamada para o evento correto no ViewModel
.
Transmitir o estado para baixo
Transferimos os eventos do nosso fluxo de dados unidirecional e agora precisamos transmitir o estado.
Edite a TodoActivityScreen
para observar os todoItems
do LiveData
usando observeAsState
:
TodoActivity.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
Essa linha vai observar o LiveData
e nos permite usar o valor atual diretamente como uma List<TodoItem>
.
Há muita coisa nessa linha, então vamos separar:
val items: List<TodoItem>
declara uma variávelitems
do tipoList<TodoItem>
.todoViewModel.todoItems
é umLiveData<List<TodoItem>
doViewModel
.- O
.observeAsState
observa umLiveData<T>
e o converte em um objetoState<T>
para que o Compose possa reagir às mudanças de valor. listOf()
é um valor inicial para evitar possíveis resultadosnull
antes doLiveData
ser inicializado. Se ele não for transmitido, ositems
seriamList<TodoItem>?
, que é anulável.by
é a sintaxe de delegação de propriedade em Kotlin, que permite separar automaticamenteState<List<TodoItem>>
deobserveAsState
em umaList<TodoItem>
comum.
Executar o app novamente
Execute o app novamente para ver uma lista que é atualizada dinamicamente. Clique no botão na parte de baixo para adicionar novos itens. Clique em um deles para remover.
Nesta seção, vimos como criar um design de fluxo de dados unidirecional no Compose usando ViewModels
. Também vimos como usar um elemento que pode ser composto sem estado para exibir uma IU com estado usando uma técnica chamada elevação de estado. Além disso, continuamos estudando as IUs dinâmicas em termos de estado e eventos.
Na próxima seção, você vai aprender a adicionar memória a funções que podem ser compostas.
5. Memória no Compose
Agora que vimos como usar o Compose com ViewModels para criar um fluxo de dados unidirecional, vamos ver como o Compose pode interagir com o estado internamente.
Na última seção, você viu como o Compose atualiza a tela chamando novamente os elementos que podem ser compostos. Esse processo é conhecido como recomposição. Conseguimos exibir uma lista dinâmica chamando TodoScreen
novamente.
Nesta e na próxima seção, vamos ver como adicionar estado aos elementos que podem ser compostos.
Nesta seção, vamos ver como adicionar memória a uma função de composição, que é um elemento básico necessário para adicionar o estado ao Compose na próxima seção.
Design desordenado
Simulação do designer
Nesta seção, um novo designer da sua equipe fez uma simulação seguindo a tendência de design mais recente: design desordenado. O princípio fundamental do design desordenado é fazer um bom design e adicionar mudanças aparentemente aleatórias para que seja "interessante".
Neste design, cada ícone é colorido para um valor alfa aleatório entre 0,3 e 0,9.
Como adicionar elementos aleatórios a um elemento que pode ser composto
Para começar, abra o arquivo TodoScreen.kt
e encontre o elemento TodoRow
que pode ser composto. Esse elemento que pode ser composto descreve uma única linha na lista do app de lista de afazeres.
Defina um novo val iconAlpha
com um valor de randomTint()
. Conforme solicitado pelo nosso designer, esse é um ponto flutuante entre 0,3 e 0,9. Em seguida, defina a tonalidade do ícone.
TodoScreen.kt
import androidx.compose.material.LocalContentColor
@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
val iconAlpha = randomTint()
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
Se você conferir a visualização novamente, vai ver que o ícone agora tem uma tonalidade aleatória.
Conhecer a recomposição
Execute o app novamente para testar o novo design desordenado. Você vai perceber imediatamente que as tonalidades mudam o tempo todo. Seu designer disse que exageramos um pouco no modo aleatório.
App com ícones que mudam de tonalidade quando a lista muda
O que está acontecendo aqui? Perceba que o processo de recomposição chama randomTint
para cada linha na tela novamente sempre que a lista muda.
A recomposição é o processo de chamar funções que podem ser compostas novamente com novas entradas para atualizar a árvore do Compose. Nesse caso, quando a TodoScreen
for chamada novamente com uma nova lista, a LazyColumn
vai fazer a recomposição de todos os filhos na tela. Isso vai chamar a TodoRow
novamente, gerando uma nova tonalidade aleatória.
O Compose gera uma árvore, mas é um pouco diferente da árvore de IU que você talvez conheça do sistema de visualização do Android. Em vez de uma árvore de widgets de IU, o Compose gera uma árvore de elementos que podem ser compostos. Podemos visualizar a TodoScreen
desta forma:
Árvore da TodoScreen
Quando o Compose executa a composição na primeira vez, ele cria uma árvore de cada função que foi chamada. Em seguida, durante a recomposição, ele atualiza a árvore com os novos elementos que podem ser compostos chamados.
O ícone é atualizado sempre que a TodoRow
faz a recomposição porque TodoRow
tem um efeito colateral oculto. Um efeito colateral é qualquer mudança visível fora da execução de uma função de composição.
A chamada para Random.nextFloat()
atualiza a variável aleatória interna usada em um gerador de números pseudoaleatórios. É assim que Random
retorna um valor diferente sempre que você pede um número aleatório.
Introdução de memória em funções de composição
Não queremos que a tonalidade mude sempre que a TodoRow
for recomposta. Para isso, precisamos de um lugar para lembrar a tonalidade que usamos na última composição. O Compose permite armazenar valores na árvore de composição. Assim, podemos atualizar a TodoRow
para armazenar o iconAlpha
na árvore de composição.
Edite a TodoRow
e coloque a chamada de randomTint
em remember
desta maneira:
TodoScreen.kt
val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
Observando a nova árvore de composição do Compose para TodoRow
, é possível ver que iconAlpha
foi adicionado:
Árvore da TodoRow usando "remember"
Se você executar o app novamente, vai ver que a tonalidade não é atualizada sempre que a lista mudar. Em vez disso, quando a recomposição acontece, o valor anterior armazenado por remember
é retornado.
Se você analisar a chamada de "remember", vai ver que estamos transmitindo todo.id
como o argumento key
.
remember(todo.id) { randomTint() }
Uma chamada de "remember" tem duas partes:
- key arguments: a "chave" que esse remetente usa é a parte que é transmitida entre parênteses. Aqui, transmitimos
todo.id
como a chave. - calculation: uma lambda que calcula um novo valor para ser lembrado, transmitido em uma lambda final. Estamos calculando um valor aleatório com
randomTint()
.
Ao fazer a composição pela primeira vez, "remember" sempre chama randomTint
e lembra o resultado para a próxima recomposição. Ele também monitora o todo.id
que foi transmitido. Durante a recomposição, ele vai pular a chamada de randomTint
e retornar o valor memorizado, a menos que um novo todo.id
seja transmitido à TodoRow
.
A recomposição de um elemento que pode ser composto precisa ser idempotente. Ao colocar a chamada em randomTint
com remember
, a chamada para um valor aleatório vai ser ignorada na recomposição, a menos que o item do app "Todo" mude. Como resultado, a TodoRow
não tem efeitos colaterais e sempre produz o mesmo resultado quando for recomposta com a mesma entrada, além de ser idempotente.
Como tornar os valores memorizados controláveis
Se você executar o app agora, vai ver que ele está exibindo uma tonalidade aleatória em cada ícone. O designer está satisfeito com o fato de que o app está seguindo os princípios do design desordenado e o aprova para envio.
No entanto, há uma pequena mudança no código que precisa ser feita antes da verificação. No momento, não há como o autor da chamada de TodoRow
especificar a tonalidade. Há muitos motivos para que o autor possa querer fazer a chamada. Por exemplo, o vice-presidente do produto pode perceber essa tela e exigir um hotfix para remover o desordenamento antes de enviar o app.
Para que o autor da chamada controle esse valor, basta mover a chamada de recall para um argumento padrão de um novo parâmetro iconAlpha
.
@Composable
fun TodoRow(
todo: TodoItem,
onItemClicked: (TodoItem) -> Unit,
modifier: Modifier = Modifier,
iconAlpha: Float = remember(todo.id) { randomTint() }
) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
Agora, o autor da chamada recebe o mesmo comportamento por padrão: a TodoRow
calcula randomTint
. No entanto, eles podem especificar qualquer valor alfa. Ao permitir que o autor da chamada controle a alphaTint
, esse elemento que pode ser composto se torna mais reutilizável. Em outra tela, um designer pode querer exibir todos os ícones com o valor alfa 0,7.
Também há um bug muito sutil com o uso de remember
. Adicione linhas de tarefas suficientes para rolar algumas telas clicando em "Add random todo" (adicionar tarefa aleatória) várias vezes e rolando. Ao rolar a tela, você vai notar que os ícones mudam sempre que eles voltam para a tela.
Nas próximas seções, vamos ver o estado e a elevação de estado, que fornece as ferramentas necessárias para corrigir bugs como esses.
6. Estado no Compose
Na última seção, aprendemos como as funções que podem ser compostas têm memória. Agora vamos explorar como usar essa memória para adicionar estado a uma função que pode ser composta.
Entrada do app "Todo" (estado: expandida) 
Entrada do app "Todo" (estado: recolhida) 
Nosso designer passou do design desordenado e agora está no pós-Material. O novo design para entrada de tarefas ocupa o mesmo espaço que um cabeçalho recolhível, além de ter dois estados principais: expandido e recolhido. A versão expandida vai ser exibida quando o texto não estiver vazio.
Para criar isso, primeiro criamos o texto e o botão. Em seguida, vamos ver como adicionar os ícones de ocultação automática.
A edição de texto em uma IU é com estado. O usuário atualiza o texto exibido no momento sempre que digita um caractere ou mesmo quando muda a seleção. No sistema de visualização do Android, esse estado é interno ao EditText
e exposto por listeners onTextChanged
. No entanto, como o Compose foi criado para um fluxo de dados unidirecional, isso não seria adequado.
No Compose, TextField
é um elemento que pode ser composto sem estado. Assim como a TodoScreen
que exibe uma lista variável de tarefas, um TextField
exibe apenas o que você quiser e emite eventos quando o usuário digita.
Criar um elemento TextField que pode ser composto com estado
Para começar a explorar o estado no Compose, faremos um componente com estado para exibir um TextField
editável.
Para começar, abra TodoScreen.kt
e adicione a função abaixo
TodoScreen.kt
import androidx.compose.runtime.mutableStateOf
@Composable
fun TodoInputTextField(modifier: Modifier) {
val (text, setText) = remember { mutableStateOf("") }
TodoInputText(text, setText, modifier)
}
Essa função usa remember
para adicionar memória a si mesma. Em seguida, ela armazena um mutableStateOf
para criar uma MutableState<String>
, que é um tipo integrado do Compose que fornece um detentor de estado observável.
Como transmitiremos imediatamente um valor e um evento setter ao TodoInputText
, vamos desestruturar o objeto MutableState
em um getter e um setter.
Pronto. Criamos um estado interno em TodoInputTextField
.
Para ver isso em ação, defina outra TodoItemInput
que pode ser composta que mostre o TodoInputTextField
e um Button
.
TodoScreen.kt
import androidx.compose.ui.Alignment
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
// onItemComplete is an event will fire when an item is completed by the user
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
TodoItemInput
tem apenas um parâmetro, um evento onItemComplete
. Quando o usuário concluir um TodoItem
, o evento vai ser acionado. Esse padrão de transmitir uma lambda é a principal maneira de definir eventos personalizados no Compose.
Além disso, atualize o elemento TodoScreen
que pode ser composto para chamar a TodoItemInput
no TodoItemInputBackground
em segundo plano, que já está definido no projeto:
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
Column {
// add TodoItemInputBackground and TodoItem at the top of TodoScreen
TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
TodoItemInput(onItemComplete = onAddItem)
}
...
Testar TodoItemInput
Como acabamos de definir uma IU que pode ser composta para o arquivo, é recomendável adicionar uma @Preview
a ele. Com isso, poderemos analisar esse elemento que pode ser composto de forma isolada, além de permitir que os leitores desse arquivo o visualizem rapidamente.
No arquivo TodoScreen.kt
, adicione uma nova função de visualização à parte de baixo:
TodoScreen.kt
@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })
Agora você pode executar essa função em uma visualização interativa ou em um emulador para depurar a função isoladamente.
Depois, você vai ver um campo de texto editável que permite ao usuário editar o texto. Sempre que um caractere for digitado, o estado é atualizado e isso aciona a recomposição atualizando o TextField
exibido ao usuário.
Clicar no botão e adicionar um item
Agora, queremos fazer com que o botão "Add" adicione um TodoItem
. Para fazer isso, vamos precisar acessar o text
do TodoInputTextField
.
Se você analisar a parte da árvore de composição da TodoItemInput
, vai ver que estamos armazenando o estado do texto no TodoInputTextField
.
Árvore de composição de TodoItemInput (elementos integrados que podem ser compostos ocultos)
Essa estrutura não nos permite conectar o método onClick
porque onClick
precisa acessar o valor atual do text
. O que queremos fazer é expor o estado do text
para a TodoItemInput
e usar o fluxo de dados unidirecional ao mesmo tempo.
O fluxo de dados unidirecional se aplica à arquitetura de alto nível e ao design de um único elemento que pode ser composto ao usar o Jetpack Compose. Aqui, queremos que isso faça com que os eventos sempre fluam para cima e o estado para baixo.
Isso significa que queremos que o estado flua para baixo de TodoItemInput
e que os eventos fluam para cima.
Diagrama de fluxo de dados unidirecional para TodoItemInput
Para fazer isso, precisamos mover o estado do elemento filho que pode ser composto, TodoInputTextField
, para o TodoItemInput
pai.
Árvore de composição de TodoItemInput com elevação de estado (elementos integrados que podem ser compostos ocultos)
Esse padrão é chamado de elevação de estado. Vamos "elevar" o estado de um elemento que pode ser composto para o tornar sem estado. A elevação de estado é o padrão principal para criar designs de fluxo de dados unidirecionais no Compose.
Para começar a elevar um estado, é possível refatorar qualquer estado interno T
de um elemento que pode ser composto para um par de parâmetros (value: T, onValueChange: (T) -> Unit)
.
Edite TodoInputTextField
para elevar o estado adicionando parâmetros (value, onValueChange)
:
TodoScreen.kt
// TodoInputTextField with hoisted state
@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
TodoInputText(text, onTextChange, modifier)
}
Esse código adiciona um parâmetro value
e onValueChange
a TodoInputTextField
. O parâmetro de valor é text
e o parâmetro onValueChange
é onTextChange
.
Em seguida, como o estado agora está elevado, removemos o estado memorizado do TodoInputTextField
.
O estado elevado dessa maneira tem algumas propriedades importantes:
- Fonte única da verdade: ao mover o estado em vez de duplicar, garantimos que existe apenas uma fonte de verdade para o texto. Isso ajuda a evitar bugs.
- Encapsulado: somente a
TodoItemInput
vai poder modificar o estado, enquanto outros componentes poderão enviar eventos para aTodoItemInput
. Ao elevar dessa forma, somente um elemento que pode ser composto é com estado, mesmo que vários elementos usem o estado. - Compartilhável: o estado elevado pode ser compartilhado como um valor imutável com vários elementos que podem ser compostos. Aqui, usaremos o estado em
TodoInputTextField
eTodoEditButton
. - Interceptável:
TodoItemInput
pode decidir ignorar ou modificar eventos antes de mudar o estado. Por exemplo,TodoItemInput
pode formatar códigos :emoji-codes: para emojis conforme o usuário digita. - Desacoplado: o estado de
TodoInputTextField
pode ser armazenado em qualquer lugar. Por exemplo, podemos optar por armazenar esse estado em um banco de dados do Room, que é atualizado sempre que um caractere é digitado sem modificar oTodoInputTextField
.
Agora, adicione o estado em TodoItemInput
e o transmita para TodoInputTextField
:
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
Agora, elevado o estado, é possível usar o valor atual do texto para direcionar o comportamento do TodoEditButton
. Conclua o callback e enable
(ative) o botão somente quando o texto não estiver em branco de acordo com o design:
TodoScreen.kt
// edit TodoItemInput
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text)) // send onItemComplete event up
setText("") // clear the internal text
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank() // enable if text is not blank
)
Estamos usando a mesma variável de estado, text
, em dois elementos que podem ser compostos diferentes. Ao elevar o estado, podemos compartilhá-lo. Além disso, conseguimos fazer isso tornando apenas TodoItemInput
um elemento que pode ser composto com estado.
Executar novamente
Execute o app novamente para ver que agora é possível adicionar itens de tarefas. Parabéns! Você acabou de aprender a adicionar estado a um elemento que pode ser composto e como o elevar.
Limpeza de código
Antes de continuar, posicione o TodoInputTextField
in-line. Adicionamos essa seção para explorar a elevação de estado. Se você analisar o código do TodoInputText
que foi fornecido com o codelab, vai ver que ele já está elevado de acordo com os padrões discutidos nesta seção.
Quando você terminar, seu arquivo TodoItemInput
vai ter esta aparência:
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text))
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
}
}
Na próxima seção, vamos continuar a criar o design e adicionar os ícones. Você vai usar as ferramentas que aprendemos nesta seção para elevar o estado e criar IUs interativas com fluxo de dados unidirecional.
7. IU dinâmica com base no estado
Na última seção, você aprendeu a adicionar um estado a um elemento que pode ser composto e como usar a elevação de estado para criar um elemento que usa outros elementos que podem ser compostos sem estado.
Agora veremos como criar uma IU dinâmica com base no estado. Voltando à simulação do designer, precisamos mostrar a linha do ícone sempre que o texto não estiver em branco.
Entrada do app "Todo" (estado: expandida, texto não em branco) 
Entrada do app "Todo" (estado: recolhida, texto em branco) 
Derivar iconsVisibles a partir do estado
Abra TodoScreen.kt
e crie uma nova variável de estado para armazenar o icon
selecionado no momento e uma nova val
iconsVisible
que seja verdadeira sempre que o texto não estiver em branco.
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
// ...
Adicionamos um segundo estado, icon
, que contém o ícone selecionado no momento.
O valor iconsVisible
não adiciona um novo estado à TodoItemInput
. A TodoItemInput
não pode ser modificada diretamente. Em vez disso, ela é baseada totalmente no valor de text
. Seja qual for o valor do text
nessa recomposição, iconsVisible
vai ser definido de acordo com isso, e vamos poder usá-lo para mostrar a IU correta.
Podemos adicionar outro estado à TodoItemInput
para controlar quando os ícones são visíveis, mas se você observar de perto as especificações, a visibilidade é totalmente baseada no texto inserido. Se criarmos dois estados, vai ser fácil para eles ficarem dessincronizados.
Em vez disso, prefira uma única fonte da verdade. Nesse elemento que pode ser composto, só precisamos que text
esteja no estado, e iconsVisible
pode se basear em text
.
Continue editando a TodoItemInput
para mostrar a AnimatedIconRow
, dependendo do valor de iconsVisible
. Se o valor iconsVisible
for verdadeiro, exiba uma AnimatedIconRow
. Caso seja falso, exiba um espaçador com 16.dp
.
TodoScreen.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
Column {
Row( /* ... */ ) {
/* ... */
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Se você executar o app novamente agora, vai ver que os ícones são animados quando você digita texto.
Estamos mudando dinamicamente a árvore de composição com base no valor do iconsVisible
. Veja um diagrama da árvore de composição para ambos os estados.
Esse tipo de lógica de exibição condicional é equivalente à visibilidade no sistema de visualização do Android.
Árvore de composição TodoItemInput quando iconsVisible muda
Se você executar o app novamente, vai ver que a linha do ícone é exibida corretamente, mas, se clicar em "Add", ele não vai aparecer na linha adicionada no app. Isso ocorre porque não atualizamos nosso evento para transmitir o novo estado do ícone, vamos fazer isso a seguir.
Atualizar o evento para usar o ícone
Edite o TodoEditButton
na TodoItemInput
para usar o novo estado icon
no listener onClick
.
TodoScreen.kt
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
É possível usar o novo estado icon
diretamente no listener onClick
. Também o redefinimos para o padrão quando o usuário termina de inserir um TodoItem
.
Se você executar o app agora, vai ver uma entrada interativa nas tarefas com botões animados. Muito bem!
Concluir o design com uma imeAction
Quando você mostrar o app ao designer, ele vai dizer que o app precisa poder enviar o item da tarefa da ação do IME (editor de método de entrada, na sigla em inglês) usando o teclado. No caso, é o botão azul no canto direito de baixo:
Teclado Android com ImeAction.Done
TodoInputText
permite que você responda à imeAction com o evento onImeAction
.
Queremos que a onImeAction
tenha exatamente o mesmo comportamento que o TodoEditButton
. Poderíamos duplicar o código, mas isso seria difícil de manter ao longo do tempo, porque seria fácil atualizar apenas um dos eventos.
Vamos extrair o evento em uma variável para que possamos usá-lo na onImeAction
do TodoInputText
e no onClick
do TodoEditButton
.
Edite TodoItemInput
novamente para declarar uma nova função lambda submit
, que processa a ação de enviar realizada pelo usuário. Em seguida, transmita a função lambda recém-definida para TodoInputText
e TodoEditButton
.
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
onImeAction = submit // pass the submit callback to TodoInputText
)
TodoEditButton(
onClick = submit, // pass the submit callback to TodoEditButton
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Se você quiser, pode extrair ainda mais a lógica dessa função. No entanto, essa função de composição está muito boa, então vamos parar por aqui.
Essa é uma das grandes vantagens do Compose. Como você está declarando a IU no Kotlin, é possível criar todas as abstrações necessárias para tornar o código separado e reutilizável.
Para gerenciar o trabalho com o teclado, o TextField
oferece dois parâmetros:
keyboardOptions
: usado para permitir a exibição da ação Done IMEkeyboardActions
: usado para especificar a ação a ser acionada em resposta a ações específicas do IME (editor de método de entrada, na sigla em inglês) acionadas. No nosso caso, depois que "Done" é pressionado, queremos quesubmit
seja chamado e o teclado fique oculto.
Para controlar o teclado de software, vamos usar LocalSoftwareKeyboardController.current
. Como esta é uma API experimental, vai ser necessário anotar a função com @OptIn(ExperimentalComposeUiApi::class)
.
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier,
onImeAction: () -> Unit = {}
) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = text,
onValueChange = onTextChange,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
maxLines = 1,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
onImeAction()
keyboardController?.hide()
}),
modifier = modifier
)
}
Execute o app novamente para testar os novos ícones.
Execute o app novamente para ver os ícones sendo mostrados e ocultados automaticamente enquanto o texto muda. Você também pode mudar a seleção de ícones. Quando você clicar no botão "Add", vai ver um novo TodoItem com base nos valores inseridos.
Parabéns, você aprendeu sobre o estado no Compose, a elevação de estado e como criar IUs dinâmicas com base no estado.
Nas próximas seções, vamos ver como criar componentes reutilizáveis que interagem com o estado.
8. Como extrair elementos sem estado que podem ser compostos
O designer está usando uma nova tendência hoje. A IU desordenada e o pós-Material são coisa do passado. O design desta semana segue a tendência "interativa neomoderna". Você perguntou o que isso significa, e a resposta foi um pouco confusa e envolvia emojis, mas, de qualquer forma, estas são as simulações.
Simulação do modo de edição
O designer diz que reutiliza a mesma IU que a entrada com os botões modificados para um emoji de "salvar e concluir".
No final da última seção, deixamos TodoItemInput
como um elemento que pode ser composto com estado. Isso não era um problema quando se tratava apenas da entrada de novas tarefas no app. No entanto, agora que é um editor, ele vai precisar de suporte para a elevação de estado.
Nesta seção, você vai aprender a extrair um estado de um elemento que pode ser composto com estado para o tornar sem estado. Assim, vamos poder reutilizar o mesmo elemento para adicionar e editar tarefas.
Converter a TodoItemInput em um elemento sem estado que pode ser composto
Para começar, precisamos elevar o estado da TodoItemInput
. Mas onde vamos colocá-lo? Podemos colocar o estado diretamente na TodoScreen
, mas já está funcionando muito bem com o estado interno e um evento finalizado. Não queremos mudar essa API.
Em vez disso, o que podemos fazer é dividir o elemento que pode ser composto em dois: um que tenha estado e outro que seja sem estado.
Abra TodoScreen.kt
e divida a TodoItemInput
em dois elementos que podem ser compostos. Em seguida, renomeie o elemento com estado para TodoItemEntryInput
, já que ele só é útil para inserir novos TodoItems
.
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
TodoItemInput(
text = text,
onTextChange = setText,
icon = icon,
onIconChange = setIcon,
submit = submit,
iconsVisible = iconsVisible
)
}
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
TodoEditButton(
onClick = submit,
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Essa transformação é muito importante para entender o Compose. Pegamos um elemento que pode ser composto com estado, TodoItemInput
, e o dividimos em dois elementos. Um com estado (TodoItemEntryInput
) e outro sem (TodoItemInput
).
O elemento que pode ser composto sem estado tem todo o código relacionado à IU, e o elemento com estado não tem nenhum código relacionado à IU. Ao fazer isso, tornamos o código da IU reutilizável em situações em que queremos armazenar o estado de outra forma.
Executar o aplicativo novamente
Execute o aplicativo "TODO" novamente para confirmar que a entrada de novas tarefas ainda funciona.
Parabéns, você extraiu com sucesso um elemento que pode ser composto sem estado de um elemento com estado sem mudar a API dele.
Na próxima seção, veremos como isso pode ser usado para reutilizar a lógica da IU em diferentes locais sem vincular a IU ao estado.
9. Usar estado no ViewModel
Analisando a simulação interativa neomoderna do nosso designer, vai ser necessário adicionar um estado para representar o item de edição atual.
Simulação do modo de edição
Agora, precisamos decidir onde adicionar o estado para esse editor. Nós podemos criar outro "TodoRowOrInlineEditor
" que pode ser composto que gerencia a exibição ou edição de um item, mas só queremos mostrar um editor de cada vez. Analisando o design de perto, a seção de cima também muda no modo de edição. Por isso, faremos uma elevação do estado para permitir que ele seja compartilhado.
Árvore de estado para TodoActivity
Como ambos os elementos TodoItemEntryInput
e TodoInlineEditor
precisam saber sobre o estado atual do editor para ocultar a entrada na parte de cima da tela, precisamos elevar o estado para no mínimo TodoScreen
. A tela é o elemento de nível mais baixo que pode ser composto na hierarquia, além de ser o pai comum de cada elemento que precisa ser editado.
No entanto, como o editor é derivado e vai modificar a lista, ele precisa estar localizado ao lado dela. Queremos elevar o estado para o nível em que ele pode ser modificado. Vamos adicionar a lista em TodoViewModel
porque é lá que ela precisa ficar.
Converter o TodoViewModel para usar mutableStateListOf
Nesta seção, você vai adicionar o estado do editor em TodoViewModel
e, na próxima seção, vai usá-lo para criar um editor in-line.
Ao mesmo tempo, vamos conhecer mais sobre o uso de mutableStateListOf
em um ViewModel
e ver como ele simplifica o código de estado em comparação com LiveData<List>
ao direcionar o app para o Compose.
mutableStateListOf
nos permite criar uma instância observável de MutableList
. Isso significa que podemos trabalhar com todoItems da mesma forma que trabalhamos com uma MutableList, removendo a sobrecarga de trabalhar com LiveData<List>
.
Abra TodoViewModel.kt
e substitua o elemento todoItems
existente por um mutableStateListOf
:
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
class TodoViewModel : ViewModel() {
// remove the LiveData and replace it with a mutableStateListOf
//private var _todoItems = MutableLiveData(listOf<TodoItem>())
//val todoItems: LiveData<List<TodoItem>> = _todoItems
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// event: addItem
fun addItem(item: TodoItem) {
todoItems.add(item)
}
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
}
}
A declaração de todoItems
é curta e captura o mesmo comportamento que a versão de LiveData
.
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
Ao especificar private set
, estamos restringindo as gravações a esse objeto de estado a um setter privado visível apenas dentro do ViewModel
.
Atualizar a TodoActivityScreen para usar o novo ViewModel
Abra TodoActivity.kt
e atualize TodoActivityScreen
para usar o novo ViewModel
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem
)
}
Execute o app novamente para ver se ele funciona com o novo ViewModel. Você mudou o estado para usar mutableStateListOf
. Agora, vamos ver como criar um estado do editor.
Definir o estado do editor
Agora é hora de adicionar um estado ao nosso editor. Para evitar a duplicação do texto da tarefa, vamos editar a lista diretamente. Para isso, em vez de manter o texto atual que estamos editando, vamos manter um índice de lista com o item atual do editor.
Abra TodoViewModel.kt
e adicione um estado de editor.
Defina uma nova private var currentEditPosition
que armazena a posição de edição atual. Ela vai conter o índice da lista que estamos editando.
Em seguida, exponha o currentEditItem
para fazer a composição usando um getter. Mesmo que essa seja uma função comum do Kotlin, a currentEditPosition
é observável para o Compose, assim como um State<TodoItem>
.
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class TodoViewModel : ViewModel() {
// private state
private var currentEditPosition by mutableStateOf(-1)
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// state
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
// ..
Sempre que uma função de composição chamar currentEditItem
, ela vai observar mudanças em todoItems
e currentEditPosition
. Se uma delas for modificada, o elemento que pode ser composto vai chamar o getter novamente para receber o novo valor.
Definir eventos do editor
Definimos nosso estado de editor. Agora, vamos definir os eventos que os elementos que podem ser compostos podem chamar para controlar a edição.
Crie três eventos: onEditItemSelected(item: TodoItem)
, onEditDone()
e onEditItemChange(item: TodoItem)
.
Os eventos onEditItemSelected
e onEditDone
apenas mudam a currentEditPosition
. Ao mudar a currentEditPosition
, o Compose recompõe qualquer elemento que pode ser composto que lê currentEditItem
.
TodoViewModel.kt
class TodoViewModel : ViewModel() {
...
// event: onEditItemSelected
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
// event: onEditDone
fun onEditDone() {
currentEditPosition = -1
}
// event: onEditItemChange
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
O evento onEditItemChange
atualiza a lista em currentEditPosition
. Isso vai mudar os valores retornados por currentEditItem
e todoItems
ao mesmo tempo. Antes de fazer isso, há algumas verificações de segurança para garantir que o autor da chamada não esteja tentando gravar o item errado.
Encerrar a edição ao remover itens
Atualize o evento removeItem
para fechar o editor atual quando um item for removido.
TodoViewModel.kt
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
Executar o app novamente
Pronto. Você atualizou o ViewModel
para usar o MutableState
e viu como ele pode simplificar o código do estado observável.
Na próxima seção, vamos adicionar um teste para esse ViewModel
e, depois, criar a IU de edição.
Como houve muitas edições nesta seção, veja uma lista completa do TodoViewModel
após a aplicação de todas as mudanças:
TodoViewModel.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class TodoViewModel : ViewModel() {
private var currentEditPosition by mutableStateOf(-1)
var todoItems = mutableStateListOf<TodoItem>()
private set
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
fun addItem(item: TodoItem) {
todoItems.add(item)
}
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
fun onEditDone() {
currentEditPosition = -1
}
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
10. Estado de teste no ViewModel
É recomendável testar o ViewModel
para verificar se a lógica do aplicativo está correta. Nesta seção, vamos criar um teste para mostrar como testar um modelo de visualização usando State<T>
para o estado.
Adicionar um teste a TodoViewModelTest
Abra TodoViewModelTest.kt
no diretório test/
e adicione um teste para remover um item:
TodoViewModelTest.kt
import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class TodoViewModelTest {
@Test
fun whenRemovingItem_updatesList() {
// before
val viewModel = TodoViewModel()
val item1 = generateRandomTodoItem()
val item2 = generateRandomTodoItem()
viewModel.addItem(item1)
viewModel.addItem(item2)
// during
viewModel.removeItem(item1)
// after
assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
}
}
Este código mostra como testar o State<T>
que é diretamente modificado por eventos. Na seção anterior, ele cria um novo ViewModel
e adiciona dois itens a todoItems
.
O método que estamos testando é removeItem
, que remove o primeiro item da lista.
Por fim, usamos declarações "Truth" para declarar que a lista contém apenas o segundo item.
Não temos que fazer nenhum trabalho extra para ler todoItems
em um teste se as atualizações tiverem sido causadas diretamente pelo teste, como estamos fazendo aqui chamando removeItem
. Ele é apenas uma List<TodoItem>
.
O restante dos testes para este ViewModel
segue o mesmo padrão básico. Por isso, ele vai ser ignorado como exercício neste codelab. Você pode adicionar mais testes ao ViewModel
para confirmar se ele funciona ou abrir o TodoViewModelTest
no módulo concluído para ver mais testes.
Na próxima seção, vamos adicionar o novo modo de edição à IU.
11. Reutilizar elementos sem estado que podem ser compostos
Finalmente, estamos prontos para implementar nosso design interativo neomoderno. Lembre-se que estamos tentando criar algo assim:
Simulação do modo de edição
Transmitir o estado e os eventos para a TodoScreen
Concluímos a definição completa do estado e dos eventos necessários para essa tela no TodoViewModel. Agora, vamos atualizar a TodoScreen e ver o estado e os eventos necessários para exibir a tela.
Abra TodoScreen.kt
e mude a assinatura de TodoScreen
para adicionar:
- O item que está sendo editado no momento:
currentlyEditing: TodoItem?
- Os três novos eventos:
onStartEdit: (TodoItem) -> Unit
, onEditItemChange: (TodoItem) -> Unit
e onEditDone: () -> Unit
.
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
// ...
}
Esses são apenas o novo estado e o evento que definimos no ViewModel
.
Em seguida, no TodoActivity.kt
, transmita os novos valores à TodoActivityScreen
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
currentlyEditing = todoViewModel.currentEditItem,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem,
onStartEdit = todoViewModel::onEditItemSelected,
onEditItemChange = todoViewModel::onEditItemChange,
onEditDone = todoViewModel::onEditDone
)
}
Isso transmite apenas o estado e os eventos que a nova TodoScreen
exige.
Definir um editor in-line que pode ser composto
Crie um novo elemento que pode ser composto em TodoScreen.kt
, que usa a TodoItemInput
que pode ser composta sem estado para definir um editor in-line.
TodoScreen.kt
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true
)
Esse elemento que pode ser composto é sem estado. Ele exibe apenas o item
transmitido e usa os eventos para solicitar a atualização do estado. Como extraímos um elemento TodoItemInput
que pode ser composto sem estado antes, podemos usá-lo facilmente nesse contexto sem estado.
Este exemplo mostra a reutilização de elementos que podem ser compostos sem estado. Mesmo que o cabeçalho use uma TodoItemEntryInput
com estado na mesma tela, podemos elevar o estado até o ViewModel
do editor in-line.
Usar o editor in-line em LazyColumn
Na LazyColumn
da TodoScreen
, mostre o TodoItemInlineEditor
se o item atual estiver sendo editado. Caso contrário, mostre a TodoRow
.
Além disso, comece a editar ao clicar em um item, em vez de o remover como antes.
TodoScreen.kt
// fun TodoScreen()
// ...
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp)
) {
items(items) { todo ->
if (currentlyEditing?.id == todo.id) {
TodoItemInlineEditor(
item = currentlyEditing,
onEditItemChange = onEditItemChange,
onEditDone = onEditDone,
onRemoveItem = { onRemoveItem(todo) }
)
} else {
TodoRow(
todo,
{ onStartEdit(it) },
Modifier.fillParentMaxWidth()
)
}
}
}
// ...
O elemento que pode ser composto LazyColumn
é o equivalente do Compose a uma RecyclerView
. Esse elemento só recompõe os itens na lista necessários para exibir a tela atual. À medida que o usuário rola, os elementos que saíram da tela são descartados e novos elementos são criados para a rolagem.
Experimentar o novo editor interativo
Execute o app novamente. Quando você clicar em uma linha de tarefas do app "Todo", o editor interativo vai ser aberto.
Estamos usando a mesma IU sem estado que pode ser composta para desenhar o cabeçalho com estado e a experiência de edição interativa. E não introduzimos nenhum estado duplicado ao fazer isso.
Esse recurso já está começando a tomar forma, mas o botão "Add" parece estar fora do lugar, e precisamos mudar o cabeçalho. Vamos concluir o design nas próximas etapas.
Trocar o cabeçalho ao editar
Em seguida, vamos concluir o design do cabeçalho e ver como trocar o botão pelos botões de emojis que o designer quer para o design interativo neomoderno.
Volte para o elemento que pode ser composto TodoScreen
e faça o cabeçalho responder às mudanças no estado do editor. Se currentlyEditing
for null
, vamos mostrar TodoItemEntryInput
e transmitir elevation = true
para TodoItemInputBackground
. Se currentlyEditing
não for null
, transmita elevation = false
para TodoItemInputBackground
e exiba o texto "Editing item" (editando item) no mesmo plano de fundo.
TodoScreen.kt
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
Column {
val enableTopSection = currentlyEditing == null
TodoItemInputBackground(elevate = enableTopSection) {
if (enableTopSection) {
TodoItemEntryInput(onAddItem)
} else {
Text(
"Editing item",
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(16.dp)
.fillMaxWidth()
)
}
}
// ..
Novamente, estamos mudando a árvore do Compose na recomposição. Quando a parte de cima é ativada, mostramos a TodoItemEntryInput
. Caso contrário, mostramos um Text
que pode ser composto e com o texto "Editing item" (editando item).
O TodoItemInputBackground
que estava no código inicial anima automaticamente o redimensionamento e também as mudanças de elevação. Assim, quando você entrar no modo de edição, esse código vai ser animado automaticamente entre os estados.
Executar o app novamente
Execute o app novamente para ver que ele é animado entre os estados de edição e não edição. O design está quase concluído.
Na próxima seção, vamos ver como estruturar o código dos botões de emoji.
12. Usar slots para transmitir seções da tela
Elementos que podem ser compostos sem estado que exibem IUs complexas podem acabar com muitos parâmetros. Se não forem muitos parâmetros e se eles configurarem diretamente o elemento que pode ser composto, tudo bem. No entanto, às vezes você precisa transmitir parâmetros para configurar os filhos de um elemento que pode ser composto.
No design interativo neomoderno, o designer quer que o botão "Add" (adicionar) fique na parte de cima, mas ele precisa ser trocado por dois botões "emoji" para o editor in-line. Nós poderíamos adicionar mais parâmetros à TodoItemInput
para lidar com esses casos, mas não está claro se isso é realmente a responsabilidade da TodoItemInput
.
O que precisamos é uma maneira de um elemento que pode ser composto usar uma seção de botões pré-configurada. Com isso, o autor da chamada vai poder configurar os botões sem precisar compartilhar todo o estado necessário para os configurar com a TodoItemInput
.
Isso vai reduzir o número de parâmetros transmitidos a elementos que podem ser compostos sem estado e os tornará mais reutilizáveis.
O padrão para transmitir uma seção pré-configurada é o de slots. Slots são parâmetros de um elemento que pode ser composto que permitem que o autor da chamada descreva uma seção da tela. Você vai ver exemplos de slots em todas as APIs integradas que podem ser compostas. Um dos exemplos mais usados é Scaffold
.
Scaffold
é o elemento que pode ser composto para descrever uma tela inteira no Material Design, como topBar
, bottomBar
e o corpo da tela.
Em vez de fornecer centenas de parâmetros para configurar cada seção da tela, o Scaffold
expõe os slots que você pode preencher com qualquer função de composição. Isso reduz o número de parâmetros para Scaffold
e facilita a reutilização. Se você quiser criar uma topBar
personalizada, ela vai ser mostrada pelo Scaffold
.
@Composable
fun Scaffold(
// ..
topBar: @Composable (() -> Unit)? = null,
bottomBar: @Composable (() -> Unit)? = null,
// ..
bodyContent: @Composable (PaddingValues) -> Unit
) {
Definir um slot no TodoItemInput
Abra TodoScreen.kt
e defina um novo parâmetro @Composable () -> Unit
com o nome buttonSlot
na TodoItemInput
.
TodoScreen.kt
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable () -> Unit
) {
// ...
Esse é um slot genérico que o autor da chamada pode preencher com os botões desejados. Ele vai ser usado para especificar botões diferentes para os cabeçalhos e os editores in-line.
Exibir o conteúdo de buttonSlot
Substitua a chamada de TodoEditButton
pelo conteúdo do slot.
TodoScreen.kt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable() () -> Unit,
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
// New code: Replace the call to TodoEditButton with the content of the slot
Spacer(modifier = Modifier.width(8.dp))
Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }
// End new code
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Podemos chamar buttonSlot()
diretamente, mas precisamos manter o align
para centralizar o que o autor da chamada nos transmite verticalmente. Para fazer isso, colocamos o slot em uma Box
, que é um elemento que pode ser composto básico.
Atualizar a TodoItemEntryInput
com estado para usar o slot
Agora, precisamos atualizar os autores das chamadas para usar o buttonSlot
. Primeiro, vamos atualizar a TodoItemEntryInput
:
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, onTextChange) = remember { mutableStateOf("") }
val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}
val submit = {
if (text.isNotBlank()) {
onItemComplete(TodoItem(text, icon))
onTextChange("")
onIconChange(TodoIcon.Default)
}
}
TodoItemInput(
text = text,
onTextChange = onTextChange,
icon = icon,
onIconChange = onIconChange,
submit = submit,
iconsVisible = text.isNotBlank()
) {
TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
}
}
Como o buttonSlot
é o último parâmetro para a TodoItemInput
, podemos usar a sintaxe de lambda final. Em seguida, na lambda, chame TodoEditButton
como fizemos antes.
Atualizar o TodoItemInlineEditor
para usar o slot
Para concluir a refatoração, mude TodoItemInlineEditor
para usar o slot também:
TodoScreen.kt
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true,
buttonSlot = {
Row {
val shrinkButtons = Modifier.widthIn(20.dp)
TextButton(onClick = onEditDone, modifier = shrinkButtons) {
Text(
text = "\uD83D\uDCBE", // floppy disk
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
Text(
text = "❌",
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
}
}
)
Aqui, transmitimos o buttonSlot
como um parâmetro nomeado. Em seguida, em buttonSlot
, criamos uma linha contendo os dois botões para o design do editor in-line.
Executar o app novamente
Execute o app novamente e teste o editor in-line.
Nesta seção, personalizamos nosso elemento que pode ser composto sem estado usando um slot, o que permitiu que o autor da chamada controlasse uma seção da tela. Ao usar slots, evitamos acoplar a TodoItemInput
a todos os designs diferentes que podem ser adicionados no futuro.
Quando você perceber que está adicionando parâmetros a elementos que podem ser compostos sem estado para personalizar os filhos, avalie se o uso de slots seria melhor. Os slots tendem a tornar os elementos que podem ser compostos mais reutilizáveis, mantendo o número de parâmetros gerenciáveis.
13. Parabéns
Parabéns! Você concluiu este codelab e aprendeu a estruturar o estado usando o fluxo de dados unidirecional em um app do Jetpack Compose.
Você aprendeu a pensar em estados e eventos para extrair elementos sem estado que podem ser compostos no Compose e viu como reutilizar um elemento complexo em diferentes situações na mesma tela. Você também aprendeu a integrar um ViewModel ao Compose usando o LiveData e o MutableState.
Qual é a próxima etapa?
Confira os outros codelabs no programa de aprendizagem do Compose:
Apps de exemplo (link em inglês)
- O JetNews mostra como usar um fluxo de dados unidirecional para elementos que podem ser compostos com estado e gerenciar o estado deles em uma tela criada com elementos sem estado.