Flutter Bottom Navigation Bar com Stateful Nested Routes usando GoRouter

Tempo de leitura: 8 minutes

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?

Exemplo de navegação aninhada com estado. Observe como o valor do contador é preservado ao alternar entre as guias.
Exemplo de navegação aninhada com estado. Observe como o valor do contador é preservado ao alternar entre as guias.

 

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!

 

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:

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 personalizado ScaffoldWithNestedNavigation (definido abaixo), que usa um argumento StatefulNavigationShell.
  • StatefulShellRoute usa uma lista de itens StatefulShellBranch, cada um representando uma ramificação com estado separada. na árvore de rota.
  • GoRouter e StatefulShellBranch usam um argumento navigatorKey (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 API GoRoute com a qual já estamos familiarizados).
  • RootScreen e DetailsScreen são apenas widgets simples com um Scaffold e um AppBar 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 o NavigationBar
  • 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 nosso ScaffoldWithNestedNavigation (que é um StatelessWidget). Isso ocorre porque toda a lógica stateful reside dentro da própria classe StatefulNavigationShell – um widget que gerencia o estado de um StatefulShellRoute, 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 um NavigationBar
  • 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 de MediaQuery.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