Estado avançado e efeitos colaterais no Jetpack Compose

Neste codelab, você aprenderá conceitos avançados relacionados às APIs State e Side Effects no Jetpack Compose. Veremos como criar um detentor de estado para elementos que podem ser compostos com estado que têm uma lógica não trivial, como criar corrotinas e chamar funções de suspensão no código do Compose e como acionar efeitos colaterais para realizar diferentes casos de uso.

O que você aprenderá

O que é necessário

O que você criará

Neste codelab, começaremos com um aplicativo inacabado, o app Crane Material Study, e adicionaremos recursos para melhorá-lo.

1fb85e2ed0b8b592.gif

Buscar 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/googlecodelabs/android-compose-codelabs

Se preferir, faça o download do repositório como um arquivo ZIP:

Fazer o download do ZIP

Conferir o app de exemplo

Você fez o download de um código que contém todos os codelabs disponíveis do Compose. Para concluir este codelab, abra o projeto AdvancedStateAndSideEffectsCodelab dentro do Android Studio Arctic Fox.

Recomendamos que você comece com o código na ramificação main e siga o codelab passo a passo no seu ritmo.

Durante o codelab, você verá snippets de código que precisam ser adicionados ao projeto. Em alguns locais, também será necessário remover o código que é explicitamente mencionado nos comentários dos snippets de código.

Familiarizar-se com o código e executar o app de exemplo

Reserve um tempo para explorar a estrutura do projeto e executar o app.

37d39b9ac4a9d2fa.png

Ao executar o app na ramificação main, você verá que algumas funcionalidades, como a gaveta ou o carregamento de destinos de voos, não funcionam. Isso é o que faremos nas próximas etapas do codelab.

1fb85e2ed0b8b592.gif

Testes de IU

O app tem a cobertura de testes de IU muito básicos disponíveis na pasta androidTest. Eles precisam ser aplicados nas ramificações main e end em todos os momentos.

[Opcional] Exibir 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 vê-lo, 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/googlecodelabs/android-compose-codelabs

Como alternativa, faça o download do código da solução aqui:

Fazer o download do código final

Perguntas frequentes

Como você pode ter notado ao executar o app na ramificação main, a lista de destinos de voo está vazia. Para ver o que está acontecendo, abra o arquivo home/CraneHome.kt e analise o elemento que pode ser composto CraneHomeContent.

Há um comentário TODO acima da definição de suggestedDestinations que foi atribuído a uma lista vazia lembrada. O que é exibido na tela é uma lista vazia. Nesta etapa, corrigiremos isso e exibiremos os destinos sugeridos que o MainViewModel expõe.

9cadb1fd5f4ced3c.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 nossa IU no CraneHomeContent que pode ser composto seja atualizada sempre que houver um novo item emitido no fluxo de dados suggestedDestinations. Podemos usar a função StateFlow.collectAsState(). Quando usado em uma função que pode ser composta, collectAsState() coleta valores do StateFlow e representa o valor mais recente usando a API State do Compose. Isso fará com que o código do Compose que lê o valor do estado recomponha-se em novas emissões.

Volte para o CraneHomeContent que pode ser composto e substitua a linha que atribui suggestedDestinations por uma chamada para collectAsState na propriedade suggestedDestinations do ViewModel:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

Se você executar o aplicativo, verá que a lista de destinos está preenchida e que eles mudam sempre que você toca no número de pessoas que viajam.

4ec666a2d1ac0903.gif

No projeto, há um arquivo home/LandingScreen.kt que não é usado no momento. Queremos adicionar uma tela de destino ao app, que poderia ser usada para carregar todos os dados necessários em segundo plano.

A tela de destino ocupará toda a tela e mostrará o logotipo do app no meio. O ideal seria exibir 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 IU. Como este app não se comunica com um back-end, usaremos 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 que pode ser composta. Mudar o estado para mostrar/ocultar a tela de destino acontecerá no callback onTimeout. Já que antes de chamar onTimeout precisamos 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 que pode ser composto, 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 LaunchedEffect sair da composição.

Embora o próximo código não esteja correto, vamos ver como usar essa API e discutir por que o código a seguir está errado. Chamaremos a LandingScreen que pode ser composta mais tarde nesta etapa.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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 usado para reiniciar o efeito sempre que uma dessas chaves for mudada. Você percebeu o erro? Não queremos reiniciar o efeito se onTimeout mudar.

Para acionar o efeito colateral apenas uma vez durante o ciclo de vida da função que pode ser composta, use uma constante como chave, por exemplo, LaunchedEffect(true) { ... }. No entanto, não estamos definindo proteções contra mudanças em onTimeout agora.

Se onTimeout mudar enquanto o efeito colateral estiver em andamento, não haverá garantia de que o último onTimeout será chamado quando o efeito terminar. Para garantir isso capturando e atualizando para o novo valor, use a API rememberUpdatedState:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Como mostrar a tela de destino

