GuidePrincipiante35 min Flutter 3.x

Creare una barra di navigazione inferiore in Flutter con BottomNavigationBar e IndexedStack

La barra di navigazione inferiore è uno dei pattern più diffusi nelle app mobile: due o più sezioni principali (Home, Ricerca, Profilo...) sempre raggiungibili con un tocco.

In Flutter questo pattern si realizza in pochissime righe grazie al widget BottomNavigationBar, che si aggancia alla proprietà bottomNavigationBar dello Scaffold.

In questo tutorial partiamo da zero e costruiamo passo passo un'app con tre sezioni. Vedremo:

  • come collegare la barra a un indice di stato con setState;
  • perché usare IndexedStack invece di ricostruire ogni volta la pagina;
  • come preservare lo scroll e lo stato dei campi di testo passando da un tab all'altro;
  • come personalizzare colori, icone e badge;
  • come passare alla nuova NavigationBar di Material 3.

Prerequisiti: Flutter installato e un progetto vuoto creato con flutter create. Serve solo il framework, nessun pacchetto esterno.

  1. 1

    Preparare il progetto e lo scheletro dell'app

    Crea un nuovo progetto (o svuota lib/main.dart di uno esistente):

    flutter create nav_demo
    cd nav_demo
    

    Sostituisci il contenuto di lib/main.dart con lo scheletro qui sotto. Per ora abbiamo solo un MaterialApp con il tema Material 3 attivo e una schermata principale ancora vuota, che diventerà il contenitore della nostra navigazione.

    Nota il widget HomeShell: è uno StatefulWidget perché dovrà ricordare quale tab è selezionato. Questa è la prima decisione importante: l'indice del tab è uno stato locale della schermata, non serve nessun package di state management.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Nav Demo',
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            colorSchemeSeed: Colors.indigo,
            useMaterial3: true,
          ),
          home: const HomeShell(),
        );
      }
    }
    
    class HomeShell extends StatefulWidget {
      const HomeShell({super.key});
    
      @override
      State<HomeShell> createState() => _HomeShellState();
    }
    
    class _HomeShellState extends State<HomeShell> {
      int _currentIndex = 0;
    
      @override
      Widget build(BuildContext context) {
        return const Scaffold(
          body: Center(child: Text('Contenuto in arrivo')),
        );
      }
    }

    Risultato atteso

    L'app si avvia mostrando una schermata bianca con la scritta "Contenuto in arrivo". Nessun errore in console.

  2. 2

    Creare le tre pagine da mostrare

    Ogni tab mostrerà una pagina diversa. Creiamo tre widget semplici ma non banali: la Home contiene una lista scrollabile (ci servirà per verificare il mantenimento dello scroll), la Ricerca un TextField, il Profilo del testo statico.

    Puoi metterli in fondo a main.dart per comodità; in un progetto reale andrebbero in file separati dentro lib/pages/.

    Osserva che SearchPage è StatefulWidget con un TextEditingController: nel passo 4 vedremo cosa succede al testo digitato quando cambiamo tab.

    class HomePage extends StatelessWidget {
      const HomePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return ListView.builder(
          itemCount: 50,
          itemBuilder: (context, i) => ListTile(
            leading: CircleAvatar(child: Text('${i + 1}')),
            title: Text('Elemento numero ${i + 1}'),
            subtitle: const Text('Scorri la lista e cambia tab'),
          ),
        );
      }
    }
    
    class SearchPage extends StatefulWidget {
      const SearchPage({super.key});
    
      @override
      State<SearchPage> createState() => _SearchPageState();
    }
    
    class _SearchPageState extends State<SearchPage> {
      final _controller = TextEditingController();
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.all(16),
          child: TextField(
            controller: _controller,
            decoration: const InputDecoration(
              labelText: 'Cerca',
              prefixIcon: Icon(Icons.search),
              border: OutlineInputBorder(),
            ),
          ),
        );
      }
    }
    
    class ProfilePage extends StatelessWidget {
      const ProfilePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)),
              SizedBox(height: 12),
              Text('Mario Rossi', style: TextStyle(fontSize: 20)),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Il codice compila senza errori: le tre pagine esistono ma non sono ancora visibili nell'app.

  3. 3

    Aggiungere la BottomNavigationBar e collegarla all'indice

    Ora colleghiamo tutto. Nello Scaffold valorizziamo la proprietà bottomNavigationBar con un BottomNavigationBar che riceve:

    • currentIndex: l'indice del tab attivo (il nostro stato);
    • onTap: callback chiamata al tocco, dove aggiorniamo lo stato con setState;
    • items: la lista dei BottomNavigationBarItem (minimo 2, altrimenti Flutter lancia un errore).

    Per il body usiamo per ora un semplice accesso alla lista di pagine tramite indice.

    ⚠️ Attenzione a un dettaglio classico: con più di 3 item la barra passa automaticamente al tipo shifting e, se non imposti i colori, le etichette non selezionate spariscono. Puoi forzare il comportamento con type: BottomNavigationBarType.fixed.

    class _HomeShellState extends State<HomeShell> {
      int _currentIndex = 0;
    
      final _pages = const [
        HomePage(),
        SearchPage(),
        ProfilePage(),
      ];
    
      final _titles = const ['Home', 'Ricerca', 'Profilo'];
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text(_titles[_currentIndex])),
          body: _pages[_currentIndex],
          bottomNavigationBar: BottomNavigationBar(
            currentIndex: _currentIndex,
            onTap: (index) => setState(() => _currentIndex = index),
            type: BottomNavigationBarType.fixed,
            items: const [
              BottomNavigationBarItem(
                icon: Icon(Icons.home_outlined),
                activeIcon: Icon(Icons.home),
                label: 'Home',
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.search_outlined),
                activeIcon: Icon(Icons.search),
                label: 'Ricerca',
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.person_outline),
                activeIcon: Icon(Icons.person),
                label: 'Profilo',
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Toccando le tre icone in basso cambiano sia il contenuto sia il titolo nella AppBar. Se però scorri la lista della Home e torni indietro, la lista riparte dall'alto e il testo digitato nella Ricerca è sparito.

  4. 4

    Mantenere lo stato delle pagine con IndexedStack

    Il problema visto nel passo precedente è normale: scrivendo body: _pages[_currentIndex] Flutter rimuove dall'albero la pagina precedente e con essa il suo State (posizione di scroll, testo dei campi, dati caricati).

    La soluzione più semplice è IndexedStack: costruisce tutti i figli e ne mostra solo uno, mantenendo gli altri vivi ma invisibili.

    Sostituisci la riga del body:

    body: IndexedStack(
      index: _currentIndex,
      children: _pages,
    ),
    

    Pro: stato preservato, cambio tab istantaneo.
    Contro: tutte le pagine vengono costruite subito. Se una pagina è molto pesante o fa chiamate di rete all'avvio, valuta il lazy loading (costruire la pagina solo la prima volta che viene aperta) come nell'esempio avanzato in fondo al codice.

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(title: Text(_titles[_currentIndex])),
        body: IndexedStack(
          index: _currentIndex,
          children: _pages,
        ),
        bottomNavigationBar: BottomNavigationBar(
          currentIndex: _currentIndex,
          onTap: (index) => setState(() => _currentIndex = index),
          type: BottomNavigationBarType.fixed,
          items: const [
            BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'),
            BottomNavigationBarItem(icon: Icon(Icons.search_outlined), activeIcon: Icon(Icons.search), label: 'Ricerca'),
            BottomNavigationBarItem(icon: Icon(Icons.person_outline), activeIcon: Icon(Icons.person), label: 'Profilo'),
          ],
        ),
      );
    }

    Risultato atteso

    Scorri la lista della Home, vai su Ricerca, scrivi qualcosa e torna alla Home: la posizione di scroll è mantenuta e, rientrando in Ricerca, il testo digitato è ancora lì.

  5. 5

    Personalizzare colori, badge ed evitare i tocchi inutili

    Rendiamo la barra più curata:

    • selectedItemColor / unselectedItemColor per i colori delle icone e delle etichette;
    • showUnselectedLabels: false per lo stile "solo icona" sui tab non attivi;
    • Badge (widget nativo da Flutter 3.7) per mostrare un contatore di notifiche;
    • un piccolo controllo in onTap per non chiamare setState se l'utente tocca il tab già attivo (evita rebuild inutili) e, opzionalmente, riportare la lista in cima al doppio tocco.

    Aggiorna il metodo onTap e gli items come nello snippet.

    int _notifiche = 3;
    
    void _onTabTapped(int index) {
      if (index == _currentIndex) return; // già selezionato: niente rebuild
      setState(() => _currentIndex = index);
    }
    
    // ...dentro build:
    bottomNavigationBar: BottomNavigationBar(
      currentIndex: _currentIndex,
      onTap: _onTabTapped,
      type: BottomNavigationBarType.fixed,
      backgroundColor: Colors.white,
      selectedItemColor: Theme.of(context).colorScheme.primary,
      unselectedItemColor: Colors.grey.shade600,
      showUnselectedLabels: true,
      selectedFontSize: 12,
      unselectedFontSize: 12,
      items: [
        const BottomNavigationBarItem(
          icon: Icon(Icons.home_outlined),
          activeIcon: Icon(Icons.home),
          label: 'Home',
        ),
        BottomNavigationBarItem(
          icon: Badge.count(
            count: _notifiche,
            isLabelVisible: _notifiche > 0,
            child: const Icon(Icons.search_outlined),
          ),
          activeIcon: const Icon(Icons.search),
          label: 'Ricerca',
        ),
        const BottomNavigationBarItem(
          icon: Icon(Icons.person_outline),
          activeIcon: Icon(Icons.person),
          label: 'Profilo',
        ),
      ],
    ),

    Risultato atteso

    La barra mostra i colori del tema, un badge rosso con il numero 3 sull'icona Ricerca e non ricostruisce l'interfaccia quando tocchi il tab già selezionato.

  6. 6

    Passare alla NavigationBar di Material 3

    Con Material 3 il widget consigliato è NavigationBar (al posto di BottomNavigationBar): è più alta, ha l'indicatore a pillola dietro l'icona selezionata e un'animazione di transizione integrata.

    Le differenze principali nell'API:

    BottomNavigationBar NavigationBar
    currentIndex selectedIndex
    onTap onDestinationSelected
    items con BottomNavigationBarItem destinations con NavigationDestination
    activeIcon selectedIcon

    Sostituisci il blocco bottomNavigationBar con lo snippet qui sotto: il resto dell'app (stato, IndexedStack, pagine) resta identico.

    Puoi anche personalizzare l'aspetto globalmente nel tema con navigationBarTheme: NavigationBarThemeData(...).

    bottomNavigationBar: NavigationBar(
      selectedIndex: _currentIndex,
      onDestinationSelected: _onTabTapped,
      labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
      destinations: [
        const NavigationDestination(
          icon: Icon(Icons.home_outlined),
          selectedIcon: Icon(Icons.home),
          label: 'Home',
        ),
        NavigationDestination(
          icon: Badge.count(
            count: _notifiche,
            isLabelVisible: _notifiche > 0,
            child: const Icon(Icons.search_outlined),
          ),
          selectedIcon: const Icon(Icons.search),
          label: 'Ricerca',
        ),
        const NavigationDestination(
          icon: Icon(Icons.person_outline),
          selectedIcon: Icon(Icons.person),
          label: 'Profilo',
        ),
      ],
    ),

    Risultato atteso

    La barra assume l'aspetto Material 3, con l'indicatore a pillola che scorre sull'icona selezionata e la stessa logica di cambio pagina.

  7. 7

    Bonus: pagine con Navigator indipendente per ogni tab

    Nelle app reali ogni tab ha spesso una propria pila di navigazione: dalla Home apri il dettaglio di un elemento e, cambiando tab e tornando indietro, ti aspetti di ritrovare il dettaglio aperto.

    Si ottiene avvolgendo ogni pagina in un Navigator con la sua GlobalKey, e gestendo il tasto Indietro di Android con PopScope: se la pila interna può fare pop, lo facciamo lì invece di chiudere l'app.

    È un pattern un po' più avanzato: se ti serve una gestione completa (deep link inclusi) valuta un router come go_router con le StatefulShellRoute, ma per un'app semplice questo approccio basta e non richiede dipendenze.

    final _navKeys = List.generate(3, (_) => GlobalKey<NavigatorState>());
    
    Widget _buildTabNavigator(int index, Widget child) {
      return Navigator(
        key: _navKeys[index],
        onGenerateRoute: (settings) => MaterialPageRoute(
          builder: (_) => child,
          settings: settings,
        ),
      );
    }
    
    @override
    Widget build(BuildContext context) {
      return PopScope(
        canPop: false,
        onPopInvokedWithResult: (didPop, result) {
          if (didPop) return;
          final nav = _navKeys[_currentIndex].currentState!;
          if (nav.canPop()) {
            nav.pop();
          } else if (_currentIndex != 0) {
            setState(() => _currentIndex = 0);
          }
        },
        child: Scaffold(
          body: IndexedStack(
            index: _currentIndex,
            children: [
              _buildTabNavigator(0, const HomePage()),
              _buildTabNavigator(1, const SearchPage()),
              _buildTabNavigator(2, const ProfilePage()),
            ],
          ),
          bottomNavigationBar: NavigationBar(
            selectedIndex: _currentIndex,
            onDestinationSelected: _onTabTapped,
            destinations: const [
              NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
              NavigationDestination(icon: Icon(Icons.search), label: 'Ricerca'),
              NavigationDestination(icon: Icon(Icons.person), label: 'Profilo'),
            ],
          ),
        ),
      );
    }

    Risultato atteso

    Ogni tab mantiene la propria cronologia: aprendo una schermata di dettaglio dalla Home e cambiando tab, al ritorno la trovi ancora aperta. Il tasto Indietro chiude prima le schermate interne e poi riporta al primo tab.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!