Escribir un Layout propio: measurables, placeables y un contenedor de flujo
Escribir un contenedor propio en Compose no es descender a una capa privilegiada del framework: es usar la misma API con la que están escritos los contenedores de la biblioteca. Esta lección recorre la firma completa de una política de medida, construye desde cero un contenedor de flujo que reparte a sus hijos en filas sucesivas respetando la regla de una sola medida por hijo, muestra cómo adjuntar datos por hijo mediante parentData y un ámbito propio al estilo del modificador de peso, y delimita cuándo basta con un modificador de layout para un único hijo.
Llega un momento en toda pantalla no trivial en el que ninguna combinación de contenedores estándar produce la disposición que se necesita: etiquetas que deben fluir a la línea siguiente cuando no caben, una rejilla cuyas columnas dependen del contenido, un carrusel en arco. La reacción habitual es apilar contenedores hasta aproximar el resultado, y el precio se paga en un árbol profundo, frágil y difícil de razonar. La alternativa es escribir la política de medida directamente, y es mucho menos exótico de lo que parece: son las mismas tres operaciones de la primera lección —recibir Constraints, obtener Placeable, colocar— expresadas en una lambda. Un contenedor propio bien escrito suele ser más corto, más rápido y más legible que la torre de contenedores que sustituye.
- Leer y escribir la firma completa de una política de medida con sus dos bloques anidados.
- Construir un contenedor de flujo respetando la regla de una única medida por hijo.
- Adjuntar datos por hijo con
parentDatay exponerlos mediante un ámbito propio. - Distinguir cuándo hace falta un contenedor completo y cuándo basta un modificador de layout.
La firma: dos bloques anidados y una regla
La primitiva recibe el contenido y una lambda que es la política de medida. Esa lambda toma dos parámetros —la lista de hijos medibles y los Constraints que llegan del padre— y debe devolver un resultado de medida, que se construye invocando la función de tamaño con la anchura y la altura elegidas. Dentro de esa invocación hay un segundo bloque, el de colocación, que se ejecuta más tarde, en la fase siguiente, y cuya única misión es situar cada Placeable en unas coordenadas.
Los dos parámetros de entrada tienen naturalezas distintas y conviene no confundirlas. Los medibles son objetos de un solo uso: cada uno admite exactamente una llamada de medida por pasada, y esa llamada devuelve el Placeable correspondiente. Los Constraints son el rango dentro del cual debes elegir tu propio tamaño; puedes transformarlos libremente antes de pasarlos a los hijos, pero el tamaño que declares al final tiene que caber en los originales, y si no cabe el sistema lo recortará por ti.
Measurable
Un hijo aún sin medir. Expone su parentData y las consultas intrínsecas. Su método de medida se puede invocar una sola vez por pasada.
Placeable
El resultado de esa medida. Tiene anchura y altura ya decididas y espera órdenes de colocación. Colocarlo varias veces es legal; medir de nuevo su origen no lo es.
Constraints
Cuatro enteros en píxeles. Se transforman con copias parciales para relajar mínimos, imponer techos o fijar medidas exactas antes de entregarlos a cada hijo.
Bloque de colocación
Una lambda diferida. Se ejecuta en la fase de colocación y puede volver a ejecutarse sin remedir, que es de donde salen las animaciones de posición baratas.
Hay dos decisiones que casi siempre hay que tomar antes de medir a nadie. La primera es relajar los mínimos: los Constraints que llegan pueden traer un mínimo impuesto por el padre, y propagarlo a los hijos les obligaría a ser al menos tan grandes como todo el contenedor; salvo que quieras exactamente eso, entrega a los hijos una copia con los mínimos a cero. La segunda es qué techo dar en cada eje, que es donde vive la personalidad del contenedor: el mismo techo para todos produce hijos independientes, el sobrante produce un reparto secuencial y un techo fijo produce columnas iguales.
Un contenedor de flujo desde cero
El contenedor de flujo coloca a sus hijos en línea y salta a la línea siguiente cuando el siguiente hijo no cabe. Es el caso de estudio ideal porque obliga a enfrentarse a la regla de una sola medida: la tentación natural es medir a todos para saber sus anchuras, decidir el reparto en filas y volver a medir, y eso está prohibido. La solución es un único recorrido que mida y agrupe a la vez, acumulando Placeable en la fila en curso y cerrándola en cuanto el ancho acumulado supera el techo.
flowchart TD A[Tomar el siguiente medible] --> B[Medirlo una sola vez con el techo del contenedor] B --> C[Cabe en la fila en curso] C -- Si --> D[Anadirlo a la fila y acumular ancho] C -- No --> E[Cerrar la fila y abrir una nueva] E --> D D --> F[Quedan medibles] F -- Si --> A F -- No --> G[Cerrar la ultima fila y calcular altura total] style B fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
@Composable
fun Flujo(
modifier: Modifier = Modifier,
espacioH: Dp = 8.dp,
espacioV: Dp = 8.dp,
content: @Composable () -> Unit,
) {
Layout(content = content, modifier = modifier) { medibles, constraints ->
val huecoH = espacioH.roundToPx()
val huecoV = espacioV.roundToPx()
val techo = constraints.maxWidth
// Los hijos eligen su tamano natural: minimos a cero.
val sueltos = constraints.copy(minWidth = 0, minHeight = 0)
val filas = mutableListOf<MutableList<Placeable>>()
var actual = mutableListOf<Placeable>()
var anchoActual = 0
medibles.forEach { medible ->
val p = medible.measure(sueltos) // una sola vez, aqui
val extra = if (actual.isEmpty()) 0 else huecoH
if (actual.isNotEmpty() && anchoActual + extra + p.width > techo) {
filas += actual; actual = mutableListOf(); anchoActual = 0
}
if (actual.isNotEmpty()) anchoActual += huecoH
actual += p
anchoActual += p.width
}
if (actual.isNotEmpty()) filas += actual
val altoFila = filas.map { fila -> fila.maxOf { it.height } }
val ancho = constraints.constrainWidth(
filas.maxOfOrNull { f -> f.sumOf { it.width } + huecoH * (f.size - 1) } ?: 0
)
val alto = constraints.constrainHeight(
altoFila.sum() + huecoV * (filas.size - 1).coerceAtLeast(0)
)
layout(ancho, alto) {
var y = 0
filas.forEachIndexed { i, fila ->
var x = 0
fila.forEach { p ->
p.placeRelative(x, y + (altoFila[i] - p.height) / 2)
x += p.width + huecoH
}
y += altoFila[i] + huecoV
}
}
}
}
Merece la pena señalar tres decisiones del ejemplo que son de diseño y no de sintaxis. La primera es que el contenedor se ajusta a su contenido en lugar de ocupar todo el ancho: su anchura declarada es la de la fila más larga, recortada al rango admisible. Cambiar eso por el techo recibido es una línea, y produce un contenedor que llena el espacio; ninguna de las dos opciones es más correcta, pero elegir sin darse cuenta sí es un error. La segunda es la alineación vertical dentro de cada fila, resuelta con una resta en el bloque de colocación: cualquier otra alineación es otra aritmética en ese mismo punto. La tercera es el uso de la colocación relativa a la dirección de lectura, que hace que el contenedor se espeje solo en configuraciones de derecha a izquierda.
El algoritmo anterior compara el ancho acumulado con el techo recibido. Si el contenedor se coloca dentro de un desplazamiento horizontal, ese techo es infinito y la comparación nunca se cumple: todos los hijos acaban en una sola fila interminable. No es un fallo del código sino la respuesta correcta a una pregunta mal planteada, porque fluir exige un ancho donde fluir. Un contenedor propio robusto detecta ese caso y decide explícitamente qué hacer: rendirse a una sola línea, usar un ancho de referencia recibido por parámetro o fallar con un mensaje claro. Lo que no debe hacer es ignorarlo, porque el síntoma aparecerá lejos del origen.
Datos por hijo: parentData y un ámbito propio
En cuanto un contenedor necesita tratar a sus hijos de forma distinta, hace falta un canal para que cada hijo declare algo sobre sí mismo. Ese canal es el parentData: un objeto arbitrario que un modificador adjunta al nodo y que el padre lee desde el medible antes de decidir cómo medirlo. Así está implementado el peso de los contenedores lineales, y así puedes implementar el tuyo.
// El dato que viaja del hijo al padre
private data class DatosFlujo(val ocupaFila: Boolean)
private class OcupaFila : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?) = DatosFlujo(true)
}
// El ambito restringe el modificador a los hijos directos del contenedor
@LayoutScopeMarker
interface FlujoScope { fun Modifier.ocupaFilaEntera(): Modifier }
// Y en la politica de medida:
// val salto = (medible.parentData as? DatosFlujo)?.ocupaFila == true
El detalle importante no es el mecanismo sino la restricción de ámbito. Declarar el modificador dentro de una interfaz marcada como ámbito de layout hace que solo esté disponible dentro del cuerpo de tu contenedor: un hijo que no sea descendiente directo no puede invocarlo, y el compilador lo impide antes de que nadie ejecute nada. Esa es la razón por la que el modificador de peso no aparece en el autocompletado fuera de un contenedor lineal, y replicarlo en un contenedor propio es la diferencia entre una API que se explica sola y una que exige documentación.
Hay un segundo canal, en dirección contraria a la habitual, que resuelve el problema de alinear por posiciones interiores: las líneas de alineación. Un hijo puede publicar la posición de su línea base tipográfica o de una línea propia que declares, y el padre puede leerla desde el Placeable para colocar en función de ella. Tu contenedor puede además republicar líneas hacia arriba en el mismo momento en que declara su tamaño, lo que permite que un contenedor compuesto siga siendo alineable por línea base desde fuera.
Cuando basta un modificador de layout
No todo contenedor propio necesita ser un contenedor. Si lo que quieres es transformar la medida o la posición de un único hijo, existe un modificador de layout que recibe un medible y unos Constraints y devuelve el mismo resultado de medida que una política completa. Es el mismo protocolo con un solo hijo, sin nodo contenedor añadido y sin lambda de contenido.
// Desplazar un hijo por su linea base sin envolverlo en un contenedor
fun Modifier.primeraLineaArriba(desde: Dp) = layout { medible, constraints ->
val p = medible.measure(constraints)
val hueco = desde.roundToPx() - p[FirstBaseline]
layout(p.width, p.height + hueco) { p.placeRelative(0, hueco) }
}
Ante una disposición inusual, el orden de preferencia razonable es: primero un modificador de layout si solo hay un hijo implicado, después un contenedor propio si hay que coordinar varios, y solo al final la subcomposición de la última lección. Cada escalón añade nodos, invalidaciones potenciales y superficie de error. Un contenedor propio con veinte líneas de política sustituye con frecuencia a tres niveles de contenedores anidados con modificadores de compensación, y el árbol resultante no solo es más rápido: es el único que alguien podrá leer dentro de un año.
Existe una frontera imaginaria que casi todo el mundo respeta durante demasiado tiempo: la que separa usar la biblioteca de escribir lo que la biblioteca escribe. En Compose esa frontera no existe, y comprobarlo cambia la forma de abordar cualquier pantalla difícil. Los contenedores lineales no disponen de ninguna capacidad que tú no tengas; su política de medida es una lambda con las mismas operaciones que acabas de usar, y su única ventaja es haber sido escrita antes. La consecuencia práctica es un cambio en el orden de las preguntas. Ante una disposición que no encaja, la pregunta habitual es qué combinación de contenedores la aproxima, y produce árboles profundos donde cada nivel corrige un efecto secundario del anterior; la pregunta correcta es qué reglas de medida y colocación la describen, y produce un nodo con una política explícita. La segunda formulación es más honesta porque obliga a decidir cosas que la primera dejaba al azar: qué hago cuando el techo es infinito, cómo alineo dentro de cada fila, me ajusto al contenido o lleno el espacio. Ese conjunto de decisiones es exactamente la especificación del componente, y escribirla en una política de medida es escribirla una sola vez, en un lugar, con nombres. La torre de contenedores también contiene esas decisiones, pero dispersas en modificadores de compensación que nadie se atreverá a tocar. Escribir contenedores propios, en definitiva, no es una técnica avanzada reservada para casos exóticos: es la forma de sustituir aproximaciones acumuladas por una descripción.
- Implementa el contenedor de flujo del ejemplo y añádele un parámetro de alineación vertical por fila con tres valores posibles. Comprueba que solo cambia aritmética dentro del bloque de colocación.
- Añade un ámbito propio con un modificador que fuerce a un hijo a ocupar una fila entera y compruébalo con el
parentDatadesde la política de medida. - Verifica en tiempo de ejecución que un segundo intento de medir el mismo medible lanza excepción, y reescribe una versión ingenua en dos recorridos para ver exactamente dónde falla.
- Sustituye una disposición existente de tu proyecto construida con tres niveles de contenedores anidados por un único contenedor propio y compara el número de nodos del árbol resultante.
- Convierte uno de tus contenedores propios de un solo hijo en un modificador de layout y razona qué has ganado en nodos y qué has perdido en expresividad.