Estado avançado e efeitos colaterais no Jetpack Compose

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

O que é necessário

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.

b2c6b8989f4332bb.gif

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.

162c42b19dafa701.png

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.

b2c6b8989f4332bb.gif

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:

  1. Abra o home/MainViewModel.kt.
  2. Defina uma variável _suggestedDestinations particular do tipo MutableStateFlow para representar a lista de destinos sugeridos e defina uma lista vazia como valor inicial.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. Defina uma segunda variável imutável suggestedDestinations do tipo StateFlow. 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 pelo ViewModel, o que o torna a única fonte da verdade. A função de extensão asStateFlow converte o fluxo de mutável para imutável.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. No bloco init do ViewModel, adicione uma chamada de destinationsRepository 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
}
  1. 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.

66ae2543faaf2e91.png

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.

d656748c7c583eb8.gif

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.

e3fd932a5b95faa0.gif

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 em openDrawer. 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.

92957c04a35e91e3.gif

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.

dde9ef06ca4e5191.gif

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 tipo String, assim como em CraneEditableUserInput. É importante usar o mutableStateOf para que o Compose acompanhe as mudanças no valor e faça a recomposição quando elas ocorrerem.
  • O text é uma var, com um set 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 evento updateText 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 o text.
  • A lógica para saber se text é a dica ou não está na propriedade isHint 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 keys 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.

aa8fd1ac660266e9.gif

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.

2c112d73f48335e0.gif

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 ExploreSectioncombiná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: