Dados com escopo local usando o CompositionLocal

O CompositionLocal é uma ferramenta para transmitir dados usando uma composição de forma implícita. Nesta página, você aprenderá detalhes sobre o CompositionLocal, verá como criar seu próprio CompositionLocal e como saber se um CompositionLocal é uma boa solução para seu caso de uso.

Apresentação do CompositionLocal

No Compose, geralmente os dados fluem para baixo na árvore da IU como parâmetros para cada função que pode ser composta. Dessa forma, as dependências da função que pode ser composta ficam explícitas. Contudo, isso pode ser complicado para dados que são usados em grande volume e frequência, como cores ou estilos de tipografia. Veja o exemplo a seguir:

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = …
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = // ← need to access colors here
    )
}

Para não precisar transmitir as cores como uma dependência de parâmetro explícita à maioria das funções que podem ser compostas, o Compose fornece o CompositionLocal, que permite criar objetos nomeados com escopo de árvore que podem ser usados como uma forma implícita de fazer com que os dados fluam pela árvore da IU.

Os elementos CompositionLocal geralmente são fornecidos com um valor em um nó específico da árvore da IU. Esse valor pode ser usado pelas funções descendentes que podem ser compostas sem declarar o CompositionLocal como um parâmetro na função que pode ser composta.

O CompositionLocal é usado internamente pelo tema do Material Design. O MaterialThemeé um objeto que fornece três CompositionLocal instâncias (cores, tipografia e formas) e permite acessá-las posteriormente em qualquer parte descendente da composição. Mais especificamente, elas são as propriedades LocalColors LocalShapes e LocalTypography que podem ser acessadas pelos atributos colors shapes e typography do MaterialTheme.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colors, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colors.primary
    )
}

Uma instância CompositionLocal tem como escopo uma parte da composição, para que seja possível fornecer valores diferentes em níveis diferentes da árvore. O valor current de um CompositionLocal corresponde ao valor mais próximo fornecido por um ancestral nesta parte da composição.

Para fornecer um novo valor a um CompositionLocal, use o CompositionLocalProvider e a função de infixo provides, que associa uma chave do CompositionLocal a um value. A lambda de content do CompositionLocalProvider receberá o valor fornecido ao acessar a propriedade current do CompositionLocal. Quando um novo valor é fornecido, o Compose faz a recomposição de partes da composição que leem o CompositionLocal.

Como exemplo, o CompositionLocal LocalContentAlpha contém o conteúdo Alfa recomendado para texto e iconografia para destacar ou remover o destaque de diferentes partes da IU. No exemplo a seguir, o CompositionLocalProvider é usado para fornecer valores diferentes para partes distintas da composição.

