wandres.dev
COMPOSE: LAYOUT · el modelo de medida

SubcomposeLayout y BoxWithConstraints: componer según el espacio

La segunda escapatoria a la pasada única no cambia cómo se mide sino cuándo se compone: aplaza la creación de los composables hasta la fase de layout, cuando los constraints ya se conocen. Esta lección explica el mecanismo de subcomposición por ranuras y su reutilización, sitúa BoxWithConstraints como el azúcar sintáctico que es, desglosa el coste real de mover trabajo de composición dentro de la medida, y ordena el catálogo de alternativas que resuelven la mayoría de los casos sin pagar esa factura.

⏱ 19 min

Queda un problema que ni la medida ordinaria ni los intrínsecos resuelven: el caso en que el espacio disponible no determina el tamaño de los composables sino cuáles existen. Una tarjeta que en una ventana estrecha muestra un resumen y en una ancha añade una columna lateral; un indicador que debe situarse bajo la pestaña seleccionada y necesita conocer la posición de todas. Aquí no hay ninguna consulta previa que sirva, porque el árbol todavía no está decidido. La respuesta de Compose es tan drástica como coherente: si la composición debe depender de los Constraints, entonces la composición tiene que ocurrir después de recibirlos, es decir, dentro de la fase de layout. Eso es SubcomposeLayout, y todo lo que cuesta se deduce de esa inversión de orden.

🎯 Al terminar esta lección sabrás
  • Explicar qué invierte exactamente la subcomposición respecto al orden normal de fases.
  • Usar la primitiva por ranuras y entender el papel de la reutilización de ranuras.
  • Situar BoxWithConstraints como una fachada y enumerar el coste que hereda.
  • Elegir entre las alternativas más baratas antes de recurrir a la subcomposición.

Componer dentro de la medida

En el orden normal, la composición decide qué nodos existen y solo después el layout los mide. La subcomposición invierte ese orden para una parte del árbol: la primitiva recibe una lambda que se ejecuta durante la medida, con los Constraints ya disponibles, y dentro de ella se invoca una función que compone un bloque de contenido y devuelve la lista de medibles resultante. A partir de ahí todo vuelve a ser el protocolo de siempre: se miden, se decide el tamaño y se colocan.

Conviene notar que la inversión es parcial y deliberadamente acotada. No se aplaza la composición de todo el árbol, solo la de las ranuras que declares dentro de ese nodo; el resto de la pantalla sigue componiéndose antes y midiéndose después, como siempre. Esa acotación es la que permite que el mecanismo conviva con el modelo general sin contaminarlo, y también la que hace que el coste sea proporcional al tamaño de lo que metas dentro y no al de la pantalla.

Cada bloque compuesto se identifica con una clave de ranura. Esa clave es lo que permite al sistema saber, entre dos pasadas, que el contenido que estás componiendo ahora es la continuación del que compusiste antes y no algo nuevo, de modo que su estado se conserve. Componer dos veces la misma clave dentro de una misma pasada es un error; usar claves distintas para contenidos que conceptualmente son el mismo produce estado que se pierde en cada medida.

// Medir la cabecera primero y componer el cuerpo sabiendo cuanto queda
SubcomposeLayout(modifier) { constraints ->
    val cabecera = subcompose("cabecera") { Cabecera() }
        .map { it.measure(constraints) }
    val altoCabecera = cabecera.maxOfOrNull { it.height } ?: 0

    val restante = constraints.copy(
        minHeight = 0,
        maxHeight = (constraints.maxHeight - altoCabecera).coerceAtLeast(0),
    )
    val cuerpo = subcompose("cuerpo") { Cuerpo(altoDisponible = restante.maxHeight) }
        .map { it.measure(restante) }

    layout(constraints.maxWidth, constraints.maxHeight) {
        cabecera.forEach { it.placeRelative(0, 0) }
        cuerpo.forEach { it.placeRelative(0, altoCabecera) }
    }
}

Hay una segunda familia de usos que no tiene que ver con el espacio disponible sino con el orden: componer una parte del contenido después de haber medido otra, para poder pasarle como parámetro algo que solo se conoce tras esa medida. Una barra de pestañas que necesita las posiciones y anchuras de todas las pestañas para componer el indicador que se sitúa bajo la seleccionada es el ejemplo canónico, y no tiene solución con la medida ordinaria porque el indicador no es un tamaño distinto: es un composable cuyos parámetros dependen de un resultado de medida.

Sobre este mecanismo está construida buena parte de la biblioteca. Las listas perezosas lo usan para componer únicamente los elementos que caben en la ventana visible, que es su razón de ser: no hay forma de saber cuántos elementos componer sin conocer antes la altura disponible. Ese uso incorpora además la pieza que hace viable el desplazamiento fluido: una política de reutilización de ranuras que, en lugar de descartar la composición de un elemento que sale de pantalla, la guarda desactivada y la reutiliza para el que entra, conservando los nodos y evitando recomponer desde cero.

flowchart TD
A[Fase de composicion normal] --> B[Fase de layout]
B --> C[El nodo con subcomposicion recibe constraints]
C --> D[Compone la ranura aqui dentro, ya en layout]
D --> E[Mide los medibles recien compuestos]
E --> F[Decide tamano y coloca]
F --> G[Fase de dibujo]
style D fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b

