Diseños en Jetpack Compose

1. Introducción

En el codelab básico de Jetpack Compose, aprendiste a compilar IU simples con Compose mediante el uso de elementos que admiten composición como Text y otros de diseños flexibles como Column y Row que te permiten ubicar elementos (de forma vertical y horizontal, respectivamente) en la pantalla y configurar la alineación de los elementos dentro de ella. Por otro lado, si no deseas que los elementos se muestren de forma vertical ni horizontal, Box te permitirá ubicar elementos delante o detrás de otros.

fbd450e8eab10338.png

Puedes usar estos componentes de diseño estándar para compilar IU como la siguiente:

d2c39f3c2416c321.png

@Composable
fun PhotographerProfile(photographer: Photographer) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(...)
        Column {
            Text(photographer.name)
            Text(photographer.lastSeenOnline, ...)
        }
    }
}

Gracias a la capacidad de integración y reutilización de Compose, puedes compilar tus propios elementos que admiten composición al combinar las diferentes partes que necesites en el nivel correcto de abstracción en una nueva función que admite composición.

En este codelab, aprenderás a usar el mayor nivel de abstracción de IU de Compose, Material Design, así como los elementos de bajo nivel que admiten composición, como Layout, que te permiten medir y ubicar elementos en la pantalla.

Si quieres crear una IU basada en Material Design, Compose te ofrece componentes de Material integrados que admiten composición, como verás en el codelab. Si no quieres usar Material Design o si quieres compilar algo que no esté en sus especificaciones, también aprenderás a crear diseños personalizados.

Qué aprenderás

En este codelab, aprenderás lo siguiente:

  • Cómo usar componentes de Material que admiten composición.
  • Qué son los modificadores y cómo usarlos en los diseños.
  • Cómo crear un diseño personalizado.
  • Cuándo podrías necesitar funciones intrínsecas.

Requisitos previos

Lo que necesitarás

2. Cómo comenzar un nuevo proyecto en Compose

Para comenzar un nuevo proyecto de Compose, abre Android Studio Bumblebee y selecciona Start a new Android Studio project como se muestra a continuación:

ec53715fe31913e6.jpeg

Si la pantalla anterior no aparece, ve a File > New > New Project.

Cuando crees un nuevo proyecto, elige Empty Compose Activity en las plantillas disponibles.

a67ba73a4f06b7ac.png

Haz clic en Next y configura tu proyecto como siempre. Asegúrate de seleccionar una minimumSdkVersion del nivel de API 21 como mínimo, que es la mínima que admite la API de Compose.

Cuando elijas la plantilla Empty Compose Activity, se generará el siguiente código en tu proyecto:

  • El proyecto ya está configurado para usar Compose.
  • Se creó el archivo AndroidManifest.xml.
  • El archivo app/build.gradle (o build.gradle (Module: YourApplicationName.app)) importa las dependencias de Compose y le permite a Android Studio trabajar con Compose con la marca buildFeatures { compose true }.
android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.activity:activity-compose:1.4.0'
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
}

Solución del codelab

Puedes obtener el código de la solución de este codelab en GitHub:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

También tienes la opción de descargar el repositorio como archivo ZIP:

Encontrarás el código de la solución en el proyecto LayoutsCodelab. Te recomendamos que sigas este codelab paso a paso, a tu ritmo y que compruebes la solución si es necesario. Durante el codelab, recibirás fragmentos de código que deberás agregar al proyecto.

3. Modificadores

Los modificadores te permiten decorar un elemento que admite composición. Puedes cambiar su comportamiento y apariencia, agregar información como etiquetas de accesibilidad, procesar entradas de los usuarios o incluso agregar interacciones de alto nivel como habilitar la posibilidad de hacer clics en un elemento, desplazarlo, arrastrarlo o ampliarlo. Los modificadores son objetos regulares de Kotlin. Puedes asignarlos a variables y volver a usarlos. También puedes encadenar varios modificadores, uno a continuación de otro, para crear una composición.

Implementemos el diseño del perfil que vimos en la sección de introducción:

d2c39f3c2416c321.png

Abre el archivo MainActivity.kt y agrega la siguiente información:

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha is defining opacity level of its children
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

Con vista previa:

bf29f2c3f5d6a27.png

A continuación, mientras se carga la foto, puedes mostrar un marcador de posición. Para ello, puedes usar una Surface y especificar en ella una forma de círculo y el color de ese marcador. A fin de especificar su tamaño, podemos usar el modificador size:

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

84f2bb229d67987b.png

Hay algunas mejoras que quisiéramos implementar aquí:

  1. Queremos agregar un espacio entre el marcador de posición y el texto.
  2. Quisiéramos que el texto estuviera centrado verticalmente.

Para el punto 1, podemos usar Modifier.padding en la Column que contiene el texto a fin de agregar un poco de espacio al start del elemento que admite composición y así separar la imagen del texto. Para el punto 2, algunos diseños ofrecen modificadores que solo se aplican a ellos y a sus características. Por ejemplo, los elementos que admiten composición en una Row pueden acceder a ciertos modificadores (desde el receptor de RowScope del contenido de la Fila) que tengan sentido, como weight o align. Determinar el alcance ofrece seguridad de tipo, de modo que no uses accidentalmente un modificador que no sirva para otro diseño. Por ejemplo, el uso de weight no tiene sentido en un Box, por lo que se presentará como un error en el tiempo de compilación.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

Con vista previa:

1542fadc7f68feb2.png

La mayoría de los elementos que admiten composición aceptan un parámetro modificador opcional a fin de hacerlos más flexibles, lo que permite que el llamador los modifique. Si estás creando tu propio elemento que admite composición, considera usar un modificador como parámetro, establécelo de forma predeterminada en Modifier (p. ej., un modificador vacío que no hace nada) y aplícalo a la raíz que admite composición de tu función. En este caso, sería así:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

El orden de los modificadores es importante

En el código, observa cómo puedes encadenar varios modificadores, uno a continuación del otro, con las funciones de extensión (p. ej., Modifier.padding(start = 8.dp).align(Alignment.CenterVertically)).

