Search code examples
flutterflutter-go-router

Keep history navigation in child tab with flutter and NavBar and go router


I would like to know how can I keep history navigation in child on tab from NavBar.

Currently I follow this post to create a navigation with NavBar.

I would like to keep the history when I navigate in the tab (Home => FeedPeople => FeedPeople => FeedPeople...) :

  • Home (General Feed) (access to FeedPeople from link)
    • FeedPeople => FeedPeople => FeedPeople...
  • Discover
  • Shop
  • My

The problem: when I click on link on Home to push FeedPeople and see the profil/:uuid, and again FeedPeople, and again FeedPeople, etc... when I click on tab from NavBar, shop for example, and click again on Home, I don't see the last FeedPeople (last profil) but directly Home screen.

main.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:my_app/feed.dart';
import 'package:my_app/feed_people.dart';
import 'package:my_app/route_names.dart';

final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      routerConfig: router,
    );
  }

  final router = GoRouter(
    initialLocation: '/',
    navigatorKey: _rootNavigatorKey,
    routes: [
      ShellRoute(
        navigatorKey: _shellNavigatorKey,
        pageBuilder: (context, state, child) {
          print(state.location);
          return NoTransitionPage(
              child: ScaffoldWithNavBar(
            location: state.location,
            child: child,
          ));
        },
        routes: [
          GoRoute(
              path: '/',
              parentNavigatorKey: _shellNavigatorKey,
              pageBuilder: (context, state) {
                return const NoTransitionPage(
                  child: Feed(uuid: 'a78987-hiIh!ç767897'),
                );
              },
              routes: [
                GoRoute(
                    name: RoutesNames.feedPeople,
                    path: 'profil/:uuid',
                    builder: (context, state) =>
                        FeedPeople(uuid: state.pathParameters['uuid']!))
              ]),
          GoRoute(
            path: '/discover',
            parentNavigatorKey: _shellNavigatorKey,
            pageBuilder: (context, state) {
              return const NoTransitionPage(
                child: Scaffold(
                  body: Center(child: Text("Discover")),
                ),
              );
            },
          ),
          GoRoute(
              parentNavigatorKey: _shellNavigatorKey,
              path: '/shop',
              pageBuilder: (context, state) {
                return const NoTransitionPage(
                  child: Scaffold(
                    body: Center(child: Text("Shop")),
                  ),
                );
              }),
        ],
      ),
      GoRoute(
        parentNavigatorKey: _rootNavigatorKey,
        path: '/login',
        pageBuilder: (context, state) {
          return NoTransitionPage(
            key: UniqueKey(),
            child: Scaffold(
              appBar: AppBar(),
              body: const Center(
                child: Text("Login"),
              ),
            ),
          );
        },
      ),
    ],
  );
}

// ignore: must_be_immutable
class ScaffoldWithNavBar extends StatefulWidget {
  String location;
  ScaffoldWithNavBar({super.key, required this.child, required this.location});

  final Widget child;

  @override
  State<ScaffoldWithNavBar> createState() => _ScaffoldWithNavBarState();
}

class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
  int _currentIndex = 0;

  static const List<MyCustomBottomNavBarItem> tabs = [
    MyCustomBottomNavBarItem(
      icon: Icon(Icons.home),
      activeIcon: Icon(Icons.home),
      label: 'HOME',
      initialLocation: '/',
    ),
    MyCustomBottomNavBarItem(
      icon: Icon(Icons.explore_outlined),
      activeIcon: Icon(Icons.explore),
      label: 'DISCOVER',
      initialLocation: '/discover',
    ),
    MyCustomBottomNavBarItem(
      icon: Icon(Icons.storefront_outlined),
      activeIcon: Icon(Icons.storefront),
      label: 'SHOP',
      initialLocation: '/shop',
    ),
    MyCustomBottomNavBarItem(
      icon: Icon(Icons.account_circle_outlined),
      activeIcon: Icon(Icons.account_circle),
      label: 'MY',
      initialLocation: '/login',
    ),
  ];

  @override
  Widget build(BuildContext context) {
    const labelStyle = TextStyle(fontFamily: 'Roboto');
    return Scaffold(
      body: SafeArea(child: widget.child),
      bottomNavigationBar: BottomNavigationBar(
        selectedLabelStyle: labelStyle,
        unselectedLabelStyle: labelStyle,
        selectedItemColor: const Color(0xFF434343),
        selectedFontSize: 12,
        unselectedItemColor: const Color(0xFF838383),
        showUnselectedLabels: true,
        type: BottomNavigationBarType.fixed,
        onTap: (int index) {
          _goOtherTab(context, index);
        },
        currentIndex: widget.location == '/'
            ? 0
            : widget.location == '/discover'
                ? 1
                : widget.location == '/shop'
                    ? 2
                    : 3,
        items: tabs,
      ),
    );
  }

  void _goOtherTab(BuildContext context, int index) {
    if (index == _currentIndex) return;
    GoRouter router = GoRouter.of(context);
    String location = tabs[index].initialLocation;

    setState(() {
      _currentIndex = index;
    });
    if (index == 3) {
      context.push('/login');
    } else {
      router.go(location);
    }
  }
}

