Flutter Dynamic TabController avec TickerProviderStateMixin
Problème
Lors de la création d'une TabBar avec des onglets dynamiques (onglets qui s'affichent/masquent selon la disponibilité des fonctionnalités,
les préférences utilisateur ou l'état asynchrone), recréer le TabController pendant le cycle de vie du widget
provoque un crash parce que SingleTickerProviderStateMixin ne permet de créer qu'un seul ticker.
Contexte / Conditions déclencheurs
Message d'erreur:
_YourStateClass is a SingleTickerProviderStateMixin but multiple tickers were created.
A SingleTickerProviderStateMixin can only be used as a TickerProvider once.
If a State is used for multiple AnimationController objects, or if it is passed to other
objects and those objects might use it more than one time in total, then instead of mixing
in a SingleTickerProviderStateMixin, use a regular TickerProviderStateMixin.
Quand cela se produit:
- Vous disposez et recréez un TabController quand le nombre d'onglets change
- Vous avez des onglets qui s'affichent conditionnellement selon l'état asynchrone (p. ex., providers Riverpod)
- Les feature flags contrôlent la visibilité des onglets
- La visibilité des onglets dépend de la disponibilité d'une API ou des permissions utilisateur
Solution
Étape 1 : Changer le Mixin
Remplacez SingleTickerProviderStateMixin par TickerProviderStateMixin:
// WRONG - crashes when TabController is recreated
class _MyScreenState extends State<MyScreen>
with SingleTickerProviderStateMixin {
// CORRECT - allows multiple TabControllers over widget lifetime
class _MyScreenState extends State<MyScreen>
with TickerProviderStateMixin {
Étape 2 : Suivre l'état du nombre d'onglets
Gardez une variable d'état pour suivre la configuration actuelle des onglets:
bool? _lastFeatureAvailable;
TabController? _tabController;
int get _tabCount => (_lastFeatureAvailable ?? false) ? 4 : 3;
void _initTabController() {
_tabController?.removeListener(_onTabChanged);
_tabController?.dispose();
_tabController = TabController(
length: _tabCount,
vsync: this,
);
_tabController!.addListener(_onTabChanged);
}
Étape 3 : Reconstruire synchroniquement lors d'un changement d'état
Quand la condition change, reconstruisez le TabController synchroniquement (pas dans postFrameCallback):
@override
Widget build(BuildContext context) {
// Watch the async state
final featureAvailableAsync = ref.watch(featureAvailableProvider);
final featureAvailable = featureAvailableAsync.asData?.value ?? false;
// Rebuild TabController SYNCHRONOUSLY when state changes
if (_lastFeatureAvailable != featureAvailable) {
_lastFeatureAvailable = featureAvailable;
_initTabController(); // Synchronous rebuild
}
// Build tabs using the SAME variable for consistency
return TabBar(
controller: _tabController,
tabs: [
const Tab(text: 'Tab 1'),
const Tab(text: 'Tab 2'),
if (_lastFeatureAvailable ?? false) const Tab(text: 'Optional Tab'),
const Tab(text: 'Tab 3'),
],
);
}
Étape 4 : Garder les onglets et TabBarView synchronisés
Utilisez la même variable d'état pour la liste des onglets et les enfants de TabBarView:
// TabBar tabs
tabs: [
const Tab(text: 'Always'),
if (_lastFeatureAvailable ?? false) const Tab(text: 'Conditional'),
],
// TabBarView children - MUST match tabs exactly
TabBarView(
controller: _tabController,
children: [
const AlwaysTab(),
if (_lastFeatureAvailable ?? false) const ConditionalTab(),
],
),
Vérification
Après avoir appliqué la correction:
- Aucun crash quand l'état asynchrone change
- Les onglets s'affichent/disparaissent correctement (pas juste désactivés)
- L'état de sélection des onglets est préservé (limité à la plage valide)
- Aucun scintillement visuel pendant la transition
Exemple
Exemple réel - masquer un onglet "Classics" quand l'API REST est indisponible:
class _ExploreScreenState extends ConsumerState<ExploreScreen>
with TickerProviderStateMixin { // NOT SingleTickerProviderStateMixin
TabController? _tabController;
bool? _lastClassicsAvailable;
int get _tabCount => (_lastClassicsAvailable ?? false) ? 4 : 3;
void _initTabController() {
final savedTabIndex = ref.read(exploreTabIndexProvider);
final validIndex = savedTabIndex.clamp(0, _tabCount - 1);
_tabController?.removeListener(_onTabChanged);
_tabController?.dispose();
_tabController = TabController(
length: _tabCount,
vsync: this,
initialIndex: validIndex,
);
_tabController!.addListener(_onTabChanged);
}
@override
Widget build(BuildContext context) {
final classicsAvailable =
ref.watch(classicVinesAvailableProvider).asData?.value ?? false;
if (_lastClassicsAvailable != classicsAvailable) {
_lastClassicsAvailable = classicsAvailable;
_initTabController(); // Synchronous, not postFrameCallback
}
return Column(
children: [
TabBar(
controller: _tabController,
tabs: [
const Tab(text: 'New'),
const Tab(text: 'Popular'),
if (_lastClassicsAvailable ?? false) const Tab(text: 'Classics'),
const Tab(text: 'Lists'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
const NewVideosTab(),
const PopularVideosTab(),
if (_lastClassicsAvailable ?? false) const ClassicsTab(),
const ListsTab(),
],
),
),
],
);
}
}
Notes
-
Pourquoi pas postFrameCallback? Utiliser
addPostFrameCallbackpour reconstruire le TabController crée une frame où la longueur du TabController ne correspond pas à la liste des onglets, ce qui entraîne des onglets "désactivés" ou autres scintillements visuels. -
Performance:
TickerProviderStateMixina un surcoût légèrement plus élevé queSingleTickerProviderStateMixin, mais c'est négligeable pour les cas d'usage typiques. -
Préservation de l'index des onglets: Quand le nombre d'onglets diminue, limitez l'index courant pour éviter les erreurs hors limites:
savedIndex.clamp(0, newTabCount - 1). -
Patterns Riverpod: Lors de l'utilisation de providers Riverpod asynchrones, rappelez-vous que
asyncValue.asData?.value ?? falsevous donne une valeur par défaut synchrone pendant le chargement.