Como navegar sem contexto com GoRouter e Riverpod no Flutter
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:
- Passando o
BuildContext
como argumento - Usando um retorno de chamada
onSuccess
- 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.
Conteudo
Exemplo: Deixar a IU de Revisão
Suponha que temos um formulário que podemos usar para enviar uma revisão:
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 propriedademounted
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 deAsyncNotifier
, 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 blocotry/catch
por uma chamada paraAsyncValue.guard
(esta é uma etapa opcional). - O
state
é definido duas vezes (primeiro comAsyncLoading
e depois com o resultado deAsyncValue.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
:
- Passando o
BuildContext
como argumento - Usando um retorno de chamada
onSuccess
- 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. 👍