Perché l'edge-to-edge è diventato un tema urgente

Con Android 15 (API 35) Google ha reso l'edge-to-edge obbligatorio: le app che targettizzano quel livello di API disegnano automaticamente sotto la status bar e sotto la barra di navigazione, e le vecchie API per colorare quelle barre (Window.setStatusBarColor, setNavigationBarColor) sono deprecate e ignorate. Dal canto suo, Flutter ha allineato il framework abilitando l'edge-to-edge di default sulle versioni recenti di Android a partire da Flutter 3.27.

In pratica: se non intervieni, potresti ritrovarti con un pulsante coperto dalla barra gestuale, un titolo sotto l'orologio della status bar, o icone bianche su sfondo bianco. Su iOS il problema esiste da anni (notch, Dynamic Island, home indicator), ma il modello concettuale di Flutter è lo stesso. Vediamo come governarlo in modo pulito.

Il modello: padding, viewPadding e viewInsets

Tutto ruota attorno a tre proprietà di MediaQuery, spesso confuse tra loro:

  • viewInsets: spazio occupato da qualcosa che copre completamente la UI e con cui non si può interagire, tipicamente la tastiera software.
  • viewPadding: spazio occupato dalle intrusioni fisiche o di sistema (notch, status bar, barra di navigazione, home indicator). È stabile, non cambia quando compare la tastiera.
  • padding: è viewPadding meno la parte già "consumata" da viewInsets. Quando la tastiera si apre, padding.bottom scende a zero perché la home indicator è coperta dalla tastiera.

Regola pratica:

  • usa padding (o SafeArea) per posizionare contenuti che devono adattarsi dinamicamente;
  • usa viewPadding quando ti serve una misura stabile, ad esempio l'altezza di una barra inferiore custom che non deve "saltare" all'apertura della tastiera;
  • usa viewInsets.bottom per lasciare spazio alla tastiera in un foglio modale.

In Flutter 3.10+ esistono i metodi granulari che evitano rebuild inutili: preferisci MediaQuery.paddingOf(context) a MediaQuery.of(context).padding, perché il widget si ricostruisce solo quando cambia quella specifica proprietà.

final padding = MediaQuery.paddingOf(context);      // dinamico
final viewPadding = MediaQuery.viewPaddingOf(context); // stabile
final keyboard = MediaQuery.viewInsetsOf(context).bottom;

Abilitare (o forzare) l'edge-to-edge

Su Android 15 è il comportamento di default, ma se vuoi coerenza anche su versioni precedenti puoi impostarlo esplicitamente all'avvio:

import 'package:flutter/services.dart';

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

SystemUiMode.edgeToEdge mantiene visibili le barre di sistema ma rende l'app responsabile dell'intera superficie dello schermo. Evita SystemUiMode.manual con overlay rimossi se non stai costruendo un player video o un'esperienza immersiva: nasconde le barre e crea problemi di accessibilità.

Controllare le icone delle barre di sistema

Con l'edge-to-edge non puoi più colorare la barra: puoi solo decidere se le sue icone devono essere chiare o scure. Si fa con SystemUiOverlayStyle, idealmente per schermata tramite AnnotatedRegion, così ogni pagina dichiara lo stile corretto e Flutter lo ripristina automaticamente al pop della route.

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

  @override
  Widget build(BuildContext context) {
    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: const SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        // Android: icone chiare su header scuro
        statusBarIconBrightness: Brightness.light,
        // iOS: attenzione, la logica è invertita
        statusBarBrightness: Brightness.dark,
        systemNavigationBarColor: Colors.transparent,
        systemNavigationBarIconBrightness: Brightness.light,
        systemNavigationBarContrastEnforced: false,
      ),
      child: const Scaffold(body: _Content()),
    );
  }
}

Due note importanti:

  • su iOS statusBarBrightness indica la luminosità dello sfondo, quindi va impostato all'opposto rispetto ad Android;
  • systemNavigationBarContrastEnforced: false disattiva lo scrim semitrasparente che Android applica dietro la barra di navigazione. Toglilo solo se il tuo contenuto garantisce già contrasto sufficiente, altrimenti la barra gestuale diventa illeggibile.

Se usi una AppBar, Material 3 imposta già uno stile coerente con il tema; AppBar.systemOverlayStyle permette di sovrascriverlo puntualmente.

SafeArea: usarla bene, non ovunque

SafeArea è il modo più semplice per non finire sotto le barre, ma applicata a tutto il body produce l'errore classico: liste che si tagliano invece di scorrere sotto la status bar.

