Navegação por rotas em Flutter usando o Nuvigator

Publicado em 12 ago 2020. Uns 12 minutos de leitura.

Um dos aspectos mais importantes de um app é a navegação entre telas. Na web, isso é feito através de links (e, com a alta dos frameworks de front-end JavaScript, reimplementando esta navegação no cliente). Num app em Flutter, existem funções de navegação para transicionar de uma tela para outra. Com elas é possível empilhar, substituir e remover telas criando o próprio widget da tela seguinte nestas funções.

O Nuvigator surgiu como uma abstração em cima desse roteamento do Flutter para facilitar a declaração e reutilização destas rotas, o que é bem útil conforme o app cresce e tem mais partes que interagem. Trouxe uma introdução a como ele funciona neste post!

O Nuvigator é uma biblioteca open source, criada no Nubank, para facilitar a declaração de rotas e a navegação entre elas em Flutter. Foi usada inicialmente em algumas seções do app, moldada para facilitar o fluxo de desenvolvimento de navegação. Eventualmente, ela chegou a um ponto que pode ser aberta para o público e beneficiar a comunidade desenvolvedora.

Navegação em Fluter 101

Vamos dar uma olhada em como funciona uma navegação básica em Flutter, seguindo os exemplos da documentação, para depois entender no que o Nuvigator pode ajudar. No exemplo original, ele cria widgets como FirstRoute. Como vamos introduzir alguns widgets de rotas mais tarde, estou chamando de FirstScreen para facilitar. Além disso, adicionei um argumento que a segunda tela recebe.

// first_screen.dart

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Screen'),
      ),
      body: Center(
        child: RaisedButton(
          child: Text('Open route'),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => SecondScreen(magicNumber: 42)),
            );
          }
        ),
      ),
    );
  }
}
// second_screen.dart
class SecondScreen extends StatelessWidget {
  SecondScreen({this.magicNumber});

  final int magicNumber;
  // ...
}

No exemplo acima, a partir da tela FirstScreen, é possível clicar num botão para abrir a tela SecondScreen. Como a tela de destino precisa receber algum parâmetro, ele pode ser passado direto no construtor do widget SecondScreen. Note também um widget MaterialPageRoute, usado para definir como a transição entre as telas vai acontecer. Se estivéssemos um app com a interface do iOS, usaríamos um CupertinoPageRoute no lugar.

Apesar de bastante simples, esse exemplo tem um problema que é o acoplamento das duas telas: a tela de origem precisa instanciar o widget da segunda tela, e para isso vai importar o arquivo em que ele for definido. Também está nela o tipo de rota (material, no caso). Caso fôssemos mudar o tipo de rota ou qual a tela de entrada no fluxo de destino, precisaríamos editar em todos os arquivos que possivelmente conseguem abrir esta rota.

Uma maneira de amenizar isso seria definir separadamente as rotas num arquivo que pode ser importado pelos widgets que querem navegar entre telas. Poderíamos ter um arquivo de navegação como este:

// navigation.dart

toSecondRoute(BuildContext context, {int magicNumber}) => Navigator.push(
    context,
    MaterialPageRoute(builder: (context) => SecondScreen(magicNumber: magicNumber)),
  );
// first_screen.dart

import './navigation.dart';

RaisedButton(
  child: Text('Open route'),
  onPressed: () => toSecondRoute(context, magicNumber: 42)
),

Com isso, isolamos nossa navegação da apresentação das telas. Se resolvermos mudar qual o widget que vai aparecer quando esta rota for chamada, só precisamos modificar na função toSecondRoute e todos que a importam vão apontar para o widget correto.

Uma outra maneira de separar estes widgets seria usar uma rota nomeada do Flutter. Ao declarar a aplicação, podemos listar as rotas que ela possui assim:

// main.dart

MaterialApp(
  // A rota que o app vai carregar ao iniciar
  initialRoute: '/',
  routes: {
    '/': (context) => FirstScreen(),
    '/second': (context) => SecondScreen(),
  },
);
// first_screen.dart

RaisedButton(
  child: Text('Open route'),
  onPressed: () => Navigator.pushNamed(context, '/second')
),

Esta maneira permite declarar rotas num formato parecido com um link e abrir estes links de qualquer lugar do app. Porém, criamos mais uma vez um acoplamento, dessa vez com a string /second. Ela estará escrita literalmente em todas as telas que quiserem acessar esta rota, e, ao contrário da função que usamos no exemplo anterior, não pode ser facilmente editada usando as ferramentas de refatoração que as principais IDEs de Flutter suportam, como Visual Studio Code ou IntelliJ. Poderíamos resolver este problema externalizando uma classe ou enum que seriam mapeados para as string, para reduzir este acoplamento.

