Perché la tastiera rompe le UI

Quasi ogni app Flutter, prima o poi, mostra il famigerato nastro giallo e nero BOTTOM OVERFLOWED BY 137 PIXELS. Il colpevole è quasi sempre lo stesso: la tastiera software compare, riduce lo spazio disponibile e il layout non è preparato a gestirlo.

Il problema è che la tastiera non è un widget: è una superficie di sistema che si sovrappone alla finestra dell'app. Flutter la espone solo come un valore numerico dentro MediaQuery, e sta a noi decidere come reagire. In questo articolo vediamo tutti gli strumenti disponibili, dai più semplici ai casi limite (toolbar ancorate, bottom sheet, form lunghi, edge-to-edge).

viewInsets, padding e viewPadding: la differenza che conta

MediaQueryData espone tre proprietà che sembrano simili ma non lo sono:

  • viewInsets: lo spazio coperto da una superficie di sistema che nasconde completamente i contenuti. La tastiera vive qui: viewInsets.bottom vale 0 a tastiera chiusa e, ad esempio, 320 a tastiera aperta.
  • viewPadding: le aree occupate da notch, barre di sistema, home indicator. Non cambia quando appare la tastiera.
  • padding: come viewPadding, ma azzerato nelle zone già coperte da viewInsets. Quando la tastiera è aperta, padding.bottom diventa 0 perché la home indicator è sotto la tastiera. È il valore usato da SafeArea.

La regola pratica: per reagire alla tastiera si usa sempre viewInsets.bottom, mai padding.bottom.

Da Flutter 3.10 esistono gli accessor granulari, che sono anche più efficienti perché il widget viene ricostruito solo quando quella specifica proprietà cambia:

// Male: si ricostruisce a ogni cambiamento di MediaQuery (rotazione, text scale, ecc.)
final inset = MediaQuery.of(context).viewInsets.bottom;

// Bene: dipendenza mirata
final inset = MediaQuery.viewInsetsOf(context).bottom;

resizeToAvoidBottomInset: cosa fa davvero

Scaffold ha il flag resizeToAvoidBottomInset, attivo di default. Quando è true, lo Scaffold aggiunge un padding inferiore pari a viewInsets.bottom al proprio body: in pratica il corpo della pagina viene rimpicciolito per stare sopra la tastiera.

Questo comportamento è identico su Android e iOS (non dipende dal windowSoftInputMode nativo) e risolve il 90% dei casi, a patto che il body sia scrollabile. Se il body è una Column rigida, lo spazio si riduce ma il contenuto no: da qui l'overflow.

Scaffold(
  appBar: AppBar(title: const Text('Registrazione')),
  body: SafeArea(
    child: SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          TextFormField(decoration: const InputDecoration(labelText: 'Nome')),
          const SizedBox(height: 12),
          TextFormField(decoration: const InputDecoration(labelText: 'Email')),
          // ...altri campi
        ],
      ),
    ),
  ),
);

Quando invece il design richiede uno sfondo a tutto schermo o una mappa che non deve deformarsi, si disattiva il resize e si gestisce l'inset a mano:

Scaffold(
  resizeToAvoidBottomInset: false,
  body: Stack(
    children: [
      const MappaFullScreen(),
      Positioned(
        left: 16,
        right: 16,
        bottom: 16 + MediaQuery.viewInsetsOf(context).bottom,
        child: const BarraRicerca(),
      ),
    ],
  ),
);

Il pattern "Column con pulsante in fondo"

Un layout ricorrente: form in alto, pulsante di conferma ancorato in basso. Con Expanded dentro una Column non scrollabile l'overflow è garantito. La soluzione robusta è LayoutBuilder + ConstrainedBox + IntrinsicHeight:

LayoutBuilder(
  builder: (context, constraints) {
    return SingleChildScrollView(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: constraints.maxHeight),
        child: IntrinsicHeight(
          child: Column(
            children: [
              const CampiForm(),
              const Spacer(), // funziona grazie a IntrinsicHeight
              const PulsanteConferma(),
            ],
          ),
        ),
      ),
    );
  },
);

