Edge-to-edge e insets: dibujar bajo las barras
Android 16 elimina la última vía de escape: para las aplicaciones que apuntan a esa versión, el contenido se dibuja bajo las barras del sistema y no existe ninguna bandera que lo desactive. Esta lección convierte esa obligación en técnica: qué son exactamente los insets y por qué hay una docena de tipos distintos, cómo se agrupan en las familias seguras de dibujo, gesto y contenido, en qué consiste el álgebra de consumo que evita el doble margen, por qué el relleno de una lista perezosa va en su contenido y no en su contenedor, cómo se coordina la animación del teclado, y cuáles son los fallos que no se ven en el emulador pero sí en el teléfono con muesca del usuario.
Durante quince años, el borde de la pantalla fue territorio del sistema y tú alquilabas el rectángulo del centro. Ese contrato terminó por etapas: primero llegó la posibilidad de dibujar bajo las barras, luego la recomendación, después la imposición para quien apuntara a la versión reciente con una bandera de escape en el manifiesto, y finalmente Android 16, donde esa bandera deja de tener efecto. Ya no hay opción: tu contenido llega hasta el último pixel, atraviesa la barra de estado, pasa por debajo de la barra de navegación y se mete en la muesca de la cámara. A cambio recibes una responsabilidad que antes ejercía el sistema por ti: saber qué zonas del lienzo están tapadas, quién las tapa y qué clase de estorbo suponen —porque no es lo mismo una franja donde no se puede ver que una donde no se puede tocar—.
- Situar el cambio normativo y sus consecuencias prácticas sobre las APIs de barras.
- Distinguir los tipos de inset y elegir la familia segura adecuada a cada caso.
- Aplicar el álgebra de consumo para evitar márgenes duplicados o ausentes.
- Diagnosticar los fallos de borde que solo se manifiestan en hardware concreto.
El fin de la excepción
La activación se hace en un solo sitio, antes de instalar el contenido, y su efecto es que la ventana deja de encoger su área de dibujo para respetar las barras. A partir de ese momento las llamadas históricas para colorear la barra de estado o la de navegación no hacen nada: en las versiones recientes están obsoletas y sin efecto, porque el color de esas zonas ya no lo decide el sistema sino lo que tú dibujes debajo.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent { TemaApp { Pantalla() } }
}
}
Lo único que conservas sobre las barras es la apariencia de sus iconos: puedes pedir iconos claros u oscuros según lo que haya debajo. Y esa petición no es cosmética, es de accesibilidad, porque una barra de estado con iconos blancos sobre una superficie clara deja al usuario sin reloj ni batería visibles. Si el fondo de tu pantalla cambia con el desplazamiento o con el modo de color, la apariencia debe cambiar con él.
// La apariencia de los iconos se ajusta cuando cambia el fondo bajo la barra.
val vista = LocalView.current
val claro = !isSystemInDarkTheme()
SideEffect {
val ventana = (vista.context as Activity).window
WindowCompat.getInsetsController(ventana, vista).isAppearanceLightStatusBars = claro
}
Conviene inventariar qué deja de funcionar el día de la migración, porque no es una lista corta. Las pantallas con un fondo de color plano dejan de cubrir las barras y muestran el fondo de la ventana. Los botones fijados al borde inferior quedan bajo la barra de navegación. Las cabeceras con imagen empiezan a asomar por detrás del reloj, que a veces es justo lo que querías y a veces deja el titular ilegible. Y cualquier disposición que calculara alturas restando constantes que alguien midió una vez pasa a estar mal en todos los dispositivos salvo aquel donde se midió.
La marca de manifiesto que permitía renunciar a la imposición se ignora en Android 16 para las aplicaciones que apuntan a esa versión. Cualquier pantalla que asumiera un lienzo recortado va a mostrar controles bajo la barra de navegación el día de la actualización. La auditoría no es opcional y conviene hacerla antes de subir el objetivo de compilación, no después.
La anatomía de los insets
Un inset es un rectángulo de márgenes que describe cuánto ocupa un elemento del sistema por cada lado. Existen muchos tipos porque describen estorbos de naturaleza distinta: las barras de estado y de navegación, el recorte de pantalla de la cámara, el teclado, las zonas reservadas a gestos del sistema, la curvatura en cascada de las pantallas con borde curvo, y las regiones donde un toque llega a tu aplicación pero conviene no poner nada importante.
Manejar esa docena de tipos a mano es un error de principiante. Existen tres familias agregadas que resuelven casi todo y que expresan una intención en lugar de una lista:
Dibujo seguro
Todo lo que puede tapar visualmente: barras, muesca, teclado. Es la elección por omisión para contenido legible.
Gestos seguros
Zonas donde el sistema se queda con el toque. Para lo que se arrastra o desliza, no basta con verlo.
Contenido seguro
La unión de las dos anteriores. El rectángulo conservador donde todo es visible y todo es tocable.
La distinción entre ver y tocar es la que más fallos sutiles evita. Un texto puede vivir tranquilamente en la franja de gestos del borde inferior porque nadie lo va a arrastrar; un carrusel horizontal, no, porque el deslizamiento se lo quedará el sistema para volver atrás y el usuario concluirá que tu aplicación va mal. Elegir la familia por lo que el elemento hace, y no por dónde está, es el criterio operativo.
Los tipos individuales siguen siendo necesarios en casos concretos, y para combinarlos existe un pequeño álgebra: unir dos conjuntos, restar uno de otro y quedarse solo con determinados lados. Ese último operador es el que resuelve el caso más común de todos, la cabecera que debe respetar la barra de estado pero no los laterales, o la barra inferior propia que debe respetar la navegación sin heredar el margen del teclado.
// Solo los lados que me interesan, y la union de dos tipos.
val insetsCabecera = WindowInsets.statusBars
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
Row(Modifier.fillMaxWidth().windowInsetsPadding(insetsCabecera)) { Cabecera() }
El álgebra del consumo
Aquí está la parte que separa una implementación correcta de una que produce dobles márgenes. Cuando un modificador aplica un inset como relleno, además de aplicarlo lo consume: los descendientes ven ese inset como ya resuelto y valdrá cero para ellos. Ese mecanismo es el que permite componer capas anidadas sin que cada una vuelva a sumar la altura de la barra de estado.
El corolario incómodo es que las funciones que solo convierten un inset en valores de relleno no consumen nada. Si usas ese resultado para separar contenido y dentro hay otro modificador de inset, la barra se cuenta dos veces. Cuando necesites esa conversión —porque el relleno va al contenido de una lista y no a su contenedor— tienes que declarar el consumo por separado.
// La lista dibuja bajo las barras y sus elementos se desplazan por debajo,
// pero ni el primero ni el ultimo quedan tapados.
LazyColumn(
modifier = Modifier.fillMaxSize().consumeWindowInsets(WindowInsets.safeDrawing),
contentPadding = WindowInsets.safeDrawing.asPaddingValues(),
) {
items(elementos, key = { it.id }) { FilaElemento(it) }
}
Esa es exactamente la razón de que el relleno de una lista perezosa vaya en su contenido: si lo pones en el contenedor, la lista se encoge, el recorte del desplazamiento sube y los elementos desaparecen antes de llegar al borde en lugar de pasar por debajo de la barra. El efecto de cristal esmerilado del sistema depende de que haya algo que se deslice por debajo.
flowchart TD
A[Insets de la ventana] --> B[Scaffold aplica y consume lo suyo]
B --> C[El contenido recibe el resto como relleno]
C --> D{Que hace el hijo con ese resto}
D -->|Aplica un modificador de inset| E[Consume y los nietos ven cero]
D -->|Solo convierte a valores de relleno| F[No consume y se puede duplicar]
F --> G[Declara el consumo aparte]
style E fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111bEl armazón de pantalla participa en esta álgebra: aplica insets a las barras superior e inferior, los consume y entrega al contenido el relleno restante. Ignorar ese relleno —el fallo más repetido de todos— produce una pantalla cuyo primer elemento vive debajo de la barra superior, y es un fallo que la previsualización no enseña porque allí no hay barras.
El teclado se expone como un inset más, y en las versiones recientes su altura se actualiza fotograma a fotograma durante la animación de apertura. Aplicarlo con el modificador correspondiente hace que tu contenido suba sincronizado con él en lugar de dar un salto al final. Requiere que la ventana esté declarada para redimensionarse ante el teclado: sin eso, el inset llega a cero y nada se mueve.
Los fallos que solo aparecen en un teléfono concreto
El código correcto no garantiza el resultado correcto, y esta es la única capa de la interfaz donde eso es literalmente cierto. La razón es que la geometría de los insets depende del hardware y de ajustes que el desarrollador no tiene puestos: el modo de navegación, la posición de la muesca, la curvatura del borde, el tamaño de fuente, el plegado del dispositivo. Un dispositivo de desarrollo cubre una sola combinación de todas ellas.
Modo de navegación
Gestos y tres botones dan alturas inferiores muy distintas. Es el fallo más frecuente y el más barato de comprobar.
Muesca y curvatura
En horizontal la muesca pasa a un lateral. Un texto centrado deja de estarlo y un botón puede quedar bajo el recorte.
Teclado
Aparece, anima y desaparece. Comprueba el campo enfocado, el botón de envío y el desplazamiento durante la animación.
Plegado y ventana
Al plegar o redimensionar cambian todos los insets a la vez. Cualquier valor capturado antes es incorrecto después.
Hay un antipatrón que conviene nombrar porque se repite: leer una vez la altura de la barra, guardarla en una variable y usarla como constante durante el resto de la sesión. Funciona en las pruebas y falla en cuanto el usuario gira el dispositivo, cambia de modo de navegación o abre el teclado. Los insets son estado observable y deben leerse en la composición, en el punto de uso, cada vez.
La forma equivocada de pensar en todo esto es “el sistema me quita sitio y yo me aparto”. La forma correcta es entender que ha cambiado la unidad de negociación. Antes, el sistema y tu aplicación repartían área: él se quedaba unas franjas, a ti te entregaba un rectángulo, y dentro de ese rectángulo la geometría era estable y conocida. Ahora repartís capas: el lienzo entero es tuyo para pintar, y lo que el sistema te comunica ya no es un recorte sino un mapa dinámico de zonas con condiciones de uso —aquí puedes pintar pero no colocar nada legible, aquí puedes colocar algo legible pero no algo que se arrastre, aquí ahora mismo hay un teclado que en trescientos milisegundos habrá desaparecido—. De esa reformulación se siguen las tres consecuencias que cuestan de interiorizar. La primera es que los insets son estado, no configuración: cambian con la rotación, con el modo de navegación, con el teclado, con el redimensionado de ventana, con el plegado del dispositivo; cualquier valor que hayas capturado una vez es un valor caducado. La segunda es que el consumo es la única forma de composición correcta: sin él, cada capa de tu jerarquía tomaría decisiones sobre un lienzo que otra capa ya había ajustado, y la única forma de que un sistema de capas anidadas no se pise es que cada una declare qué parte del contrato ha satisfecho. Y la tercera, la que más duele, es que el fallo característico es específico de hardware: la muesca central, la muesca lateral en horizontal, la barra de tres botones, la curvatura en cascada, la pantalla interna del plegable. Ninguna de esas configuraciones existe en el dispositivo del desarrollador, todas existen en el del usuario, y su síntoma no es una excepción sino un botón que no se puede pulsar. Por eso la disciplina profesional aquí no es escribir bien los modificadores, que es lo fácil, sino construir una matriz de verificación de bordes y pasarla entera antes de cada publicación: es el único terreno de la interfaz donde la corrección del código no implica la corrección del resultado.
- Sube el objetivo de compilación a Android 16 y recorre la aplicación buscando controles bajo la barra de navegación.
- Prueba cada pantalla en navegación por gestos y en tres botones: la altura de la barra inferior cambia y con ella todo el reparto.
- Gira a horizontal un dispositivo con muesca lateral y comprueba que ningún texto entra en el recorte.
- Busca una lista con relleno en su contenedor y muévelo a su contenido; describe la diferencia al desplazar.
- Localiza un caso de doble margen y explica exactamente qué capa aplicó el inset sin consumirlo.