O Android 16 torna impossível empurrar o problema de edge-to-edge para depois. Desde o Android 15, apps com targetSdk 35 já desenham por baixo das barras do sistema em muitos cenários. No Android 16, para apps mirando API 36, a rota de escape windowOptOutEdgeToEdgeEnforcement deixa de resolver o problema. Se a sua tela Compose ainda depende de statusBarColor, margens fixas ou padding(top = 24.dp), a migração para targetSdk = 36 pode revelar botões cobertos, listas cortadas, FAB colado na barra de navegação e campos de texto escondidos pelo teclado.

A boa notícia: em Kotlin com Jetpack Compose, a solução costuma ser limpa quando o time trata WindowInsets como parte da arquitetura de UI. Edge-to-edge não significa deixar tudo atrás da barra do sistema. Significa permitir que o app use a janela inteira e aplicar espaçamentos conscientes onde o conteúdo precisa permanecer legível, clicável e acessível.

Se você já acompanha nossos guias de Jetpack Compose, Navigation 3 no Android, Baseline Profiles e Macrobenchmark e testes Android com Compose e Maestro, este artigo fecha uma lacuna prática: preparar a UI para o comportamento visual que o Android moderno exige.

O que muda no Android 16?

O ponto central é simples: apps que miram Android 16 precisam assumir que o conteúdo pode ocupar a área por trás de status bar, navigation bar e display cutouts. Em versões anteriores, alguns times mantinham a aparência antiga com flags de opt-out. Essa estratégia fica frágil quando o app avança o targetSdk.

Na prática, você deve revisar:

  • telas com Scaffold e barras superiores;
  • listas em LazyColumn ou LazyVerticalGrid;
  • botões fixos no rodapé;
  • formulários com teclado aberto;
  • bottom sheets, dialogs e overlays;
  • telas com imagens hero ou mapas;
  • navegação com predictive back;
  • flows de login, checkout, perfil e detalhes.

O erro mais comum é tratar insets como um ajuste cosmético. Eles são uma regra de layout. Se o app já usa Compose, estado de UI, coroutines e navegação declarativa, vale integrar insets ao mesmo nível de cuidado que você dá a temas, breakpoints e acessibilidade.

Configure o projeto para target SDK 36

Antes de ajustar UI, deixe claro onde o projeto está. Um exemplo mínimo de Gradle Kotlin DSL para um app Android moderno seria:

plugins {
    id("com.android.application")
    kotlin("android")
}

android {
    namespace = "br.dev.kotlin.edge"
    compileSdk = 36

    defaultConfig {
        applicationId = "br.dev.kotlin.edge"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }

    buildFeatures {
        compose = true
    }
}

Não faça essa mudança como um commit isolado no fim da sprint. Atualizar targetSdk deve vir acompanhado de uma bateria de telas críticas, porque a regressão visual aparece em tempo de execução, não necessariamente no build.

Ative edge-to-edge na Activity

Em apps Compose, o ponto de entrada normalmente é a ComponentActivity. A recomendação moderna é ativar edge-to-edge cedo, antes de desenhar o conteúdo:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        setContent {
            KotlinBrasilTheme {
                AppRoot()
            }
        }
    }
}

Esse passo sozinho não resolve layout. Ele apenas coloca o app no modo certo. A partir daqui, cada tela precisa decidir quais áreas podem ser decorativas e quais áreas precisam de padding para não ficarem sob as barras do sistema.

Use Scaffold com insets explícitos

Um padrão seguro é deixar o Scaffold receber os insets corretos e propagar o padding para o conteúdo:

@Composable
fun FeedScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Artigos Kotlin") }
            )
        },
        contentWindowInsets = WindowInsets.safeDrawing
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            contentPadding = innerPadding
        ) {
            items(artigos) { artigo ->
                ArtigoCard(artigo)
            }
        }
    }
}

WindowInsets.safeDrawing é um bom ponto de partida para telas em que todo conteúdo importante precisa ficar visível. Ele considera áreas onde desenho pode ser inseguro por causa de barras do sistema e cutouts.

Mas nem toda tela deve usar o mesmo comportamento. Uma tela com imagem de capa pode desenhar a imagem atrás da status bar e aplicar padding apenas ao título ou aos botões:

