Datos de alcance local con CompositionLocal

CompositionLocal es una herramienta que permite pasar datos de manera implícita mediante la composición. En esta página, aprenderás con más detalle qué es CompositionLocal y cómo crear tu propio elemento CompositionLocal, y sabrás si CompositionLocal es una buena solución para tu caso de uso.

Presentamos CompositionLocal

Por lo general, en Compose, los datos fluyen hacia abajo a través del árbol de IU como parámetros para cada función que admite composición. De esta manera, se logra que las dependencias de un elemento que admite composición sean explícitas. Sin embargo, esto puede ser complicado para los datos que se usan con mucha frecuencia, como los colores o los estilos de tipo. Observa el siguiente ejemplo:

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

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

Para lograr que no se necesite pasar los colores como una dependencia explícita de parámetros a la mayoría de los elementos que admiten composición, Compose ofrece CompositionLocal, que te permite crear objetos que tengan un nombre y un alcance de árbol, y que se puedan usar como una manera implícita para que los datos fluyan a través del árbol de IU.

En general, los elementos CompositionLocal se aprovisionan con un valor en un nodo determinado del árbol de IU. Sus elementos subordinados que admiten composición pueden utilizar ese valor sin declarar CompositionLocal como parámetro en la función de componibilidad.

CompositionLocal es lo que usa MaterialTheme de forma interna. MaterialTheme es un objeto que proporciona tres instancias de CompositionLocal: colorScheme, typography y shapes, lo que te permite recuperarlas más tarde en cualquier parte descendiente de la composición. En particular, estas son las propiedades LocalColorScheme, LocalShapes y LocalTypography a las que tienes acceso mediante los atributos colorScheme, shapes y typography de MaterialTheme.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, 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.colorScheme.primary
    )
}

Una instancia de CompositionLocal tiene el alcance para una parte de la composición de manera que puedas brindar valores diferentes en distintos niveles del árbol. El valor current de un elemento CompositionLocal corresponde al valor más cercano que brinda un objeto principal en esa parte de la composición.

Para brindar un valor nuevo a un elemento CompositionLocal, usa el objeto CompositionLocalProvider y su función infija provides, que asocia una clave CompositionLocal a value. La lambda content del elemento CompositionLocalProvider obtendrá el valor proporcionado cuando acceda a la propiedad current de CompositionLocal. Cuando se brinda un valor nuevo, Compose recompone partes de la composición que lee el elemento CompositionLocal.

A modo de ejemplo, el objeto LocalContentColor de CompositionLocal incluye el color de contenido preferido que se usa para el texto y la iconografía para garantizar que contraste con el color de fondo actual. En el siguiente ejemplo, CompositionLocalProvider se usa a fin de proporcionar diferentes valores para distintas partes de la composición.

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

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

Figura 1: Vista previa del elemento CompositionLocalExample que admite composición

En el último ejemplo, los elementos de Material que admiten composición usan de manera interna las instancias de CompositionLocal. Para acceder al valor actual de un elemento CompositionLocal, usa su propiedad current. En el siguiente ejemplo, se utiliza el valor Context actual del objeto LocalContext CompositionLocal que, por lo general, se usa en las apps para Android a fin de darle formato al 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)
}

Cómo crear tu propio CompositionLocal

CompositionLocal es una herramienta que permite pasar datos de manera implícita mediante la composición.

Otro indicador clave para usar CompositionLocal es cuando el parámetro es transversal y las capas intermedias de implementación no deberían estar al tanto de su existencia, ya que, si estuvieran al tanto, se limitaría la utilidad del elemento que admite composición. Por ejemplo, la consulta de permisos de Android se otorga mediante un elemento CompositionLocal interno. Un selector de medios que admite composición puede agregar funcionalidades nuevas para acceder al contenido protegido por permisos en el dispositivo sin cambiar su API ni solicitarles a los llamadores del selector de medios que estén al tanto de este contexto adicional que se usa desde el entorno.

Sin embargo, CompositionLocal no siempre es la mejor solución. No te recomendamos que uses CompositionLocal de manera excesiva, ya que tiene algunas desventajas:

CompositionLocal causa que sea más difícil comprender el comportamiento de un elemento que admite composición. Como crean dependencias implícitas, los llamadores de elementos que admiten composición que las usan necesitan asegurarse de que se cumpla un valor para cada CompositionLocal.

Además, es posible que no exista una fuente de información clara para esta dependencia, ya que puede mutar en cualquier parte de la composición. Por lo tanto, puede ser más desafiante depurar la app cuando se produce un problema, ya que debes navegar hasta la composición para verificar dónde se brindó el valor current. Las herramientas como Find usages en el IDE o el Inspector de diseño de Compose brindan suficiente información para mitigar este problema.

Cómo decidir si usar CompositionLocal

Si se cumplen ciertas condiciones, CompositionLocal puede ser una buena solución para tu caso de uso:

