1. Introdução
Neste codelab, você vai aprender conceitos avançados relacionados às APIs State e Side Effects no Jetpack Compose. Você aprenderá a criar um detentor de estado para elementos combináveis com estado que tenham uma lógica não trivial, criar corrotinas e chamar funções de suspensão no código do Compose, assim como acionar efeitos colaterais para diferentes casos de uso.
Para receber mais informações durante este codelab, confira as orientações neste vídeo (em inglês):
O que você vai aprender
- Como observar fluxos de dados do código do Compose para atualizar a interface.
- Como criar um detentor de estado para elementos combináveis com estado.
- APIs de efeitos colaterais, como
LaunchedEffect
.rememberUpdatedState
,DisposableEffect
,produceState
ederivedStateOf
. - Como criar corrotinas e chamar funções de suspensão em elementos combináveis usando a API
rememberCoroutineScope
.
O que é necessário
- Versão mais recente do Android Studio.
- Experiência com a sintaxe do Kotlin, incluindo lambdas.
- Experiência básica com o Compose. É recomendável fazer o codelab de Noções básicas do Jetpack Compose antes deste.
- Conceitos básicos de estado no Compose, como fluxo de dados unidirecional (UDF, na sigla em inglês), ViewModels, elevação de estado, elementos combináveis sem estado/com estado, APIs de slot e as APIs de estado
remember
emutableStateOf
. Para ter esse conhecimento, leia a documentação Estado e Jetpack Compose ou conclua o codelab Usar estado no Jetpack Compose. - Conhecimento básico de corrotinas de Kotlin.
- Conhecimentos básicos sobre o ciclo de vida de elementos combináveis.
O que você vai criar
Neste codelab, você vai começar com um aplicativo inacabado, o app Crane Material Study, e adicionar recursos para melhorá-lo.
2. Etapas da configuração
Acessar o código
O código deste codelab pode ser encontrado no repositório android-compose-codelabs do GitHub (link em inglês). Para cloná-lo, execute:
$ git clone https://github.com/android/codelab-android-compose
Se preferir, faça o download do repositório como um arquivo ZIP:
Conferir o app de exemplo
Você fez o download de um arquivo com código para todos os codelabs disponíveis do Compose. Para concluir este codelab, abra o projeto AdvancedStateAndSideEffectsCodelab
no Android Studio.
Recomendamos que você comece com o código na ramificação principal (main) e siga todas as etapas do codelab no seu ritmo.
Durante o codelab, vamos ensinar os snippets de código que precisam ser adicionados ao projeto. Em alguns locais, também vai ser necessário remover o código que é explicitamente mencionado nos comentários dos snippets de código.
Conhecer o código e executar o app de exemplo
Reserve um tempo para explorar a estrutura do projeto e executar o app.
Ao executar o app na ramificação main, você vai notar que algumas funcionalidades, como a gaveta ou o carregamento de destinos de voos, não funcionam. Isso é o que você vai fazer nas próximas etapas do codelab.
Testes de interface
O app tem a cobertura de testes de interface muito básicos disponíveis na pasta androidTest
. Eles sempre precisam ser aplicados nas ramificações main
e end
.
[Opcional] Mostrar o mapa na tela de detalhes
Não é necessário exibir o mapa da cidade na tela de detalhes para acompanhar o codelab. No entanto, se você quiser ter acesso a ele, será necessário conseguir uma chave de API pessoal, conforme descrito na documentação do Google Maps. Inclua essa chave no arquivo local.properties
da seguinte maneira:
// local.properties file
google.maps.key={insert_your_api_key_here}
Solução para o codelab
Para conseguir a ramificação end
pelo git, use o seguinte comando:
$ git clone -b end https://github.com/android/codelab-android-compose
Como alternativa, faça o download do código da solução aqui:
Perguntas frequentes
3. Pipeline de produção do estado da interface
Como você pode ter notado ao executar o app na ramificação main
, a lista de destinos de voo está vazia.
Para corrigir isso, você precisa seguir estas duas etapas:
- Adicionar a lógica a
ViewModel
para produzir o estado da interface. No seu caso, essa é a lista de destinos sugeridos. - Consumir o estado da interface, que vai mostrá-la na tela.
Nesta seção, você vai concluir a primeira etapa.
Uma boa arquitetura para apps é organizada em camadas para obedecer a práticas básicas de design do sistema, como separação de problemas e testabilidade.
A produção do estado da interface se refere ao processo em que o app acessa a camada de dados, aplica as regras de negócios necessárias e expõe o estado da interface a ser consumido na interface.
A camada de dados deste aplicativo já foi implementada. Agora, você vai produzir o estado (a lista de destinos sugeridos) para que a interface possa consumi-lo.
Há algumas APIs que podem ser usadas para produzir o estado da interface. As alternativas estão resumidas na documentação Tipos de saída em pipelines de produção de estado. Em geral, é recomendável usar o StateFlow
do Kotlin para produzir o estado da interface.
Para produzir esse estado, siga estas etapas:
- Abra o
home/MainViewModel.kt
. - Defina uma variável
_suggestedDestinations
particular do tipoMutableStateFlow
para representar a lista de destinos sugeridos e defina uma lista vazia como valor inicial.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- Defina uma segunda variável imutável
suggestedDestinations
do tipoStateFlow
. Essa é a variável pública somente leitura que pode ser consumida na interface. É recomendado expor uma variável somente leitura ao usar a variável mutável internamente. Ao fazer isso, você garante que o estado da interface não possa ser modificado, a não ser que seja peloViewModel
, o que o torna a única fonte da verdade. A função de extensãoasStateFlow
converte o fluxo de mutável para imutável.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- No bloco init do
ViewModel
, adicione uma chamada dedestinationsRepository
para acessar os destinos da camada de dados.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- Por fim, remova a marca de comentário dos usos da variável interna
_suggestedDestinations
que você encontra nessa classe para que ela possa ser atualizada corretamente com eventos provenientes da interface.
Pronto. A primeira etapa está concluída. Agora, o ViewModel
pode produzir o estado da interface. Na próxima etapa, você vai consumir esse estado da interface.
4. Como consumir um fluxo do ViewModel com segurança
A lista de destinos de voo ainda está vazia. Na etapa anterior, você produziu o estado da interface no MainViewModel
. Agora, você vai consumir o estado exposto por MainViewModel
para que ele apareça na interface.
Abra o arquivo home/CraneHome.kt
e observe o CraneHomeContent
combinável.
Há um comentário TODO acima da definição de suggestedDestinations
que foi atribuído a uma lista vazia lembrada. O que aparece na tela é uma lista vazia. Nesta etapa, você vai corrigir isso e mostrar os destinos sugeridos que o MainViewModel
expõe.
Abra home/MainViewModel.kt
e observe o StateFlow de suggestedDestinations
que é inicializado para destinationsRepository.destinations
e atualizado quando as funções updatePeople
ou toDestinationChanged
são chamadas.
Queremos que sua interface no CraneHomeContent
combinável seja atualizada sempre que houver um novo item emitido no fluxo de dados suggestedDestinations
. Use a função collectAsStateWithLifecycle()
. O collectAsStateWithLifecycle()
coleta valores do StateFlow
e representa o valor mais recente usando a API State do Compose com reconhecimento do ciclo de vida. Isso fará com que o código do Compose que lê o valor do estado se recomponha em novas emissões.
Para começar a usar a API collectAsStateWithLifecycle
, adicione a seguinte dependência a app/build.gradle
. A variável lifecycle_version
já está definida no projeto com a versão adequada.
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}
Volte para o CraneHomeContent
combinável e substitua a linha que atribui suggestedDestinations
por uma chamada para collectAsStateWithLifecycle
na propriedade suggestedDestinations
do ViewModel
:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun CraneHomeContent(
onExploreItemClicked: OnExploreItemClicked,
openDrawer: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MainViewModel = viewModel(),
) {
val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
// ...
}
Se você executar o aplicativo, o resultado será uma lista de destinos já preenchida, sendo que esses destinos mudam sempre que você toca no número de passageiros.
5. LaunchedEffect e rememberUpdatedState
No projeto, há um arquivo home/LandingScreen.kt
que não é usado no momento. Adicione uma tela de destino ao app, que poderia ser usada para carregar todos os dados necessários em segundo plano.
A tela de destino vai ocupar toda a tela e mostrar o logotipo do app no meio. O ideal seria mostrar a tela e, depois que todos os dados fossem carregados, notificar o autor da chamada de que a tela de destino pode ser dispensada usando o callback onTimeout
.
As corrotinas do Kotlin são a maneira recomendada de realizar operações assíncronas no Android. Um app geralmente usa corrotinas para carregar itens em segundo plano quando é iniciado. O Jetpack Compose oferece APIs que tornam o uso de corrotinas seguro na camada da interface. Como este app não se comunica com um back-end, você vai usar a função delay
das corrotinas para simular o carregamento de itens em segundo plano.
Um efeito colateral no Compose é uma mudança no estado do app que acontece fora do escopo de uma função combinável. A mudança do estado para mostrar/ocultar a tela de destino vai acontecer no callback onTimeout
. Já que antes de chamar onTimeout
você precisa carregar itens usando corrotinas, a mudança de estado precisa acontecer no contexto de uma corrotina.
Para chamar funções de suspensão com segurança de dentro de um elemento combinável, use a API LaunchedEffect
, que aciona um efeito colateral com escopo de corrotina no Compose.
Quando LaunchedEffect
entra na composição, ele inicia uma corrotina com o bloco de código transmitido como um parâmetro. A corrotina será cancelada se o LaunchedEffect
sair da composição.
Embora o próximo código não esteja correto, vamos aprender a usar essa API e discutir por que o código a seguir está errado. Você vai chamar o elemento combinável LandingScreen
mais tarde, ainda nesta etapa.
// home/LandingScreen.kt file
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Start a side effect to load things in the background
// and call onTimeout() when finished.
// Passing onTimeout as a parameter to LaunchedEffect
// is wrong! Don't do this. We'll improve this code in a sec.
LaunchedEffect(onTimeout) {
delay(SplashWaitTime) // Simulates loading things
onTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
Algumas APIs de efeitos colaterais, como LaunchedEffect
, usam um número variável de chaves como um parâmetro para reiniciar o efeito sempre que uma dessas chaves mudar. Você percebeu o erro? Não vamos reiniciar a LaunchedEffect
se os autores dessa chamada para a função combinável transmitirem um valor de lambda onTimeout
diferente. Isso faria com que o delay
começasse de novo, sem atender aos requisitos necessários.
Vamos corrigir isso. Para acionar o efeito colateral apenas uma vez durante o ciclo de vida da função combinável, use uma constante como chave, por exemplo, LaunchedEffect(Unit) { ... }
. No entanto, há outro problema.
Se o onTimeout
mudar enquanto o efeito colateral estiver em andamento, não haverá garantia de que o último onTimeout
vai ser chamado quando o efeito terminar. Para garantir que o último onTimeout
seja chamado, armazene o valor de onTimeout
usando a API rememberUpdatedState
. Essa API captura e atualiza o valor mais recente:
// home/LandingScreen.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes or onTimeout changes,
// the delay shouldn't start again.
LaunchedEffect(Unit) {
delay(SplashWaitTime)
currentOnTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
Use o rememberUpdatedState
quando uma expressão lambda ou de objeto de longa duração faz referência a parâmetros ou valores calculados durante a composição, o que pode ser comum ao trabalhar com LaunchedEffect
.
Como mostrar a tela de destino
Agora você precisa mostrar a tela de destino quando o app é aberto. Abra o arquivo home/MainActivity.kt
e confira a MainScreen
combinável que é chamada primeiro.
Nessa MainScreen
, você pode simplesmente adicionar um estado interno que rastreia se o destino precisa ser mostrado ou não:
// home/MainActivity.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
Surface(color = MaterialTheme.colors.primary) {
var showLandingScreen by remember { mutableStateOf(true) }
if (showLandingScreen) {
LandingScreen(onTimeout = { showLandingScreen = false })
} else {
CraneHome(onExploreItemClicked = onExploreItemClicked)
}
}
}
Se você executar o app agora, a LandingScreen
vai aparecer e desaparecer após dois segundos.
6. rememberCoroutineScope
Nesta etapa, você vai fazer a gaveta de navegação funcionar. Atualmente, nada acontece quando você tenta tocar no menu de navegação.
Abra o arquivo home/CraneHome.kt
e confira o CraneHome
combinável para saber onde você precisa abrir a gaveta de navegação: no callback openDrawer
.
Em CraneHome
, você tem um scaffoldState
que contém um DrawerState
. O DrawerState
tem métodos para abrir e fechar a gaveta de navegação de forma programática. No entanto, se você tentar gravar scaffoldState.drawerState.open()
no callback openDrawer
, receberá um erro. Isso ocorre porque a função open
é uma função de suspensão. Estamos no caminho das corrotinas novamente.
Além das APIs para tornar as corrotinas de chamada seguras na camada de interface, algumas APIs do Compose são funções de suspensão. Um exemplo disso é a API para abrir a gaveta de navegação. As funções de suspensão, além de executarem código assíncrono, também ajudam a representar conceitos que ocorrem com o tempo. Como a abertura da gaveta exige tempo, movimento e possíveis animações, isso é perfeitamente refletido na função de suspensão, que suspenderá a execução da corrotina em que ela foi chamada até terminar e retomar a execução.
O scaffoldState.drawerState.open()
precisa ser chamado em uma corrotina. O que você pode fazer? openDrawer
é uma função de callback simples, portanto:
- não é possível simplesmente chamar funções de suspensão porque
openDrawer
não é executado no contexto de uma corrotina; - não é possível usar
LaunchedEffect
como antes porque elementos combináveis não podem ser chamados emopenDrawer
. Não estamos na composição.
Inicie uma corrotina. Qual escopo precisamos usar? O ideal é que um CoroutineScope
siga o ciclo de vida do site de chamada. O uso da API rememberCoroutineScope
retorna um CoroutineScope
vinculado ao ponto na composição. O escopo será cancelado automaticamente quando sair da composição. Com esse escopo, você poderá iniciar corrotinas quando não estiver na composição, por exemplo, no callback openDrawer
.
// home/CraneHome.kt file
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun CraneHome(
onExploreItemClicked: OnExploreItemClicked,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
modifier = Modifier.statusBarsPadding(),
drawerContent = {
CraneDrawer()
}
) {
val scope = rememberCoroutineScope()
CraneHomeContent(
modifier = modifier,
onExploreItemClicked = onExploreItemClicked,
openDrawer = {
scope.launch {
scaffoldState.drawerState.open()
}
}
)
}
}
Se você executar o aplicativo, a gaveta de navegação será aberta quando você tocar no ícone de menu de navegação.
LaunchedEffect x rememberCoroutineScope
Não foi possível usar LaunchedEffect
neste caso porque você precisava acionar a chamada para criar uma corrotina em um callback regular que estava fora da composição.
Analisando a etapa da tela de destino que usou LaunchedEffect
, é possível usar rememberCoroutineScope
e chamar scope.launch { delay(); onTimeout(); }
em vez de LaunchedEffect
?
Você poderia ter feito isso e pareceria funcionar, mas não estaria correto. Conforme explicado na documentação Trabalhando com o Compose, as funções que podem ser compostas podem ser chamadas pelo Compose a qualquer momento. O LaunchedEffect
garante que o efeito colateral será executado quando a chamada para essa função entrar na composição. Se você usar rememberCoroutineScope
e scope.launch
no corpo da LandingScreen
, a corrotina será executada sempre que a LandingScreen
for chamada pelo Compose, independentemente de essa chamada chegar à composição ou não. Portanto, você desperdiçará recursos e não executará esse efeito colateral em um ambiente controlado.
7. Como criar um detentor de estado
Você notou que, ao tocar em Escolher o destino, é possível editar o campo e filtrar as cidades com base nas informações da pesquisa? Você provavelmente também percebeu que, ao modificar o campo Escolher destino, o estilo do texto muda.
Abra o arquivo base/EditableUserInput.kt
. A CraneEditableUserInput
de composição com estado usa alguns parâmetros, como hint
e caption
, que correspondem ao texto opcional ao lado do ícone. Por exemplo, a caption
Para aparece quando você pesquisa um destino.
// base/EditableUserInput.kt file - code in the main branch
@Composable
fun CraneEditableUserInput(
hint: String,
caption: String? = null,
@DrawableRes vectorImageId: Int? = null,
onInputChanged: (String) -> Unit
) {
// TODO Codelab: Encapsulate this state in a state holder
var textState by remember { mutableStateOf(hint) }
val isHint = { textState == hint }
...
}
Qual é o motivo disso?
A lógica para atualizar o textState
e determinar se o que foi exibido corresponde à dica está toda no corpo da CraneEditableUserInput
combinável. Isso traz algumas desvantagens:
- O valor de
TextField
não é suspenso e, portanto, não pode ser controlado de fora, dificultando o teste. - A lógica dessa função de composição fica mais complexa, e o estado interno pode ficar dessincronizado com mais facilidade.
Ao criar um detentor de estado responsável pelo estado interno dessa função de composição, você pode centralizar todas as mudanças de estado em um só lugar. Com isso, é mais difícil que o estado seja dessincronizado, e a lógica relacionada é agrupada em uma única classe. Além disso, esse estado pode ser facilmente suspenso e consumido pelos autores de chamada dessa função de composição.
Nesse caso, elevar o estado é uma boa ideia, já que ele é um componente de interface de baixo nível que pode ser reutilizado em outras partes do app. Portanto, quanto mais flexível e controlável ele for, melhor.
Como criar o detentor de estado
Como CraneEditableUserInput
é um componente reutilizável, crie uma classe normal chamada EditableUserInputState
como detentora de estado no mesmo arquivo, semelhante a esta:
// base/EditableUserInput.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
private set
fun updateText(newText: String) {
text = newText
}
val isHint: Boolean
get() = text == hint
}
A classe precisa ter as seguintes características:
- O
text
é um estado mutável do tipoString
, assim como emCraneEditableUserInput
. É importante usar omutableStateOf
para que o Compose acompanhe as mudanças no valor e faça a recomposição quando elas ocorrerem. - O
text
é umavar
, com umset
particular para que não possa mudado diretamente de fora da classe. Em vez de tornar essa variável pública, você pode expor um eventoupdateText
para modificá-la, o que faz com que a classe seja a única fonte da verdade. - A classe usa um
initialText
como uma dependência usada para inicializar otext
. - A lógica para saber se
text
é a dica ou não está na propriedadeisHint
que realiza a verificação sob demanda.
Se a lógica se tornar mais complexa no futuro, basta fazer mudanças em uma classe: EditableUserInputState
.
Como se lembrar do detentor de estado
É preciso se lembrar sempre dos detentores de estado para mantê-los na composição e não criar um novo toda vez. Recomendamos criar um método no mesmo arquivo que faça isso, para remover código clichê e evitar erros. No arquivo base/EditableUserInput.kt
, adicione este código:
// base/EditableUserInput.kt file
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
remember(hint) {
EditableUserInputState(hint, hint)
}
Se você remember
(lembrar) apenas esse estado, ele não vai sobreviver às recriações de atividades. Para isso, você pode usar a API rememberSaveable
, que se comporta de maneira semelhante a remember
, mas o valor armazenado também sobrevive à recriação de processos e atividades. Internamente, ela usa o mecanismo de estado da instância salva.
rememberSaveable
faz tudo isso sem nenhum trabalho extra para objetos que podem ser armazenados dentro de um Bundle
. Esse não é o caso da classe EditableUserInputState
que você criou no seu projeto. Portanto, é necessário informar a rememberSaveable
como salvar e restaurar uma instância dessa classe usando um Saver
.
Como criar um saver personalizado
Um Saver
descreve como um objeto pode ser convertido em algo que é Saveable
. As implementações de um Saver
precisam modificar duas funções:
save
, para converter o valor original em um que pode ser salvo.restore
, para converter o valor restaurado em uma instância da classe original.
Para esse caso, em vez de criar uma implementação personalizada de Saver
para a classe EditableUserInputState
, você pode usar algumas das APIs do Compose existentes, como listSaver
ou mapSaver
(que armazena os valores a serem salvos em List
ou Map
) para reduzir a quantidade de código que precisa programar.
É uma boa prática colocar as definições de Saver
próximas à classe com que trabalham. Como ele precisa ser acessado estaticamente, adicione o Saver
para EditableUserInputState
em um companion object
. No arquivo base/EditableUserInput.kt
, adicione a implementação de Saver
:
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
val Saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1],
)
}
)
}
}
Nesse caso, você usa um listSaver
como um detalhe de implementação para armazenar e restaurar uma instância de EditableUserInputState
no saver.
Agora, você pode usar esse saver em rememberSaveable
(em vez de remember
) no método rememberEditableUserInputState
que criou antes:
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
rememberSaveable(hint, saver = EditableUserInputState.Saver) {
EditableUserInputState(hint, hint)
}
Com isso, o estado lembrado de EditableUserInput
vai sobreviver às recriações de processos e atividades.
Como usar o detentor de estado
Use EditableUserInputState
em vez de text
e isHint
, mas não apenas como um estado interno em CraneEditableUserInput
, já que não há como o elemento combinável do autor da chamada controlar o estado. Em vez disso, eleve EditableUserInputState
para que os autores da chamada possam controlar o estado de CraneEditableUserInput
. Se você elevar o estado, a função combinável vai poder ser usada em visualizações e testada com mais facilidade, já que será possível modificar o estado do autor da chamada.
Para isso, você precisa mudar os parâmetros da função combinável e fornecer um valor padrão caso seja necessário. Como é possível permitir CraneEditableUserInput
com dicas vazias, adicione um argumento padrão:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
Você provavelmente percebeu que o parâmetro onInputChanged
não está mais lá. Como o estado pode ser elevado, se os autores da chamada quiserem saber se a entrada mudou, eles poderão controlar o estado e transmiti-lo para essa função.
Em seguida, você precisa ajustar o corpo da função para usar o estado elevado em vez do estado interno que foi usado anteriormente. Após a refatoração, a função terá esta aparência:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) {
CraneBaseUserInput(
caption = caption,
tintIcon = { !state.isHint },
showCaption = { !state.isHint },
vectorImageId = vectorImageId
) {
BasicTextField(
value = state.text,
onValueChange = { state.updateText(it) },
textStyle = if (state.isHint) {
captionTextStyle.copy(color = LocalContentColor.current)
} else {
MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
},
cursorBrush = SolidColor(LocalContentColor.current)
)
}
}
Autores de chamadas do detentor do estado
Como você mudou a API de CraneEditableUserInput
, é necessário conferir todos os lugares em que ela é chamada para garantir a transmissão dos parâmetros adequados.
O único local do projeto em que você chama essa API é o arquivo home/SearchUserInput.kt
. Abra-o e acesse a função combinável ToDestinationUserInput
. Um erro de build vai aparecer. Como a dica agora faz parte do detentor de estado, e como você quer uma dica personalizada para essa instância de CraneEditableUserInput
na composição, é necessário lembrar do estado no nível de ToDestinationUserInput
e transmiti-lo a CraneEditableUserInput
:
// home/SearchUserInput.kt file
import androidx.compose.samples.crane.base.rememberEditableUserInputState
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
}
snapshotFlow
O código acima não tem uma funcionalidade para notificar o autor da chamada de ToDestinationUserInput
quando a entrada mudar. Devido à forma como o app está estruturado, não eleve o EditableUserInputState
para uma posição mais alta na hierarquia. Você não quer unir os outros elementos combináveis, como FlySearchContent
, a esse estado. Como seria possível chamar o lambda onToDestinationChanged
de ToDestinationUserInput
e ainda manter esse elemento combinável reutilizável?
Você pode acionar um efeito colateral usando LaunchedEffect
sempre que a entrada mudar e chamar o lambda onToDestinationChanged
:
// home/SearchUserInput.kt file
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
Você já usou LaunchedEffect
e rememberUpdatedState
antes, mas o código acima também usa uma nova API. A API snapshotFlow
converte os objetos State<T>
do Compose em um fluxo. Quando o estado lido dentro de snapshotFlow
muda, o Fluxo emite o novo valor ao coletor. Nesse caso, você converte o estado em um fluxo para usar a potência dos operadores de fluxo. Com isso, você filter
(filtra) quando o text
não é a hint
e collect
(coleta) os itens emitidos para notificar o pai de que o destino atual mudou.
Não há mudanças visuais nesta etapa do codelab, mas você melhorou a qualidade dessa parte do código. Se você executar o app agora, vai notar que tudo está funcionando como antes.
8. DisposableEffect
Quando você toca em um destino, a tela de detalhes é aberta e mostra a cidade no mapa. Esse código está no arquivo details/DetailsActivity.kt
. No elemento combinável CityMapView
, você está chamando a função rememberMapViewWithLifecycle
. Se você abrir essa função, que está no arquivo details/MapViewUtils.kt
, vai perceber que ela não está conectada a nenhum ciclo de vida. Ela apenas se lembra de uma MapView
e chama onCreate
nela:
// details/MapViewUtils.kt file - code in the main branch
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
// TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
return remember {
MapView(context).apply {
id = R.id.map
onCreate(Bundle())
}
}
}
Mesmo que o app funcione bem, isso é um problema porque a MapView
não está seguindo o ciclo de vida correto. Portanto, ela não saberá quando o app for movido para o segundo plano, quando a visualização precisa ser pausada etc. Vamos corrigir isso.
Como MapView
é uma visualização, e não um elemento combinável, é necessário que ela siga o ciclo de vida da atividade em que é usada, bem como o ciclo de vida da composição. Isso significa que você precisa criar um LifecycleEventObserver
para detectar eventos de ciclo de vida e chamar os métodos certos na MapView
. Em seguida, você precisa adicionar esse observador ao ciclo de vida da atividade atual.
Comece criando uma função que retorna um LifecycleEventObserver
que chama os métodos correspondentes em uma MapView
depois de um determinado evento:
// details/MapViewUtils.kt file
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
Agora, adicione esse observador ao ciclo de vida atual, que pode ser acessado usando o LifecycleOwner
atual com o local de composição do LocalLifecycleOwner
. No entanto, não basta adicionar o observador: também é necessário removê-lo. Você precisa de um efeito colateral que informe quando o efeito está saindo da composição para que seja possível executar um código de limpeza. A API de efeitos colaterais que você está procurando é DisposableEffect
.
DisposableEffect
é destinada a efeitos colaterais que precisam ser limpos após as chaves mudarem ou a função combinável sair da composição. O código rememberMapViewWithLifecycle
final faz exatamente isso. Implemente as seguintes linhas no projeto:
// details/MapViewUtils.kt file
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(key1 = lifecycle, key2 = mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
O observador é adicionado ao lifecycle
atual e será removido sempre que o ciclo de vida atual mudar ou essa função de composição sair da composição. Com as key
s em DisposableEffect
, se o lifecycle
ou a mapView
mudarem, o observador será removido e adicionado novamente ao lifecycle
correto.
Com as mudanças que você fez, a MapView
sempre vai seguir o lifecycle
do LifecycleOwner
atual, e ela vai ter um comportamento como se fosse usada no contexto das visualizações.
Execute o app e abra a tela de detalhes para verificar se a MapView
ainda é renderizada corretamente. Não há mudanças visuais nesta etapa.
9. produceState
Nesta seção, você vai melhorar a forma como a tela de detalhes é iniciada. A DetailsScreen
combinável no arquivo details/DetailsActivity.kt
vai receber os cityDetails
do ViewModel de forma síncrona e chamar DetailsContent
se o resultado for bem-sucedido.
Contudo, os cityDetails
podem evoluir para ter um carregamento mais caro na linha de execução de interface e podem usar corrotinas para mover o carregamento dos dados para outra linha de execução. Você vai melhorar esse código para adicionar uma tela de carregamento e mostrar o DetailsContent
quando os dados estiverem prontos.
Uma maneira de modelar o estado da tela é com a seguinte classe que abrange todas as possibilidades: dados a serem mostrados na tela e sinais de carregamento e de erro. Adicione a classe DetailsUiState
ao arquivo DetailsActivity.kt
:
// details/DetailsActivity.kt file
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
É possível mapear o que a tela precisa mostrar e o UiState
na camada do ViewModel usando um fluxo de dados, um StateFlow
do tipo DetailsUiState
, que o ViewModel atualiza quando as informações estão prontas e que esse Compose coleta com a API collectAsStateWithLifecycle()
, que você já conhece.
No entanto, para este exercício, você vai implementar uma alternativa. Se você quisesse mover a lógica de mapeamento uiState
para o Compose, seria possível usar a API produceState
.
produceState
permite que você converta o estado que não é do Compose em um Estado do Compose. Ela inicia uma corrotina com escopo para a composição que pode enviar valores para o State
retornado usando a propriedade value
. Como acontece com o LaunchedEffect
, o produceState
também usa chaves para cancelar e reiniciar o cálculo.
Para este caso de uso, você pode usar produceState
para emitir atualizações de uiState
com um valor inicial de DetailsUiState(isLoading = true)
, desta forma:
// details/DetailsActivity.kt file
import androidx.compose.runtime.produceState
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
// In a coroutine, this can call suspend functions or move
// the computation to different Dispatchers
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
// TODO: ...
}
Em seguida, dependendo do uiState
, você vai mostrar os dados, a tela de carregamento ou uma mensagem de erro. Confira o código completo do elemento combinável DetailsScreen
:
// details/DetailsActivity.kt file
import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
Se você executar o app, a imagem do ícone de carregamento será mostrada antes dos detalhes da cidade.
10. derivedStateOf
A última melhoria que você vai fazer no Crane é mostrar um botão Voltar ao topo sempre que rolar na lista de destinos de voo depois de passar pelo primeiro elemento da tela. Tocar no botão leva você ao primeiro elemento na lista.
Abra o arquivo base/ExploreSection.kt
que contém esse código. A ExploreSection
combinável corresponde ao que aparece no pano de fundo da estrutura.
Para calcular se o usuário transmitiu o primeiro item, use o LazyListState
da LazyColumn
e confira se listState.firstVisibleItemIndex > 0
.
Uma implementação simples terá esta aparência:
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
Essa solução não é tão eficiente quanto poderia ser, porque a função combinável que lê showButton
é recomposta sempre que o firstVisibleItemIndex
muda, o que acontece com frequência ao rolar a tela. Em vez disso, a função vai precisar ser recomposta somente quando a condição mudar entre true
e false
.
A API derivedStateOf
permite isso.
listState
é um State
observável do Compose. Seu cálculo, showButton
, também precisa ser um State
do Compose, já que a interface vai precisar ser recomposta quando o valor mudar para mostrar ou ocultar o botão.
Use derivedStateOf
quando quiser que um State
do Compose seja derivado de outro State
. O bloco de cálculo derivedStateOf
é executado sempre que o estado interno muda, mas a função combinável só é recomposta quando o resultado do cálculo é diferente do último. Isso minimiza a quantidade de vezes que as funções que leem o showButton
são recompostas.
Usar a API derivedStateOf
nesse caso é uma alternativa melhor e mais eficiente. Você também vai envolver a chamada com a API remember
para que o valor calculado sobreviva à recomposição.
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
Você já conhece o novo código para a ExploreSection
combinável. Você está usando uma Box
para colocar o Button
mostrado condicionalmente sobre a ExploreList
. Além disso, use o rememberCoroutineScope
para chamar a função de suspensão listState.scrollToItem
dentro do callback onClick
do Button
.
// base/ExploreSection.kt file
import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch
@Composable
fun ExploreSection(
modifier: Modifier = Modifier,
title: String,
exploreList: List<ExploreModel>,
onItemClicked: OnExploreItemClicked
) {
Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
Text(
text = title,
style = MaterialTheme.typography.caption.copy(color = crane_caption)
)
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f)) {
val listState = rememberLazyListState()
ExploreList(exploreList, onItemClicked, listState = listState)
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
val coroutineScope = rememberCoroutineScope()
FloatingActionButton(
backgroundColor = MaterialTheme.colors.primary,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 8.dp),
onClick = {
coroutineScope.launch {
listState.scrollToItem(0)
}
}
) {
Text("Up!")
}
}
}
}
}
}
Se você executar o app, role a tela e transmita o primeiro elemento dela para fazer o botão aparecer na parte de baixo.
11. Parabéns!
Parabéns, você concluiu este codelab e aprendeu conceitos avançados de APIs de efeito colateral e de estado em um app do Jetpack Compose.
Você aprendeu a criar detentores de estado, APIs de efeito colateral, como LaunchedEffect
, rememberUpdatedState
, DisposableEffect
, produceState
e derivedStateOf
, e a usar corrotinas no Jetpack Compose.
Qual é a próxima etapa?
Confira os outros codelabs no Programa de treinamentos do Compose e outros exemplos de código (link em inglês), incluindo o Crane.
Documentação
Para mais informações e orientações sobre esses tópicos, consulte a seguinte documentação: