Anatomia das barras do sistema: status bar e navigation bar no Flutter, Jetpack Compose e SwiftUI

Já tive publicações recusadas na App Store por causa do contraste da status bar. Como funcionam a status bar, a navigation bar e o home indicator, o que o edge-to-edge mudou no Android e como configurar cada barra no Flutter, no Jetpack Compose e no SwiftUI.

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. As barras do sistema funcionam do mesmo jeito para qualquer app mobile, e as APIs de cada stack só mudam o nome das coisas, às vezes para o lado oposto. Por isso resolvi escrever sobre a anatomia dessas barras, como elas se comportam e como configurá-las no Flutter, no Jetpack Compose e no SwiftUI.

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. No iOS não existe navigation bar: o rodapé tem o home indicator, e o topo divide a status bar com o notch ou a Dynamic Island.

Em nenhum dos dois o app 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 AppBar, 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.

Quem usa Flutter também já está nesse cenário. Desde a versão 3.27, o projeto padrão do Flutter usa targetSdk 35, então qualquer app atualizado roda em edge-to-edge no Android 15.

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.

Um detalhe dos presets do Flutter: SystemUiOverlayStyle.dark e SystemUiOverlayStyle.light só mudam a status bar. Nos dois, a navigation bar vem preta com botões claros, então ela precisa ser configurada à parte, como no exemplo da próxima seção.

No Flutter: SystemUiOverlayStyle

Anatomia no Flutter: Android e iPhone com setas ligando statusBarColor, statusBarIconBrightness, AppBar, SafeArea, systemNavigationBarColor, systemNavigationBarIconBrightness e statusBarBrightness à parte da tela que cada um controla

O ponto de partida é definir um estilo padrão no main, antes do runApp:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

const estiloPadrao = SystemUiOverlayStyle(
  // Android: barras transparentes e ícones escuros
  statusBarColor: Colors.transparent,
  statusBarIconBrightness: Brightness.dark,
  systemNavigationBarColor: Colors.transparent,
  systemNavigationBarIconBrightness: Brightness.dark,
  systemNavigationBarContrastEnforced: true,
  // iOS: fundo claro atrás da status bar, então texto escuro
  statusBarBrightness: Brightness.light,
);

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
  SystemChrome.setSystemUIOverlayStyle(estiloPadrao);
  runApp(const MeuApp());
}

Repare que o iOS usa outra propriedade. O statusBarIconBrightness só vale no Android e descreve o ícone, então fica Brightness.dark. O statusBarBrightness só vale no iOS e descreve o fundo, então fica Brightness.light, mesmo que o resultado seja o mesmo texto escuro.

O systemNavigationBarContrastEnforced deixa o Android manter o fundo translúcido na navegação de 3 botões, que é a proteção contra o botão voltar invisível. O statusBarColor transparente não muda nada em apps com targetSdk 35 ou maior, mas mantém o mesmo visual nos apps e versões anteriores.

Esse estilo global perde para o AppBar, que publica o próprio estilo por cima do que foi definido no main. Se a tela tem AppBar, a configuração precisa ir para o tema, em AppBarTheme(systemOverlayStyle: estiloPadrao). Sem isso, o Flutter calcula um estilo a partir da cor de fundo da AppBar.

Para as telas que fogem do padrão, a ferramenta é o AnnotatedRegion. Ele declara o estilo de uma parte da árvore de widgets, e o Flutter aplica o estilo da região que estiver embaixo de cada barra:

class PlayerScreen extends StatelessWidget {
  const PlayerScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: SystemUiOverlayStyle.light.copyWith(
        statusBarColor: Colors.transparent,
        systemNavigationBarColor: Colors.transparent,
      ),
      child: const Scaffold(
        backgroundColor: Colors.black,
        body: SafeArea(
          child: Center(
            child: Icon(Icons.play_circle, color: Colors.white, size: 96),
          ),
        ),
      ),
    );
  }
}

Quando o usuário volta para a tela anterior, a região do player sai da árvore e o estilo anterior volta sozinho, sem nada para desfazer. O SafeArea cuida dos insets: o fundo preto ocupa a tela inteira e o conteúdo fica dentro da área livre. Em listas, o ListView já faz isso por conta própria, porque sem um padding explícito ele usa o padding do MediaQuery.

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.

No SwiftUI: color scheme e safe area

Anatomia no SwiftUI: um iPhone com setas ligando toolbarColorScheme, toolbarBackground, preferredColorScheme, ignoresSafeArea, safe area e persistentSystemOverlays à parte da tela que cada um controla

O SwiftUI não tem uma API direta para escolher a cor dos ícones da status bar. No estilo padrão, o iOS escolhe a aparência sozinho para manter o contraste com o conteúdo de baixo. Quando uma tela precisa de um resultado garantido, como um player escuro, o caminho é declarar o color scheme da barra ou da apresentação.

Dentro de um NavigationStack, cada tela pode declarar o color scheme da própria barra:

struct PlayerView: View {
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            Image(systemName: "play.circle.fill")
                .font(.system(size: 96))
                .foregroundStyle(.white)
        }
        .navigationTitle("Player")
        .toolbarBackground(.black, for: .navigationBar)
        .toolbarBackground(.visible, for: .navigationBar)
        .toolbarColorScheme(.dark, for: .navigationBar)
    }
}

O toolbarColorScheme(.dark, for: .navigationBar) deixa a status bar com texto claro enquanto essa tela está no topo da pilha, e ao voltar vale o que a tela anterior declarou. O nome segue a lógica do Compose: .dark descreve o fundo, e o resultado é texto claro. As duas linhas de toolbarBackground são obrigatórias, porque sem o fundo da barra visível o SwiftUI ignora o toolbarColorScheme. Os três modificadores exigem iOS 16.

Para telas sem barra de navegação, como um player aberto com .fullScreenCover, o caminho é o .preferredColorScheme(.dark). Ele vale para a apresentação inteira, então funciona bem numa tela modal, mas numa tela comum da pilha escurece o app todo. Se nenhuma das duas opções servir, sobra o UIKit: uma subclasse de UIHostingController que sobrescreve preferredStatusBarStyle.

Os insets funcionam ao contrário do Android. O SwiftUI respeita a safe area por padrão, e desenhar atrás das barras é uma escolha explícita. No exemplo, só o Color.black usa .ignoresSafeArea(), então o fundo ocupa a tela toda e o ícone continua na área livre.

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 quatro 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 AppBar. Splash, onboarding, player, câmera e qualquer tela com imagem atrás da status bar.

iPhone com notch ou Dynamic Island, no modo claro e no escuro. No iOS, o fundo atrás da status bar é sempre o do app, então vale testar as telas escuras com o aparelho no modo claro e as telas claras no modo escuro.

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 e o estilo padrão do iOS tenta manter o contraste, 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.