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.

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

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

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

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

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

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

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

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.

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.
Continue lendo
Aprimorando a Experiência do Usuário com Teclados no iOS usando Keyboard Actions
Você nunca mais vai lutar contra o Teclado do iOS no Flutter! Veja como resolver um problema comum em aplicativos mobile para iOS que é…
Flutter Widgetbook: Como Documentar seu Design System do jeito certo!
Como o Widgetbook resolve o problema de documentar componentes em projetos Flutter com design system próprio. No Direção Fácil, virou catálogo visual, contrato com o Figma e contexto preciso para o Claude Code gerar telas usando os widgets certos.
Criando um Agente de IA no Claude Code para Controlar meu Smartphone
Automatizando documentação e testes de apps Android com inteligência artificial. Eu estava enfrentando um desafio específico: precisava documentar automaticamente diversos fluxos de uma aplicação Android. Foi quando comecei a explorar maneiras de realizar esse processo, e uma das ferramentas que decidi testar foi o Claude Code.