Outra dificuldade que aparece ao usar rotas nomeadas é a passagem de parâmetros para elas. A documentação do Flutter tem um artigo só sobre passagem de parâmetros para rotas nomeadas, que envolve criar uma classe separada para os argumentos que a rota recebe e instanciar esta classe ao chamar a rota nomeada. Isso traz algumas questões por si só e, conforme a aplicação cresce, lidar com este boilerplate fica mais difícil.

Entra o Nuvigator

O Nuvigator não substitui o roteamento do Flutter, mas sim abstrai alguns conceitos dele, como a criação de funções para chamar cada rota, criação de classes de argumentos usadas de maneira transparentes e hierarquia de deep links, para permitir acesso direto a uma parte do app, como nas rotas nomeadas.

Para isso, o Nuvigator se vale de geração de código Dart. Por isso, dizemos que ele fornece uma API declarativa em que você define as rotas e o Nuvigator gera código para facilitar o uso delas.

O Nuvigator estava na versão 0.6.0 quando este post foi escrito. Dependendo de quão do futuro você é, alguns conceitos ou exemplos abaixo podem estar desatualizados, pois o projeto está ativamente em desenvolvimento! 📈

Vamos definir as rotas que vimos acima em termos de um Router do Nuvigator:

// app_router.dart

part 'app_router.g.dart';

@NuRouter()
class AppRouter extends Router {
  @NuRoute()
  ScreenRoute<void> firstScreen() => ScreenRoute(
    builder: (context) => FirstScreen()
  )

  @NuRoute()
  ScreenRoute<void> secondScreen({int magicNumber}) => ScreenRoute(
    builder: (context) => SecondScreen(magicNumber: magicNumber)
  )

  @override
  Map<RouteDef, ScreenRouteBuilder> get screensMap => _$screensMap;
}

As classes Router e ScreenRoute do exemplo acima vêm do Nuvigator. Este exemplo é bem parecido com o segundo, em que criamos uma função para chamar a segunda tela. As diferenças são que, neste caso, estas funções são métodos de uma classe nova AppRouter que criamos, e que elas estão anotadas com @NuRoute. Estas anotações servem para indicar ao Nuvigator que cada função destas é uma rota, e que ele deve gerar o código necessário para que ela funcione. Além disso, há um getter screensMap, que vai ser usado pelo código gerado, também. Eu aproveitei já e criei uma rota para a primeira tela.

Enfim, adicionamos o Nuvigator no nosso app:

// main.dart

MaterialApp(
  builder: Nuvigator(
    router: AppRouter(), // O Router que criamos acima
    screenType: materialScreenType, // O tipo de rota padrão
    initialRoute: AppRoutes.firstScreen,
  ), 
);

O widget Nuvigator recebe um Router e toma conta de lidar com a navegação a partir de agora. Uma outra classe é gerada a partir do nosso AppRouter que é a AppRoutes. Usamos ela no exemplo acima para especificar a rota inicial sem usar uma string diretamente no código.

Eu falei algumas vezes sobre gerar código. Isso funciona usando um pacote chamado build_runner. Ele adiciona um comando que podemos executar no projeto que vai escanear o código fonte da nossa aplicação e gerar código adicional de acordo com os pacotes que suportem essa função. É para isso que as anotações servem. No topo do arquivo, colocamos uma diretriz part. Ela serve para dizer que há outro arquivo que estende a funcionalidade deste. Esse arquivo ainda não existe, mas vai ser gerado ao rodar o comando abaixo:

$ flutter pub run build_runner build --delete-conflicting-outputs

Neste exemplo, o Nuvigator vai gerar funções para abrir cada rota. Essas rotas, por padrão, vão abrir como um push (como nos primeiros exemplo), mas há suporte para definir múltiplas maneiras de abrir uma rota e o Nuvigator gerará um método para cada uma, como pushReplacement por exemplo. Nosso widget da primeira tela poderia buscar o Nuvigator no contexto para ter acesso a essas funções, mas como ele já é criado dentro da classe do nuvigator, podemos passar as funções de navegação que o widget precisa como argumentos no seu construtor, e deixando o widget totalmente desacoplado da navegação.

// app_router.dart 

@NuRoute()
ScreenRoute<void> firstScreen() => ScreenRoute(
  builder: (context) => FirstScreen(
    // A função `toSecondScreen` é gerada automaticamente usando o
    // tipo de rota padrão, `push`
    toSecondScreen: toSecondScreen, 
  )
)
// first_screen.dart

class FirstScreen extends StatelessWidget {
  FirstScreen({@required this.toSecondScreen});

  final Function({int magicNumber}) toSecondScreen;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Screen'),
      ),
      body: Center(
        child: RaisedButton(
          child: Text('Open route'),
          onPressed: () => toSecondScreen(magicNumber: 42)
        ),
      ),
    );
  }
}

