Anatomia das barras do sistema no Flutter: status bar e navigation bar com SystemUiOverlayStyle

Como funcionam a status bar e a navigation bar no Android e no iOS e como configurá-las no Flutter com SystemUiOverlayStyle, AnnotatedRegion e SafeArea.

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, mas no Flutter ele aparece nas duas plataformas de uma vez: o mesmo código desenha a tela no Android e no iOS, e cada sistema lê uma propriedade diferente para decidir a cor dos ícones. Por isso resolvi escrever sobre a anatomia dessas barras, como elas se comportam e como configurá-las no Flutter.

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.

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.