Con la tastiera chiusa il pulsante resta in fondo allo schermo; con la tastiera aperta il contenuto diventa scrollabile e nulla va in overflow. Attenzione: IntrinsicHeight ha un costo di layout non banale, quindi evitalo dentro liste lunghe o ricostruzioni frequenti.

In alternativa, se il pulsante deve restare sempre visibile sopra la tastiera, usa bottomNavigationBar dello Scaffold: viene posizionato correttamente rispetto agli insets.

Scroll automatico verso il campo con focus

Flutter fa già qualcosa per te: EditableText chiama internamente Scrollable.ensureVisible quando un campo riceve il focus, usando la proprietà scrollPadding (di default EdgeInsets.all(20)). Se hai una toolbar personalizzata o un pulsante ancorato sopra la tastiera, quei 20 logical pixel non bastano:

TextField(
  scrollPadding: const EdgeInsets.only(bottom: 120),
  decoration: const InputDecoration(labelText: 'Note'),
)

Se invece devi portare in vista un blocco più grande del singolo campo (per esempio una card che contiene campo + messaggio di errore), gestiscilo manualmente con un FocusNode:

class CampoConScroll extends StatefulWidget {
  const CampoConScroll({super.key, required this.label});
  final String label;

  @override
  State<CampoConScroll> createState() => _CampoConScrollState();
}

class _CampoConScrollState extends State<CampoConScroll> {
  final _focusNode = FocusNode();

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_onFocusChange);
  }

  void _onFocusChange() {
    if (!_focusNode.hasFocus) return;
    // Attende che il layout si sia aggiornato con la tastiera aperta
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      Scrollable.ensureVisible(
        context,
        alignment: 0.1, // il widget si posiziona in alto nel viewport
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOut,
      );
    });
  }

  @override
  void dispose() {
    _focusNode.removeListener(_onFocusChange);
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      decoration: InputDecoration(labelText: widget.label),
    );
  }
}

Ricorda sempre di fare dispose() dei FocusNode: sono una delle fonti più comuni di memory leak nei form.

Passare da un campo all'altro

Per una UX curata, il tasto di invio della tastiera deve portare al campo successivo e, sull'ultimo, inviare il form:

final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();

Column(
  children: [
    TextFormField(
      focusNode: _emailFocus,
      keyboardType: TextInputType.emailAddress,
      textInputAction: TextInputAction.next,
      autofillHints: const [AutofillHints.email],
      onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
    ),
    TextFormField(
      focusNode: _passwordFocus,
      obscureText: true,
      textInputAction: TextInputAction.done,
      autofillHints: const [AutofillHints.password],
      onFieldSubmitted: (_) => _submit(),
    ),
  ],
)

In alternativa, FocusScope.of(context).nextFocus() sposta il focus seguendo l'ordine di traversal, senza dover dichiarare un nodo per ogni campo. Gli autofillHints, oltre a migliorare la compilazione automatica, fanno comparire sulla tastiera i suggerimenti di sistema e i password manager: avvolgi il form in un AutofillGroup per completezza.

Scegli inoltre il keyboardType corretto (TextInputType.number, phone, emailAddress, TextInputType.numberWithOptions(decimal: true)) e usa inputFormatters per filtrare l'input: su iOS la tastiera numerica non ha il tasto "indietro" del form, quindi valuta TextInputAction.done.

Chiudere la tastiera al momento giusto

Tre strategie complementari:

// 1. Tocco fuori dai campi
GestureDetector(
  onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
  behavior: HitTestBehavior.opaque,
  child: const ContenutoPagina(),
);

// 2. Chiusura durante lo scroll (comportamento nativo iOS)
ListView.builder(
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  itemBuilder: (_, i) => ...,
);

// 3. Chiusura programmatica prima di una navigazione
void _submit() {
  FocusScope.of(context).unfocus();
  context.push('/riepilogo');
}

Il punto 3 è importante: se navighi con la tastiera aperta, la transizione risulta a scatti perché il layout della pagina uscente cambia durante l'animazione.

Bottom sheet, dialog e tastiera

È il caso in cui sbagliare è più facile. Un showModalBottomSheet con un TextField deve essere isScrollControlled: true e applicare l'inset al proprio contenuto, usando il context interno al builder:

showModalBottomSheet<void>(
  context: context,
  isScrollControlled: true,
  useSafeArea: true,
  builder: (sheetContext) {
    return Padding(
      padding: EdgeInsets.only(
        left: 16,
        right: 16,
        top: 16,
        bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const TextField(autofocus: true),
          const SizedBox(height: 12),
          FilledButton(onPressed: () {}, child: const Text('Salva')),
        ],
      ),
    );
  },
);

Senza isScrollControlled: true il sheet è limitato a metà schermo e la tastiera lo coprirebbe. Con AnimatedPadding al posto di Padding (durata ~200 ms, curva decelerate) il movimento risulta più morbido su Android, dove l'animazione della tastiera non è sincronizzata con quella di Flutter.

Toolbar ancorata sopra la tastiera

Per barre di formattazione, contatori di caratteri o pulsanti "Fatto", il pattern è ancorare un widget all'viewInsets.bottom dentro uno Stack:

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

  @override
  Widget build(BuildContext context) {
    final inset = MediaQuery.viewInsetsOf(context).bottom;
    final visibile = inset > 0;

    return AnimatedPositioned(
      duration: const Duration(milliseconds: 150),
      left: 0,
      right: 0,
      bottom: inset,
      child: AnimatedOpacity(
        duration: const Duration(milliseconds: 150),
        opacity: visibile ? 1 : 0,
        child: IgnorePointer(ignoring: !visibile, child: child),
      ),
    );
  }
}

Se hai bisogno di sapere quando la tastiera si apre o chiude al di fuori del build (per esempio per loggare un evento o mettere in pausa un'animazione), puoi osservare gli insets con un WidgetsBindingObserver e didChangeMetrics, oppure usare il pacchetto flutter_keyboard_visibility che espone uno Stream<bool>.

Edge-to-edge e Android moderno

Con le app in modalità edge-to-edge (obbligatoria su Android 15 per le app che puntano a SDK 35) la gestione degli insets diventa più delicata: SafeArea protegge da barre di sistema e gesture bar, mentre la tastiera resta gestita da viewInsets. L'errore tipico è sommare due volte lo stesso spazio, usando sia SafeArea sia un padding manuale con padding.bottom. Ricorda che, a tastiera aperta, MediaQuery.paddingOf(context).bottom vale 0: se il tuo pulsante ancorato usa quel valore, non c'è rischio di doppio padding, ma se usi viewPadding sì.

Testare il comportamento

Nei widget test puoi simulare la tastiera sovrascrivendo il MediaQuery:

testWidgets('il pulsante resta visibile con la tastiera aperta', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: MediaQuery(
        data: const MediaQueryData(
          size: Size(390, 844),
          viewInsets: EdgeInsets.only(bottom: 336),
        ),
        child: const PaginaLogin(),
      ),
    ),
  );

  expect(tester.takeException(), isNull); // nessun overflow
  expect(find.byType(FilledButton), findsOneWidget);
});

In debug, ogni overflow genera un'eccezione catturabile con tester.takeException(): è un modo economico per prevenire regressioni sui form principali.

Checklist finale

  • Usa MediaQuery.viewInsetsOf(context) e mai padding.bottom per la tastiera.
  • Rendi scrollabile ogni schermata che contiene campi di testo.
  • Disattiva resizeToAvoidBottomInset solo se gestisci gli insets a mano.
  • Imposta scrollPadding sui campi se hai elementi ancorati in basso.
  • Concatena i campi con textInputAction + FocusNode e aggiungi gli autofillHints.
  • Fai dispose() di FocusNode e TextEditingController.
  • Nei bottom sheet: isScrollControlled: true + padding con gli insets del context interno.
  • Chiudi la tastiera prima di navigare.

Gestire bene la tastiera non aggiunge funzionalità visibili, ma è esattamente il tipo di dettaglio che distingue un'app che "sembra nativa" da una che sembra un prototipo.