@Composable
fun CompositionLocalExample() {
    MaterialTheme { // MaterialTheme sets ContentAlpha.high as default
        Column {
            Text("Uses MaterialTheme's provided alpha")
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium value provided for LocalContentAlpha")
                Text("This Text also uses the medium value")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the disabled alpha now")
}

Figura 1. Visualização da função CompositionLocalExample que pode ser composta.

Em todos os exemplos acima, as instâncias CompositionLocal eram usadas internamente por funções que podem ser compostas do Material Design. Para acessar o valor atual de um CompositionLocal, use a propriedade current. No exemplo a seguir, o valor atual do Context do CompositionLocal LocalContext, usado com frequência em apps Android, é usado para formatar o texto:

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

Como criar seu próprio CompositionLocal

O CompositionLocal é uma ferramenta para transmitir dados usando uma composição de forma implícita.

Outro sinal importante para usar o CompositionLocal é quando o parâmetro é transversal e as camadas de implementação intermediárias não podem detectar a existência desse parâmetro porque, caso o detectassem, a utilidade da função que pode ser composta seria limitada. Por exemplo, a consulta de permissões do Android é realizada internamente por um CompositionLocal. Uma função de seletor de mídia pode adicionar novas funcionalidades para acessar conteúdos protegido por alguma permissão no dispositivo sem mudar a API nem exigir que os autores da chamada do seletor de mídia identifiquem o contexto extra usado no ambiente.

No entanto, o CompositionLocal nem sempre é a melhor solução. Não recomendamos o uso excessivo do CompositionLocal, porque ele apresenta algumas desvantagens:

O CompositionLocal dificulta a compreensão do comportamento de funções que podem ser compostas. À medida que criam dependências implícitas, os autores das chamadas das funções que podem ser compostas e usam essas dependências precisam garantir que o valor de cada CompositionLocal seja atendido.

Além disso, pode não existir uma fonte de verdade clara para essa dependência, já que ela pode ser modificada em qualquer parte da composição. Dessa forma, pode ser mais difícil depurar o app quando ocorrer um problema, já que seria preciso navegar pela composição para identificar o local em que o valor current foi fornecido. Ferramentas como Find usages no ambiente de desenvolvimento integrado ou o Layout Inspector do Compose fornecem informações suficientes para atenuar esse problema.

Como decidir se é necessário usar um CompositionLocal

Há algumas condições que podem fazer com que o CompositionLocal seja uma boa solução para seu caso de uso:

O CompositionLocal precisa ter um bom valor padrão. Caso não haja um valor padrão, é necessário garantir que as chances de um desenvolvedor se deparar com uma situação em que nenhum valor é fornecido para CompositionLocal sejam extremamente baixas. Não informar um valor padrão pode causar problemas e frustração ao criar testes ou fazer com que a visualização de uma função que pode ser composta e usa esse CompositionLocal sempre exija que ele seja fornecido de forma explícita.

Evite usar o CompositionLocal para conceitos que não tenham a árvore ou sub-hierarquia como escopo. Faz sentido implementar um CompositionLocal quando ele pode ser usado por todos os descendentes, e não apenas por alguns.

Se o caso de uso não atende a esses requisitos, consulte a seção Alternativas a serem consideradas antes de criar um CompositionLocal.

Um exemplo de prática não recomendada é criar um CompositionLocal contendo o ViewModel de uma tela específica para que todas as funções que podem ser compostas nessa tela possam usar o ViewModel para executar uma determinada lógica. Essa é uma prática não recomendada porque nem todas as funções que podem ser compostas abaixo de uma árvore da IU específica precisam ser compatíveis com um ViewModel. Uma prática recomendada é transmitir para as funções que podem ser compostas somente as informações de que elas precisam, seguindo o padrão estados fluem para baixo e eventos fluem para cima. Essa abordagem fará com que as funções que podem ser compostas sejam mais reutilizáveis e fáceis de testar.

Como criar um CompositionLocal

Existem duas APIs para criar um CompositionLocal:

  • compositionLocalOf: mudar o valor fornecido durante a recomposição invalida somente o conteúdo que lê o valor current.

  • staticCompositionLocalOf: diferentemente do compositionLocalOf, as leituras de um staticCompositionLocalOf não são monitoradas pelo Compose. Mudar o valor faz com que toda a lambda de content em que o CompositionLocal é fornecido seja recomposta, e não apenas os locais em que o valor current é lido na composição.

Caso seja muito improvável que o valor fornecido ao CompositionLocal mude, ou caso ele nunca mude, use staticCompositionLocalOf para ter benefícios de desempenho.

Por exemplo, o sistema de design de um app pode ser rigoroso quanto à forma como as funções que podem ser compostas são elevadas para o componente de IU usando uma sombra. Como as diferentes elevações do app se propagam por toda a árvore da IU, nós usamos um CompositionLocal. Como o valor do CompositionLocal é derivado de forma condicional de acordo com o tema do sistema, nós usamos a API compositionLocalOf:

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

Como fornecer valores a um CompositionLocal

A função que pode ser composta CompositionLocalProvider vincula valores a instâncias do CompositionLocal para a hierarquia fornecida. Para fornecer um novo valor a um CompositionLocal, use a função de infixo provides, que associa uma chave do CompositionLocal a um value desta maneira:

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

Como usar o CompositionLocal

O CompositionLocal.current retorna o valor fornecido pelo CompositionLocalProvider mais próximo, que fornece um valor para esse CompositionLocal:

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    Card(elevation = LocalElevations.current.card) {
        // Content
    }
}

Alternativas a considerar

Implementar um CompositionLocal pode ser uma solução exagerada para alguns casos de uso. Se o caso de uso não atender aos critérios especificados na seção Como decidir se é necessário usar um CompositionLocal, é possível que outra solução seja mais adequada para esse caso.

Transmitir parâmetros explícitos

É importante definir as dependências das funções que podem ser compostas de forma explícita. Recomendamos transmitir somente as informações necessárias às funções que podem ser compostas. Para incentivar o desacoplamento e a reutilização das funções que podem ser compostas, cada função precisa conter a menor quantidade de informações possível.

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { ... }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

Inversão de controle

Outra forma de evitar a transmissão de dependências desnecessárias para funções que podem ser compostas é usando a inversão de controle. Em vez do descendente, é o pai que recebe uma dependência para executar uma determinada lógica.

Veja o exemplo a seguir em que um descendente precisa acionar a solicitação para carregar alguns dados:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

Dependendo do caso, o MyDescendant pode ter muitas responsabilidades. Além disso, transmitir o MyViewModel como uma dependência faz com que o MyDescendant seja menos reutilizável, já que acopla ambos. Considere a alternativa que não transmite a dependência ao descendente e usa os princípios da inversão de controle, tornando o ancestral responsável pela execução da lógica:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

Essa abordagem pode ser mais adequada para alguns casos de uso porque desacopla o elemento filho dos ancestrais imediatos. As funções ancestrais que podem ser compostas costumam se tornar mais complexas, o que favorece a existência de funções que podem ser compostas mais flexíveis de nível mais baixo.

Do mesmo modo, lambdas de conteúdo @Composable podem ser usadas desta forma para ter os mesmos benefícios:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}