Passando a função de navegação para o widget, ao invés de acessá-la diretamente nele, nos permite fazer testes unitários deste widget sem necessidade de navegação. Por exemplo, podemos passar uma implementação que atualiza uma variável local no teste e verificamos se, ao tocar no botão, essa variável foi atualizada.

É possível também acessar a segunda rota a partir de um deep link ao invés de passar a função para o widget. Para isso, precisamos nomeá-la. Na implementação de deep links do Nuvigator, é possível passar argumentos tanto como parte do path como via query string, sem configuração adicional na rota. O gerador de código verifica o nome dos argumentos que a rota recebe e sabe tratar automaticamente, inclusive convertendo para o tipo final (int neste caso) para alguns tipos simples como String, int, double, bool e DateTime a partir da versão 0.6.0.

// app_router.dart

@NuRoute(deepLink: '/second') // Adicionamos o link para esta rota
ScreenRoute<void> secondScreen({int magicNumber}) => ScreenRoute(
  builder: (context) => FirstScreen(magicNumber: magicNumber)
)
// first_screen.dart

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Screen'),
      ),
      body: Center(
        child: RaisedButton(
          child: Text('Open route'),
          onPressed: () => 
            Nuvigator.of(context).openDeepLink(Uri.parse('/second?magicNumber=42')),
        ),
      ),
    );
  }
}

O uso de deep links pode gerar algum acomplamento se eles ficarem espalhados pela aplicação, mas é bem útil para, por exemplo, definir no back-end da aplicação qual rota abrir. O app recebe a string do deep link e pode abri-lo a partir de alguma interação do usuário. Esse comportamento é particularmente interessante em BFFs (back-ends for front-ends).

Em algumas situações, você pode querer navegar para um fluxo ao invés de uma tela. Um fluxo é uma sequência de telas que são agrupadas entre si e que, em algum momento (usualmente no fim do fluxo), você pode fechar todas elas de uma vez só e voltar para onde estava antes de chamar o fluxo. O Nuvigator dá suporte a definição de fluxos através de Nuvigators aninhados, que é criar uma rota cujo widget é outro Nuvigator. Este é um trecho de código do app de exemplo no projeto do Nuvigator:

// sample_router.dart (do app de exemplo do Nuvigator)

@NuRoute(deepLink: '/friendRequests')
ScreenRoute<String> friendRequests() =>
    ScreenRoute(
      builder: Nuvigator(
        router: FriendRequestRouter(),
        initialRoute: FriendRequestRoutes.listRequests,
        screenType: materialScreenType,
      ),
    );

Neste caso, ao invés de retornar um widget para a rota /friendRequest, estamos retornando um novo Nuvigator com um novo Router. O Nuvigator consegue lidar com esse aninhamento e, caso alguma tela dentro do fluxo chame a função de navegação closeFlow(), provida pelo Nuvigator, ela vai fechar todas as telas que fazem parte do mesmo fluxo, voltando para onde o usuário estava antes de chamar o fluxo pela primeira vez. Para a tela que vai chamar o fluxo, nada muda. Ela apenas vai abrir uma rota via função ou via deep link. A rota inclusive pode receber argumentos, se necessário.

O que eu ganho com isso?

O Nuvigator foi feito para automatizar tarefas como a criação de boilerplate para definir rotas. Originalmente, esse trabalho é feito manualmente. Ele pode também não ser feito e o código de roteamento ficar espalhado pela aplicação.

Para apps pequenos com fluxos simples, os benefícios podem não parecer relevantes junto a introdução de geração de código, uma dependência nova e classes com anotações. Mas onde o Nuvigator brilha são em projetos maiores que têm multiplos fluxos, com suporte a Nuvigators e Routers aninhados, hierarquia de deep links, classes de argumentos autogeradas, criação de funções para diferentes tipos de roteamento para uma mesma tela, e gerenciamento de alguns comportamentos mais complicados de navegação.

Independente de usar o Nuvigator ou não, uma sugestão que deixo é definir algum padrão para navegação na aplicação. O Nuvigator, apesar de razoavelmente genérico, tem um pouco de opinião sobre como estruturar essa navegação, e essa abordagem pode ser útil ou não para o seu projeto.

Quero saber mais!

No repositório do Nuvigator, há um projeto de exemplo para demonstrar algumas dessas funcionalidades em uso. Inclusive, recentemente, eu subi um pull request para refatorar este projeto e facilitar o entendimento de cada demonstração. Nele há também exemplos de usos mais complexos como um Bloc envelopando todas as telas de um Nuvigator aninhado usando o pacote Provider e a função wrapper do Nuvigator. Experimente executar o projeto num simulador e olhar o código-fonte dele.

O Readme do Nuvigator explica cada um dos conceitos que ele introduz com exemplos, também. Há uma sessão específica sobre geração de código que mostra quais classes e extensões são geradas e para que cada uma serve. Se algo não estiver claro, pode abrir uma issue no GitHub (em inglês ou em português) que alguém vai te ajudar!