Ten cuidado a la hora de encadenar modificadores, ya que el orden es importante. Dado que se concatenan en un único argumento, el orden afecta el resultado final.

Si quisieras que se pueda hacer clics en el perfil de Fotógrafo y que este tenga un poco de padding, podrías hacer algo como lo siguiente:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

Si usas una vista previa interactiva o ejecutas un emulador:

c15a1050b051617f.gif

Observa que no se puede hacer clics en ninguna parte del área. Esto sucede porque se aplicó padding antes que el modificador clickable. Si aplicáramos el modificador de padding después del clickable, entonces el padding se incluiría en el área en la que es posible hacer clics:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Si usas una vista previa interactiva o ejecutas un emulador:

a1ea4c8e16d61ffa.gif

¡Deja volar tu imaginación! Los modificadores te permiten cambiar tu elemento que admite composición de una manera muy flexible. Por ejemplo, si quisieras agregar espacio en la parte externa, cambia el color de fondo del elemento que admite composición y, cerca de los extremos de la Row, puedes usar el código siguiente:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Si usas una vista previa interactiva o ejecutas un emulador:

4c7652fc71ccf8dc.gif

Más adelante en el codelab, exploraremos más acerca del funcionamiento interno de los modificadores.

4. API de ranuras

Compose brinda Componentes de Material de alto nivel que admiten composición y que puedes usar a fin de compilar tu IU. Como se trata de elementos fundamentales para crear IU, necesitarás proporcionar la información de lo que quieras mostrar en la pantalla.

Las API de ranuras son un patrón que Compose presenta a los efectos de incorporar una capa de personalización sobre los elementos que admiten composición, en este caso de uso, los Componentes de Material disponibles que admiten composición.

Veamos un ejemplo:

Si deseas un Botón de Material, hay un lineamiento establecido que detalla el aspecto y el contenido que un botón debería tener, lo que podemos convertir en una API simple de usar:

Button(text = "Button")

b3cb99320ec18268.png

Sin embargo, con frecuencia, querrás personalizar componentes en mucho mayor medida de lo que quizás esperamos. Podemos agregar un parámetro para cada elemento que puedas eventualmente personalizar, pero eso se descontrolará con rapidez:

Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

ef5893f332864e28.png

Por eso, en lugar de agregar varios parámetros a fin de personalizar un componente de una manera no prevista por nosotros, agregamos las Ranuras. Las ranuras dejan un espacio vacío en la IU de modo que el desarrollador lo complete como quiera.

fccfb817afa8876e.png

Por ejemplo, en el caso del Botón, podemos dejar la parte interna del Botón para que la completes. Quizás quieras insertar una fila con un ícono y texto:

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

A fin de habilitar esto, brindamos una API para un Botón que toma un elemento lambda secundario que admite composición (content: @Composable () -> Unit). Esto te permitirá definir tu propio elemento de este tipo que se emitirá dentro del Botón.

@Composable
fun Button(
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    ...
    content: @Composable () -> Unit
)

Observa que esta lambda, que llamamos content, es el último parámetro. Esto nos permite usar la sintaxis de expresión lambda final para de insertar contenido en el Botón de manera estructurada.

Compose usa las Ranuras en gran medida en componentes más complejos como la Barra superior de la app.

4365ce9b02ec2805.png

Aquí podemos personalizar más cosas además del título:

2decc9ec64c79a84.png

Ejemplo de uso:

TopAppBar(
    title = {
        Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
        Icon(myNavIcon)
    }
)

Cuando compilas tus propios elementos que admiten composición, puedes usar el patrón de la API de Ranuras para facilitar su reutilización.

En las próximas secciones, veremos los diferentes Componentes disponibles de Material que admiten composición, así como la forma de usarlos a la hora de compilar una app para Android.

5. Componentes de Material

Compose viene con Componentes de Material integrados que admiten composición y que puedes usar para crear tu app. El elemento de este tipo de más alto nivel es Scaffold.

Scaffold

Scaffold te permite implementar una IU con la estructura básica de diseño de Material Design. Proporciona ranuras para los componentes de Material de nivel superior más comunes, como TopAppBar, BottomAppBar, FloatingActionButton y Drawer. Con Scaffold, te aseguras que estos componentes se posicionarán y funcionarán juntos de forma correcta.

Con base en la plantilla de Android Studio generada, modificaremos el código de muestra para usar Scaffold. Abre MainActivity.kt. Puedes quitar los elementos Greeting y GreetingPreview que admiten composición, ya que no se usarán.

Crea un nuevo elemento que admita composición llamado LayoutsCodelab que modificaremos a lo largo del codelab:

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.codelab.layouts.ui.LayoutsCodelabTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LayoutsCodelabTheme {
                LayoutsCodelab()
            }
        }
    }
}

