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: èviewPaddingmeno la parte già "consumata" daviewInsets. Quando la tastiera si apre,padding.bottomscende a zero perché la home indicator è coperta dalla tastiera.
Regola pratica:
- usa
padding(oSafeArea) per posizionare contenuti che devono adattarsi dinamicamente; - usa
viewPaddingquando ti serve una misura stabile, ad esempio l'altezza di una barra inferiore custom che non deve "saltare" all'apertura della tastiera; - usa
viewInsets.bottomper 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
statusBarBrightnessindica la luminosità dello sfondo, quindi va impostato all'opposto rispetto ad Android; systemNavigationBarContrastEnforced: falsedisattiva 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,viewInsetsOfinvece diMediaQuery.ofper 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.