Agora precisamos mostrar a tela de destino quando o app for aberto. Abra o arquivo home/MainActivity.kt e confira a MainScreen que pode ser composta que é chamada primeiro.

Nessa MainScreen, podemos simplesmente adicionar um estado interno que rastreia se o destino precisa ser exibido 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 aparecerá e desaparecerá após dois segundos.

fda616dda280aa3e.gif

Nesta etapa, faremos 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 que pode ser composto para ver onde precisamos abrir a gaveta de navegação: no callback openDrawer.

Em CraneHome, temos 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 IU, 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 podemos 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 podemos usar LaunchedEffect como antes porque não podemos chamar elementos que podem ser compostos em openDrawer. Não estamos na composição.

Queremos iniciar uma corrotina. Qual escopo precisamos usar? O ideal é que um CoroutineScope siga o ciclo de vida do site de chamada. Para isso, use a API rememberCoroutineScope. 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, verá a gaveta de navegação abrir quando tocar no ícone de menu de navegação.

ad44883754b14efe.gif

LaunchedEffect x rememberCoroutineScope

Não foi possível usar LaunchedEffect neste caso porque precisávamos 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 de 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.

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 Escolher destino, o estilo do texto muda.

99dec71d23aef084.gif

Abra o arquivo base/EditableUserInput.kt. A CraneEditableUserInput que pode ser composta 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 que pode ser composta. 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 que pode ser composta 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 que pode ser composta, 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 que pode ser composta.

Nesse caso, elevar o estado é uma boa ideia, já que ele é um componente de IU 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 do estado

Como CraneEditableUserInput é um componente reutilizável, vamos criar uma classe normal como detentor de estado chamada EditableUserInputState 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)

    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 temos 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 é um var, o que permite que ele seja mudado diretamente de fora da classe.
  • A classe usa um initialText como uma dependência usada para inicializar 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ê apenas remember esse estado, ele não sobreviverá às recriações de atividades. Para isso, podemos 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 criamos em nosso projeto. Portanto, precisamos 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 nosso caso, em vez de criar uma implementação personalizada de Saver para a classe EditableUserInputState, podemos 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 precisamos escrever.

É uma boa prática colocar as definições de Saver próximas à classe com que trabalham. Como ele precisa ser acessado estaticamente, vamos adicionar 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, usamos um listSaver como um detalhe de implementação para armazenar e restaurar uma instância de EditableUserInputState no saver.

Agora, podemos usar esse saver em rememberSaveable (em vez de remember) no método rememberEditableUserInputState que criamos 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 sobreviverá às recriações de processos e atividades.

Como usar o detentor de estado

Usaremos EditableUserInputState em vez de text e isHint, mas não queremos usá-lo apenas como um estado interno em CraneEditableUserInput, porque não há como o elemento que pode ser composto do autor da chamada controlar o estado. Em vez disso, queremos elevar EditableUserInputState para que os autores da chamada possam controlar o estado de CraneEditableUserInput. Se elevarmos o estado, a função que pode ser composta poderá ser usada em visualizações e testada com mais facilidade, já que é possível modificar seu estado do autor da chamada.

Para isso, precisamos mudar os parâmetros da função que pode ser composta e fornecer um valor padrão caso seja necessário. Como podemos permitir CraneEditableUserInput com dicas vazias, adicionamos 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, precisamos ajustar o corpo da função para usar o estado elevado, em vez do estado interno 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.text = 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 mudamos a API de CraneEditableUserInput, precisamos verificar todos os locais em que ela é chamada para garantir a transmissão dos parâmetros adequados.

O único local do projeto em que chamamos de API é o arquivo home/SearchUserInput.kt. Abra-a e acesse a função ToDestinationUserInput que pode ser composta; você verá um erro de compilação. Como a dica agora faz parte do detentor de estado, e queremos uma dica personalizada para essa instância de CraneEditableUserInput na composição, precisamos lembrar do estado no nível da ToDestinationUserInput e transmiti-lo para 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 queremos elevar o EditableUserInputState para uma posição mais alta na hierarquia porque queremos unir os outros elementos que podem ser compostos, como FlySearchContent, com esse estado. Como podemos chamar o lambda onToDestinationChanged de ToDestinationUserInput e ainda manter esse elemento que pode ser composto reutilizável?

Podemos 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)
            }
    }
}

Já usamos LaunchedEffect e rememberUpdatedState antes, mas o código acima também usa uma nova API. Usamos a API snapshotFlow para converter objetos State<T> do Compose em um Fluxo. Quando o estado lido dentro de snapshotFlow muda, o Fluxo permite o novo valor para o coletor. No nosso caso, convertemos o estado em um fluxo para usar a potência dos operadores de fluxo. Com isso, filter (filtramos) quando o text não é a hint e collect (coletamos) os itens emitidos para notificar o pai de que o destino atual mudou.

