Flutter Bottom Navigation Bar com Stateful Nested Routes usando GoRouter
Um dos padrões de UX mais populares em dispositivos móveis é mostrar uma barra de navegação inferior com guias.
Com esse padrão, todas as guias estão dentro da “Área de uso”, onde podem ser acessadas facilmente:
Mas como podemos implementar a navegação inferior em um aplicativo Flutter?
Acontece que o widget NavigationBar
nos oferece uma maneira conveniente de alternar entre os destinos principais (também conhecidos como guias) em nosso aplicativo.
E enquanto a NavigationBar
se encarrega de mostrar a interface do usuário de navegação inferior, ela não lida com nenhum estado ou lógica de roteamento:
Scaffold( body: /* TODO: decide qual página mostrar, dependendo do índice selecionado */, bottomNavigationBar: NavigationBar( selectedIndex: /* TODO: de onde vem isso? */, destinations: const [ // a aparência de cada guia é definida com um widget [NavigationDestination] NavigationDestination(label: 'Seção A', icon: Icon(Icons.home)), NavigationDestination(label: 'Seção B', icon: Icon(Icons.settings)), ], onDestinationSelected: (index) { /* TODO: move to the tab at index */ }, ), )
E uma vez que mergulhamos um pouco mais fundo, algumas outras questões surgem:
- Como mostrar páginas diferentes quando alternamos entre as guias?
- Como executar a navegação aninhada e empurrar páginas adicionais dentro de cada guia?
- Como preservar o estado de navegação de cada guia?
Em outras palavras, como podemos implementar esse comportamento?
Agora temos bibliotecas de roteamento poderosas, como GoRouter, Beamer e AutoRoute, que são construídas sobre as APIs do Flutter Navigator 2.0.
E a partir de junho de 2023, todas essas bibliotecas oferecem suporte à navegação stateful nested.
Portanto, neste artigo, mostrarei como usar as APIs GoRouter mais recentes (StatefulShellRoute, StatefulShellBranch, StatefulNavigationShell) para implementar a navegação stateful nested em seus aplicativos Flutter.
E quando todo o código de roteamento essencial estiver pronto, também mostrarei como criar uma UI responsiva, para que possamos alternar entre NavigationBar e NavigationRail dependendo do tamanho da janela, como neste exemplo:
Por fim, compartilharei alguns códigos-fonte de referência que você pode usar como modelo para não precisar reinventar a roda em seus aplicativos Flutter.
Preparar? Vamos!
Conteudo
Navegação Stateful Nested com GoRouter
Antes de mergulharmos no código, vamos dar uma olhada nisso:
Como podemos ver, a IU acima nos permite alternar entre diferentes páginas quando clicamos nas guias correspondentes (chamadas “Seção A” e “Seção B”).
E para criar este aplicativo simples, são necessários dois recursos separados:
- Suporta várias pilhas de navegação: oferecidas pela API ShellRoute (disponível desde GoRouter 4.5.0)
- Preservar o estado das rotas: oferecido pelas APIs StatefulShellRoute, StatefulShellBranch e StatefulNavigationShell (disponível desde GoRouter 7.1.0)
Então vamos ver como usar as novas APIs para obter o resultado desejado.
Implementação GoRouter com StatefulShellRoute
De acordo com a documentação, StatefulShellRoute
é:
Uma rota que exibe um shell de interface do usuário com navegadores separados para suas subrotas.
Também diz isto:
Semelhante a ShellRoute, esta classe de rota coloca sua sub-rota em um Navegador diferente do Navegador raiz. No entanto, essa classe de rota difere porque cria navegadores separados para cada uma de suas ramificações aninhadas (ou seja, árvores de navegação paralelas), tornando possível criar um aplicativo com navigation stateful nested.
Como isso funciona na prática?
Vamos considerar este exemplo para referência:
Quando a página de detalhes é apresentada, ela é empilhada acima da Tela A, mas as guias na parte inferior (o shell da interface do usuário) permanecem visíveis.
A hierarquia de rota correspondente pode ser representada assim:
GoRouter └─ StatefulShellRoute ├─ StatefulShellBranch │ └─ GoRoute('/a') │ └─ GoRoute('details') └─ StatefulShellBranch └─ GoRoute('/b') └─ GoRoute('details')
E implementado assim usando as APIs do GoRouter:
// private navigators final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _shellNavigatorAKey = GlobalKey<NavigatorState>(debugLabel: 'shellA'); final _shellNavigatorBKey = GlobalKey<NavigatorState>(debugLabel: 'shellB'); final goRouter = GoRouter( initialLocation: '/a', // * Passar um navigatorKey causa um problema no hot reload: // * No entanto, ainda é necessário, caso contrário, o navegador volta para // * root no hot reload navigatorKey: _rootNavigatorKey, debugLogDiagnostics: true, routes: [ // Navegação Stateful baseada em: // https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return ScaffoldWithNestedNavigation(navigationShell: navigationShell); }, branches: [ StatefulShellBranch( navigatorKey: _shellNavigatorAKey, routes: [ GoRoute( path: '/a', pageBuilder: (context, state) => const NoTransitionPage( child: RootScreen(label: 'A', detailsPath: '/a/details'), ), routes: [ GoRoute( path: 'details', builder: (context, state) => const DetailsScreen(label: 'A'), ), ], ), ], ), StatefulShellBranch( navigatorKey: _shellNavigatorBKey, routes: [ // Shopping Cart GoRoute( path: '/b', pageBuilder: (context, state) => const NoTransitionPage( child: RootScreen(label: 'B', detailsPath: '/b/details'), ), routes: [ GoRoute( path: 'details', builder: (context, state) => const DetailsScreen(label: 'B'), ), ], ), ], ), ], ), ], ); // use assim: // MaterialApp.router(routerConfig: goRouter, ...)
Algumas notas:
- A instância GoRouter deve ser declarada como uma variável global para que não seja reconstruída no hot reload.
- Usamos
StatefulShellRoute.indexedStack
para criar um widget personalizadoScaffoldWithNestedNavigation
(definido abaixo), que usa um argumentoStatefulNavigationShell
. StatefulShellRoute
usa uma lista de itensStatefulShellBranch
, cada um representando uma ramificação com estado separada. na árvore de rota.GoRouter
eStatefulShellBranch
usam um argumentonavigatorKey
(todos os navegadores são definidos na parte superior).- Usamos uma
NoTransitionPage
dentro das rotas/a
e/b
para evitar animações indesejadas ao alternar entre guias (esse é o comportamento padrão em aplicativos iOS populares). - Cada
StatefulShellBranch
pode definir sua própria hierarquia de rotas (usando a APIGoRoute
com a qual já estamos familiarizados). RootScreen
eDetailsScreen
são apenas widgets simples com umScaffold
e umAppBar
regular.
O que é mais interessante é o argumento do construtor dentro de StatefulShellRoute.indexedStack
.👇
Onde a mágica acontece: StatefulNavigationShell
Quando usamos StatefulShellRoute.indexedStack
, obtemos um construtor que nos fornece um navigationShell que podemos usar para construir nosso shell:
StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { // the UI shell return ScaffoldWithNestedNavigation(navigationShell: navigationShell); }, branches: [ ... ] )
Veja como podemos implementar essa classe:
class ScaffoldWithNavigationBar extends StatelessWidget { const ScaffoldWithNavigationBar({ super.key, required this.body, required this.selectedIndex, required this.onDestinationSelected, }); final Widget body; final int selectedIndex; final ValueChanged<int> onDestinationSelected; @override Widget build(BuildContext context) { return Scaffold( body: body, bottomNavigationBar: NavigationBar( selectedIndex: selectedIndex, destinations: const [ NavigationDestination(label: 'Seção A', icon: Icon(Icons.home)), NavigationDestination(label: 'Seção B', icon: Icon(Icons.settings)), ], onDestinationSelected: onDestinationSelected, ), ); } }
Como podemos ver, o navigationShell
é passado diretamente para o corpo do Scaffold:
Scaffold( body: body, bottomNavigationBar: NavigationBar( selectedIndex: selectedIndex, destinations: const [ NavigationDestination(label: 'Seção A', icon: Icon(Icons.home)), NavigationDestination(label: 'Seção B', icon: Icon(Icons.settings)), ], onDestinationSelected: onDestinationSelected, ), );
E também podemos usá-lo para:
- recupera o
currentIndex
que podemos passar para oNavigationBar
- chame o método
goBranch
para que possamos mudar para uma nova ramificação quando um novo destino for selecionado
O que é ótimo em usar o
navigationShell
é que não precisamos armazenar e atualizar nenhum estado (como o índice selecionado) dentro do nossoScaffoldWithNestedNavigation
(que é umStatelessWidget
). Isso ocorre porque toda a lógica stateful reside dentro da própria classeStatefulNavigationShell
– um widget que gerencia o estado de umStatefulShellRoute
, criando um Navigator separado para cada uma de suas branches nested.
Stateful Nested Navigation: Resumo
Acontece que o código acima já resolve esses problemas:
- Podemos alternar facilmente entre as guias (usando
navigationShell.goBranch
) escrevendo apenas código sem estado - Stateful nested navigation “simplesmente funciona”, e cada ramificação lembra seu próprio estado (todas as páginas dentro de cada guia)
Veja como fica o resultado final:
Para tornar isso possível, precisávamos apenas de alguns ingredientes:
- Um
StatefulShellRoute
que define uma lista de ramificações - Um widget
ScaffoldWithNestedNavigation
personalizado que declara a interface do usuário do shell desejada usando umNavigationBar
- A lógica para alternar entre ramificações com o objeto
navigationShell
E agora que cobrimos a funcionalidade principal, vamos tornar nossa interface do usuário responsiva. 👇
Tornando a interface do usuário responsiva com NavigationRail e LayoutBuilder
A navegação inferior com NavigationBar
funciona bem em dispositivos móveis, mas não tanto em formatos maiores:
Embora possamos usar pacotes como flutter_adaptive_scaffold
para criar layouts responsivos complexos, isso é um exagero para nosso exemplo simples.
Em vez disso, podemos aproveitar o LayoutBuilder
e o NavigationRail
para tornar nossa interface do usuário responsiva sem nenhum pacote de terceiros.
Aqui está o que estamos procurando:
Parece bom. Agora vamos construir! 👇
Criando um scaffold com o widget NavigationBar
Como primeiro passo, vamos mover todo o código de IU do ScaffoldWithNestedNavigation
para um novo widget ScaffoldWithNavigationBar
:
class ScaffoldWithNavigationBar extends StatelessWidget { const ScaffoldWithNavigationBar({ super.key, required this.body, required this.selectedIndex, required this.onDestinationSelected, }); final Widget body; final int selectedIndex; final ValueChanged<int> onDestinationSelected; @override Widget build(BuildContext context) { return Scaffold( body: body, bottomNavigationBar: NavigationBar( selectedIndex: selectedIndex, destinations: const [ NavigationDestination(label: 'Seção A', icon: Icon(Icons.home)), NavigationDestination(label: 'Seção B', icon: Icon(Icons.settings)), ], onDestinationSelected: onDestinationSelected, ), ); } }
Nada extravagante aqui. Tudo o que esse widget faz é retornar um Scaffold
com um NavigationBar
, configurado com os argumentos body
, selectedIndex
e onDestinationSelected
que são passados para o construtor.
Usaremos este widget apenas no celular:
Criando um scaffold com o widget NavigationRail
Mas em telas maiores, precisamos de um widget diferente que use NavigationRail
:
class ScaffoldWithNavigationRail extends StatelessWidget { const ScaffoldWithNavigationRail({ super.key, required this.body, required this.selectedIndex, required this.onDestinationSelected, }); final Widget body; final int selectedIndex; final ValueChanged<int> onDestinationSelected; @override Widget build(BuildContext context) { return Scaffold( body: Row( children: [ NavigationRail( selectedIndex: selectedIndex, onDestinationSelected: onDestinationSelected, labelType: NavigationRailLabelType.all, destinations: const <NavigationRailDestination>[ NavigationRailDestination( label: Text('Seção A'), icon: Icon(Icons.home), ), NavigationRailDestination( label: Text('Seção B'), icon: Icon(Icons.settings), ), ], ), const VerticalDivider(thickness: 1, width: 1), // This is the main content. Expanded( child: body, ), ], ), ); } }
Observe como o corpo do Scaffold é uma Row com três filhos:
- a
NavigationRail
- a
VerticalDivider
- um widget
Expanded
que preenche o restante da tela com o widget de corpo fornecido como argumento.
Com esses widgets instalados, podemos atualizar nosso widget ScaffoldWithNestedNavigation
. 👇
Scaffold atualizado com Navigation Nested
Como último passo, vamos atualizar o método build do nosso ScaffoldWithNestedNavigation
:
class ScaffoldWithNestedNavigation extends StatelessWidget { const ScaffoldWithNestedNavigation({ Key? key, required this.navigationShell, }) : super( key: key ?? const ValueKey<String>('ScaffoldWithNestedNavigation')); final StatefulNavigationShell navigationShell; void _goBranch(int index) { navigationShell.goBranch( index, // Um padrão comum ao usar as barras de navegação inferiores é oferecer suporte // navegando para o local inicial ao tocar no item que está // já ativo. Este exemplo demonstra como suportar este comportamento, // usando o parâmetro initialLocation de goBranch. initialLocation: index == navigationShell.currentIndex, ); } @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { //print(constraints.maxWidth); if (constraints.maxWidth < 501) { return ScaffoldWithNavigationBar( body: navigationShell, selectedIndex: navigationShell.currentIndex, onDestinationSelected: _goBranch, ); } else { return ScaffoldWithNavigationRail( body: navigationShell, selectedIndex: navigationShell.currentIndex, onDestinationSelected: _goBranch, ); } }); } }
Observe como usamos um LayoutBuilder
para decidir qual widget retornar, dependendo da largura máxima do widget pai.
Como alternativa ao
LayoutBuilder
, podemos obter o tamanho deMediaQuery.sizeOf(context)
. De qualquer forma, nosso widget será reconstruído se o tamanho do pai mudar (por exemplo, ao redimensionar a janela na Web ou na área de trabalho).
E como temos uma boa separação de preocupações entre interface do usuário e lógica de roteamento, não precisamos alterar nada no código de roteamento.
E voilà! Com essas mudanças, nossa IU agora é responsiva:
Código fonte
Neste artigo, concentrei-me apenas na lógica importante necessária para habilitar navigation
nested
stateful
.
Você pode encontrar o código-fonte completo no GitHub e usá-lo como modelo para seus projetos:
Exemplos de Stateful Nested Navigation: GoRouter vs Beamer