Il pattern corretto per una lista a tutto schermo è disabilitare il lato interessato e spostare l'inset nel padding dello scroll view:

Scaffold(
  body: SafeArea(
    bottom: false, // lasciamo scorrere il contenuto sotto la barra gestuale
    child: ListView.builder(
      padding: EdgeInsets.only(
        bottom: MediaQuery.paddingOf(context).bottom + 16,
      ),
      itemCount: items.length,
      itemBuilder: (context, index) => ListTile(title: Text(items[index])),
    ),
  ),
)

Così lo sfondo della lista arriva fino al bordo dello schermo, ma l'ultimo elemento resta raggiungibile.

Un'alternativa elegante per gli scroll view è MediaQuery.removePadding, utile quando un widget figlio applicherebbe un inset già gestito dal genitore.

Barre inferiori custom e extendBody

I widget Material come NavigationBar, BottomNavigationBar e BottomAppBar gestiscono già gli inset inferiori. Se invece costruisci una barra personalizzata, devi aggiungere tu il padding e — importante — usare viewPadding per evitare che la barra si restringa quando si apre la tastiera:

class GlassBottomBar extends StatelessWidget {
  const GlassBottomBar({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
    return Container(
      padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomInset),
      color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
      child: child,
    );
  }
}

Se vuoi che il contenuto scorra sotto una barra traslucida con effetto blur, imposta extendBody: true sullo Scaffold (e extendBodyBehindAppBar: true per la parte superiore):

Scaffold(
  extendBody: true,
  extendBodyBehindAppBar: true,
  bottomNavigationBar: const GlassBottomBar(child: _Tabs()),
  body: const _Content(),
)

Tastiera e bottom sheet

Un bottom sheet con campi di testo deve sollevarsi con la tastiera. Il modo più diretto è combinare viewInsets con il padding sicuro:

showModalBottomSheet(
  context: context,
  isScrollControlled: true,
  useSafeArea: true,
  builder: (context) => Padding(
    padding: EdgeInsets.only(
      bottom: MediaQuery.viewInsetsOf(context).bottom,
    ),
    child: const _CommentForm(),
  ),
);

Nel corpo della pagina, invece, Scaffold.resizeToAvoidBottomInset (attivo di default) ridimensiona il body all'apertura della tastiera. Disattivalo solo se gestisci gli inset manualmente, ad esempio con un AnimatedPadding.

Display cutout e schermi curvi

Per i ritagli fisici puoi ispezionare MediaQuery.of(context).displayFeatures e padding in modalità landscape, dove il notch finisce sui lati. Un errore frequente è applicare SafeArea solo in verticale: in orizzontale padding.left o padding.right possono valere 40+ logical pixel. SafeArea senza parametri li gestisce tutti; se li disabiliti, ricordati di reintrodurli nel padding orizzontale dei contenuti.

Testare senza dieci dispositivi

Gli inset sono semplici da simulare nei widget test, perché basta avvolgere la UI in un MediaQuery con i valori desiderati:

testWidgets('il CTA resta sopra la barra gestuale', (tester) async {
  await tester.pumpWidget(
    MediaQuery(
      data: const MediaQueryData(
        padding: EdgeInsets.only(top: 54, bottom: 34),
        viewPadding: EdgeInsets.only(top: 54, bottom: 34),
      ),
      child: const MaterialApp(home: CheckoutPage()),
    ),
  );

  final buttonRect = tester.getRect(find.byKey(const Key('cta')));
  final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
  expect(buttonRect.bottom, lessThanOrEqualTo(screen.height - 34));
});

Aggiungi un golden test con gli stessi inset e avrai una rete di sicurezza contro le regressioni di layout sui dispositivi con notch.

Checklist finale

  • Verifica l'app su Android 15 con navigazione gestuale e a tre pulsanti: gli inset inferiori sono molto diversi.
  • Non colorare più status bar e navigation bar: usa solo la brightness delle icone.
  • Usa paddingOf, viewPaddingOf, viewInsetsOf invece di MediaQuery.of per ridurre i rebuild.
  • Lascia scorrere i contenuti sotto le barre, ma aggiungi padding agli scroll view.
  • Controlla il contrasto dietro la barra gestuale prima di disattivare systemNavigationBarContrastEnforced.
  • Se hai codice nativo che chiama setStatusBarColor, rimuovilo: su API 35 non ha più effetto.

Adottare correttamente l'edge-to-edge non è solo conformità alle regole di Android: è ciò che fa sembrare un'app Flutter davvero nativa su schermi moderni, dove il contenuto arriva fino al bordo e le barre di sistema diventano parte dell'interfaccia.