Anatomia das barras do sistema no Jetpack Compose: status bar e navigation bar com enableEdgeToEdge

Como funcionam a status bar e a navigation bar no Android, o que o edge-to-edge mudou e como configurá-las no Jetpack Compose com enableEdgeToEdge e insets.

Anatomia das barras do sistema, no Flutter, Jetpack Compose e SwiftUI, com um Android e um iPhone e as barras rotuladas

Já tive publicações recusadas na App Store por causa da status bar. O problema era contraste: a cor dos ícones do sistema não combinava com o fundo da tela, e a informação do topo ficava difícil de ler. No Android, o mesmo descuido costuma aparecer no rodapé, com o ícone de voltar branco sobre uma navigation bar branca, e o usuário não encontra como sair da tela.

Esse tipo de erro não depende do framework. No Jetpack Compose, o edge-to-edge mudou a regra: pintar a status bar com a cor do app deixou de funcionar, e a cor dos ícones passou a depender do que o app desenha atrás deles. Por isso resolvi escrever sobre a anatomia dessas barras, como elas se comportam e como configurá-las no Compose.

O que é do sistema e o que é do app

Android e iPhone lado a lado, com status bar, navigation bar, home indicator, recorte da câmera, Dynamic Island e área do app rotulados

No Android, a tela tem duas faixas que não pertencem ao app. No topo fica a status bar, com hora, rede e bateria, dividindo espaço com o recorte da câmera. No rodapé fica a navigation bar, que pode ser a de 3 botões (voltar, início e recentes) ou só a barra de gestos.

O app não desenha os ícones dessas barras. O que ele controla são duas coisas: o que aparece atrás delas e se os ícones do sistema vão ser claros ou escuros. A primeira resposta vem do layout do app. A segunda é uma configuração que muita gente nunca toca, e é ela que decide se a hora e o botão de voltar vão ser legíveis.

O erro que normalmente acontece

A mesma tela de formulário duas vezes: à esquerda, ícones brancos sobre fundo branco e botão voltar invisível; à direita, ícones escuros e legíveis

Esse erro chega até a loja porque depende de contexto. Quem desenvolve costuma testar com um tema e um modo de navegação, geralmente o de gestos, em que a barra de baixo quase não aparece. O usuário usa o tema escuro, ou a navegação de 3 botões, ou chega numa tela sem TopAppBar, como a splash, o onboarding ou um player de vídeo. Essas telas herdam o estilo da tela anterior, que foi pensado para outro fundo.

A crença que sustenta o problema é "a barra é do sistema, então o sistema cuida dela". Ela tem uma parte verdadeira: quem desenha a hora e a bateria é o sistema operacional. Mas a cor desses ícones é uma decisão do app, e o sistema não confere se ela combina com o que está atrás.

Edge-to-edge mudou a regra no Android

Duas telas de perfil em edge-to-edge: sem insets, o título encosta na hora e o botão fica sob a barra de gestos; com insets, o fundo passa por trás e o conteúdo fica na área livre

Por muito tempo, o jeito de resolver era pintar a status bar com a cor do app. A partir do Android 15, todo app com targetSdk 35 desenha de ponta a ponta por padrão: o conteúdo vai para trás da status bar e da navigation bar. O setStatusBarColor passou a ser ignorado, a navegação por gestos fica transparente e a de 3 botões ganha um fundo translúcido com 80% de opacidade. No Android 15 ainda dava para desativar esse comportamento, mas para apps com targetSdk 36, no Android 16, essa opção deixou de existir.

Na prática, sobram duas responsabilidades. A primeira é escolher a cor dos ícones de acordo com o fundo que vai ficar atrás deles. A segunda é aplicar os insets, as medidas de quanto cada barra ocupa, para que o fundo passe por trás mas o conteúdo interativo não fique embaixo da hora nem da barra de gestos.

Como cada framework chama claro e escuro

Tabela com fundo claro e fundo escuro nas linhas e Flutter, Jetpack Compose e SwiftUI nas colunas, mostrando o nome que cada um usa para ícones escuros ou claros

Antes de escrever código, responda uma pergunta: o fundo atrás da barra é claro ou escuro? Fundo claro pede ícones escuros, e fundo escuro pede ícones claros.

A confusão é que cada framework dá nome a isso de um jeito. No Flutter, o nome descreve o ícone, então ícones escuros são dark. No Compose e no SwiftUI, o nome descreve o fundo, então os mesmos ícones escuros são light.

No Jetpack Compose: enableEdgeToEdge

Anatomia no Jetpack Compose: um Android com setas ligando statusBarStyle, navigationBarStyle, isAppearanceLightStatusBars, isAppearanceLightNavigationBars, isNavigationBarContrastEnforced, WindowInsets e innerPadding à parte da tela que cada um controla

No Compose, a configuração fica na Activity. O enableEdgeToEdge liga o modo de ponta a ponta também nas versões anteriores ao Android 15 e recebe o estilo de cada barra:

import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge(
            // fundo claro atrás da status bar: ícones escuros
            statusBarStyle = SystemBarStyle.light(
                scrim = Color.TRANSPARENT,
                darkScrim = Color.BLACK,
            ),
            // navigationBarStyle fica no padrão: transparente nos gestos,
            // translúcida na navegação de 3 botões
        )
        setContent { MeuAppTheme { AppNavHost() } }
    }
}

O nome aqui é SystemBarStyle.light, e o resultado são ícones escuros, o inverso do preset do Flutter com o mesmo nome. O darkScrim só entra em aparelhos antigos, em que o sistema não consegue desenhar ícones escuros. Se o app acompanha o tema escuro do sistema, troque por SystemBarStyle.auto, que alterna sozinho.

Para uma tela que foge do padrão, o caminho é ajustar o WindowInsetsControllerCompat enquanto ela está na composição e devolver o valor anterior quando ela sai:

@Composable
fun StatusBarIcons(escuros: Boolean) {
    val view = LocalView.current
    if (view.isInEditMode) return
    DisposableEffect(escuros) {
        val window = (view.context as Activity).window
        val controller = WindowCompat.getInsetsController(window, view)
        val anterior = controller.isAppearanceLightStatusBars
        controller.isAppearanceLightStatusBars = escuros
        onDispose { controller.isAppearanceLightStatusBars = anterior }
    }
}

@Composable
fun PlayerScreen() {
    StatusBarIcons(escuros = false) // fundo preto, ícones claros
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black)
            .safeDrawingPadding(),
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            imageVector = Icons.Filled.PlayArrow,
            contentDescription = "Reproduzir",
            tint = Color.White,
            modifier = Modifier.size(96.dp),
        )
    }
}

O onDispose faz o papel que o AnnotatedRegion faz no Flutter: quando o player sai da tela, os ícones voltam a ser escuros. O safeDrawingPadding() é o equivalente ao SafeArea.

Nas listas, o Scaffold entrega os insets prontos no innerPadding:

@Composable
fun PerfilScreen(itens: List<String>) {
    Scaffold { innerPadding ->
        LazyColumn(contentPadding = innerPadding) {
            items(itens) { item ->
                Text(item, modifier = Modifier.padding(16.dp))
            }
        }
    }
}

Passar o innerPadding como contentPadding, e não como Modifier.padding, é o que deixa a lista rolar por trás das barras sem esconder o primeiro e o último item.

Duas telas: a inicial clara com ícones escuros e o player escuro com ícones claros, com o código de Flutter, Jetpack Compose e SwiftUI que declara o estilo de cada uma

Antes de mandar para a loja

A maior parte desses erros só aparece em uma combinação específica de tema, navegação e tela. Antes de cada envio, vale passar por três cenários:

Tema claro e escuro. Troque o tema do sistema com o app aberto e navegue por todas as telas.

Navegação de 3 botões. Ative nas configurações do Android. É nela que o botão voltar some quando o contraste está errado.

Telas sem TopAppBar. Splash, onboarding, player, câmera e qualquer tela com imagem atrás da status bar.

A regra que vale para qualquer framework

Toda decisão sobre as barras do sistema começa pela cor do fundo atrás delas. Com essa resposta, o nome de cada API vira detalhe de tradução, e o resto é aplicar os insets para o conteúdo não ficar embaixo da hora nem dos gestos.

Nenhuma dessas APIs confere a sua configuração. O fundo translúcido do Android protege a navegação de 3 botões, mas quando o app declara a cor errada, o ícone branco sobre fundo branco chega até a revisão da loja do mesmo jeito.

Depois das barras, o próximo passo são os outros insets que o edge-to-edge deixou expostos: o teclado, o recorte da câmera quando o app gira para paisagem e a dobra dos aparelhos dobráveis. Em todos eles o sistema ocupa uma parte da tela, e o app precisa saber qual.

Bora construir algo, ou só dar um oi

Se você tem um projeto em mente, quer falar sobre tecnologia ou só dar um oi, eu leio tudo que chega na caixa de entrada.