@Composable
fun DetalheArtigoScreen() {
    Box(Modifier.fillMaxSize()) {
        HeaderImage(
            modifier = Modifier
                .fillMaxWidth()
                .height(260.dp)
        )

        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(WindowInsets.safeDrawing.asPaddingValues())
        ) {
            BackButton()
            Spacer(Modifier.height(180.dp))
            ArticleBody()
        }
    }
}

A diferença é intencional: decoração pode ocupar a borda; ação e texto precisam respeitar área segura.

Não some padding duas vezes

Um bug clássico em Compose é aplicar insets no Scaffold, depois aplicar statusBarsPadding() ou navigationBarsPadding() dentro do conteúdo e acabar com espaçamento duplicado.

Evite misturar padrões sem necessidade:

// Suspeito: pode duplicar padding dependendo da tela
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
    Column(
        Modifier
            .padding(innerPadding)
            .statusBarsPadding()
    ) {
        Conteudo()
    }
}

Prefira um ponto de verdade por tela. Se o Scaffold controla o padding, passe innerPadding para a área rolável. Se a tela é customizada com Box, use WindowInsets.safeDrawing.asPaddingValues() manualmente. O time deve conseguir olhar a tela e responder: “quem é responsável pelos insets aqui?”.

Listas precisam de rodapé seguro

Em apps reais, a maioria das regressões aparece em listas. O último item fica atrás da navigation bar, um botão flutuante cobre conteúdo ou o scroll não permite enxergar a ação final.

Uma LazyColumn com CTA fixo no rodapé precisa combinar insets com espaçamento extra:

@Composable
fun VagasKotlinScreen() {
    Box(Modifier.fillMaxSize()) {
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(
                start = 16.dp,
                top = 16.dp,
                end = 16.dp,
                bottom = 96.dp
            )
        ) {
            items(vagas) { vaga ->
                VagaCard(vaga)
            }
        }

        Button(
            onClick = { /* salvar alerta */ },
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .navigationBarsPadding()
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Criar alerta de vaga")
        }
    }
}

Aqui, a lista ganha espaço para não terminar sob o botão, e o botão respeita a navigation bar. Em telas com FAB, a lógica é a mesma: o componente fixo deve ter padding de sistema, e a área rolável precisa de bottom padding suficiente para não ficar escondida.

Teclado: imePadding não é opcional

Formulários são outra fonte de dor. Edge-to-edge com teclado aberto exige tratar IME inset. Uma tela de login simples pode usar:

@Composable
fun LoginScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .safeDrawingPadding()
            .imePadding()
            .verticalScroll(rememberScrollState())
            .padding(24.dp),
        verticalArrangement = Arrangement.Center
    ) {
        Text("Entrar", style = MaterialTheme.typography.headlineMedium)
        Spacer(Modifier.height(24.dp))
        OutlinedTextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("E-mail") },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(Modifier.height(12.dp))
        OutlinedTextField(
            value = senha,
            onValueChange = { senha = it },
            label = { Text("Senha") },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(Modifier.height(24.dp))
        Button(onClick = ::entrar, modifier = Modifier.fillMaxWidth()) {
            Text("Continuar")
        }
    }
}

imePadding() desloca o conteúdo quando o teclado aparece. O verticalScroll garante que telas menores ainda consigam acessar o botão. Sem isso, a tela pode parecer correta em um Pixel grande e quebrar em aparelhos intermediários, tablets dobráveis ou layouts com fonte aumentada.

Top bars e status bar transparente

Com edge-to-edge, barras superiores devem parecer parte da UI, não uma faixa quebrada. Em Compose Material 3, TopAppBar funciona bem, mas você precisa decidir se ela terá cor sólida, translúcida ou se ficará sobre uma imagem.

Para telas comuns:

TopAppBar(
    title = { Text("Configurações") },
    colors = TopAppBarDefaults.topAppBarColors(
        containerColor = MaterialTheme.colorScheme.surface
    )
)