Não há mudanças visuais nesta etapa do codelab, mas melhoramos a qualidade dessa parte do código. Se você executar o app agora, verá que tudo está funcionando como antes.

Quando você toca em um destino, a tela de detalhes é aberta e é possível ver onde está a cidade no mapa. Esse código está no arquivo details/DetailsActivity.kt. Na CityMapView que pode ser composta, estamos chamando a função rememberMapViewWithLifecycle. Se você abrir essa função, que está no arquivo details/MapViewUtils.kt, verá 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 uma função que pode ser composta, queremos que ela siga o ciclo de vida da atividade em que é usada, em vez do ciclo de vida da composição. Isso significa que precisamos criar um LifecycleEventObserver para detectar eventos de ciclo de vida e chamar os métodos certos na MapView. Em seguida, precisamos adicionar esse observador ao ciclo de vida da atividade atual.

Vamos começar criando uma função que retorna um LifecycleEventObserver que chama os métodos correspondentes em uma MapView, dada 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, precisamos adicionar esse observador ao ciclo de vida atual, que pode ser usado usando o LifecycleOwner atual com o local de composição do LocalLifecycleOwner. No entanto, não basta adicionar o observador; também precisamos removê-lo. Precisamos de um efeito colateral que informe quando o efeito está saindo da composição para que possamos executar um código de limpeza. A API de efeitos colaterais que estamos procurando é DisposableEffect.

DisposableEffect é destinada a efeitos colaterais que precisam ser limpos após as chaves mudarem ou a função que pode ser composta 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 que pode ser composta 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 fizemos, a MapView sempre seguirá o lifecycle do LifecycleOwner atual, e ela 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.

Nesta seção, vamos melhorar a forma como a tela de detalhes é iniciada. A DetailsScreen que pode ser composta no arquivo details/DetailsActivity.kt receberá os cityDetails de forma síncrona do ViewModel 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 IU e podem usar corrotinas para mover o carregamento dos dados para outra linha de execução. Vamos melhorar esse código para adicionar uma tela de carregamento e exibir 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 exibidos 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
)

Podemos mapear o que a tela precisa exibir 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 esse Compose coleta com a API collectAsState() que você já conhece.

No entanto, para este exercício, vamos implementar uma alternativa. Se quiséssemos mover a lógica de mapeamento uiState para o Compose, poderíamos 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 LaunchedEffect, produceState também usa chaves para cancelar e reiniciar o cálculo.

Para nosso caso de uso, podemos usar produceState para emitir atualizações de uiState com um valor inicial de DetailsUiState(isLoading = true) da seguinte maneira:

// 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, exibiremos os dados, a tela de carregamento ou informaremos o erro. Veja o código completo para a DetailsScreen que pode ser composta:

// 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, verá a imagem do ícone de carregamento antes de mostrar os detalhes da cidade.

18956feb88725ca5.gif

A última melhoria que faremos no Crane é mostrar um botão Voltar ao topo sempre que você rolar na lista de destinos de voo depois de passar pelo primeiro elemento da tela. Ao tocar no botão, você será direcionado para o primeiro elemento na lista.

59d2d10bd334bdb.gif

Abra o arquivo base/ExploreSection.kt que contém esse código. A ExploreSection que pode ser composta corresponde ao que você vê no pano de fundo da estrutura.

A solução para implementar o comportamento visto no vídeo não será uma surpresa para você. No entanto, há uma nova API que ainda não conhecemos e é importante neste caso de uso: a API derivedStateOf.

derivedStateOf é usada quando você quer um State do Compose derivado de outro State. O uso dessa função garante que o cálculo só ocorra quando um dos estados usados no cálculo mudar.

Para calcular se o usuário transmitiu o primeiro item usando o listState, basta verificar se listState.firstVisibleItemIndex > 0. No entanto, o firstVisibleItemIndex é encapsulado na API mutableStateOf, o que o torna um estado observável do Compose. O cálculo também precisa ser de estado do Compose, já que queremos recompor a IU para exibir o botão.

Uma implementação simples e ineficiente será semelhante ao exemplo a seguir. Não a copie no seu projeto. A implementação correta será copiada para seu projeto com o restante da lógica para a tela mais tarde:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Uma alternativa melhor e mais eficiente é usar a API derivedStateOf, que calcula showButton somente quando listState.firstVisibleItemIndex muda:

// 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
    }
}

Você já conhece o novo código para a ExploreSection que pode ser composta. Veja novamente como usamos o rememberCoroutineScope para chamar a função de suspensão listState.scrollToItem dentro do callback onClick do Button. Estamos usando uma Box para colocar o Button mostrado condicionalmente sobre ExploreList:

// 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 com.google.accompanist.insets.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, verá o botão na parte inferior ao rolar a tela e transmitir o primeiro elemento dela.

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 como usar corrotinas no Jetpack Compose.

Qual é a próxima etapa?

Confira os outros codelabs no Caminho 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: