Search code examples
flutterbottomnavigationviewtabbar

setState() or markNeedsBuild() Error When Using TabBarView with BottomNavigationBar and StatefulShellRoute(go_router)


I am building a Flutter app with a BottomNavigationBar and a TabBarView in one of the screens using StatefulShellRoute(go_router). When I swipe between tabs in the TabBarView and quickly switch to a different bottom navigation item before the tab-switch animation completes, the app crashes with the following error,

setState() or markNeedsBuild() called during build.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const App());
}


class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) =>
      MaterialApp.router(routerConfig: mainRouter);
}

final _mainKey = GlobalKey<NavigatorState>();
final _dashboardKey = GlobalKey<NavigatorState>();
final _favouritesKey = GlobalKey<NavigatorState>();
final _settingsKey = GlobalKey<NavigatorState>();
final GoRouter mainRouter = GoRouter(
  initialLocation: '/dashboard',
  navigatorKey: _mainKey,
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) =>
          HomeNavHost(navigationShell: navigationShell),
      branches: [
        StatefulShellBranch(
          navigatorKey: _dashboardKey,
          routes: [
            GoRoute(
              path: '/dashboard',
              pageBuilder: (context, state) =>
                  NoTransitionPage(child: Center(child: Text('Dashboard'))),
            ),
          ],
        ),
        StatefulShellBranch(
          navigatorKey: _favouritesKey,
          routes: [
            GoRoute(
              path: '/favourites',
              pageBuilder: (context, state) =>
                  NoTransitionPage(child: FavouritesScreen()),
            ),
          ],
        ),
        StatefulShellBranch(
          navigatorKey: _settingsKey,
          routes: [
            GoRoute(
              path: '/settings',
              pageBuilder: (context, state) =>
                  NoTransitionPage(child: Center(child: Text('Settings'))),
            ),
          ],
        ),
      ],
    )
  ],
);

class HomeNavHost extends StatelessWidget {
  const HomeNavHost({required this.navigationShell, super.key});

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        onTap: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.dashboard),
            label: 'Dashboard',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.favorite),
            label: 'Favourites',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Settings',
          ),
        ],
      ),
    );
  }
}

class FavouritesScreen extends StatefulWidget {
  const FavouritesScreen({super.key});

  @override
  State<FavouritesScreen> createState() => _FavouritesScreenState();
}

class _FavouritesScreenState extends State<FavouritesScreen> {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: Text('Favourites'),
          bottom: TabBar(
            tabs: [
              Tab(text: 'Catergory 1'),
              Tab(text: 'Catergory 2'),
            ],
          ),
        ),
        body: TabBarView(
          children: [
            Center(child: Text('Catergory 1')),
            Center(child: Text('Catergory 2')),
          ],
        ),
      ),
    );
  }
}

Using TabBarView with a BottomNavigationBar works seamlessly under normal conditions(i.e. without go_router), and switching tabs by tapping on the tabTitle at the top works without any issues.

However, when using StatefulShellRoute, swiping between tabs in the TabBarView and quickly switching to another bottom navigation item results in error. I've tried:

  • Using AutomaticKeepAliveClientMixin.
  • Using WidgetsBinding.instance.addPostFrameCallback & SchedulerBinding.instance.addPostFrameCallback to delay bottomBar navigation.

How can I avoid this issue when switching between BottomNavigationBar & TabBarView?

Any guidance or best practices for managing TabBarView in combination with a BottomNavigationBar would be greatly appreciated!


Solution

  • Please refactor this part of your dart code:

    from:

    onTap: (index) => navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    ),
    

    to:

    onTap: (index) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        );
      });
    },
    

    UPDATE:

    I tried to reproduce your issue, and I found out that the exception is introduced by the FavouritesScreen class, where it should be disposed of when the user is not currently viewing this widget in the widget tree. When you swipe or navigate between TabBar/TabBarView within the FavouritesScreen class while navigating through your BottomNavigationBar, it introduces the

    setState() or markNeedsBuild() called during build.

    because of these simultaneous events. So you have to refactor your FavouritesScreen class with this code snippet:

    import 'package:flutter/material.dart';
    
    class FavouritesScreen extends StatefulWidget {
      const FavouritesScreen({super.key});
    
      @override
      State<FavouritesScreen> createState() => _FavouritesScreenState();
    }
    
    class _FavouritesScreenState extends State<FavouritesScreen>
        with SingleTickerProviderStateMixin {
      TabController? _tabController;
    
      @override
      void initState() {
        super.initState();
        _tabController = TabController(length: 2, vsync: this);
      }
    
      @override
      void dispose() {
        _tabController!.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
              title: Text('Favourites'),
              bottom: TabBar(
                controller: _tabController,
                tabs: [
                  Tab(text: 'Catergory 1'),
                  Tab(text: 'Catergory 2'),
                ],
              ),
            ),
            body: TabBarView(
              controller: _tabController,
              children: [
                Center(child: Text('Catergory 1')),
                Center(child: Text('Catergory 2')),
              ],
            ),
          );
      }
    }
    

    Then test it again after modifying your code.

    It should work as expected now.

    I hope it helps!