flutter-dynamic-tabcontroller-ticker-mixin

Par divinevideo · divine-mobile

Corrige le crash du `TabController` Flutter lors de l'affichage/masquage dynamique d'onglets. À utiliser quand : (1) La reconstruction du `TabController` provoque l'erreur `"SingleTickerProviderStateMixin but multiple tickers were created"`, (2) Les onglets doivent apparaître/disparaître selon des feature flags ou un état asynchrone, (3) `TabController.length` change à l'exécution en fonction de l'état d'un provider. Le correctif consiste à utiliser `TickerProviderStateMixin` à la place de `SingleTickerProviderStateMixin`.

npx skills add https://github.com/divinevideo/divine-mobile --skill flutter-dynamic-tabcontroller-ticker-mixin

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:

  1. Aucun crash quand l'état asynchrone change
  2. Les onglets s'affichent/disparaissent correctement (pas juste désactivés)
  3. L'état de sélection des onglets est préservé (limité à la plage valide)
  4. 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 addPostFrameCallback pour 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: TickerProviderStateMixin a un surcoût légèrement plus élevé que SingleTickerProviderStateMixin, 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 ?? false vous donne une valeur par défaut synchrone pendant le chargement.

Références

Skills similaires