Un elemento CompositionLocal debe tener un buen valor predeterminado. Si no existe un valor predeterminado, debes garantizar que, para un desarrollador, sea muy difícil intervenir en una situación en la que no se brinde un valor para CompositionLocal. No proporcionar un valor predeterminado puede causar problemas y generar frustración cuando se crean pruebas, u obtener una vista previa de un objeto que admite composición que usa ese elemento CompositionLocal siempre exigirá que se brinde de forma explícita.

Evita CompositionLocal para los conceptos sobre los cuales no se considera que tengan un alcance de árbol o de subjerarquía. Un objeto CompositionLocal tiene sentido cuando cualquier elemento subordinado (no solo unos pocos) puede usarlo.

Si tu caso de uso no cumple con estos requisitos, consulta la sección Alternativas que debes tener en cuenta antes de crear un elemento CompositionLocal.

Por ejemplo, no te recomendamos que crees un elemento CompositionLocal que contenga el ViewModel de una pantalla específica para que todos los elementos que admitan composición en esa pantalla puedan obtener una referencia al ViewModel a fin de realizar alguna lógica. Se trata de una práctica no recomendada, ya que no todos los elementos que admiten composición debajo de un árbol de IU determinado deben estar al tanto de la existencia de un ViewModel. Te recomendamos que pases a los elementos que admiten composición solo la información que necesiten según el patrón que indica que el estado fluye hacia abajo y los eventos fluyen hacia arriba. Con este enfoque, los elementos que admiten composición se podrán volver a utilizar más, y será más fácil probarlos.

Cómo crear un CompositionLocal

Existen dos API para crear un elemento CompositionLocal:

  • compositionLocalOf: Cambiar el valor que se brinda durante la recomposición invalida solo el contenido que lee su valor current.

  • staticCompositionLocalOf: A diferencia de compositionLocalOf, Compose no realiza un seguimiento de las lecturas de staticCompositionLocalOf. Cambiar el valor causa que se recomponga toda la lambda content en la que se proporciona CompositionLocal, en lugar de solo los lugares en los que se lee el valor current en la composición.

Si es poco probable que cambie el valor que se brinda al elemento CompositionLocal, o si nunca cambia, usa staticCompositionLocalOf para obtener beneficios de rendimiento.

Por ejemplo, es posible que el sistema de diseño de una app se defina en la manera en que los elementos que admiten composición se elevan mediante una sombra para el componente de IU. Como las diferentes elevaciones de la app deben propagarse por el árbol de IU, usamos un elemento CompositionLocal. Como el valor CompositionLocal se deriva de manera condicional en función del tema del sistema, usamos la API de 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() }

Cómo proporcionar valores a un CompositionLocal

El elemento CompositionLocalProvider que admite composición vincula los valores con las instancias de CompositionLocal para la jerarquía determinada. Para brindar un valor nuevo a un elemento CompositionLocal, usa la función infija provides que asocia una clave CompositionLocal a value de la siguiente manera:

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

Cómo consumir CompositionLocal

CompositionLocal.current muestra el valor que brinda el objeto CompositionLocalProvider más cercano que proporciona un valor a ese elemento CompositionLocal:

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

Alternativas que debes tener en cuenta

Un elemento CompositionLocal puede ser una solución exagerada para algunos casos de uso. Si tu caso de uso no cumple con los criterios que se especifican en la sección Cómo decidir si usar CompositionLocal, es probable que otra solución sea más adecuada para este.

Cómo pasar parámetros explícitos

Es una buena idea ser explícito acerca de las dependencias que admiten composición. Te recomendamos que pases a los elementos que admiten composición solo lo que necesiten. Para promover la separación y la reutilización de elementos que admiten composición, cada uno debe incluir la menor cantidad de información posible.

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

Inversión de control

Otra manera de evitar pasar dependencias innecesarias a un elemento que admite composición es mediante la inversión de control. En lugar de que el elemento subordinado tome una dependencia para ejecutar alguna lógica, el elemento superior lo hace.

Observa el siguiente ejemplo, en el que un elemento subordinado debe activar la solicitud para cargar algunos datos:

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

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

Según el caso, es posible que MyDescendant tenga mucha responsabilidad. Además, pasar MyViewModel como una dependencia causa que MyDescendant se pueda volver a utilizar menos, ya que ahora están vinculados. Ten en cuenta la alternativa que no pasa la dependencia al elemento subordinado y recurre a los principios de la inversión de control que causan que el elemento principal sea responsable de ejecutar la lógica:

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

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

Este enfoque es más adecuado para algunos casos de uso, ya que separa el elemento secundario de sus elementos principales inmediatos. Los elementos principales que admiten composición suelen ser más complejos a cambio de tener elementos más flexibles que admiten composición de nivel inferior.

De manera similar, se pueden usar lambdas de contenido @Composable del mismo modo para obtener estos beneficios:

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

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