Estado e Jetpack Compose

O estado em um app é qualquer valor que pode mudar ao longo do tempo. Essa é uma definição muito ampla e compreende tudo, de um banco de dados da Room até uma variável de classe.

Todos os apps Android exibem o estado para o 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ção de ripple em botões que são reproduzidas quando um usuário clica neles.
  • Adesivos que podem ser desenhados sobre uma imagem.

O Jetpack Compose ajuda você a especificar onde e como você armazena e usa o estado em um app Android. Este guia se concentra na conexão entre o estado e os elementos compostos, bem como nas APIs que o Jetpack Compose oferece para trabalhar mais facilmente com o estado.

Estado no Compose

O conceito de estado está na essência do Compose. Pense em um exemplo simples: uma tela em que o usuário pode inserir o nome, e uma saudação é exibida como resposta. O código a seguir inclui texto para a saudação e um campo de texto para entrada do nome:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

Se você executar esse código, verá que nada acontece. Isso ocorre porque o TextField não atualiza a si mesmo. Ele é atualizado quando o parâmetro value muda. Isso acontece devido à maneira como a composição e a recombinação funcionam no Compose.

Composição e recomposição

Uma composição descreve a IU e é produzida pela execução de elementos que podem ser compostos. Uma composição é uma estrutura em árvore dos elementos compostos que descrevem sua IU.

Durante a composição inicial, o Jetpack Compose acompanhará os elementos que você chama para descrever sua IU em uma composição. Depois, quando o estado do app mudar, o Jetpack Compose programará a recomposição. A recomposição executa os elementos compostos que podem ter mudado em resposta a modificações de estado, e o Jetpack Compose atualiza a composição para refletir as mudanças.

Uma composição só pode ser produzida por uma composição inicial e atualizada por recomposição. A única maneira de modificar uma composição é pela recomposição.

Para saber mais sobre a composição inicial e a recomposição, consulte Trabalhando com o Compose.

Como introduzir o estado

Para atualizar o elemento que pode ser composto, transmita um valor que represente o estado de TextField e adicione código para atualizar o estado quando o valor de TextField mudar.