Para hero visual, use contraste forte e teste legibilidade em modo claro, escuro e com imagem carregando lentamente. O problema não é só técnico; é de UX. Um ícone branco sobre imagem clara atrás da status bar vira bug de acessibilidade.

Cutouts e telas grandes

Edge-to-edge também conversa com tablets, dobráveis e display cutouts. Não presuma que status bar fica sempre no topo ou que navigation bar fica sempre embaixo. Em landscape, foldables e janelas redimensionáveis, os insets podem mudar bastante.

Por isso, evite constantes mágicas:

// Frágil
Modifier.padding(top = 24.dp, bottom = 48.dp)

Prefira APIs de inset:

Modifier.windowInsetsPadding(WindowInsets.safeDrawing)

E combine com layout adaptativo quando a tela crescer. Se o projeto usa duas colunas, navigation rail ou painel mestre-detalhe, revise também nossos conteúdos sobre Compose Multiplatform e Kotlin Multiplatform, porque a disciplina de layout responsivo é parecida: estado claro, áreas seguras e componentes que não dependem de um único tamanho de tela.

Como testar sem depender do “olhômetro”

A migração para edge-to-edge precisa de checklist. Um fluxo básico:

  1. rode o app em Android 15 e Android 16;
  2. teste navegação por gestos e por três botões;
  3. abra teclado em todos os formulários;
  4. aumente fonte e display size;
  5. teste landscape em telas críticas;
  6. capture screenshots antes/depois;
  7. rode Macrobenchmark ou testes de screenshot onde fizer sentido.

Para Compose, testes automatizados podem validar se elementos existem e continuam clicáveis, mas screenshot tests ajudam a detectar corte visual. O artigo sobre Baseline Profiles e Macrobenchmark cobre a parte de medição de performance; para edge-to-edge, o foco é combinar essa disciplina com regressão visual e QA manual em aparelhos representativos.

Também vale revisar a pipeline. Se o time usa GitHub Actions, o guia de CI/CD para Kotlin ajuda a organizar checks por pull request. Para comparar com estratégias de validação em outras stacks mobile/backend, há paralelos úteis em automação e testes no ecossistema Python, especialmente na disciplina de separar teste rápido, teste visual e teste end-to-end.

Checklist de migração

Antes de subir targetSdk = 36 para produção, revise:

  • enableEdgeToEdge() chamado na Activity principal;
  • nenhum uso cego de padding fixo para status/navigation bar;
  • Scaffold com contentWindowInsets definido conscientemente;
  • listas com bottom padding suficiente;
  • CTAs fixos com navigationBarsPadding();
  • formulários com imePadding() e scroll;
  • telas com hero image testadas com contraste real;
  • bottom sheets e dialogs revisados;
  • screenshots em Android 15/16;
  • QA com fonte aumentada;
  • regressão de performance em telas críticas.

Erros comuns

O primeiro erro é achar que edge-to-edge é só “deixar transparente”. Transparência sem insets vira conteúdo ilegível.

O segundo é corrigir tela por tela com números mágicos. Isso funciona até trocar aparelho, orientação ou modo de navegação.

O terceiro é ignorar teclado. Muitas migrações parecem boas até o usuário editar um campo no fim da tela.

O quarto é duplicar padding. Se a tela parece “baixa demais”, desconfie de Scaffold mais safeDrawingPadding() no mesmo eixo.

O quinto é não envolver design e QA. Edge-to-edge muda densidade visual, hierarquia e contraste. Não é apenas uma tarefa de Android.

Conclusão

Android 16 transforma edge-to-edge em requisito prático para apps Kotlin modernos. A migração não precisa ser traumática, mas precisa ser deliberada: ative o modo corretamente, escolha o responsável pelos insets em cada tela, trate listas e formulários como casos críticos e valide em dispositivos reais.

Para times que já usam Compose, coroutines, Navigation 3 e CI/CD, essa é uma boa oportunidade de elevar a qualidade visual do app. Em vez de “consertar barra do sistema”, pense em uma UI que entende a janela inteira, protege conteúdo importante e aproveita melhor o espaço disponível.

O resultado é um app mais atual, mais consistente e pronto para a próxima onda de aparelhos Android — de celulares compactos a dobráveis, tablets e janelas redimensionáveis.