BoxWithConstraints es una fachada

El contenedor que expone los Constraints recibidos a su contenido no es una primitiva distinta: es una subcomposición de una sola ranura con un ámbito que traduce los cuatro enteros a unidades independientes de densidad. Todo lo que se diga del mecanismo se aplica íntegro a él, incluida la imposibilidad de responder a consultas intrínsecas que se anticipó en la lección anterior. Que su uso quepa en tres líneas no cambia lo que ocurre por debajo.

BoxWithConstraints {
    // Este cuerpo se compone durante la fase de layout
    if (maxWidth < 480.dp) ColumnaUnica(datos) else DosPaneles(datos)
}

La consecuencia práctica más importante es que el cuerpo se vuelve a componer cada vez que los Constraints cambian. En una pantalla estática eso ocurre una vez y no importa; dentro de algo cuyo tamaño se anima, o dentro del elemento de una lista que se remide al desplazarse, ocurre por fotograma. Y como esa composición sucede dentro de la medida, no se beneficia de las mismas oportunidades de omisión que una composición ordinaria: el trabajo está atrapado en la fase equivocada.

Hay además un efecto sobre la legibilidad que se subestima. Un cuerpo que decide su estructura en función del ancho disponible mezcla en un mismo punto dos decisiones de naturaleza distinta —qué contenido existe y cómo se dispone— y hace que la respuesta a la pregunta qué se ve en esta pantalla dependa de ejecutar el layout. Cuando esa decisión es realmente de producto, es mejor tomarla arriba y una sola vez.

⚠️
Los intrinsecos y la subcomposicion son incompatibles por construccion

Un componente basado en subcomposición no puede responder a una consulta intrínseca porque, en el momento de la pregunta, todavía no sabe qué hijos tendrá: sus hijos son función de unos Constraints que la consulta aún no ha entregado. No es una limitación pendiente de implementar sino una imposibilidad lógica, y por eso la excepción es explícita y menciona los componentes afectados. La regla de convivencia es sencilla: en cuanto un subárbol contiene una lista perezosa o un contenedor que expone sus Constraints, ningún ancestro puede usar tamaños intrínsecos sobre él.

Lo que cuesta de verdad

Conviene desglosar la factura en partidas, porque el resumen habitual de que es caro no ayuda a decidir nada.

🔁

Composición en la fase equivocada

Componer durante la medida impide aprovechar las omisiones de la fase de composición. El trabajo que en un árbol normal se saltaría, aquí se ejecuta.

🧷

Contabilidad de ranuras

Cada ranura mantiene su identidad, su estado y su composición asociada. Es memoria y trabajo de gestión que un contenedor ordinario no paga.

🚫

Sin intrínsecos

El subárbol queda vetado para cualquier ancestro que quiera consultar tamaños intrínsecos, y ese veto se propaga hacia arriba.

⛓️

Acoplamiento de fases

Un cambio de estado leído dentro del cuerpo invalida composición y layout a la vez, en lugar de solo una de las dos.

Ninguna de esas partidas es prohibitiva por separado, y ese es justamente el motivo de que el abuso pase desapercibido. El patrón que degrada una pantalla no es una subcomposición grande sino muchas pequeñas en un camino caliente: un contenedor que expone Constraints en la raíz de cada elemento de una lista, para tomar una decisión que era idéntica en los cincuenta elementos y que podría haberse tomado una sola vez fuera.

Hay además una consecuencia sobre la observabilidad que complica el diagnóstico. Las herramientas de conteo de recomposiciones atribuyen el trabajo al nodo que lo dispara, y en una subcomposición ese nodo es el contenedor, no el punto del árbol donde alguien escribió una decisión discutible. El resultado es que las cifras señalan al mensajero. Cuando una pantalla con varias subcomposiciones va lenta, el camino corto suele ser desactivarlas una a una y observar, en lugar de intentar leer la atribución.

Merece la pena señalar el contraste con las listas perezosas, que son el uso ejemplar del mecanismo. Ahí la subcomposición no es un atajo sino el núcleo del algoritmo, se hace una vez por contenedor y no una por elemento, y la política de reutilización de ranuras amortiza el coste a lo largo del desplazamiento. La lección no es que la subcomposición sea mala, sino que su precio se justifica cuando compra algo que no se puede comprar de otra forma.

Las alternativas, en orden de preferencia

Antes de recurrir a la subcomposición merece la pena recorrer el catálogo, porque la mayoría de los casos reales caen en alguno de los escalones anteriores.

// Peldano bajo: la decision de producto se toma una vez, cerca de la raiz,
// y baja como un parametro ordinario que cualquiera puede leer.
@Composable
fun Pantalla(clase: WindowWidthSizeClass, datos: Datos) {
    when (clase) {
        WindowWidthSizeClass.COMPACT -> ColumnaUnica(datos)
        else -> DosPaneles(datos)
    }
}