Para introduzir um estado local que contenha o nome a ser exibido, use remember { mutableStateOf() }, transmitindo o valor padrão para o texto. Dessa forma, sempre que o estado name mudar, o valor exibido por TextField também será alterado.

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        Text(
            text = "Hello",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

As funções que podem ser compostas podem armazenar um único objeto na memória usando o elemento remember. Um valor calculado por remember é armazenado na composição durante a composição inicial, e o valor armazenado é retornado durante a recomposição. É possível usar remember para armazenar objetos mutáveis e imutáveis.

O mutableStateOf cria um MutableState, que é um tipo observável no Compose. Qualquer mudança no valor dele programará a recomposição de qualquer função composta que leia esse valor.

remember ajuda a preservar o estado nas recomposições. Se você usar mutableStateOf sem usar remember, o estado será reinicializado como uma string vazia sempre que o elemento HelloContent passar por recomposição.

É possível usar o valor salvo como parâmetro para outros elementos ou mesmo como lógica em instruções para mudar quais elementos são exibidos. Por exemplo, se você não quiser exibir a saudação se o nome estiver vazio, use o estado em uma instrução if:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

Embora remember ajude a manter o estado em recomposições, o estado não é mantido em todas as mudanças de configuração. Para isso, use rememberSaveable. O rememberSaveable salva automaticamente qualquer valor que possa ser salvo em um Bundle. Para outros valores, é possível transmitir um objeto de economia personalizado.

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by rememberSaveable { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.h5
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

Elementos que podem ser compostos sem estado

Quando um elemento tem o próprio estado, como no exemplo acima, isso dificulta a reutilização e o teste, além de manter esse elemento rigidamente associado à forma como o estado é armazenado. Em vez disso, transforme-o um elemento que pode ser composto sem estado, ou seja, um elemento que não contenha nenhum estado.

Para fazer isso, você pode usar a elevação de estado. A elevação de estado é um padrão de programação em que você move o estado de um elemento que pode ser composto para o autor da chamada dele. Uma maneira simples de fazer isso é substituir o estado por um parâmetro e usar lambdas para representar eventos.

No exemplo, o name e o onValueChange são extraídos de HelloContent e movidos para cima na árvore a um elemento HelloScreen composto que chama HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

HelloContent tem acesso ao estado como um parâmetro String imutável, bem como um onNameChange lambda que ele pode chamar quando quiser solicitar a mudança de estado.

Lambdas são a maneira mais comum de descrever eventos em um elemento que pode ser composto. Neste exemplo, um evento chamado onNameChange é definido usando um lambda que aceita um String com a sintaxe de tipo de função do Kotlin: (String) -> Unit. O lambda é chamado de onNameChange no presente, porque o evento não significa que o estado já foi modificado, mas que o elemento que pode ser composto está solicitando que o manipulador de eventos o modifique.

Ao elevar o estado de HelloContent, é mais fácil fundamentar o elemento, reutilizá-lo em situações diferentes e fazer testes. O HelloContent está dissociado do modo como o estado é armazenado. Isso significa que, se você modificar ou substituir HelloScreen, não precisará mudar a forma como o HelloContent é implementado.

O padrão em que o estado desce e os eventos sobem é chamado de fluxo de dados unidirecional. Nesse caso, o estado desce de HelloScreen para HelloContent e os eventos sobem de HelloContent para HelloScreen. Ao seguir o fluxo de dados unidirecional, você pode dissociar os elementos que podem ser compostos que exibem o estado na IU das partes do app que o armazenam e mudam.

ViewModel e estado

No Jetpack Compose, você pode usar o ViewModel para expor o estado em um suporte observável (como LiveData ou Flow) e também processar eventos que afetam esse estado. O exemplo de HelloScreen acima seria implementado usando um ViewModel como este:

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

O observeAsState observa um LiveData<T> e retorna um objeto State<T> que é atualizado sempre que o LiveData muda. O State<T> é um tipo observável que o Jetpack Compose pode usar diretamente. O observeAsState observará o LiveData somente quando ele estiver na composição.

A linha:

val name: String by helloViewModel.name.observeAsState("")

... é o intervalo sintático para decodificar automaticamente o objeto de estado retornado por observeAsState. Também é possível atribuir o objeto de estado usando um operador de atribuição (=), que o transforma em um State<String>, em vez de um String:

val nameState: State<String> = helloViewModel.name.observeAsState("")

O HelloViewModel e o HelloScreen seguem o padrão de fluxo de dados unidirecional, em que o estado sai de HelloViewModel e os eventos saem de HelloScreen.

O estado e o fluxo de eventos entre HelloInput, HelloScreen e HelloViewModel

Considere o loop de evento de IU para esta tela:

  1. Evento: o onValueChange é chamado em resposta à digitação de um caractere pelo usuário.
  2. Estado de atualização: o HelloViewModel.onNameChange gerencia o processamento e define o estado do elemento mutável LiveData como _name.
  3. Estado de exibição: o valor de HelloViewmodel.name muda, o que é observado pelo Compose em observeAsState. Depois, o HelloScreen é executado novamente (ou faz a recomposição) para descrever a IU com base no novo valor de name.

Consulte Como arquitetar a IU do Compose para saber mais sobre como implementar o fluxo de dados unidirecional usando o ViewModel e o Jetpack Compose.

Como usar remember

As funções que podem ser compostas podem armazenar um único objeto na memória usando o elemento remember. Um valor calculado por remember é armazenado durante a composição inicial, e o valor armazenado é retornado durante a recomposição. O remember pode ser usado para armazenar objetos mutáveis e imutáveis.

Usar remember para armazenar valores imutáveis

É possível armazenar valores imutáveis ao armazenar em cache operações da IU de alto custo, como o cálculo da formatação de texto. O valor memorizado é armazenado na composição com o elemento que pode ser composto que chamou o remember.

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    /*...*/
}
Composição de FancyText com formattedText como filho

Usar remember para criar um estado interno em um elemento que pode ser composto

Ao armazenar um objeto mutável usando o remember, você adiciona o estado a um elemento que pode ser composto. É possível usar essa abordagem para criar um estado interno para um único elemento que pode ser composto com estado.

É altamente recomendável que todo estado mutável usado por elementos que podem ser compostos seja observável. Isso permite que o Compose recomponha automaticamente sempre que o estado mudar. O Compose vem com um tipo State<T> observável integrado, que é diretamente integrado ao ambiente de execução dele.

Um bom exemplo de estado interno em um elemento que pode ser composto é o movimento de expandir e recolher de um ExpandingCard quando o usuário clica em um botão.

O elemento que pode ser composto ExpandedCard em animação entre os estados recolhido e expandido

Esse elemento que pode ser composto tem um estado importante: expanded. Quando estiver no estado expanded, o elemento mostrará o corpo, mas o ocultará quando estiver recolhido.

Composição de ExpandingCard com o estado expanded como filho

É possível adicionar um estado expanded a um elemento que pode ser composto. Para isso, lembre-se de mutableStateOf(initialValue).

@Composable
fun ExpandingCard(title: String, body: String) {
    // expanded is "internal state" for ExpandingCard
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                // TODO: show body & collapse icon
            } else {
                // TODO: show expand icon
            }
        }
    }
}

