/voyager

The Widget Router

Primary LanguageDartMIT LicenseMIT

banner

pub package Codemagic build status codecov Kotlinlang slack

Navigate and prosper 🖖

Router, requirements & dependency injection library for Flutter.

Features

If your app is a list of screens with respective paths then this library is for you.

  • YAML/JSON based Navigation Spec
    • support for query parameters
    • support for global parameters
    • path subsections
    • parameters interpolation in subsections
    • logicless
    • deliverable over the air (think Firebase remote config)
    • code generator for paths/tests/plugins
    • schema validation (draft v7)
  • Highly customizable plugin architecture.
  • VoyagerWidget to embed your path at any point
  • Provider to inject any data coming with the path
  • Works with Flutter Web

Getting started

To use this plugin, add voyager as a dependency in your pubspec.yaml file.

You should ensure that you add the router as a dependency in your flutter project.

dependencies:
 voyager: ^latest_release
 provider: ^3.0.0+1 # if you don't have it yet

You can also reference the git repo directly if you want:

dependencies:
 voyager:
   git: git://github.com/vishna/voyager.git

Then in the code, make sure you import voyager package:

/// prevent name clash with upcoming Flutter's Router class using hide
import 'package:voyager/voyager.dart' hide Router;
import 'package:voyager/voyager.dart' as voyager;

Navigation Spec

It’s best to start with describing what paths your app will have and what subsections will they be made of.

---
'/home' :
  type: 'home'
  widget: HomeWidget
  title: "This is Home"
'/other/:title' :
  type: 'other'
  widget: OtherWidget
  title: "This is %{title}"

You can either put this in assets as a yaml file or use triple quotes ''' and keep it in your code as a string. The String approach while a bit uglier allows for faster reloads while updating assets requires project rebuild.

Creating Router Instance

Your router requires paths and plugins as constructor parameters. Getting paths is quite straightforwad and basically means parsing that YAML file we just defined.

final paths = loadPathsFromString('''
---
'/home' :
  type: 'home'
  widget: HomeWidget
  title: "This is Home"
'/other/:title' :
  type: 'other'
  widget: OtherWidget
  title: "This is %{title}"
''');

or if the file is in the assets folder, you can:

final paths = loadPathsFromAssets("assets/navigation.yml");

NOTE: JSON support is available as of version 0.2.3, please check voyager_test.dart for reference.

The other important ingredient of voyager router are plugins. You need to tell router what kind of plugins you plan to use and those depend on what you have written in the navigation file. In our example we use 2 widget and title. This library comes with predefined plugins for widget and in the next paragraph you can read how to create your own plugin for title.

final plugins = [
  WidgetPluginBuilder() /// provide widget builders for expressions used in YAML
    .add<HomeWidget>((context) => HomeWidget())
    .add<OtherWidget>((context) => OtherWidget())
    .build(),
  TitlePlugin() /// custom plugin
];

Now you're all set for getting your router instance:

Future<voyager.Router> router = loadRouter(paths, plugins)

Custom Plugins

You can define as many plugins as you want. Here's how you could handle the title nodes from the example navigation yaml.

class TitlePlugin extends RouterPlugin {
  TitlePlugin() : super("title"); // YAML node to intercept

  @override
  void outputFor(RouterContext context, dynamic config, Voyager voyager) {
    // config can be anything that is passed from YAML
    voyager["title"] = config.toString(); // output of this plugin
  }
}

NOTE: Above plugin is redundant, Voyager will repackage the primitive types from configuration and you don't need to do anything 😎 Use plugins to resolve primitive types to custom types , e.g. take a look at IconPlugin from the example app.

Router's Default Output: Voyager

Voyager instance is the composite output of all the relevant plugins that are nested under the path being resolved. Observe:

Voyager voyager = router.find("/home")
print(voyager["title"]); /// originates from the title plugin, prints: "This is home"
print(voyager["type"]); /// automatically inherited from the YAML map
print(voyager.type); /// strong typed type
assert(voyager["widget"] is WidgetBuilder); /// originates from the widget plugin

NOTE: Any attempt to modify voyager keys will fail unless done from plugin's outputFor method. If you want to add some values to Voyager later on, use Voyager.storage public map.

NOTE: Planning on using Voyager with Flutter web? Keep in mind that class names in release mode are getting obfuscated by dart2js. Currently the workaround for this is to provide obfuscation map yourself before registering any plugins:

VoyagerUtils.addObfuscationMap({
  HomeWidget: "HomeWidget",
  OtherWidget: "OtherWidget"
});

Failure to provide obfuscation map might result in grey screen of death.

Embed any screen path with VoyagerWidget

If your path uses widget plugin you can try using VoyagerWidget and embed any path you want like this:

VoyagerWidget(path: "/home", router: router);

RECOMMENDED: Provide router at the top of your widget tree and omit passing router parameter.

Inject your information via Provider

If you use VoyagerWidget to create screens for your paths, you can obtain Voyager anywhere from BuildContext using extension getter (that is using Provider underneath):

final voyager = context.voyager;

Now going back to our mystery OtherWidget class from the example navigation spec, that widget could be implemented something like this:

class OtherWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final voyager = context.voyager; // injecting voyager from build context
    final title = voyager["title"];

    return Scaffold(
      appBar: AppBar(
        title: Text(title), // et voilà
      ),
      body: Center(
        child: Text("Other Page"),
      )
    );
  }
}

Integrating with MaterialApp

Defining inital path & handling navigation

final initalPath = "/my/fancy/super/path"

Provider<voyager.Router>.value(
  value: router,
  child: MaterialApp(
    home: VoyagerWidget(path: initalPath),
    onGenerateRoute: router.generator()
  )
)

Make sure you wrap your app with router provider.

NOTE: You can use MaterialApp.initalRoute but please read this first if you find MaterialApp.initalRoute is not working for you. TL;DR: It's working as intended ¯\_(ツ)_/¯

Navigation

Having BuildContext and Material.onGenerateRoute set up, you can simply:

Navigator.of(context).pushNamed("/path/to/go");

If you need to push new screen from elsewhere you probably should set navigatorKey to your MaterialApp

Custom Transitions

The article "Create Custom Router Transition in Flutter using PageRouteBuilder" by Agung Surya explains in detail how to create custom reusable transtions.

Essentially you need to extend a PageRouteBuilder class and pass it a widget you want to be transitioning to. In our case that widget is a VoyagerWidget.

In the aforementioned artile, the author created SlideRightRoute transition. We can combine that transition with any path from our navigation spec by using code below:

Navigator.push(
  context,
  SlideRightRoute(widget: VoyagerWidget.fromPath(context, "/path/to/go")),
);

Adding global values

If you want to expose some global parameters to specs interpolation, you can do so by doing the following:

router.registerGlobalParam("isTablet", false);

NOTE: Because we interpolate String here, only primitve types are allowed.

If you want to make some global entities available via router instance, you can do so by doing the following:

router.registerGlobalEntity("database", someDatabase);

Sample App

voyager_edited

Check out full example here

Code generation

IMPORTANT: Code generation relies heavily on the type value. It should be unique per path definition, also the values should_be_snake_case

Voyager supports generating dart code based on the configuration yaml file. Simply run the following command and wait for the script to set it up for you.

flutter packages pub run voyager:codegen

This should create a voyager-codegen.yaml file in a root of your project, like so:

- name: Voyager # base name for generated classes, e.g. VoyagerData, VoyagerTests etc.
  source: assets/navigation.yaml
  target: lib/gen/voyager_gen.dart

Whenever you edit the voyager-codegen.yaml or source file the code generation logic will pick it up (as long as pub run is running) and generate new dart souces to the target location.

CODE FORMATTING: If you want to have Flutter's default code formatting, make sure you have dart-sdk in you PATH, it's included with flutter sdk, so you can e.g.:

export PATH="$PATH:/path/to/flutter/bin/cache/dart-sdk/bin"

Proper formatting relies on dartfmt command being available.

NOTE 1: For code generator implementation details please check the source code at vishna/voyager-codegen.

NOTE 2: Should you want run code generation only once (and not watch files continously) you can supply additional --run-once flag to pub run command:

flutter packages pub run voyager:codegen --run-once

This can be useful if running in a CI/CD context.

NOTE 3: You might want to add .jarCache/ to your .gitignore to avoid checking in binary jars to your repo.

NOTE 4: If you're a Windows user make sure you have wget installed.

Strong Typed Paths

Typing navigation paths by hand is error prone, for this very reason it is recommended to use code generator for the paths, so rather than typing:

Navigator.of(context).pushNamed("/other/thingy");

you can rely on your IDE's autocompletion and do this:

Navigator.of(context).pushNamed(pathOther("thingy"));

Schema Validation & Strong Typed Outputs

Add your validation in voyager-codegen.yaml, for instance to cover IconPlugin you can now do this:

- name: Voyager
  source: lib/main.dart
  target: lib/gen/voyager_gen.dart
  schema:
    icon:
      pluginStub: true # add if you want to generate aplugin stub
      output: Icon # associated Dart class produced by the plugin
      import: "package:flutter/widgets.dart" # Dart import for the output class, if necessary
      input: # write schema for your the icon node (JSON Schema draft-07 layout)
        type: string
        pattern: "^[a-fA-F0-9]{4}$"

Now whenever you run voyager:codegen you'll get an extra message stating all is fine:

✅ Schema validated properly

...or an error specific to your router configuration map, e.g.:

🚨 /fab@icon: #/icon: string [e88fd] does not match pattern ^[a-fA-F0-9]{4}$

Furthermore you gain strong typed reference to the plugin output in extended Voyager instance:

assert(voyager.icon is Icon);

Finally, pluginStub: true gets you an abstract plugin class, so that you can avoid typing voyager["node_name"] manually. Just focus on parsing the node's config input and converting it into an expected output:

class IconPlugin extends IconPluginStub {
  @override
  Icon buildObject(RouterContext context, dynamic config) {
    /// write your code here
  }
}

Automated Widget Tests (Experimental Feautre)

Screenshot 2019-07-31 at 15 19 15

If you want to try this feature out, your voyager-codegen.yaml should look something like that:

- name: Voyager
  source: assets/navigation.yaml
  target: lib/gen/voyager_gen.dart
  testTarget: test/gen/voyager_test_scenarios.dart

testTarget points to where the generated test code should go.

Say your regular test file is located in the test directory, this is how you could integrate with the generated code:

import 'gen/voyager_test_scenarios.dart';

/// override abstract base class with all the scenarios to test
class TestScenarios extends VoyagerTestScenarios {

  /// default wrapper for all the widgets
  MyVoyagerScenarios() : super((widget) => MaterialApp(home: widget));

  @override
  /// example scenario implementation for the `/home` path
  List<VoyagerTestHomeScenario> homeScenarios() {
    return [
      VoyagerTestHomeScenario.write((tester) async {
        expect(find.text("Home Page"), findsOneWidget);
      })
    ];
  }

  /// etc...
}

void main() {
  /// finally invoke tests, you need to suply `router` as `Future<Router>`
  voyagerAutomatedTests("voyager auto tests", router, TestScenarios());
}

Full code available at example/test/widget_test.dart.

voyagerAutomatedTests comes with a positional argument forceTests set to true by default. This will assert every widget has at least one scenario written for it, otherwise your tests will fail. Set it to false to disable this behaviour.

The scenario code is by default being executed within WidgetTester's runAsync meaning you should be able to perform real asynchronous methods.

The router is loaded every time the scenario is running - if this is something you don't need consider using e.g. AsyncMemoizer

More Resources

Acknowledgments

  • fluro As their repo says: "The brightest, hippest, coolest router for Flutter." Probably the most know flutter router out there.
  • angel-route "A powerful, isomorphic routing library for Dart." Voyager internally was depending on this library till version 0.2.3. It was a server oriented library and too big dependency for this project - voyager is now using abstract_router.dart which is < 300 LOC.
  • eyeem/router Protoplast of the voyager library, written in Java, for Android.
  • NASA Voyager 2 Interstellar Poster Beautiful artwork I found on NASA page also a base content for the banner - changed colors to flutter ones, cropped the poster, added flutter antenna.