@Composable
fun LayoutsCodelab() {
    Text(text = "Hi there!")
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

Si ves la función de vista previa de Compose que debe tener la anotación @Preview, verás el LayoutsCodelab de esta manera:

bd1c58d4497f523f.png

Agreguemos el elemento Scaffold que admite composición a nuestro ejemplo de modo que podamos tener una estructura típica de Material Design. Todos los parámetros de la Scaffold API son opcionales excepto el contenido del cuerpo que es de tipo @Composable (InnerPadding) -> Unit: la expresión lambda recibe un padding como parámetro. Ese es el padding que debe aplicarse al elemento que admite composición correspondiente a la raíz del contenido a fin de limitar los elementos de forma apropiada en la pantalla. Para empezar por lo sencillo, agreguemos a Scaffold sin ningún otro componente de Material:

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

Con vista previa:

54b175d305766292.png

Si quisiéramos tener una Column con el contenido principal de nuestra pantalla, deberíamos aplicar el modificador a la Column:

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

Con vista previa:

aceda77e27f25fe9.png

A fin de mejorar la reutilización y las pruebas de nuestro código, deberíamos estructurarlo en partes pequeñas. Para eso, creemos otra función que admita composición con el contenido de nuestra pantalla.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

En apps para Android, resulta frecuente ver una Barra superior con información sobre la pantalla actual, la navegación y las acciones. Agreguemos eso a nuestro ejemplo.

TopAppBar

Scaffold tiene una ranura para una Barra superior de la app con el parámetro topBar de tipo @Composable () -> Unit, lo que significa que podemos completar la ranura con cualquier elemento que admite composición que deseemos. Por ejemplo, si solo queremos que contenga un texto de estilo h3, podríamos usar Text en la ranura proporcionada como se muestra a continuación:

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Con vista previa:

6adf05bb92b48b76.png

Sin embargo, como sucede con la mayoría de los componentes de Material, Compose viene con un elemento que admite composición de TopAppBar que tiene ranuras para ubicar un título, un ícono de navegación y acciones. Además, viene con contenido predeterminado que se ajusta a las recomendaciones de las especificaciones de Material, como el color que se usará en cada componente.

Siguiendo el patrón de la API de ranuras, queremos que la ranura del title de la TopAppBar contenga un Text con el título de la pantalla:

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Con vista previa:

c93d09851d6560c7.png

En general, las Barras superiores de las apps tienen algunos elementos de acción. En nuestro ejemplo, agregaremos un botón de favoritos que podrás presionar cuando consideres que hayas aprendido algo. Compose también incluye algunos íconos predefinidos de Material que puedes usar, por ejemplo, los íconos de cerrar, favoritos y menú.

La ranura para los elementos de acción de la Barra superior de la app es el parámetro de actions que usa de manera interna una Row de modo que varias acciones se ubiquen horizontalmente. A fin de que se use uno de los íconos predefinidos, podemos usar el elemento IconButton que admite composición con un Icon dentro de él:

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Con vista previa:

b2d81ccec4667ef5.png

En general, las acciones modifican de alguna manera el estado de tu aplicación. Si deseas obtener más información sobre el estado, puedes obtener los conceptos básicos de la administración del estado en el codelab básico de Compose.

Cómo ubicar los modificadores

Cuando creamos elementos nuevos que admiten composición, contar con un parámetro modifier con valor predeterminado establecido en Modifier resulta una buena práctica a fin de facilitar su reutilización. Nuestro elemento BodyContent que admite composición ya toma un modificador como parámetro. Si quisiéramos agregar padding adicional a BodyContent, ¿dónde deberíamos ubicar el modificador de padding?

Tenemos dos posibilidades:

  1. Aplicar el modificador al único elemento secundario directo dentro del elemento que admite composición de modo que todas las llamadas a BodyContent apliquen el padding adicional:
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}
  1. Aplicar el modificador cuando se llame al elemento que admite composición que agregará el padding adicional solo cuando se necesite:
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

Decidir el lugar depende por completo del tipo de elemento y del caso de uso. Si el modificador es intrínseco al elemento que admite composición, ubícalo dentro de él. De lo contrario, ubícalo por fuera. En nuestro caso, optaríamos por la opción 2 dado que el padding es algo que no siempre forzaremos cuando llamemos a BodyContent; debería aplicarse de manera individual.

Los modificadores pueden encadenarse llamando a cada función de modificador sucesiva desde la anterior. Cuando no haya un método de encadenamiento disponible, puedes usar .then(). En nuestro ejemplo, comenzamos con modifier (letra minúscula), es decir, la cadena se compila sobre la que se pasó como parámetro.

Más íconos

Además de los íconos que mencionamos más arriba, puedes agregar una nueva dependencia al proyecto y usar la lista completa de íconos de Material. En caso de que quieras experimentar con esos íconos, abre el archivo app/build.gradle (o build.gradle (Module: app)) e importa la dependencia de ui-material-icons-extended:

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

Puedes cambiar los íconos de la TopAppBar tanto como quieras.

Trabajo adicional

Scaffold y TopAppBar son solo algunos de los elementos que admiten composición que pueden usarse a los efectos de tener una aplicación que luzca como Material. Lo mismo puede hacerse para otros componentes de Material, como BottomNavigation o BottomDrawer. A modo de ejercicio, te invitamos a que completes las ranuras de Scaffold con aquellas API de la misma manera que vimos hasta el momento.

6. Cómo trabajar con listas

Mostrar una lista de elementos es un patrón común en las aplicaciones. Jetpack Compose hace que este patrón resulte fácil de implementar con los elementos Column y Row que admiten composición, pero también ofrece listas diferidas que solo componen y muestran los elementos que estén visibles.

Practiquemos y creemos una lista vertical de 100 elementos mediante el elemento Column que admite composición:

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Como Column no controla el desplazamiento de forma predeterminada, algunos elementos no están visibles dado que están fuera de la pantalla. Agrega el modificador verticalScroll a fin de habilitar el desplazamiento dentro de la Column:

@Composable
fun SimpleList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Lista diferida

La Column renderiza todos los elementos de la lista, incluso aquellos que no estén visibles en la pantalla, lo cual representa un problema de rendimiento cuando la lista aumenta de tamaño. Para evitar este problema, usa LazyColumn, que renderiza solo los elementos visibles en pantalla, permite el aumento de rendimiento y no requiere el modificador scroll.

LazyColumn tiene una DSL para describir la lista de contenido. Usarás items, que puede tomar un número como tamaño de la lista. También admite arrays y listas (obtén más información en la sección de documentación sobre Listas).

@Composable
fun LazyList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

1c747e54111e28c.gif

Cómo mostrar imágenes

Como vimos con anterioridad con la PhotographCard, Image es un elemento que admite composición y que puedes usar para mostrar un Mapa de bits o una imagen vectorial. Si la imagen se recupera de forma remota, el proceso involucra más pasos, dado que tu app necesita descargar el elemento, decodificarlo como un mapa de bits y, finalmente, renderizarlo dentro de una Image.

A fin de simplificar estos pasos, usarás la biblioteca de Coil, que proporciona elementos que admiten composición y que ejecutan estas tareas de forma eficiente.