mutableStateOf cria um MutableState<T> observável, que é integrado ao ambiente de execução do Compose.

interface MutableState<T> : State<T> {
   override var value: T
}

Qualquer mudança em value programará a recomposição de qualquer função que pode ser composta que leia value. No caso de ExpandingCard, sempre que expanded é mudado, isso faz com que ExpandingCard seja recomposto.

Há três maneiras de declarar um objeto MutableState em um elemento que pode ser composto:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Essas declarações são equivalentes e são fornecidas como açúcar de sintaxe para diferentes usos do estado. É preciso escolher aquela que produz o código mais fácil de ler no elemento que pode ser composto que você está escrevendo.

É possível usar o valor do estado interno em um elemento que pode ser composto como um parâmetro para outro elemento ou até mesmo mudar quais elementos são chamados. Em ExpandingCard, uma declaração "if" mudará o conteúdo do cartão com base no valor atual de expanded.

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

Modificar o estado interno em um elemento que pode ser composto

O estado precisa ser modificado por eventos em um elemento que pode ser composto. Se você modificar o estado ao executar um elemento que pode ser composto em vez de em um evento, esse será um efeito colateral do elemento, o que precisará ser evitado. Para ver mais informações sobre efeitos colaterais no Jetpack Compose, consulte Trabalhando com o Compose.

Para concluir o elemento que pode ser composto ExpandingCard, exibiremos o body e um botão de recolher quando expanded for true e um botão de expansão quando expanded for false.

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                Text(text = body, Modifier.padding(top = 8.dp))
                // change expanded in response to click events
                IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                // change expanded in response to click events
                IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

Nesse elemento que pode ser composto, o estado é modificado em resposta a eventos onClick. Como expanded está usando var com a sintaxe de delegação de propriedade (link em inglês), os callbacks onClick podem atribuir expanded diretamente.

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

Agora, podemos descrever o loop de atualização da IU para ExpandingCard para ver como o estado interno é modificado e usado pelo Compose.

  1. Evento: onClick é chamado em resposta ao toque do usuário em um dos botões.
  2. Estado de atualização: expanded é mudado no listener onClick usando a atribuição.
  3. Estado de exibição: ExpandingCard faz a recomposição porque expanded é o State<Boolean> que foi modificado, e ExpandingCard o lê na linha if(expanded). Em seguida, ExpandingCard descreve a tela para o novo valor de expanded.

Usar outros tipos de estado no Jetpack Compose

O Jetpack Compose não exige que você use MutableState<T> para manter o estado. Ele é compatível com outros tipos observáveis. Antes de ler outro tipo observável no Jetpack Compose, você precisa convertê-lo em um State<T> para que o Jetpack Compose possa fazer automaticamente a recomposição quando o estado for modificado.

O Compose é enviado com funções para criar State<T> a partir de tipos observáveis comuns usados em apps Android:

Você pode criar uma função de extensão para o Jetpack Compose para ler outros tipos observáveis se o app usar uma classe observável personalizada. Consulte a implementação dos builtins para ver exemplos de como fazer isso. Qualquer objeto que permita que o Jetpack Compose faça a inscrição em todas as mudanças pode ser convertido em State<T> e lido por um elemento que pode ser composto.

Separar o estado interno dos elementos de IU que podem ser compostos

O ExpandingCard na última seção tem o estado interno. Como resultado, o autor da chamada não pode controlar o estado. Isso significa, por exemplo, que se você quiser iniciar um ExpandingCard no estado expandido, não será possível fazer isso. Também não é possível fazer o cartão se expandir em resposta a outro evento, como o clique do usuário em um Fab. Isso também significa que se você quiser mover o estado expanded para um ViewModel, isso não será possível.

Por outro lado, usando o estado interno em ExpandingCard, um autor de chamada que não precisa controlar ou elevar o estado pode usá-lo sem precisar gerenciá-lo.

