Como navegar sem contexto com GoRouter e Riverpod no Flutter

Tempo de leitura: 8 minutes

Você já precisou abrir a rota atual ou navegar para uma nova tela com base em alguma lógica condicional ou depois de executar algum código assíncrono?

Talvez você tenha recebido uma notificação e precise navegar para a página A ou página B, dependendo da carga da mensagem.

Ou talvez você precise enviar um formulário e só abrir a rota atual se os dados foram gravados sem erros, como neste exemplo:

// algum retorno de chamada de botão
onPressed: () async {
  try {
    // obtenha o banco de dados de um provedor (usando Riverpod)
    final database = ref.read(databaseProvider);
    // faça algum trabalho assíncrono
    await database.submit(someFormData);
    // em caso de sucesso, abra a rota se o widget ainda estiver montado
    if (context.mounted) {
      context.pop();
    }
  } catch (e, st) {
    // TODO: mostrar erro de alerta
  }
}

Como podemos ver, a chamada para context.pop() ocorre apenas condicionalmente e após um intervalo assíncrono, com base em alguma lógica de negócio.

Mas onde deveriam pertencer a lógica de negócios e o código de roteamento?

Se mantivermos tudo dentro de um callback de widget (como no exemplo acima), nossa lógica se mistura com o código da UI e fica difícil de testar. 😥

Se movermos a lógica de negócios para uma classe separada, estabeleceremos uma boa separação de interesses. Mas como podemos navegar sem contexto fora dos nossos widgets?

Estou feliz que você perguntou. 😊

 

Neste artigo, exploraremos três técnicas de navegação diferentes, juntamente com suas vantagens e desvantagens:

  1. Passando o BuildContext como argumento
  2. Usando um retorno de chamada onSuccess
  3. Navegue sem contexto usando ref.read(goRouterProvider)

Usaremos GoRouter para navegação e a classe AsyncNotifier do pacote Riverpod, mas os mesmos princípios se aplicam se você usar ChangeNotifier ou Cubit, ou um pacote de navegação completamente diferente.

No final, você conhecerá mais alguns truques para escrever código de roteamento sustentável.

Uma maneira confiável de obter navegação sem contexto é declarar um navigatorKey global conforme explicado nesta resposta do StackOverflow. Isso funciona bem para aplicativos que ainda usam o Navigator 1.0. Mas neste artigo, vamos nos concentrar em uma abordagem mais moderna para aplicativos desenvolvidos com GoRouter e Riverpod.

 

Exemplo: Deixar a IU de Revisão

Suponha que temos um formulário que podemos usar para enviar uma revisão:

Exemplo de UI para deixar um comentário sobre um determinado produto
Exemplo de UI para deixar um comentário sobre um determinado produto

Esta é a aparência do fluxo de revisão de licenças:

  • na página de um produto, clicamos em um botão e navegamos até a página “Leave a review”
  • selecionamos uma pontuação de classificação (1 a 5) e, opcionalmente, deixamos um comentário de revisão
  • quando pressionamos o botão enviar, tentamos gravar os dados no banco de dados e somente se a operação for bem-sucedida, voltamos à página anterior

Nada de surpreendente aqui – apenas um exemplo clássico de CRUD.

Então, vamos ver como podemos implementar isso. 👇

 

Implementação com uma classe de widget

Como ponto de partida, vamos considerar uma classe de widget LeaveReviewForm que poderíamos usar para representar a UI acima:

class LeaveReviewForm extends ConsumerStatefulWidget {
  const LeaveReviewForm({super.key, required this.productId});
  final ProductID productId;

  @override
  ConsumerState<LeaveReviewForm> createState() => _LeaveReviewFormState();
}

class _LeaveReviewFormState extends ConsumerState<LeaveReviewForm> {
  // uma variável de estado que será atualizada quando alterarmos a pontuação da classificação
  double _rating = 0;
  // um controlador que será usado para obter o texto do campo de comentários
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    // retornar um formulário com uma barra de classificação, um campo de texto e um botão de envio
  }
}

E suponha que temos um método _submitReview que é chamado a partir do retorno de chamada onPressed do botão enviar:

class _LeaveReviewFormState extends ConsumerState<LeaveReviewForm> {

  // chamado a partir de um retorno de chamada de botão
  Future<void> _submitReview() async {
    try {
      // crie um objeto de revisão com a classificação e o comentário
      final review = Review(rating: _rating, comment: _controller.text);
      // obtenha o serviço de revisão de um provedor Riverpod
      final reviewsService = ref.read(reviewsServiceProvider);
      // use-o para enviar a avaliação (productId é uma propriedade da classe do widget)
      await reviewsService.submitReview(productId: widget.productId, review: review);
      // o widget ainda está montado após a operação assíncrona
      if (context.mounted) {
        // abrir a rota atual (usa a extensão GoRouter)
        context.pop();
      }
    } catch (e, st) {
      // TODO: mostrar erro de alerta
    }
  }
}