Agrega la dependencia de Coil en tu archivo de proyecto build.gradle:

// build.gradle
implementation 'io.coil-kt:coil-compose:1.4.0'

Como estaremos recuperando una imagen remota, agrega el permiso INTERNET a tu archivo de manifiesto:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

Ahora, crea un elemento que admite composición en el que mostrarás una imagen con el índice del elemento junto a ella:

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

A continuación, cambia el elemento de Text que admite composición en tu lista con este ImageListItem:

@Composable
fun ImageList() {
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

9c6a666c57a84211.gif

Cómo desplazarse por listas

Ahora controlemos de forma manual la posición de desplazamiento de la lista. Agregaremos dos botones que permitan desplazarnos sin problemas al comienzo y al final de la lista. A fin de evitar el bloqueo de la lista que tiene tu nombre, las API de desplazamiento son funciones de suspensión. Por lo tanto, necesitaremos llamarla en una corrutina. A fin de hacer eso, podemos crear un CoroutineScope usando la función rememberCoroutineScope para crear corrutinas desde los controladores de eventos de botón. Este CoroutineScope seguirá el ciclo de vida del lugar de la llamada. Si deseas obtener más información acerca de los ciclos de vida de los elementos que admiten composición, corrutinas y efectos colaterales, consulta la siguiente guía.

val listSize = 100
// We save the scrolling position with this state
val scrollState = rememberLazyListState()
// We save the coroutine scope where our animated scroll will be executed
val coroutineScope = rememberCoroutineScope()

Por último, agregaremos nuestros botones que controlarán el desplazamiento:

Row {
    Button(onClick = {
        coroutineScope.launch {
            // 0 is the first item index
            scrollState.animateScrollToItem(0)
        }
    }) {
        Text("Scroll to the top")
    }

    Button(onClick = {
        coroutineScope.launch {
            // listSize - 1 is the last index of the list
            scrollState.animateScrollToItem(listSize - 1)
        }
    }) {
        Text("Scroll to the end")
    }
}

9bc52801a90401f3.gif

Código completo de esta sección

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

@Composable
fun ScrollingList() {
    val listSize = 100
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()
    // We save the coroutine scope where our animated scroll will be executed
    val coroutineScope = rememberCoroutineScope()

    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0 is the first item index
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }

            Button(onClick = {
                coroutineScope.launch {
                    // listSize - 1 is the last index of the list
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the end")
            }
        }

        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

7. Cómo crear tu diseño personalizado

Compose promueve la reutilización de los elementos que admiten composición como pequeñas partes que pueden resultar suficientes para diseños personalizados cuando se combinan elementos integrados que admiten composición, como Column, Row o Box.

Sin embargo, quizás necesites compilar algo único para tu app que requiera medir y distribuir los elementos secundarios de forma manual. Para ello, puedes usar el elemento Layout que admite composición. De hecho, todos los diseños de nivel superior, como Column y Row se compilan con él.

Antes de que nos aboquemos a crear diseños personalizados, necesitamos saber más sobre los principios de los Diseños en Compose.

Principios de los diseños en Compose

Algunas funciones que admiten composición emiten una porción de la IU cuando se las invoca, que luego se agregan a un árbol de IU que se renderizará en la pantalla. Cada emisión (o elemento) tiene un elemento superior y, posiblemente, varios secundarios. Además, tiene una ubicación dentro de su elemento superior, una posición (x, y), y un tamaño, un width y un height.

Se solicitará a los elementos que se midan a sí mismos mediante Restricciones que deben cumplirse. Las restricciones limitan los valores mínimos y máximos de width y height de un elemento. Si un elemento tiene elementos secundarios, puede medir cada uno de ellos para ayudar a determinar su tamaño. Una vez que un elemento informa su propio tamaño, tiene la oportunidad de colocar sus elementos secundarios en relación con ellos. Esto se explicará en mayor detalle a la hora de crear el diseño personalizado.

La IU de Compose no permite la medición de varios pasos. Eso significa que un elemento de diseño no puede medir ninguno de sus elementos secundarios más de una vez para probar diferentes configuraciones de medición. La medición de un solo paso es ideal en términos de rendimiento y permite que Compose procese de manera eficiente los árboles detallados de la IU. Supongamos que un elemento midió dos veces a su elemento secundario, y el elemento secundario, a su vez, midió dos veces a su elemento secundario, y así sucesivamente. Un solo intento para implementar toda la IU requeriría muchísimo trabajo, lo que dificultaría lograr que tu app funcione bien. Sin embargo, hay momentos en los que realmente necesitas información adicional, más allá de lo que te pueda indicar una sola medición del elemento secundario. Para estos casos, tenemos formas de hacer esto, que revisaremos más adelante.

Cómo usar el modificador de diseño

Usa el modificador de layout a fin de controlar de forma manual cómo medir y posicionar un elemento. En general, la estructura común de un modificador de layout personalizado es la siguiente:

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
  ...
})

Cuando usas el modificador de layout, obtienes dos parámetros lambda:

  • measurable, el elemento secundario que se medirá y posicionará
  • constraints, el valor mínimo y máximo del ancho y el alto del elemento secundario

Supongamos que quieres mostrar un Text en la pantalla y controlar la distancia desde la parte superior hasta la línea de base de la primera línea de texto. Para lograrlo, necesitarás ubicar de forma manual el elemento que admite composición en la pantalla usando el modificador de layout. Consulta la siguiente imagen para ver el comportamiento deseado, donde la distancia desde la parte superior hasta la línea de base es 24.dp:

4ee1054702073598.png

Primero, creemos un modificador firstBaselineToTop:

fun Modifier.firstBaselineToTop(
  firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...
    }
)

Lo primero que debes hacer es medir el elemento que admite composición. Como mencionamos en la sección Principios de los diseños en Compose, solo puedes medir tu elemento secundario una vez.

Mide el elemento que admite composición llamando a measurable.measure(constraints). Cuando llames a measure(constraints), podrás pasar las restricciones dadas del elemento disponible que admite composición en el parámetro lambda de constraints o bien crear las tuyas. El resultado de la llamada a measure() en un elemento Measurable es un elemento Placeable que puede posicionarse llamando a placeRelative(x, y), como haremos más adelante.