Si la decisión es de estructura de pantalla —una columna o dos paneles, navegación inferior o lateral—, pertenece a la adaptación por clase de tamaño de ventana, que se calcula una vez cerca de la raíz y se propaga como un parámetro corriente. Es una decisión de producto y se lee como tal en el código, sin que ningún nodo tenga que componerse durante la medida.

Si lo que cambia con el espacio disponible no es qué composables existen sino cómo se miden o dónde se colocan, el instrumento correcto es un contenedor propio como el de la tercera lección. Un contenedor de flujo, una rejilla de columnas variables o una disposición que reordena a sus hijos según el ancho no necesitan recomponer nada: los composables son siempre los mismos y solo cambia la política. Esta es, con diferencia, la sustitución que más subcomposiciones innecesarias elimina.

Hay un caso intermedio que merece nombre propio porque se resuelve mal muy a menudo: el de una decisión que sí depende del espacio pero que no cambia en cada fotograma, como elegir cuántas columnas caben en una rejilla. Ahí no hace falta recomponer el contenido en cada medida; basta con que el contenedor propio calcule el número de columnas dentro de su política y coloque en consecuencia a los hijos que ya existen. La diferencia entre las dos soluciones es que una compone contenido nuevo cada vez que el ancho cambia un píxel y la otra solo cambia aritmética.

Si la dependencia es entre hermanos y el subárbol es pequeño, una consulta intrínseca cuesta menos que una subcomposición, aunque solo sirva cuando los hijos ya están decididos. Y si lo que necesitas es reaccionar a un tamaño para animar o para ajustar una posición, hay dos vías que no obligan a componer durante la medida: leer el tamaño dentro del bloque de colocación mediante un modificador con lambda, que recoloca sin remedir, o usar el ámbito de previsualización de layout que permite conocer el tamaño de destino antes de llegar a él y animar hacia ese valor.

💡
La pregunta que decide

Antes de escribir un contenedor que exponga sus Constraints, formula esta pregunta: al cambiar el ancho, ¿cambia el conjunto de composables que existen, o solo su tamaño y su posición? Si la respuesta es solo su tamaño y su posición, un contenedor propio lo resuelve sin pagar nada. Si de verdad cambia el conjunto, la siguiente pregunta es si esa decisión podría tomarse una vez más arriba en lugar de en cada nodo. Solo cuando ambas respuestas te dejan sin salida, la subcomposición es la herramienta correcta, y entonces lo es sin reservas.

El nivel entero es una escala de aplazamientos, y cada peldano cuesta mas

Visto desde el final, este nivel describe una única magnitud creciente: cuánto se aplaza una decisión y cuánto se paga por aplazarla. En la base está la pasada única, donde nada se aplaza: los Constraints bajan, los tamaños suben y todo se resuelve en una travesía lineal. Los contenedores de la biblioteca ni siquiera necesitan más que eso, y por eso su coste es indistinguible del mínimo teórico. Un peldaño más arriba está tu propio contenedor, que tampoco aplaza nada pero sustituye una combinación de políticas ajenas por una propia; sigue siendo una travesía, y la única inversión es intelectual. El tercer peldaño son los intrínsecos, el primero que cuesta de verdad: aplazas la decisión sobre unos Constraints hasta haber preguntado, y pagas una travesía extra por pregunta, con la advertencia de que las preguntas anidadas se multiplican. El cuarto es la subcomposición, donde ya no se aplaza una medida sino la existencia misma de los nodos, y el precio deja de ser una travesía extra para convertirse en un cambio de régimen: parte de la composición se mete dentro del layout y pierde las optimizaciones que allí no existen. La utilidad de esta escala no es memorizarla sino usarla al revés, como diagnóstico. Cuando una pantalla va mal, la pregunta productiva no es qué componente es lento, sino en qué peldaño estoy y si de verdad necesitaba subir tan alto. La respuesta es sorprendentemente a menudo que se subió dos peldaños por comodidad —un contenedor que expone Constraints porque era más rápido de escribir que una política de veinte líneas— y que bajar uno solo devuelve el fotograma. Ese es el criterio que sobrevive a las versiones de la biblioteca: elige siempre el peldaño más bajo que resuelva el problema, y sube únicamente cuando puedas nombrar con precisión qué te impide quedarte donde estás.

⚔️ Bajar un peldano
  1. Escribe una tarjeta que en anchos pequeños muestre una columna y en anchos grandes dos paneles usando el contenedor que expone Constraints, y después reescribe la misma decisión con una clase de tamaño de ventana calculada en la raíz. Compara dónde queda la decisión en cada versión.
  2. Coloca ese contenedor en la raíz del elemento de una lista perezosa de doscientos elementos y cuenta cuántas veces se compone su cuerpo durante un desplazamiento completo.
  3. Sustituye un caso donde solo cambian tamaños y posiciones por un contenedor propio y comprueba que el número de recomposiciones cae a cero.
  4. Envuelve cualquiera de las dos versiones en un modificador de tamaño intrínseco, lee la excepción y explica con tus palabras por qué es lógicamente imposible responder a esa consulta.
  5. Escribe en cinco líneas, para un componente real de tu proyecto, en qué peldaño de la escala está y qué haría falta para bajarlo uno.