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.
Puedes usar estos componentes de diseño estándar para compilar IU como la siguiente:
@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
- Experiencia con la sintaxis de Kotlin, incluidas las funciones de lambdas
- Conocimiento sobre los conceptos básicos de Compose
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:
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.
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
(obuild.gradle (Module: YourApplicationName.app)
) importa las dependencias de Compose y le permite a Android Studio trabajar con Compose con la marcabuildFeatures { 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:
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:
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)
}
}
}
}
Hay algunas mejoras que quisiéramos implementar aquí:
- Queremos agregar un espacio entre el marcador de posición y el texto.
- 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:
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:
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:
¡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:
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")
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,
...
)
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.
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.
Aquí podemos personalizar más cosas además del título:
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:
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:
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:
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:
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:
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:
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:
- 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")
}
}
- 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")
}
}
}
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)
}
}
}
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")
}
}
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
:
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:
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:
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:
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:
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:
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:
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 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:
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:
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()
(ocreateRef()
) y cada elemento que admite composición enConstraintLayout
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 elementoConstraintLayout
.
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
.
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:
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:
Ten en cuenta lo siguiente:
- Las barreras (y todas las demás ayudas) pueden crearse en el cuerpo de
ConstraintLayout
, pero no dentro deconstrainAs
. 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()
}
}
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:
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:
- Pasa un
ConstraintSet
como parámetro aConstraintLayout
. - Asigna referencias creadas en el
ConstraintSet
a los elementos que admiten composición con el modificadorlayoutId
.
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:
¿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:
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:
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