Para este caso de uso, no apliques más restricciones y usa solamente las dadas:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        ...
    }
)

Ahora que se midió el elemento que admite composición, debes calcular su tamaño y especificarlo llamando al método layout(width, height), que también acepta una expresión lambda que se usa para posicionar el contenido.

En este caso, el ancho de nuestro elemento que admite composición será el width del elemento medido, la altura será su height y la altura deseada será la de la parte superior hasta la línea de base menos la primera línea de base:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        // Check the composable has a first baseline
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        val firstBaseline = placeable[FirstBaseline]

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            ...
        }
    }
)

Ahora puedes posicionar el elemento que admite composición en la pantalla llamando a placeable.placeRelative(x, y). Si no llamas a placeRelative, no podrás ver el elemento. placeRelative automáticamente ajusta la posición del parámetro placeable con base en la layoutDirection actual.

En este caso, la posición y del texto corresponde al padding superior menos la posición de la primera línea de base:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            // Where the composable gets placed
            placeable.placeRelative(0, placeableY)
        }
    }
)

Para verificar que funcione como se espera, usa este modificador sobre un Text como viste en la imagen anterior:

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
  }
}

@Preview
@Composable
fun TextWithNormalPaddingPreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.padding(top = 32.dp))
  }
}

Con vista previa:

dccb4473e2ca09c6.png

Cómo usar el elemento de Diseño que admite composición

En lugar de controlar cómo medir y mostrar en pantalla un único elemento que admite composición, quizás necesites hacer lo mismo para un grupo de elementos de este tipo. A tal fin, puedes usar el elemento Layout que admite composición y controlar de forma manual la medición y el posicionamiento de los elementos secundarios del diseño. En general, la estructura común de un elemento que admite composición que usa Layout es la siguiente:

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Los parámetros mínimos requeridos para un CustomLayout son un modifier y un content. Luego, estos parámetros se pasarán al Layout. En la expresión lambda final del Layout (de tipo MeasurePolicy), obtienes los mismos parámetros lambda que obtuviste con el modificador de layout.

A fin de ver el Layout en acción, comencemos a implementar una Column muy básica mediante un Layout para comprender la API. Más adelante, compilaremos algo más complejo con el fin de mostrar la flexibilidad del elemento Layout que admite composición.

Cómo implementar una Columna básica

Nuestra implementación personalizada del elemento Column distribuye los elementos de forma vertical. Además, para mayor simplicidad, nuestro diseño ocupa tanto espacio como puede en su elemento superior.

Crea un nuevo elemento que admite composición llamado MyOwnColumn y agrega la estructura común de un elemento Layout de ese tipo:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Como vimos antes, lo primero que debemos hacer es medir nuestros elementos secundarios, que solo pueden medirse una vez. De forma similar al funcionamiento del modificador de diseño, en el parámetro lambda measurables, obtendrás todo el content que puedes medir llamando a measurable.measure(constraints).

Para este caso de uso, no aplicarás más restricciones sobre nuestras vistas secundarias. Cuando midas los elementos secundarios, te recomendamos que también registres el width y la height máxima de cada fila a fin de poder posicionarlos en la pantalla de forma correcta más adelante.

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

Ahora que tienes en la lógica la lista de los elementos secundarios medidos, antes de posicionarlos en la pantalla, debes calcular el tamaño de nuestra versión del elemento Column. Como lo estás haciendo tan grande como su elemento superior, su tamaño corresponderá a las restricciones pasadas por el elemento superior. Especifica el tamaño de nuestra propia Column llamando al método layout(width, height), que también te dará la expresión lambda usada para posicionar los elementos secundarios:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Measure children - code in the previous code snippet
        ...

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

Por último, posicionemos nuestros elementos secundarios en la pantalla llamando a placeable.placeRelative(x, y). A fin de posicionarlos de forma vertical, llevaremos un registro de la coordenada y a cuya altura posicionamos elementos secundarios. El código final de MyOwnColumn se ve de la siguiente manera:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }

        // Track the y co-ord we have placed children up to
        var yPosition = 0

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children in the parent layout
            placeables.forEach { placeable ->
                // Position item on the screen
                placeable.placeRelative(x = 0, y = yPosition)

                // Record the y co-ord placed up to
                yPosition += placeable.height
            }
        }
    }
}

MyOwnColumn en acción

Veamos a MyOwnColumn en la pantalla usándola en el elemento BodyContent que admite composición. Reemplaza el contenido dentro de BodyContent con lo siguiente:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    MyOwnColumn(modifier.padding(8.dp)) {
        Text("MyOwnColumn")
        Text("places items")
        Text("vertically.")
        Text("We've done it by hand!")
    }
}

Con vista previa:

e69cdb015e4d8abe.png

8. Diseños personalizados complejos

Una vez que hayamos cubierto los conceptos básicos de Layout, creemos un ejemplo más complejo a fin de mostrar la flexibilidad de la API. Compilaremos la cuadrícula escalonada y personalizada de Study Owl de Material que puedes ver en el centro de la siguiente imagen:

7a54fe8390fe39d2.png

La cuadrícula escalonada de Owl presenta los elementos de forma vertical y completa una columna por vez en función de una cantidad n dada de filas. Hacer esto con una Row de Columns no es posible, ya que no podrías escalonar el diseño. Aplicar una Column de Rows podría ser posible si preparas los datos de modo que se muestren de forma vertical.

Sin embargo, el diseño personalizado también te da la oportunidad de limitar el alto de todos los elementos en la cuadrícula escalonada. Por lo tanto, a fin de tener más control sobre el diseño y aprender a crear uno personalizado, mediremos y posicionaremos los elementos secundarios por nuestra cuenta.