Algumas coisas a serem observadas:

  • O método é assíncrono, pois precisamos await para que a revisão seja enviada antes de fazer qualquer outra coisa
  • Se a operação falhar, usamos o bloco catch para mostrar um erro de alerta e permanecer na mesma página
  • Se a operação for bem-sucedida, abriremos a rota somente se o widget ainda estiver montado

O último ponto é importante porque se o usuário fechar a página antes que a operação assíncrona seja concluída, a rota atual será alterada e não queremos abri-la (de novo!):

Desde o Flutter 3.7, o tipo BuildContext contém uma propriedade mounted que podemos usar para verificar se o widget ainda está na árvore de widgets. Isto é útil quando queremos fazer algo com o contexto após um intervalo assíncrono.

O código acima funciona, mas manter nossa lógica de negócios na classe widget não é o ideal.

Então, vamos melhorar isso usando um AsyncNotifier.

 

Implementação com AsyncNotifier

Na próxima etapa, podemos mover toda a nossa lógica de negócios para uma nova classe LeaveReviewController que estende AutoDisposeAsyncNotifier:

// uma classe de controlador que conterá toda a nossa lógica
class LeaveReviewController extends AutoDisposeAsyncNotifier<void> {
  @override
  FutureOr<void> build() {
    // nada para fazer
  }

  Future<void> submitReview({
    required ProductID productId,
    required double rating,
    required String comment,
  }) async {
    // crie um objeto de revisão com a classificação e o comentário
    final review = Review(rating: rating, comment: comment);
    // obtenha o serviço de revisão de um provedor Riverpod
    final reviewsService = ref.read(reviewsServiceProvider);
    // definir o estado do widget para carregamento
    state = const AsyncLoading();
    // enviar a revisão e atualizar o estado quando terminar
    state = await AsyncValue.guard(() =>
        reviewsService.submitReview(productId: productId, review: review));
    // TODO: verifique se está montado
    if (state.hasError == false) {
      // TODO: rota pop
    }
  }
}

// o fornecedor correspondente
final leaveReviewControllerProvider =
    AutoDisposeAsyncNotifierProvider<LeaveReviewController, void>(
        LeaveReviewController.new);

Algumas notas:

  • Estendemos de AutoDisposeAsyncNotifier em vez de AsyncNotifier, e o tipo de provedor é AutoDisposeAsyncNotifierProvider. Isso garante que o controlador seja descartado assim que o próprio widget for desmontado.
  • No método submitReview, substituí o antigo bloco try/catch por uma chamada para AsyncValue.guard (esta é uma etapa opcional).
  • O state é definido duas vezes (primeiro com AsyncLoading e depois com o resultado de AsyncValue.guard). Isso ocorre para que possamos lidar com estados de carregamento e erro no widget.
  • Existem alguns TODOs sobre “mounted” e “pop route”, pois não temos um context dentro do controlador e não podemos mover todo o código do widget como está.

Lidaremos com os TODOs em breve. Mas observe como o código do widget já é muito mais simples, pois podemos remover o antigo método _submitReview e fazer isso:

onPressed: () => ref.read(leaveReviewControllerProvider.notifier)
  .submitReview(
    productId: widget.productId,
    rating: _rating, // obter a pontuação de classificação de uma variável de estado local
    comment: _controller.text, // obtenha o texto do TextEditingController
  ),

 

Cuidando da Navegação

Como lembrete, queremos exibir a rota atual se reviewsService.submitReview for concluído com sucesso.

E como vimos, dentro do nosso widget, estávamos usando o context para fazer isso:

// o widget ainda está montado após a operação assíncrona
if (context.mounted) {
  // abrir a rota atual (usa a extensão GoRouter)
  context.pop();
}

Mas agora que nossa lógica está dentro do LeaveReviewController, o que devemos fazer?

Aqui estão algumas opções:

1. Passe o BuildContext como argumento

Passando BuildContext para o método submitReview, obtemos algo assim:

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

class LeaveReviewController extends AutoDisposeAsyncNotifier<void> {
  // @override build method

  Future<void> submitReview({
    required ProductID productId,
    required double rating,
    required String comment,
    required BuildContext context,
  }) {
    // toda a lógica anterior aqui
    state = await AsyncValue.guard(...);
    // então faça isso:
    if (state.hasError == false) {
      if (context.mounted) {
        // abrir a rota atual (usa a extensão GoRouter)
        context.pop();
      }
    }
  }
}

Isso é ruim, pois não queremos que nosso controlador dependa da UI (observe como somos forçados a importar material.dart se usarmos BuildContext). E isso significa que não podemos mais escrever testes unitários para ele.

 

2. Use um retorno de chamada onSuccess

Uma solução melhor é passar um retorno de chamada:

class LeaveReviewController extends AutoDisposeAsyncNotifier<void> {
  // @override build method