Ao desenvolver elementos que podem ser compostos reutilizáveis, frequentemente você quer expor uma versão com estado e uma sem estado do mesmo elemento. A versão com estado é conveniente para autores de chamadas que não se importam com ele, e a sem estado é necessária para autores de chamada que precisam controlar ou elevar o estado.

Para fornecer as interfaces com e sem estado, extraia um elemento que pode ser composto sem estado que exiba a IU usando uma elevação de estado.

Observe que os dois elementos são chamados ExpandingCard, mesmo que tenham parâmetros diferentes. A convenção de nomenclatura para elementos que podem ser compostos que emitem a IU é um substantivo com iniciais maiúsculas que descreve o que o elemento representa na tela. Nesse caso, ambos representam um ExpandingCard. Essa convenção de nomenclatura é aplicada em todas as bibliotecas do Compose, como em TextField e TextField.

Este ExpandingCard é dividido em elementos que podem ser compostos com e sem estado:

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    ExpandingCard(
        title = title,
        body = body,
        expanded = expanded,
        onExpand = { expanded = true },
        onCollapse = { expanded = false }
    )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
    title: String,
    body: String,
    expanded: Boolean,
    onExpand: () -> Unit,
    onCollapse: () -> Unit
) {
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(title)
            if (expanded) {
                Spacer(Modifier.height(8.dp))
                Text(body)
                IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

A elevação de estado no Compose é um padrão para mover um estado para o autor da chamada e transformar um elemento que pode ser composto sem estado. O padrão geral para elevação de estado no Jetpack Compose é substituir a variável por dois parâmetros:

  • value: T: o valor atual a ser exibido.
  • onValueChange: (T) -> Unit: um evento que solicita a mudança do valor, em que T é o novo valor proposto.

No entanto, você não se limita a onValueChange. Se eventos mais específicos forem apropriados para o elemento que pode ser composto, defina-os usando lambdas da mesma forma que ExpandingCard faz com onExpand e onCollapse.

O estado elevado dessa maneira tem algumas propriedades importantes:

  • Fonte única da verdade: ao mover o estado em vez de duplicá-lo, garantimos que existe apenas uma fonte de verdade para expanded. Isso ajuda a evitar bugs.
  • Encapsulado: somente o ExpandingCard com estado poderá modificar o estado. É totalmente interno.
  • Compartilhável: o estado elevado pode ser compartilhado com vários elementos que podem ser compostos. Digamos que queremos ocultar um botão Fab quando o Card for expandido. A elevação poderia nos permitir fazer isso.
  • Interceptável: os autores de chamadas para ExpandingCard sem estado podem decidir ignorar ou modificar eventos antes de mudar o estado.
  • Desacoplado: o estado de ExpandingCard sem estado pode ser armazenado em qualquer lugar. Por exemplo, agora é possível mover title, body e expanded para um ViewModel.

A hospedagem feita dessa forma também segue o fluxo de dados unidirecional. O estado é transmitido para baixo do elemento que pode ser composto com estado, e os eventos fluem para cima do elemento sem estado.

Diagrama de fluxo de dados unidirecional para ExpandingCard com e sem estado

Restaurar o estado da IU após recriação de atividade ou processo

Use o rememberSaveable para restaurar o estado da IU após a recriação de uma atividade ou processo. O rememberSaveable mantém o estado nas recomposições. Além disso, o rememberSaveable também mantém o estado nas recriações de atividades e de processos.

@Composable
fun MyExample() {
    var selectedId by rememberSaveable<String?> { mutableStateOf(null) }
    /*...*/
}

Todos os tipos de dados adicionados ao Bundle são salvos automaticamente. Caso você queira salvar algo que não possa ser adicionado ao Bundle, há várias opções.

A solução mais simples é adicionar a anotação @Parcelize ao objeto. O objeto se tornará parcelable e poderá ser empacotado. Por exemplo, esse código cria um tipo de dados parcelable City e o salva no estado.

@Parcelize
data class City(val name: String, val country: String)

@Composable
fun MyExample() {
  var selectedCity = rememberSaveable { mutableStateOf(City("Madrid", "Spain")) }
}

Se, por algum motivo, @Parcelize não for adequado, use mapSaver para definir sua própria regra de conversão de um objeto em um conjunto de valores que o sistema poderá salvar em Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, nameKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
}

Para evitar a necessidade de definir as chaves do mapa, você também pode usar listSaver e usar seus índices como chaves:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
    /*...*/
}

Saiba mais

Para saber mais sobre o estado e o Jetpack Compose, consulte Como usar estados no codelab do Jetpack Compose.