Si quisiéramos reutilizar la cuadrícula en diferentes orientaciones, podríamos tomar como parámetro la cantidad de filas que queremos ver en pantalla. Dado que deberíamos obtener esa información cuando se invoque el diseño, la pasaremos como un parámetro:

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Como vimos antes, lo primero que debemos hacer es medir nuestros elementos secundarios. Recuerda que solo puedes medir tus elementos secundarios una vez.

Para nuestro caso de uso, no aplicaremos más restricciones sobre nuestras vistas secundarias. Cuando midamos los elementos secundarios, también deberíamos registrar el width y la height máxima de cada fila:

Layout(
    modifier = modifier,
    content = content
) { measurables, constraints ->

    // Keep track of the width of each row
    val rowWidths = IntArray(rows) { 0 }

    // Keep track of the max height of each row
    val rowHeights = IntArray(rows) { 0 }

    // Don't constrain child views further, measure them with given constraints
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->

        // Measure each child
        val placeable = measurable.measure(constraints)

        // Track the width and max height of each row
        val row = index % rows
        rowWidths[row] += placeable.width
        rowHeights[row] = Math.max(rowHeights[row], placeable.height)

        placeable
    }
    ...
}

Ahora que tenemos en la lógica la lista de los elementos secundarios medidos, antes de posicionarlos en la pantalla, debemos calcular el tamaño de nuestra cuadrícula (width y height completos). Además, dado que ya sabemos la altura máxima de cada fila, podemos calcular dónde posicionaremos los elementos de cada una en la posición Y. Guardaremos las posiciones Y en la variable rowY:

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Grid's width is the widest row
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // Grid's height is the sum of the tallest element of each row
    // coerced to the height constraints
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // Y of each row, based on the height accumulation of previous rows
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

Por último, posicionemos nuestros elementos secundarios en la pantalla llamando a placeable.placeRelative(x, y). En nuestro caso de uso, también registraremos la coordenada X de cada fila en la variablerowX:

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Set the size of the parent layout
    layout(width, height) {
        // x cord we have placed up to, per row
        val rowX = IntArray(rows) { 0 }

        placeables.forEachIndexed { index, placeable ->
            val row = index % rows
            placeable.placeRelative(
                x = rowX[row],
                y = rowY[row]
            )
            rowX[row] += placeable.width
        }
    }
}

Cómo usar la StaggeredGrid personalizada en un ejemplo

Ahora que tenemos nuestra cuadrícula personalizada que sabe cómo medir y posicionar elementos secundarios, usémosla en nuestra app. Para simular los chips de Owl en la cuadrícula, podemos crear con facilidad un elemento que admite composición y que haga algo similar:

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier.size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

Con vista previa:

f1f8c6bb7f12cf1.png

Ahora, creemos una lista de temas que podemos mostrar en nuestro BodyContent y mostrémoslos en la StaggeredGrid:

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        BodyContent()
    }
}

Con vista previa:

e9861768e4e27dd4.png

Observa que podemos cambiar la cantidad de filas de nuestra cuadrícula, y esta seguirá funcionando como lo esperamos:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier, rows = 5) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

Con vista previa:

555f88fd41e4dff4.png

Dado que, en función de la cantidad de filas, es posible que los temas salgan de la pantalla, podemos hacer que nuestro BodyContent admita desplazamiento si unimos la StaggeredGrid en una Row que lo admita y pasamos el modificador a ella en lugar de hacerlo a StaggeredGrid.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Si usas el botón de vista previa interactiva bb4c8dfe4b8debaa.png o ejecutas la app en el dispositivo presionando el botón de ejecutar de Android Studio, verás que podrás desplazar el contenido de forma horizontal.

9. Funcionamiento interno de los modificadores de diseño

Ahora que conocemos los aspectos básicos de los modificadores, cómo crear elementos personalizados que admiten composición y cómo medir y posicionar elementos secundarios de forma manual, comprenderemos mejor el funcionamiento interno de los modificadores.

A modo de repaso, los modificadores te permiten personalizar el comportamiento de un elemento que admite composición. Puedes combinar varios modificadores si los encadenas juntos. Hay varios tipos de modificadores, pero en esta sección nos concentraremos en los LayoutModifier, ya que pueden cambiar la forma en que se mide y muestra un componente de la IU.

Los elementos que admiten composición son responsables de su propio contenido, el cual puede no ser inspeccionado ni manipulado por un elemento superior a menos que el autor del elemento que admite composición exponga una API explícita a tal fin. De forma similar, los modificadores de un elemento del tipo mencionado decoran lo que modifican de la misma manera opaca: los modificadores están encapsulados.

Cómo analizar un modificador

Dado que Modifier y LayoutModifier son interfaces públicas, puedes crear tus propios modificadores. Como ya usamos Modifier.padding antes, analicemos su implementación a fin de comprender mejor los modificadores.

padding es una función respaldada por una clase que implementa la interfaz de LayoutModifier y que anulará el método measure. PaddingModifier es una clase normal que implementa una función equals() de modo que el modificador pueda compararse entre recomposiciones.

A modo de ejemplo, este es el código fuente de la forma en que el padding modifica el tamaño y las restricciones del elemento en el que se aplica:

// How to create a modifier
@Stable
fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
    )

// Implementation detail
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

El width nuevo del elemento será el width del elemento secundario + los valores inicial y final del padding convertidos a las restricciones de ancho del elemento. La height será la height del elemento secundario + los valores superior e inferior del padding convertidos a las restricciones de altura del elemento.

El orden es importante

Como viste en la primera sección, el orden a la hora de encadenar modificadores es importante, ya que se aplican al elemento que modifican y que admite composición del primero al último, lo que significa que la medición y el diseño de los modificadores que se encuentren a la izquierda afectarán el modificador que se encuentre a la derecha. El tamaño final del elemento que admite composición depende de todos los modificadores que se pasen como parámetros.

Primero, los modificadores actualizarán las restricciones de izquierda a derecha y, luego mostrarán el tamaño de derecha a izquierda. Veamos un ejemplo:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .size(200.dp)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Los modificadores aplicados de esta manera generan esta vista previa:

cb209bb5edf634d6.png