  Future<void> submitReview({
    required ProductID productId,
    required double rating,
    required String comment,
    required VoidCallback onSuccess,
  }) {
    // toda a lógica anterior aqui
    state = await AsyncValue.guard(...);
    // então faça isso:
    if (state.hasError == false) {
      onSuccess();
    }
  }
}

Então, na classe do widget, podemos fazer isso:

onPressed: () => ref.read(leaveReviewControllerProvider.notifier)
  .submitReview(
    productId: widget.productId,
    rating: _rating, // obter a pontuação de classificação de uma variável de estado local
    comment: _controller.text, // obtenha o texto do TextEditingController
    onSuccess: context.pop, // pop using GoRouter extension
  ),

Com esta configuração, LeaveReviewController não depende mais de material.dart. E podemos atualizar quaisquer testes de unidade para verificar se o retorno de chamada onSuccess foi chamado.

Este é um bom passo em frente.

Mas esta solução é menos clara, uma vez que a nossa lógica de negócio e o código de navegação já não pertencem mais um ao outro. Em outras palavras, somos forçados a olhar o código do controlador e do widget separadamente para realmente entender o que está acontecendo.

Vamos ver se podemos fazer melhor. 👇

 

3. Navegue sem context usando ref.read(goRouterProvider)

Ao escrever aplicativos Flutter, GoRouter e Riverpod são uma ótima combinação que pode desbloquear alguns truques interessantes! 🚀

Um desses truques é criar um provedor que retorne nossa instância GoRouter:

final goRouterProvider = Provider<GoRouter>((ref) {
  return GoRouter(...);
});

Isso facilita o acesso ao GoRouter em qualquer lugar, desde que tenhamos um ref. Por exemplo, podemos configurar o MaterialApp.router assim:

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final goRouter = ref.watch(goRouterProvider);
    return MaterialApp.router(
      routerConfig: goRouter,
      ...
    );
  }

Isso também significa que podemos atualizar nosso controlador assim:

class LeaveReviewController extends AutoDisposeAsyncNotifier<void> {
  // @override build method

  Future<void> submitReview({
    required ProductID productId,
    required double rating,
    required String comment,
  }) {
    // toda a lógica anterior aqui
    state = await AsyncValue.guard(...);
    // então faça isso:
    if (state.hasError == false) {
      // pegue a instância do GoRouter e chame pop nela
      ref.read(goRouterProvider).pop();
    }
  }
}

Isso é ótimo porque:

  • o controlador não depende mais do BuildContext
  • não precisamos mais usar um retorno de chamada
  • a lógica de negócios e o código de navegação pertencem juntos dentro de um método que é fácil de testar por unidade

O que é o que eu chamo de ganha-ganha-ganha! 😀

No entanto, lembre-se de que a navegação sem contexto deve ser usada com cuidado. 👇

 

Uma palavra de cautela

Depois de ter um Provider<GoRouter> em seu aplicativo, você pode ficar tentado a usar ref.read(goRouterProvider) toda vez que quiser navegar sem um BuildContext (ou seja, em qualquer lugar fora dos widgets).

Mas lembre-se de que o BuildContext nos ajuda a acompanhar onde estamos na árvore de widgets.

E às vezes, só queremos navegar para uma nova página se certas condições forem verdadeiras:

  • apenas abra a rota atual se o widget ainda estiver montado (como no exemplo acima)
  • lidar apenas com um link dinâmico e navegar para uma nova rota se o usuário não estiver preenchendo um formulário (para evitar perda de dados)

Nestes casos, devemos considerar qual página está visível no momento e levar em consideração o estado da aplicação, para navegarmos apenas quando apropriado.

 

Conclusão

Começamos com um exemplo de código para enviar um formulário e exibir a rota atual dentro de uma classe de widget.

Em seguida, aprendemos como mover toda a lógica de negócios para uma subclasse AsyncNotifier para melhor separação de interesses.

E exploramos três maneiras de realizar a navegação dentro das subclasses AsyncNotifier:

  1. Passando o BuildContext como argumento
  2. Usando um retorno de chamada onSuccess
  3. Usando ref.read(goRouterProvider)

Como vimos, a terceira opção é melhor porque o notificador não depende mais do BuildContext ou de um argumento de retorno de chamada. E a lógica de negócios e o código de navegação pertencem a um método fácil de testar a unidade.

Na prática, existem outros cenários em que pode ser necessário navegar sem contexto, como quando você recebe uma notificação e precisa navegar para páginas diferentes dependendo do conteúdo da mensagem. Nesses casos, você também pode chamar ref.read(goRouterProvider) e usá-lo conforme necessário.

Essa técnica o ajudará a escrever um código de roteamento mais sustentável e testável, desde que você saiba o que está fazendo e não se empolgue. Portanto, fique à vontade para usá-lo em seus aplicativos Flutter. 👍