Navigation avancée : Bottom Navigation, Tabs et Drawer
Navigation principale avec BottomNavigationBar
La BottomNavigationBar est utilisée pour la navigation entre les principales sections d'une application.
iOS (Cupertino)
L'équivalent est CupertinoTabBar, utilisé avec CupertinoTabScaffold.
Exemple iOS:
import 'package:flutter/cupertino.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return CupertinoApp(
home: CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: [
BottomNavigationBarItem(
icon: Icon(CupertinoIcons.home),
label: 'Accueil',
),
BottomNavigationBarItem(
icon: Icon(CupertinoIcons.search),
label: 'Recherche',
),
BottomNavigationBarItem(
icon: Icon(CupertinoIcons.person),
label: 'Profil',
),
],
),
tabBuilder: (context, index) {
switch (index) {
case 0:
return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(middle: Text('Accueil')),
child: Center(child: Text('Accueil')),
);
case 1:
return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(middle: Text('Recherche')),
child: Center(child: Text('Recherche')),
);
default:
return const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(middle: Text('Profil')),
child: Center(child: Text('Profil')),
);
}
},
),
);
}
}
Exemple basique
Cet exemple mémorise seulement l’index sélectionné. Il ne conserve pas l’état interne des pages. Pour garder l’état de chaque onglet, utilisez un IndexedStack.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MainScreen(),
);
}
}
class HomeWidget extends StatelessWidget {
const HomeWidget({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Accueil')),
);
}
}
class SearchWidget extends StatelessWidget {
const SearchWidget({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Recherche')),
);
}
}
class ProfileWidget extends StatelessWidget {
const ProfileWidget({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Profil')),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
static const List<Widget> _pages = [
HomeWidget(),
SearchWidget(),
ProfileWidget(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Accueil',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Recherche',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profil',
),
],
),
);
}
}
BottomNavigationBar avec Navigator par onglet
Pour maintenir l'état de navigation de chaque onglet :
Différence avec l'exemple précédent :
- Exemple basique : un seul
Navigator, on change simplement la page affichée. L'état interne peut être perdu si la page est reconstruite. - Par onglet : chaque onglet possède son propre Navigator. Chaque onglet garde son historique de navigation (piles séparées), ce qui est idéal pour des sections complexes.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MainScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Accueil')),
body: const Center(child: Text('Accueil')),
);
}
}
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Recherche')),
body: const Center(child: Text('Recherche')),
);
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profil')),
body: const Center(child: Text('Profil')),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
Widget _buildNavigator(int index) {
return Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (settings) {
Widget page;
switch (index) {
case 0:
page = const HomeScreen();
break;
case 1:
page = const SearchScreen();
break;
case 2:
page = const ProfileScreen();
break;
default:
page = const HomeScreen();
}
return MaterialPageRoute(builder: (_) => page);
},
);
}
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final currentNavigator = _navigatorKeys[_selectedIndex].currentState;
if (currentNavigator != null && currentNavigator.canPop()) {
currentNavigator.pop();
return false;
}
return true;
},
child: Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: List.generate(3, (index) => _buildNavigator(index)),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Accueil'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Recherche'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profil'),
],
),
),
);
}
}
NavigationBar (Material 3)
Version Material 3 de la bottom navigation :
Ce que ça apporte (vs BottomNavigationBar) :
- Style Material 3 natif (typographie, couleurs, shapes)
- Élévation/contour gérés automatiquement selon le thème
- Meilleure intégration avec
ThemeData(useMaterial3: true) - Support des destinations avec états
selectedIcon/icon
NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemTapped,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Accueil',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Recherche',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profil',
),
],
)
TabBar : Navigation par onglets
Les TabBar sont utilisées pour organiser du contenu en catégories horizontales.
Différence avec BottomNavigationBar :
- TabBar : navigation secondaire à l’intérieur d’un écran (contenus liés).
- BottomNavigationBar : navigation principale entre sections majeures de l’app.
- TabBar est souvent combinée avec
AppBar, BottomNavigationBar vit au bas de l’écran.
TabBar basique avec TabBarView
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: TabScreen(),
);
}
}
class TabScreen extends StatelessWidget {
const TabScreen({super.key});
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Onglets'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.directions_car), text: 'Voiture'),
Tab(icon: Icon(Icons.directions_bike), text: 'Vélo'),
Tab(icon: Icon(Icons.directions_boat), text: 'Bateau'),
],
),
),
body: const TabBarView(
children: [
Center(child: Icon(Icons.directions_car, size: 100)),
Center(child: Icon(Icons.directions_bike, size: 100)),
Center(child: Icon(Icons.directions_boat, size: 100)),
],
),
),
);
}
}
TabBar avec contrôleur manuel
Pour plus de contrôle sur les onglets :
Ce que ça apporte :
- Contrôle programmatique de l’onglet actif (
animateTo,index) - Écoute d’événements lors du changement d’onglet (listener)
- Synchronisation possible avec d’autres widgets (ex: bouton, timer, état)
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: TabScreenWithController(),
);
}
}
class TabScreenWithController extends StatefulWidget {
const TabScreenWithController({super.key});
State<TabScreenWithController> createState() =>
_TabScreenWithControllerState();
}
class _TabScreenWithControllerState extends State<TabScreenWithController>
with SingleTickerProviderStateMixin {
late TabController _tabController;
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// Écouter les changements d'onglet
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
print('Onglet actif : ${_tabController.index}');
}
});
}
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Onglets'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Onglet 1'),
Tab(text: 'Onglet 2'),
Tab(text: 'Onglet 3'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
Center(child: Text('Contenu 1')),
Center(child: Text('Contenu 2')),
Center(child: Text('Contenu 3')),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Changer d'onglet programmatiquement
_tabController.animateTo(
(_tabController.index + 1) % 3,
);
},
child: const Icon(Icons.navigate_next),
),
);
}
}
TabBar scrollable
Pour un grand nombre d'onglets, activez isScrollable: true pour éviter l'écrasement si vous avez beaucoup d'onglets.
Drawer : Menu latéral
Le Drawer est un panneau latéral pour la navigation secondaire.
Drawer basique
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
home: const DrawerScreen(),
routes: {
'/settings': (context) => const SettingsScreen(),
},
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Paramètres')),
body: const Center(child: Text('Paramètres')),
);
}
}
class DrawerScreen extends StatelessWidget {
const DrawerScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Menu latéral')),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const UserAccountsDrawerHeader(
accountName: Text('John Doe'),
accountEmail: Text('john@example.com'),
currentAccountPicture: CircleAvatar(
child: Icon(Icons.person, size: 40),
),
decoration: BoxDecoration(color: Colors.blue),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Accueil'),
onTap: () {
Navigator.pop(context);
// Navigation
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Paramètres'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/settings');
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Déconnexion'),
onTap: () {
Navigator.pop(context);
// Logique de déconnexion
},
),
],
),
),
body: const Center(child: Text('Contenu principal')),
);
}
}
PageView : Navigation par glissement
Pour une navigation par swipe horizontal :
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: PageViewScreen(),
);
}
}
class PageViewScreen extends StatefulWidget {
const PageViewScreen({super.key});
State<PageViewScreen> createState() => _PageViewScreenState();
}
class _PageViewScreenState extends State<PageViewScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
void dispose() {
_pageController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pages')),
body: Column(
children: [
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
children: const [
Center(child: Text('Page 1', style: TextStyle(fontSize: 32))),
Center(child: Text('Page 2', style: TextStyle(fontSize: 32))),
Center(child: Text('Page 3', style: TextStyle(fontSize: 32))),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
3,
(index) => Container(
margin: const EdgeInsets.all(4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPage == index ? Colors.blue : Colors.grey,
),
),
),
),
const SizedBox(height: 20),
],
),
);
}
}
Bonnes pratiques
- IndexedStack pour maintenir l'état de chaque page dans bottom navigation
- GlobalKey NavigatorState pour navigation indépendante par onglet
- WillPopScope pour gérer le bouton retour avec bottom navigation
- dispose() pour libérer les TabController et PageController
- Material 3 : préférer
NavigationBaràBottomNavigationBar - Drawer pour navigation secondaire, pas primaire
- TabBar isScrollable pour plus de 5 onglets
- Navigator.pop(context) avant navigation depuis le Drawer
Versioning et Migrations de navigation
Lors de mises à jour d'application, la structure de navigation peut changer. Il faut gérer la compatibilité.
Versioning des routes
- Maintenir un historique des routes (deprecated routes)
- Rediriger les anciennes routes vers les nouvelles
- Exemple :
/profile→/user/profile(version 2.0.0)
Gestion des migrations de navigation
// Routes dépréciées
Map<String, WidgetBuilder> get deprecatedRoutes {
return {
'/old_home': (context) => const NewHomePage(), // Redirige vers nouvelle route
'/old_profile': (context) => const UserProfilePage(),
};
}
// Routes courantes
Map<String, WidgetBuilder> get routes {
return {
'/': (context) => const HomePage(),
'/user/profile': (context) => const UserProfilePage(),
'/settings': (context) => const SettingsPage(),
};
}
Backward compatibility
- Ne jamais supprimer brusquement une route
- Marquer comme dépréciée pendant 2-3 versions
- Rediriger gracieusement vers la nouvelle route
- Afficher un message optionnel à l'utilisateur