Primero, cambiemos el fondo a fin de ver cómo los modificadores afectan la IU; luego, apliquemos restricciones al tamaño para obtener un width y una height de 200.dp y, por último, apliquemos padding para agregar un poco de espacio entre el texto y su entorno.

Dado que las restricciones se propagan a lo largo de la cadena de izquierda a derecha, aquellas con las que se medirá el contenido de la Row será de (200-16-16)=168 dp tanto para el valor mínimo como para el máximo de width y height. Esto significa que el tamaño de la StaggeredGrid será de 168x168 dp exactamente. Por lo tanto, el tamaño final de la Row que admite desplazamiento, luego de que la cadena modifySize se ejecute de derecha a izquierda, será de 200x200 dp.

Si cambiamos el orden de los modificadores y aplicamos primero el padding y luego el tamaño, obtendremos una IU diferente:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray, shape = RectangleShape)
            .padding(16.dp)
            .size(200.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Con vista previa:

17da5805d6d8fc91.png

En este caso, las restricciones que originalmente tenían la Row que admite desplazamiento y el padding se convertirán a las restricciones de size a fin de medir los elementos secundarios. Por lo tanto, la StaggeredGrid tendrá una restricción de 200 dp tanto para el valor mínimo como para el máximo de width y height. El tamaño de StaggeredGrid es de 200x200 dp y, dado que el tamaño se modifica de derecha a izquierda, el modificador de padding aumentará el tamaño a (200+16+16)x(200+16+16)=232x232, que también será el tamaño final de la Row.

Dirección del diseño

Puedes cambiar la dirección del diseño de un elemento que admite composición usando el ambiente de LayoutDirection.

Si quieres posicionar elementos que admiten composición de manera manual en la pantalla, la layoutDirection forma parte del LayoutScope del modificador layout o del elemento Layout. Cuando usas layoutDirection, posiciona los elementos que admiten composición usando place, ya que, al contrario del método placeRelative, no duplicará automáticamente la posición en el contexto de derecha a izquierda.

Código completo de esta sección

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.codelab.layouts.ui.LayoutsCodelabTheme
import kotlin.math.max

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        .background(color = Color.LightGray)
        .padding(16.dp)
        .size(200.dp)
        .horizontalScroll(rememberScrollState()),
        content = {
            StaggeredGrid {
                for (topic in topics) {
                    Chip(modifier = Modifier.padding(8.dp), text = topic)
                }
            }
        })
}

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Keep track of the width of each row
        val rowWidths = IntArray(rows) { 0 }

        // Keep track of the max height of each row
        val rowHeights = IntArray(rows) { 0 }

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.mapIndexed { index, measurable ->
            // Measure each child
            val placeable = measurable.measure(constraints)

            // Track the width and max height of each row
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = Math.max(rowHeights[row], placeable.height)

            placeable
        }

        // Grid's width is the widest row
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // Grid's height is the sum of the tallest element of each row
        // coerced to the height constraints
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

        // Y of each row, based on the height accumulation of previous rows
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // Set the size of the parent layout
        layout(width, height) {
            // x co-ord we have placed up to, per row
            val rowX = IntArray(rows) { 0 }

            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

10. Diseño de restricciones

ConstraintLayout puede ayudarte a posicionar elementos que admiten composición en relación con otros en la pantalla y es una alternativa al uso de varios elementos Row, Column y Box. ConstraintLayout resulta útil cuando se implementan diseños más grandes con requisitos de alineación más complejos.

Puedes encontrar la dependencia del diseño de restricciones de Compose en el archivo build.gradle de tu proyecto:

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"

En Compose, ConstraintLayout funciona con una DSL:

  • Las referencias se crean con createRefs() (o createRef()) y cada elemento que admite composición en ConstraintLayout debe tener una referencia asociada.
  • Las restricciones se proporcionan mediante el modificador constrainAs, que toma la referencia como parámetro y te permite especificar sus restricciones en la expresión lambda del cuerpo.
  • Las restricciones se especifican mediante linkTo o algún otro método útil.
  • parent es una referencia existente que se puede usar para especificar restricciones hacia el mismo elemento ConstraintLayout.

Comencemos con un ejemplo simple.

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // Create references for the composables to constrain
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Assign reference "button" to the Button composable
            // and constrain it to the top of the ConstraintLayout
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Assign reference "text" to the Text composable
        // and constrain it to the bottom of the Button composable
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

@Preview
@Composable
fun ConstraintLayoutContentPreview() {
    LayoutsCodelabTheme {
        ConstraintLayoutContent()
    }
}

Esto restringe la parte superior del Button al elemento principal, con un margen de 16.dp, y un Text a la parte inferior del Button, también con un margen de 16.dp.

72fcb81ab2c0483c.png

Si quisiéramos centrar el texto de forma horizontal, podemos usar la función centerHorizontallyTo que establece tanto el start como el end del Text a los bordes del elemento parent:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // Same as before

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })
    }
}

Con vista previa:

729a1b4c03f1f187.png

El tamaño de ConstraintLayout será tan pequeño como sea posible para ajustar su contenido. Ese es el motivo por el que el Text parece estar centrado en torno al Button en lugar del elemento superior. Si quisieras otro comportamiento de tamaño, deberías aplicar los modificadores de tamaño (p. ej., fillMaxSize y size) al elemento ConstraintLayout que admite composición como harían para cualquier otro diseño en Compose.

Ayudas

La DSL también admite la creación de lineamientos, barreras y cadenas. Por ejemplo:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        val (button1, button2, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button 1")
        }

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            centerAround(button1.end)
        })

        val barrier = createEndBarrier(button1, text)
        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text("Button 2")
        }
    }
}

Con vista previa:

a4117576ef1768a2.png

Ten en cuenta lo siguiente:

  • Las barreras (y todas las demás ayudas) pueden crearse en el cuerpo de ConstraintLayout, pero no dentro de constrainAs.
  • linkTo puede crearse a fin de restringir con lineamientos y barreras de la misma manera que funciona para los bordes del diseño.