class MyCustomBottomNavBarItem extends BottomNavigationBarItem {
  final String initialLocation;

  const MyCustomBottomNavBarItem(
      {required this.initialLocation,
      required Widget icon,
      String? label,
      Widget? activeIcon})
      : super(icon: icon, label: label, activeIcon: activeIcon ?? icon);
}

I tried to set the route directly in Home routes, but same problem. The history isn't keep, and when I navigate from NavBar, I see directly the "first" screen (Home and not the last FeedPeople).


Solution

  • With go_router, use StatefulShellRoute to obtain the persistent state for the tabs. Below is a modified version of the dartpad go_router example but uses StatefulShellRoute and NavigationBar (instead of BottomNavigationBar) to get the desired output.

    you can play with the below code in dartpart here

    import 'package:flutter/material.dart';
    import 'package:go_router/go_router.dart';
    import 'package:english_words/english_words.dart';
    import 'dart:math' as math;
    
    void main() {
      runApp(MusicAppDemo());
    }
    
    class MusicAppDemo extends StatelessWidget {
      MusicAppDemo({Key? key}) : super(key: key);
    
      final MusicDatabase database = MusicDatabase.mock();
    
      final GoRouter _router = GoRouter(
        initialLocation: '/login',
        routes: <RouteBase>[
          GoRoute(
            path: '/login',
            builder: (BuildContext context, GoRouterState state) {
              return const LoginScreen();
            },
          ),
          StatefulShellRoute.indexedStack(
            builder: (BuildContext context, GoRouterState state,
                StatefulNavigationShell navigationShell) {
              return MusicAppShell(
                navigationShell: navigationShell,
              );
            },
            branches: <StatefulShellBranch>[
              StatefulShellBranch(
                routes: <RouteBase>[
                  GoRoute(
                    path: '/library',
                    pageBuilder: (context, state) {
                      return FadeTransitionPage(
                        child: const LibraryScreen(),
                        key: state.pageKey,
                      );
                    },
                    routes: <RouteBase>[
                      GoRoute(
                        path: 'album/:albumId',
                        builder: (BuildContext context, GoRouterState state) {
                          return AlbumScreen(
                            albumId: state.pathParameters['albumId'],
                          );
                        },
                        routes: [
                          GoRoute(
                            path: 'song/:songId',
                            // Display on the root Navigator
                            builder: (BuildContext context, GoRouterState state) {
                              return SongScreen(
                                songId: state.pathParameters['songId']!,
                              );
                            },
                          ),
                        ],
                      ),
                    ],
                  ),
                ],
              ),
              StatefulShellBranch(
                routes: <RouteBase>[
                  GoRoute(
                    path: '/recents',
                    pageBuilder: (context, state) {
                      return FadeTransitionPage(
                        child: const RecentlyPlayedScreen(),
                        key: state.pageKey,
                      );
                    },
                    routes: <RouteBase>[
                      GoRoute(
                        path: 'song/:songId',
                        // Display on the root Navigator
                        builder: (BuildContext context, GoRouterState state) {
                          return SongScreen(
                            songId: state.pathParameters['songId']!,
                          );
                        },
                      ),
                    ],
                  ),
                ],
              ),
              StatefulShellBranch(
                routes: <RouteBase>[
                  GoRoute(
                    path: '/search',
                    pageBuilder: (context, state) {
                      final query = state.queryParameters['q'] ?? '';
                      return FadeTransitionPage(
                        child: SearchScreen(
                          query: query,
                        ),
                        key: state.pageKey,
                      );
                    },
                  ),
                ],
              ),
            ],
          ),
        ],
      );
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Music app',
          theme: ThemeData(primarySwatch: Colors.pink),
          routerConfig: _router,
          builder: (context, child) {
            return MusicDatabaseScope(
              state: database,
              child: child!,
            );
          },
        );
      }
    }
    
    class LoginScreen extends StatelessWidget {
      const LoginScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Center(child: Text('Welcome!'))),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const FlutterLogo(
                  size: 150,
                ),
                const SizedBox(
                  height: 50,
                ),
                ElevatedButton(
                  child: const Text('Login'),
                  onPressed: () {
                    context.go('/library');
                  },
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class MusicAppShell extends StatelessWidget {
      final StatefulNavigationShell navigationShell;
    
      const MusicAppShell({
        Key? key,
        required this.navigationShell,
      }) : super(key: key ?? const ValueKey<String>('MusicAppShell'));
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: navigationShell,
          bottomNavigationBar: NavigationBar(
            destinations: const [
              NavigationDestination(
                icon: Icon(Icons.my_library_music_rounded),
                label: 'Library',
              ),
              NavigationDestination(
                icon: Icon(Icons.timelapse),
                label: 'Recently Played',
              ),
              NavigationDestination(
                icon: Icon(Icons.search),
                label: 'Search',
              ),
            ],
            selectedIndex: navigationShell.currentIndex,
            onDestinationSelected: (int index) {
              navigationShell.goBranch(index);
            },
          ),
        );
      }
    }
    
    class LibraryScreen extends StatelessWidget {
      const LibraryScreen({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final database = MusicDatabase.of(context);
        return Scaffold(
          appBar: const CustomAppBar(title: 'Library'),
          body: ListView.builder(
            itemBuilder: (context, albumId) {
              final album = database.albums[albumId];
              return AlbumTile(
                album: album,
                onTap: () {
                  GoRouter.of(context).go('/library/album/$albumId');
                },
              );
            },
            itemCount: database.albums.length,
          ),
        );
      }
    }
    
    class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
      final String title;
      final double height;
    
      const CustomAppBar({
        super.key,
        required this.title,
        this.height = kToolbarHeight,
      });
    
      @override
      Size get preferredSize => Size.fromHeight(height);
    
      @override
      Widget build(BuildContext context) {
        return AppBar(
          title: Text(title),
          actions: <Widget>[
            PopupMenuButton(
                icon: const Icon(Icons.settings),
                itemBuilder: (context) {
                  return [
                    const PopupMenuItem<int>(
                      value: 0,
                      child: Text("Settings"),
                    ),
                    const PopupMenuItem<int>(
                      value: 1,
                      child: Text("Logout"),
                    ),
                  ];
                },
                onSelected: (value) {
                  if (value == 1) {
                    context.go('/login');
                  }
                }),
          ],
        );
      }
    }
    
    class RecentlyPlayedScreen extends StatelessWidget {
      const RecentlyPlayedScreen({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final database = MusicDatabase.of(context);
        final songs = database.recentlyPlayed;
        return Scaffold(
          appBar: const CustomAppBar(title: 'Recently Played'),
          body: ListView.builder(
            itemBuilder: (context, index) {
              final song = songs[index];
              final albumIdInt = int.tryParse(song.albumId)!;
              final album = database.albums[albumIdInt];
              return SongTile(
                album: album,
                song: song,
                onTap: () {
                  GoRouter.of(context).go('/recents/song/${song.fullId}');
                },
              );
            },
            itemCount: songs.length,
          ),
        );
      }
    }
    
    class SearchScreen extends StatefulWidget {
      final String query;
    
      const SearchScreen({Key? key, required this.query}) : super(key: key);
    
      @override
      State<SearchScreen> createState() => _SearchScreenState();
    }
    
    class _SearchScreenState extends State<SearchScreen> {
      String? _currentQuery;
    
      @override
      Widget build(BuildContext context) {
        final database = MusicDatabase.of(context);
        final songs = database.search(widget.query);
        return Scaffold(
          appBar: const CustomAppBar(title: 'Search'),
          body: Column(
            children: [
              Padding(
                padding: const EdgeInsets.all(12.0),
                child: TextField(
                  decoration: const InputDecoration(
                    hintText: 'Search...',
                    border: OutlineInputBorder(),
                  ),
                  onChanged: (String? newSearch) {
                    _currentQuery = newSearch;
                  },
                  onEditingComplete: () {
                    GoRouter.of(context).go(
                      '/search?q=$_currentQuery',
                    );
                  },
                ),
              ),
              Expanded(
                child: ListView.builder(
                  itemBuilder: (context, index) {
                    final song = songs[index];
                    return SongTile(
                      album: database.albums[int.tryParse(song.albumId)!],
                      song: song,
                      onTap: () {
                        GoRouter.of(context).go(
                            '/library/album/${song.albumId}/song/${song.fullId}');
                      },
                    );
                  },
                  itemCount: songs.length,
                ),
              ),
            ],
          ),
        );
      }
    }
    
    class AlbumScreen extends StatelessWidget {
      final String? albumId;
    
      const AlbumScreen({
        required this.albumId,
        Key? key,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final database = MusicDatabase.of(context);
        final albumIdInt = int.tryParse(albumId ?? '');
        final album = database.albums[albumIdInt!];
        return Scaffold(
          appBar: CustomAppBar(title: 'Album - ${album.title}'),
          body: Center(
            child: Column(
              children: [
                Row(
                  children: [
                    SizedBox(
                      width: 200,
                      height: 200,
                      child: Container(
                        color: album.color,
                        margin: const EdgeInsets.all(8),
                      ),
                    ),
                    Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          album.title,
                          style: Theme.of(context).textTheme.headlineMedium,
                        ),
                        Text(
                          album.artist,
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                      ],
                    ),
                  ],
                ),
                Expanded(
                  child: ListView.builder(
                    itemBuilder: (context, index) {
                      final song = album.songs[index];
                      return ListTile(
                        title: Text(song.title),
                        leading: SizedBox(
                          width: 50,
                          height: 50,
                          child: Container(
                            color: album.color,
                            margin: const EdgeInsets.all(8),
                          ),
                        ),
                        trailing: SongDuration(
                          duration: song.duration,
                        ),
                        onTap: () {
                          GoRouter.of(context)
                              .go('/library/album/$albumId/song/${song.fullId}');
                        },
                      );
                    },
                    itemCount: album.songs.length,
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class SongScreen extends StatelessWidget {
      final String songId;
    
      const SongScreen({
        Key? key,
        required this.songId,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final database = MusicDatabase.of(context);
        final song = database.getSongById(songId);
        final albumIdInt = int.tryParse(song.albumId);
        final album = database.albums[albumIdInt!];
    
        return Scaffold(
          appBar: CustomAppBar(title: 'Song - ${song.title}'),
          body: Column(
            children: [
              Row(
                children: [
                  SizedBox(
                    width: 300,
                    height: 300,
                    child: Container(
                      color: album.color,
                      margin: const EdgeInsets.all(8),
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          song.title,
                          style: Theme.of(context).textTheme.displayMedium,
                        ),
                        Text(
                          album.title,
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                      ],
                    ),
                  )
                ],
              )
            ],
          ),
        );
      }
    }
    
    class MusicDatabase {
      final List<Album> albums;
      final List<Song> recentlyPlayed;
      final Map<String, Song> _allSongs = {};
    
      MusicDatabase(this.albums, this.recentlyPlayed) {
        _populateAllSongs();
      }
    
      factory MusicDatabase.mock() {
        final albums = _mockAlbums().toList();
        final recentlyPlayed = _mockRecentlyPlayed(albums).toList();
        return MusicDatabase(albums, recentlyPlayed);
      }
    
      Song getSongById(String songId) {
        if (_allSongs.containsKey(songId)) {
          return _allSongs[songId]!;
        }
        throw ('No song with ID $songId found.');
      }
    
      List<Song> search(String searchString) {
        final songs = <Song>[];
        for (var song in _allSongs.values) {
          final album = albums[int.tryParse(song.albumId)!];
          if (song.title.contains(searchString) ||
              album.title.contains(searchString)) {
            songs.add(song);
          }
        }
        return songs;
      }
    
      void _populateAllSongs() {
        for (var album in albums) {
          for (var song in album.songs) {
            _allSongs[song.fullId] = song;
          }
        }
      }
    
      static MusicDatabase of(BuildContext context) {
        final routeStateScope =
            context.dependOnInheritedWidgetOfExactType<MusicDatabaseScope>();
        if (routeStateScope == null) throw ('No RouteState in scope!');
        return routeStateScope.state;
      }
    
      static Iterable<Album> _mockAlbums() sync* {
        for (var i = 0; i < Colors.primaries.length; i++) {
          final color = Colors.primaries[i];
          final title = WordPair.random().toString();
          final artist = WordPair.random().toString();
          final songs = <Song>[];
          for (var j = 0; j < 12; j++) {
            final minutes = math.Random().nextInt(3) + 3;
            final seconds = math.Random().nextInt(60);
            final title = WordPair.random();
            final duration = Duration(minutes: minutes, seconds: seconds);
            final song = Song('$j', '$i', '$title', duration);
    
            songs.add(song);
          }
          yield Album('$i', title, artist, color, songs);
        }
      }
    
      static Iterable<Song> _mockRecentlyPlayed(List<Album> albums) sync* {
        for (var album in albums) {
          final songIndex = math.Random().nextInt(album.songs.length);
          yield album.songs[songIndex];
        }
      }
    }
    
    class MusicDatabaseScope extends InheritedWidget {
      final MusicDatabase state;
    
      const MusicDatabaseScope({
        required this.state,
        required Widget child,
        Key? key,
      }) : super(child: child, key: key);
    
      @override
      bool updateShouldNotify(covariant InheritedWidget oldWidget) {
        return oldWidget is MusicDatabaseScope && state != oldWidget.state;
      }
    }
    
    class Album {
      final String id;
      final String title;
      final String artist;
      final Color color;
      final List<Song> songs;
    
      Album(this.id, this.title, this.artist, this.color, this.songs);
    }
    
    class Song {
      final String id;
      final String albumId;
      final String title;
      final Duration duration;
    
      Song(this.id, this.albumId, this.title, this.duration);
    
      String get fullId => '$albumId-$id';
    }
    
    class AlbumTile extends StatelessWidget {
      final Album album;
      final VoidCallback? onTap;
    
      const AlbumTile({Key? key, required this.album, this.onTap})
          : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return ListTile(
          leading: SizedBox(
            width: 50,
            height: 50,
            child: Container(
              color: album.color,
            ),
          ),
          title: Text(album.title),
          subtitle: Text(album.artist),
          onTap: onTap,
        );
      }
    }
    
    class SongTile extends StatelessWidget {
      final Album album;
      final Song song;
      final VoidCallback? onTap;
    
      const SongTile(
          {Key? key, required this.album, required this.song, this.onTap})
          : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return ListTile(
          leading: SizedBox(
            width: 50,
            height: 50,
            child: Container(
              color: album.color,
              margin: const EdgeInsets.all(8),
            ),
          ),
          title: Text(song.title),
          trailing: SongDuration(
            duration: song.duration,
          ),
          onTap: onTap,
        );
      }
    }
    
    class SongDuration extends StatelessWidget {
      final Duration duration;
    
      const SongDuration({
        required this.duration,
        Key? key,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Text(
            '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}');
      }
    }
    
    /// A page that fades in an out.
    class FadeTransitionPage extends CustomTransitionPage<void> {
      /// Creates a [FadeTransitionPage].
      FadeTransitionPage({
        required LocalKey key,
        required Widget child,
      }) : super(
                key: key,
                transitionsBuilder: (BuildContext context,
                        Animation<double> animation,
                        Animation<double> secondaryAnimation,
                        Widget child) =>
                    FadeTransition(
                      opacity: animation.drive(_curveTween),
                      child: child,
                    ),
                child: child);
    
      static final CurveTween _curveTween = CurveTween(curve: Curves.easeIn);
    }