flutter / uxr

UXR work for Flutter
BSD 3-Clause "New" or "Revised" License
229 stars 28 forks source link

Declarative vs. imperative routing API #15

Closed InMatrix closed 3 years ago

InMatrix commented 3 years ago

Looking at the many packages for routing in Flutter as listed in #13, most of them seem to offer an imperative API similar to Navigator 1.0 (e.g. push, pop). I'm curious why a declarative routing API, a key feature of Navigator 2.0 (see example below), didn't get adopted in those package. Did I miss a package that does offer a declarative routing API?

@override
Widget build(BuildContext context) {
    // Return a Navigator with a list of Pages representing the current app
    // state.
    return Navigator(
      key: navigatorKey,
      onPopPage: _handlePopPage,
      pages: [
        MaterialPageBuilder(
          key: ValueKey<String>('home'),
          builder: (context) => HomePageWidget(),
        ),
        if (state.searchQuery != null)
          MaterialPageBuilder(
            key: ValueKey<String>('search'),
            builder: (context) => SearchPageWidget(),
          ),
        if (state.stockSymbol != null)
          MaterialPageBuilder(
            key: ValueKey<String>('details'),
            builder: (context) => DetailsPageWidget(),
          ),
      ],
    );
  }

Here's a link to full example in Navigator 2.0's design doc.

Cc: @slovnicki, @johnpryan, @theweiweiway, @lulupointu, @nguyenxndaidev

esDotDev commented 3 years ago

Wow, great thread and discussion. On the topic of "Flows" being a good use case for state-based navigation.

What is also really well suited to flows is just the classic imperative navigation!

So I think maybe we are overthinking it a bit. I think just some really intuitive clear declarative API for app navigation is what we need, and really the classic imperitive API are more than fine to handle flows.

State based declarative nav is mainly good at clearing up confusion around a complex nav stack, where many entities may be pushing and popping the stack. In a flow scenario, really none of that confusion exists, you're just drilling forwards and back so it doesn't really need fixing imo.

esDotDev commented 3 years ago

Ideally without Any other boilerplate. That is what web browsers deliver with html.

I think this is the key point, we should be starting from this use case, and working backwards.

The web is fundamentally an imperative paradigm with a bookmarking system. It is constantly just calling pushReplacement(), while at the same time, accumulating more and more 'state' in memory that the views can share.

Trying to marry this inherently imperative approach with a declarative stacked approach is what causes all the headaches I think.

I'm not too sure where I'm going with this, except I guess I agree that making the existing imperative API clearer and more of a first class citizen would be good. What I really want from Flutter are just 3 things:

I think for the most part this is well served already by MaterialApp.onGenerateRoute, but maybe it can be made even easier to use with a dedicated widget that can be stuck anywhere in the tree? Or losing the dependence on PageRoute which has loads of baggage and legacy considerations.

Using MaterialApp.onGenerateRoute to navigate is still much more complicated than basic HTML hrefs and .location for example.

To be honest, I think the jury is still out on whether declarative is actually better at all in these complex scenarios. I think you can argue it has inherent scaling issues, which imperative doesn't really suffer from. Imperative is more just a constant level of confusion vs an ever growing one :p

esDotDev commented 3 years ago

Having played around with VRouter for a bit, I've landed on what I think is my preferred approach, which to NOT have state drive navigation, but rather allow navigation to change state, and to declare all of that in one place inside the routing table.

There is no getting around the fact that some navigation changes are event based, not state based. Trying to support both event based and state based quickly ends in a mess. You start creating state, to emulate events, like isShowingSettings, rather than showSettings(), and this just doesn't scale well. It starts out simple, and then very quickly becomes a headache when realworld complexities enter the scene.

It seems much more straightforward to keep the actual navigation changes imperative (pushNamed), but then to have some declared logic around those routes that can modify the state.

This would be identical to how html has worked for decades. When a page is loaded, args are extracted from the path, and (maybe) injected into the current $_SESSION, which acts as shared state for all pages. All page changes are imperative. If someone lands somewhere they shouldn't be, they are simply re-directed elsewhere.

This is a bit of pseudocode, but for example, if we want to show a specific product, that can be deeplinked to, you can have something like:

AppRoute(
  path: ProductPage.path + "/:id",
  // App state is updated, anytime we goto this page. Not vice versa...
  beforeEnter: (redirector) async {
    if (getModel().isLoggedIn == false) {
      redirector.pushNamed(LoginPage.path);
      return;
    }
    getModel().currentPage = HomePage.path;
    getModel().currentId = navKey.currentState!.pathParameters["id"];
  },
  widget: ProductPage(),
),

So here we can go to any product url, and the app state is updated to match. To move around our app we'd have links like: pushNamed("${ProductPage.Path}/$itemId"), which when triggered, update the app state.

The product page can then simply check the model for the productId, and load what it needs. Or, we could pass it directly if using a view model, something like:

  beforeEnter: (_) async {
    getModel().currentPage = ProductPage.path;
  },
  widget: ProductPage(productId: vrouter.pathParameters["id"]),

This makes for a very nice mix of declarative and imperitive, with a foot in each. The tree structure and nesting are all declarative, defined in one spot and easy to grokk/change. But the actual changing of views is event based as most developers are used to, and then the updating of state is a mix of declarative callbacks and imperative code inside them.

There is no need to consider how to convert your app state to a url, because the data flow only goes one way. All you need to consider, is what state should change when you enter a given path, and what the pre-conditions are for viewing that path.

lulupointu commented 3 years ago

I'm happy that you could reach this point of view. It is exactly what I have been trying to defend in this thread and what I had in mind when building VRouter.

I am happy that other are trying different approaches but I still strongly believe that this single flow way of thinking is the best way to navigate.

xuanswe commented 3 years ago

There is no need to consider how to convert your app state to a url, because the data flow only goes one way. All you need to consider, is what state should change when you enter a given path.

This is exactly what I see when I am trying to create a complex scenario with declarative approach. That's why in my plan for Navi 0.2.0, I will not allow to navigate by manipulating state directly. So developers will use declarative for converting current state to pages. But when they need to change the navigation state, they need to start with changing the current URL, not the state directly.

For other points in your comment, I will spend more time to evaluate with some ideas to find the best solution.

idkq commented 3 years ago

I've been using declarative for a while now and the latest challenge I found is regarding paths.

Consider these paths:

/main/
/main/product/
/main/product/details/

Using declarative, I can link state <-> screen. Let's assume 'product' is currently visible now. If go 100% declarative-model and want 'details' to be visible then I simply put a condition to trigger/show 'details'. HOWEVER (here is the tricky part) if I want to mix declarative with a bit of imperative, it gets ugly.

For example, if I have a enum-holding-variable AppState.currentVisibleScreen = ScreensEnum.productScreen and I want to go back & up, I can read my list of paths and find what is above 'product' which is 'main'. Easy. But if I introduce a new path:

/main/checkout/product/

I can show 'product'. But to go up & back, now I have two candidates for parent of product: 'checkout' and 'main'. I could store a list of previous visited paths but it is really a lot of work!

Long story short: the more I use declarative, the more I reverse course to imperative.

xuanswe commented 3 years ago

Hello guys, finally I found an elegant API, which is simple and easy to learn, yet keeping full power of Navigator 2.0 combine with imperative API on top of the core declarative API.

The result is published as Navi 0.2.0. Navi 0.2.0 marks the end of POC phase of the package. I am confident to start using it in my application from now on. Next step is to write more tests, more documentation, optimization and of course continuing to add new features.

I hope you will enjoy Navi!

slovnicki commented 3 years ago

@nguyenxndaidev I like where navi is going.