Cómo personalizar las dimensiones

De forma predeterminada, los elementos secundarios de ConstraintLayout podrán elegir el tamaño que necesitan para ajustar su contenido. Por ejemplo, esto significa que un Texto podrá salir de los bordes de la pantalla cuando el texto sea demasiado largo:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(start = guideline, end = parent.end)
            }
        )
    }
}

@Preview
@Composable
fun LargeConstraintLayoutPreview() {
    LayoutsCodelabTheme {
        LargeConstraintLayout()
    }
}

616c19b971811cfa.png

Obviamente, querrás que las líneas del texto se dividan de modo que entren en el espacio disponible. Para lograr esto, podemos cambiar el comportamiento del width del texto:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(guideline, parent.end)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

Con vista previa:

fc41cacd547bbea.png

Los comportamientos disponibles de las Dimension son los siguientes:

  • preferredWrapContent: El diseño ajustará el contenido en función de las restricciones de esa dimensión.
  • wrapContent: El diseño ajustará el contenido incluso cuando las restricciones no lo permitan.
  • fillToConstraints: El diseño se expandirá hasta llenar el espacio definido por las restricciones de esa dimensión.
  • preferredValue: El diseño será un valor fijo de dp en función de las restricciones de esa dimensión.
  • value: El diseño será un valor fijo de dp, independientemente de las restricciones de esa dimensión.

Además, algunos elementos Dimension pueden convertirse:

width = Dimension.preferredWrapContent.atLeast(100.dp)

API desacoplada

Hasta el momento, en los ejemplos, se especificaron restricciones intercaladas, con un modificador en el elemento que admite composición en el cual se aplicaban. Sin embargo, existen casos en los que vale la pena mantener las restricciones desacopladas de los diseños a los que aplican: un ejemplo común consiste en cambiar con facilidad las restricciones con base en la configuración de la pantalla o realizar animaciones entre 2 conjuntos de restricciones.

En estos casos, puedes usar ConstraintLayout de otro modo:

  1. Pasa un ConstraintSet como parámetro a ConstraintLayout.
  2. Asigna referencias creadas en el ConstraintSet a los elementos que admiten composición con el modificador layoutId.

La forma de esta API aplicada al primer ejemplo de ConstraintLayout que se muestra más arriba, optimizada para el ancho de la pantalla, tiene el siguiente aspecto:

@Composable
fun DecoupledConstraintLayout() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin= margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

11. Funciones intrínsecas

Una de las reglas de Compose es que solo debes medir tus elementos secundarios una vez. Si lo haces dos veces, se genera una excepción de tiempo de ejecución. Sin embargo, hay momentos en los que necesitas información sobre tus elementos secundarios antes de medirlos.

Las funciones intrínsecas te permiten realizar consultas a los elementos secundarios antes de que se midan.

Para un elemento que admite composición, puedes solicitar su intrinsicWidth o intrinsicHeight:

  • (min|max)IntrinsicWidth: Con esta altura, ¿cuál es el ancho mínimo y máximo con el que puedes pintar el contenido de manera correcta?
  • (min|max)IntrinsicHeight: Con este ancho, ¿cuál es la altura mínima o máxima con la que puedes pintar correctamente el contenido?

Por ejemplo, si solicitas la minIntrinsicHeight de un Text con width infinito, se mostrará la height del Text como si se hubiera dibujado el texto en una línea individual.

Funciones intrínsecas en acción

Imagina que queremos crear un elemento componible que muestre dos textos en la pantalla separados por un divisor como este:

835f0b8c9f07cd9.png

¿Cómo podemos hacer esto? Podemos tener un objeto Row con dos Text que se expandan tanto como sea posible y un Divider en el medio. Queremos que el divisor sea tan alto como el Text más alto y delgado (width = 1.dp).

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

En la vista previa, vemos que el divisor se expande a toda la pantalla, pero eso no es lo que deseamos:

d61f179394ded825.png

Esto ocurre porque Row mide cada elemento secundario de forma individual, y la altura de Text no se puede usar para restringir Divider. Queremos que el Divider ocupe el espacio disponible con una altura determinada. Para eso, podemos usar el modificador height(IntrinsicSize.Min).

height(IntrinsicSize.Min) ajusta su tamaño a los elementos secundarios para que sean tan altos como su altura mínima intrínseca. Como es recurrente, realizará consultas a Row y sus elementos secundarios minIntrinsicHeight.

Cuando lo apliquemos a nuestro código, funcionará según lo esperado:

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

Con vista previa:

835f0b8c9f07cd9.png

La minIntrinsicHeight de la Fila será la minIntrinsicHeight máxima de sus elementos secundarios. La minIntrinsicHeight del Divisor es 0, ya que no ocupa espacio si no se le aplican restricciones. La minIntrinsicHeight del Texto será aquella del texto en función de un width específico. Por lo tanto, la restricción de height de la Fila será la minIntrinsicHeight máxima de los elementos Text. Luego, Divider expandirá su height a la restricción de height proporcionada por la Fila.

Hazlo tú mismo

Cuando crees tu diseño personalizado, puedes modificar la forma en que se calculan las funciones intrínsecas con el (min|max)Intrinsic(Width|Height) de la interfaz MeasurePolicy. Sin embargo, los valores predeterminados deberían ser adecuados en la mayoría de los casos.

Además, puedes modificar las funciones intrínsecas con modificadores que anulen los métodos Density.(min|max)Intrinsic(Width|Height)Of de la interfaz del Modificador, que también tiene un valor predeterminado adecuado.

12. Felicitaciones

¡Felicitaciones! Completaste este codelab con éxito.

Solución del codelab

Puedes obtener el código de la solución de este codelab en GitHub:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

También tienes la opción de descargar el repositorio como archivo ZIP:

¿Qué sigue?

Consulta los otros codelabs sobre la ruta de aprendizaje de Compose:

Lecturas adicionales

Apps de ejemplo

  • Owl crea diseños personalizados
  • Rally muestra gráficos y tablas
  • Jetsnack y los diseños personalizados