flutter / uxr

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

General feedback on the Nav 2 API usability project #6

Closed InMatrix closed 3 years ago

InMatrix commented 3 years ago

If you have any feedback on the scope of the Nav 2 API usability project and the approach we are taking, please leave your comments below. I encourage you to check out the project's overview page, which might have answers to your questions. Thanks!

InMatrix commented 3 years ago

I'd like to clarify the intent and scope of the project, prompted by some questions in #4.

First, the primary goal of this project is to design or endorse an easy-to-use package for implementing common navigation patterns through usability research. While @johnpryan has prototyped the page_router package, we're not attached to a first-party solution. At the moment, it's mostly used as a tool for investigating where the sweet spot might be between API usability and flexibility. We are aware of several community efforts around simplifying Navigator 2.0, and a potential outcome of this project is to recommend one of them in a way similar to how the provider package became the recommended state management solution for relatively simple Flutter apps.

But we need to establish a standard to make sure whatever we recommend or choose to build ourselves meets the needs of Flutter users and strikes the right balance between usability and flexibility. This project is about establishing that standard and developing a method for measuring whether a package has reached that standard. The first step, which is discussed in this issue, is to define the set of navigation scenarios most Flutter developers would need and design the high-level API for these scenarios. The biggest question we're looking into right now is whether nested routing should be supported by a high-level navigation API or not, and how much supporting that could negatively impact the usability of the API in other scenarios such as deeplinking.

The scenario definition work drives the next few activities in our research and design process. We'd like to include a community package in our API usability studies alongside page_router, so we can understand how users react to different API design approaches. The results of those studies will be published in the wiki section of this repo. If you're an author of a navigation package, we'd love to get in touch and learn about your future plans.

Last, we recognize that usability was insufficiently considered in the design process of Navigator 2.0 and we'd like to develop a process for not only facilitating the simplification of Navigator 2.0 through high-level APIs but also aid future API design projects in Flutter.

slovnicki commented 3 years ago

Thank you for opening this discussion. I'm the creator of beamer and would like to contribute to this investigation.

Beamer was created during my development of a fairly large mobile/web app, trying to abstract the concepts from this article by John Ryan and an official design doc.

I ended up forming an idea of BeamLocation which represents the RouterDelegate.currentConfiguration. It holds everything related to path and is responsible for providing pages to the Navigator. The pages list is dynamically created, conforming to the rules the developer has set having examined the available path properties. Common usage is that the developer groups "related" Pages into a custom implementation of BeamLocation to which the user can then beamTo and the appropriate stack will be built. User can also update it's current stack via updateCurrentLocation.

The end result is having all the rules for app's routing in a single place and developer needs not to worry much about "navigation state". It ends up being similar to named routes, but now we have "named locations". There are also some additional features such as beamBack and guards.

The package is still in early stages and with lots of improvements planned, all of which can be monitored at issue board. I have made examples of some scenarios and they are available as gifs in README and as full code samples at example (the original books example) and examples.

Any contribution is welcome and I believe this discussion will be of great help for future work.

xuanswe commented 3 years ago

Hi,

First of all, thanks flutter team so much for listening to developers and making the next big change to Navigator API.

I am an Angular developer with some years of experience. Even I still have some issues with angular router in my projects, I love angular router a lot. With the experience when working with angular router, I checked all the navigation packages I found on pub.dev, but to be honest, I don't like all of them. I will definitely not use them for my projects. Therefore, last month, I started to create my own project to copy angular router design to flutter. I do not publish it yet, and will not publish it because now I believe flutter team will soon deliver a better official API.

So, I would like to give my feedback from what I learned when I tried to copy angular router design to flutter.

From my point of view, flutter could copy almost stuffs from angular router with improvements for usability and flexibility. Below are some improvements I am trying to do while copying angular router to flutter:

Below is my imagine of the way I want to configure routes for a normal app:

Router(
  initialRoute: '/public', // default should be `/`
  route: Route(
    '',
    interceptors: [AuthInjectionInterceptor(), HomeInterceptor()],
    children: [
      Route(
        'auth',
        builder: () => AuthPageWidget()
      ),
      Route(
        'public',
        builder: () => PublicPageWidget(),
        interceptors: [PublicInjectionInterceptor()]
      ),
      Route(
        'private',
        interceptors: [AuthInterceptor(), PrivateInjectionInterceptor()],
        children: [
          Route('', redirectTo: 'a'),
          Route('a', redirectTo: 'b', relative: true), // `a/b` does not match this route!
          Route('b/**', redirectTo: '/private/c', relative: false), // `b/c` matches this route!
          Route('c', builder: () => PrivateCWidget()),
          Route('d?/**/e*', builder: () => PrivateDEWidget()), // a complex ant path is useful here
          Route('**', builder: () => LastPrivateWidget()), // `/private/f/g/h` does not match all previous paths, show LastPrivateWidget()
        ],
      ),
      Route(
        '**',
        builder: () => const Center(
          child: Text('404 - Page Not Found!'),
        ),
      )
    ],
  )
)
class AuthInjectionInterceptor implements RouteInterceptor {
  // Normally, we should use an injection framework here. For example, I could choose to use GetIt with scopes.
  // before enter the route, inject AuthService instance
  // before exit the route (because we use this interceptor for the root route, it means exit app), destroy AuthService instance
}
class HomeInterceptor implements RouteInterceptor {
  // because, AuthInjectionInterceptor injected the AuthService in the root route, we can get the instance here. 
  // if unauthenticated => redirect to '/public', stop next interceptors
  // if authenticated => redirect to '/private', stop next interceptors
}
class AuthInterceptor implements RouteInterceptor {
  // because, AuthInjectionInterceptor injected the AuthService in the root route, we can get the instance here. 
  // if unauthenticated => redirect to '/auth', stop next interceptors
  // if authenticated => continue to run next interceptors => if passing all interceptors, allow to navigate to the desired route
}
class PrivateInjectionInterceptor implements RouteInterceptor {
  // Normally, we should use an injection framework here. For example, I could choose to use GetIt with scopes.
  // before enter the route, inject multiple required instances for private pages
  // before exit the route, destroy multiple required instances for private pages
}
idkq commented 3 years ago

Hi, many thanks for this, excited to make this better. Few general comments:

Regarding standards, in my opinion:

Hope it helps as a start.

lulupointu commented 3 years ago

Hi,

I am the developer of the vrouter plugin. My approach was based on Vue.js router.

The basic idea is to make a routing tree at the root of the application, describing your paths and the widgets which belong to each paths. I believe this is working very well in terms of simplicity and flexibility.

Here is a basic example:

VRouter(
  routes: [
    VStacked(
      path: '/login',
      widget: LoginWidget(),
    ),
    VStacked(
      path: '/profile',
      widget: ProfileWidget(),
      subroutes: [
        VStacked(
          path: 'settings', // This matches the path '/profile/settings'
          widget: ProfileWidget(),
        ),
      ],
    ),
    VRouteRedirector(
      path: ':_(.*)', // This matches any path
      redirectTo: '/login',
    )
  ],
)

As you can see, this makes for something very condensed. However there are a lot of other functionalities such as:

I created a website to explain how to use it, in hope to make it easy for anyone to use: vrouter.dev

agordeev commented 3 years ago

Many of us use flutter_bloc for state management. It'd be great if the docs include an example of how to properly use Navigator 2.0 with cubits/blocs.

Another good example of a very common scenario is Firebase Analytics integration. The default FirebaseAnalyticsObserver has a lot of limitations and can't be used in a real-world app.

It is also unclear how to present various modal pickers. What is the right way of doing this?

lulupointu commented 3 years ago

I created the 6 scenarios describes in Navigation scenario storyboards using vrouter.

I tried to produce minimal yet realistic implementation of the described situations. Please tell me if you want me to comment the code more than I did, or if I misunderstood any of the situations.

1.Deep Linking - Path Parameters ```dart import 'dart:math'; import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; main() { runApp( VRouter( routes: [ VStacked( path: '/home', widget: HomePage(), subroutes: [ VStacked( path: r'/book/:id(\d+)', // We match any id number widget: DetailPage(), ), ], ), VRouteRedirector(path: ':_(.*)', redirectTo: '/home'), ], ), ); } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Center(child: Text('Path Parameters')), ), body: ListView.builder( itemBuilder: (BuildContext context, int index) { final bookColor = Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0); return Container( color: bookColor, height: 50, child: TextButton( onPressed: () => VRouterData.of(context) .push('/book/$index', routerState: '${bookColor.value}'), child: Text('Book number $index'), ), ); }, ), ); } } class DetailPage extends StatefulWidget { @override _DetailPageState createState() => _DetailPageState(); } class _DetailPageState extends State { Color bookColor; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Path Parameters'), ), body: VNavigationGuard( afterEnter: (_, __, ___) => setState(() { bookColor = Color(int.tryParse(VRouterData.of(context).historyState)); }), child: Container( color: bookColor, child: Center( child: Text('This is book number ${VRouteData.of(context).pathParameters['id']}')), ), ), ); } } ```
2.Deep Linking - Query Parameters ```dart import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; main() { runApp( VRouter( routes: [ VStacked( path: '/products', widgetBuilder: (context) => ProductsPage( sortAlphabetically: VRouteData.of(context).queryParameters['sortAlphabetically'] == 'true'), ), VRouteRedirector(path: ':_(.*)', redirectTo: '/products'), ], ), ); } class ProductsPage extends StatelessWidget { final itemCount = 200; final bool sortAlphabetically; const ProductsPage({Key key, @required this.sortAlphabetically}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Center(child: Text('Query Parameters')), ), body: Column( children: [ TextButton( onPressed: () => VRouterData.of(context).pushReplacement('/products', queryParameters: {'sortAlphabetically': (!sortAlphabetically).toString()}), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('Click to sort'), Icon(sortAlphabetically ? Icons.arrow_downward : Icons.arrow_upward), ], ), ), Expanded( child: ListView.builder( itemCount: itemCount, itemBuilder: (context, index) { return Container( alignment: Alignment.center, decoration: BoxDecoration( border: Border(top: BorderSide(color: Colors.black)), ), height: 50, child: Text( 'Book number ${sortAlphabetically ? index : (itemCount - index - 1)}'), ); }, ), ), ], ), ); } } ```
3. “Dynamic Linking” ```dart import 'dart:math'; import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; class GameInfo { final String username; final int id; GameInfo({@required this.username, @required this.id}); } // This global variable is set yet we enter a game room GameInfo gameInfo; main() { runApp( VRouter( routes: [ VStacked( path: '/game', widget: GamePage(), subroutes: [ VStacked( path: r':id(\d+)', // Matches any digit name: 'gameRoom', widget: GameRoomPage(), // Before we enter a gameRoom, we check that the gameInfo is set // if not we redirect to "/game" beforeEnter: (vRedirector) async => (gameInfo == null) ? vRedirector.push('/game', routerState: vRedirector.newVRouteData.pathParameters['id']) : null, ) ], ), // Redirect any miss-typed url to "/game" VRouteRedirector(path: ':_(.*)', redirectTo: '/game'), ], ), ); } class GamePage extends StatefulWidget { @override _GamePageState createState() => _GamePageState(); } class _GamePageState extends State { // The username is whatever the user types in the TextFild String username; // The game id is gotten from the history state if any // This happens when the user goes back to the previous page int gameId; @override Widget build(BuildContext context) { return VNavigationGuard( // in afterEnter we try to get the gameId from the historyState afterEnter: (_, __, ___) => (VRouterData.of(context).historyState != null) ? setState(() => gameId = int.parse(VRouterData.of(context).historyState)) : null, child: Material( child: Column( children: [ TextField( decoration: InputDecoration( border: OutlineInputBorder(borderSide: BorderSide(color: Colors.teal)), ), onChanged: (username) => this.username = username, onSubmitted: (_) => (gameId == null) ? createGameRoom() : joinGameRoom(id: gameId), ), TextButton( onPressed: () => (gameId == null) ? createGameRoom() : joinGameRoom(id: gameId), child: Text((gameId == null) ? 'Create game room' : 'Join game room $gameId'), ), if (gameId == null && gameInfo != null) TextButton( onPressed: () => joinGameRoom(id: gameInfo.id), child: Text('Go back game room ${gameInfo.id}'), ), ], ), ), ); } // This is used when the user want to create a fresh new room void createGameRoom() { gameInfo = GameInfo(username: username, id: Random().nextInt(1000)); VRouterData.of(context).pushNamed('gameRoom', pathParameters: {'id': '${gameInfo.id}'}); } // This is used when the user want to join a game room which he has the game id void joinGameRoom({@required int id}) { gameInfo = GameInfo(username: username, id: id); VRouterData.of(context).pushNamed('gameRoom', pathParameters: {'id': '${gameInfo.id}'}); } } class GameRoomPage extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: Material( child: Text('Welcome ${gameInfo.username}, you are in the room number ${gameInfo.id}' '\nCopy the url to invite other to join!'), ), ); } } ```
4. “Skipping Stacks” ```dart import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; main() { runApp( VRouter( debugShowCheckedModeBanner: false, theme: ThemeData( textButtonTheme: TextButtonThemeData( style: ButtonStyle( padding: MaterialStateProperty.all(EdgeInsets.all(20.0)), backgroundColor: MaterialStateColor.resolveWith((states) => Colors.black), textStyle: MaterialStateProperty.all(TextStyle(fontSize: 16, color: Colors.white)), ), ), ), routes: [ VStacked( path: '/search', widget: SearchResultPage(), subroutes: [ VStacked( path: ':productName', name: 'productPage', widgetBuilder: (context) => ProductPage( product: allProducts.firstWhere( (product) => product.name == VRouteElementData.of(context).pathParameters['productName'], ), ), ), ], ), VStacked( // In parenthesis is a regExp which allows us to limit the productType // - This is less verbose than creating multiple route // - We can access the product type with the path parameter productType path: r'/:productType(book|shoes|pillow)', widgetBuilder: (context) => CategoryPage( products: allProducts .where((product) => productTypeToString(product.type) == VRouteElementData.of(context).pathParameters['productType']) .toList(), ), subroutes: [ VStacked( path: ':productName', name: 'productPage', widgetBuilder: (context) => ProductPage( product: allProducts.firstWhere( (product) => product.name == VRouteElementData.of(context).pathParameters['productName'], ), ), ), ], ), VRouteRedirector(path: ':_(.*)', redirectTo: '/search'), ], ), ); } // We do not implement a search bar but only the page on which a user would be // after he/she typed in a certain url class SearchResultPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Center(child: Text('Dynamic linking')), ), body: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 30.0, horizontal: 20), child: Align( alignment: Alignment.topLeft, child: Text( 'Here is the result for you search "stars":', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ), ), ProductList( products: allProducts.where((product) => product.name.contains("stars")).toList(), ) ], ), ); } } class ProductPage extends StatelessWidget { final Product product; const ProductPage({Key key, @required this.product}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Center(child: Text('Dynamic linking')), actions: [ IconButton( icon: Icon(Icons.search), onPressed: () => VRouterData.of(context).push('/search'), ) ], ), body: Hero( tag: product.name, child: Material( child: Container( color: productTypeColor(product.type), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Type: ${productTypeToString(product.type)}'), Text(product.name), SizedBox( height: 20, ), TextButton( onPressed: () => VRouterData.of(context).push('/${productTypeToString(product.type)}'), child: Text( 'See all ${productTypeToString(product.type)}', style: TextStyle(color: Colors.white), ), ), ], ), ), ), ), ), ); } } class CategoryPage extends StatelessWidget { final List products; const CategoryPage({Key key, @required this.products}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Center(child: Text('Dynamic linking')), actions: [ IconButton( icon: Icon(Icons.search), onPressed: () => VRouterData.of(context).push('/search'), ) ], ), body: ProductList( products: products, ), ); } } class ProductList extends StatelessWidget { final List products; const ProductList({Key key, @required this.products}) : super(key: key); @override Widget build(BuildContext context) { return Center( child: Wrap( spacing: 20, runSpacing: 20, alignment: WrapAlignment.center, children: products, ), ); } } class Product extends StatelessWidget { final String name; final ProductTypes type; const Product({Key key, @required this.name, @required this.type}) : super(key: key); @override Widget build(BuildContext context) { return InkWell( onTap: () => VRouterData.of(context).push(name), child: Hero( tag: name, child: Material( child: Container( height: 200, width: 200, color: productTypeColor(type), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Type: ${productTypeToString(type)}'), Text(name), ], ), ), ), ), ), ); } } enum ProductTypes { book, shoe, pillow } Color productTypeColor(ProductTypes itemType) { switch (itemType) { case ProductTypes.book: return Colors.greenAccent; case ProductTypes.shoe: return Colors.redAccent; case ProductTypes.pillow: return Colors.blueAccent; default: return Colors.black; } } String productTypeToString(ProductTypes itemType) { switch (itemType) { case ProductTypes.book: return 'book'; case ProductTypes.shoe: return 'shoes'; case ProductTypes.pillow: return 'pillow'; default: return 'unknown'; } } final bookProducts = [ Product(name: 'Book stars', type: ProductTypes.book), Product(name: 'old book', type: ProductTypes.book), Product(name: 'eBook', type: ProductTypes.book), ]; final shoesProducts = [ Product(name: 'Shoes of stars', type: ProductTypes.shoe), Product(name: 'Only One Shoe', type: ProductTypes.shoe), Product(name: 'Shoe fleur', type: ProductTypes.shoe), ]; final pillowProducts = [ Product(name: 'Best pillow', type: ProductTypes.pillow), Product(name: 'Pillow stars', type: ProductTypes.pillow), Product(name: 'This is not a pillow', type: ProductTypes.pillow), ]; final allProducts = [...bookProducts, ...shoesProducts, ...pillowProducts]; ```
5. Login/Logout/Sign-up Routing ```dart // Note that this example uses shared_preferences to persistently store the fact that one is logged in import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:vrouter/vrouter.dart'; main() { runApp( VRouter( debugShowCheckedModeBanner: false, theme: ThemeData( textButtonTheme: TextButtonThemeData( style: ButtonStyle( padding: MaterialStateProperty.all(EdgeInsets.all(20.0)), backgroundColor: MaterialStateColor.resolveWith((states) => Colors.black), textStyle: MaterialStateProperty.all(TextStyle(fontSize: 16, color: Colors.white)), ), ), ), // beforeEnter is called to check that we do not access something we are not supposed to beforeEnter: (vRedirector) async { if (vRedirector.to != '/login') { final sharedPreferences = await SharedPreferences.getInstance(); // If isLoggedIn is false, we are not logged in, so we get redirected if (!(sharedPreferences.getBool('isLoggedIn') ?? false)) vRedirector.push('/login'); } }, routes: [ VStacked( path: '/login', widget: LoginPage(), // If we are already connected, no need to go here so we redirect to "/home" beforeEnter: (vRedirector) async { final sharedPreferences = await SharedPreferences.getInstance(); if (sharedPreferences.getBool('isLoggedIn') ?? false) vRedirector.push('/home'); }, ), VStacked(path: '/home', widget: HomePage()), VRouteRedirector(path: ':_(.*)', redirectTo: '/home') ], ), ); } class LoginPage extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: TextButton( onPressed: () async { final sharedPreferences = await SharedPreferences.getInstance(); await sharedPreferences.setBool('isLoggedIn', true); VRouterData.of(context).push('/home'); }, child: Text('Click to connect!'), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Material( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Well done, you are connected!'), SizedBox(height: 20), TextButton( onPressed: () async { final sharedPreferences = await SharedPreferences.getInstance(); await sharedPreferences.setBool('isLoggedIn', false); VRouterData.of(context).push('/login'); }, child: Text('Click to logout!'), ), ], ), ), ); } } ```
6. Nested Routing ```dart import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; main() { runApp( VRouter( routes: [ VStacked( key: ValueKey('MyScaffold'), widget: MyScaffold(), subroutes: [ VChild( path: '/${HomePage.label}', name: HomePage.label, widget: HomePage(), ), VChild( path: '/${SchedulePage.label}', name: SchedulePage.label, widget: SchedulePage(), subroutes: [ VChild( path: '${MainViewTab.label}', name: '${MainViewTab.label}', widget: MainViewTab(), // This will never be used in practice ), VChild( path: '${KanbanViewTab.label}', name: '${KanbanViewTab.label}', widget: KanbanViewTab(), // This will never be used in practice ), ], // "/schedule" should never be accessed, if it is we redirect to "/schedule/main" beforeEnter: (vRedirector) async => vRedirector.pushReplacement('/${SchedulePage.label}/${MainViewTab.label}'), ), VChild( path: '/${BudgetPage.label}', name: BudgetPage.label, widget: BudgetPage(), ), VChild( path: '/${TeamPage.label}', name: TeamPage.label, widget: TeamPage(), ), ], ), VRouteRedirector(path: ':_(.*)', redirectTo: '/${HomePage.label}'), ], ), ); } class MyScaffold extends StatelessWidget { @override Widget build(BuildContext context) { return Material( child: Row( children: [ LeftNavigationBar(), Expanded(child: VRouteElementData.of(context).vChild), ], ), ); } } class LeftNavigationBar extends StatelessWidget { @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ LeftNavigationBarTextButton(label: HomePage.label), LeftNavigationBarTextButton(label: SchedulePage.label), LeftNavigationBarTextButton(label: BudgetPage.label), LeftNavigationBarTextButton(label: TeamPage.label), ], ); } } class LeftNavigationBarTextButton extends StatelessWidget { final String label; const LeftNavigationBarTextButton({Key key, @required this.label}) : super(key: key); @override Widget build(BuildContext context) { final isSelected = (VRouteElementData.of(context).vChildName == label); return TextButton( onPressed: () { if (!isSelected) VRouterData.of(context).pushNamed(label); }, style: ButtonStyle( alignment: Alignment.topLeft, padding: MaterialStateProperty.all(EdgeInsets.all(20.0)), ), child: Text( label, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: isSelected ? Colors.blueAccent : Colors.black, ), ), ); } } class HomePage extends StatelessWidget { static final label = 'home'; @override Widget build(BuildContext context) { return Center(child: Text('This is the $label page.')); } } class SchedulePage extends StatefulWidget { static final label = 'schedule'; @override _SchedulePageState createState() => _SchedulePageState(); } class _SchedulePageState extends State with SingleTickerProviderStateMixin { TabController tabController; @override void initState() { tabController = TabController(length: 2, vsync: this); super.initState(); } @override void dispose() { tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // We check the url to see if the current selected tab is the same as the one // in the url final tabBarViewIndex = VRouteElementData.of(context).vChildName == MainViewTab.label ? 0 : 1; if (tabBarViewIndex != tabController.index) tabController.animateTo(tabBarViewIndex); // We listen to the tabController and push a new route if the index changes tabController.addListener(() { if (tabBarViewIndex != tabController.index) VRouterData.of(context).pushReplacementNamed( (tabController.index == 0) ? MainViewTab.label : KanbanViewTab.label); }); return Column( mainAxisSize: MainAxisSize.min, children: [ Center(child: Text('This is the ${SchedulePage.label} page.')), Container( color: Colors.greenAccent, child: TabBar( controller: tabController, labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), labelPadding: const EdgeInsets.all(20.0), tabs: [ Center( child: Text(MainViewTab.label), ), Center( child: Text(KanbanViewTab.label), ), ], ), ), Expanded( child: TabBarView( controller: tabController, children: [ MainViewTab(), KanbanViewTab(), ], ), ) ], ); } } class MainViewTab extends StatelessWidget { static final label = 'main'; @override Widget build(BuildContext context) { return Center(child: Text('This is the $label tab view.')); } } class KanbanViewTab extends StatelessWidget { static final label = 'kanban'; @override Widget build(BuildContext context) { return Center(child: Text('This is the $label tab view.')); } } class BudgetPage extends StatelessWidget { static final label = 'budget'; @override Widget build(BuildContext context) { return Center(child: Text('This is the $label page.')); } } class TeamPage extends StatefulWidget { static final label = 'team'; @override _TeamPageState createState() => _TeamPageState(); } class _TeamPageState extends State { final List names = [ 'Liam', 'Olivia', 'Noah', 'Emma', 'Oliver', 'Ava', 'William', 'Sophia', 'Elijah', 'Isabella', 'James', 'Charlotte', 'Benjamin', 'Amelia', 'Lucas', 'Mia', 'Mason', 'Harper', 'Ethan', 'Evelyn' ]; String nameEditBeingShown; @override Widget build(BuildContext context) { final nameBeingEdited = VRouteData.of(context).queryParameters['edit']; if (nameBeingEdited != nameEditBeingShown) { if (nameBeingEdited == null) { Navigator.of(context, rootNavigator: true).pop(); } else { if (nameEditBeingShown != null) { Navigator.of(context, rootNavigator: true).pop(); } WidgetsBinding.instance.addPostFrameCallback((timeStamp) { showDialog( barrierDismissible: false, context: context, builder: (_) => Material( color: Colors.transparent, child: InkWell( onTap: () => VRouterData.of(context).push('/team'), child: Center( child: Container( padding: EdgeInsets.all(20.0), color: Colors.white, child: Text('You are editing $nameBeingEdited'), ), ), ), ), ); }); } nameEditBeingShown = nameBeingEdited; } return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 200), child: Column( children: [ Text('This is the ${TeamPage.label} page.'), Expanded( child: ListView( children: names .map( (name) => ListTile( title: Text(name), trailing: IconButton( icon: Icon(Icons.edit), onPressed: () { VRouterData.of(context) .push('/team', queryParameters: {'edit': name}); }, ), ), ) .toList(), ), ), ], ), ), ); } } ```

If you are tired of copy/pasting, you can also clone this repo

I hope to hear want you think !

InMatrix commented 3 years ago

@lulupointu That's awesome. Thank you so much. I played with vrouter yesterday and I found it really easy to get started. The way it handles nested routes feels easy to understand as well. The scenario implementations you shared will greatly help us conduct the comparative analysis we plan on doing next (#7).

jacobaraujo7 commented 3 years ago

Hello! flutter_modular use Navigator 2.0 as well. https://pub.dev/packages/flutter_modular/versions/3.0.0-nullsafety.45

xuanswe commented 3 years ago

Another idea is to use the way of Spring Framework MVC to handle routes with annotation. In the controller method, developer can decide to build a widget or redirect to another route.

The main advantage is that, path and query parameters could be handled immediately and passing to widgets safely. So widgets don't need to care how to handle these data from the current route. But I think with other approaches, we can also find a solution for this problem easily.

esDotDev commented 3 years ago

The one thing that seems missing from the proposal, and honestly you see this quite often in the Widget library itself, is lack of research into prior art. When you have things like React Router, in it's 4th iteration, after years and years and thousands of real world apps, we should borrow from their insight!

esDotDev commented 3 years ago

Looking at the latest implementation of React Router (v4) 2 things stand out immediately:

  1. It is self-contained, not bundled inside an App components, using 3 delegates to do everything. This means it is very simple to grok.
  2. Within 5m, you have the urlBar changing, a route rendering, deep-linking and back/forward support and there is virtually no way to mess it up. This is night and day in comparison to the Flutter approach which is full of edge cases and potential failure pts.

In terms of dev experience, this is vastly more focused, and easier to understand: https://reactrouter.com/

If flutter can get anywhere close to this ease of use, I'm sure developers will be extremely pleased.

xuanswe commented 3 years ago

I think the approach of react router is the most similar to the way of flutter widget. It worth to check it. IMO, react router is much more powerful than the way of angular and vue.

TahaTesser commented 3 years ago

@InMatrix We just triaged a proposal https://github.com/flutter/flutter/issues/77360, we like to know if it possible or could be implemented in the future Thanks!

slovnicki commented 3 years ago

@TahaTesser

It's definitely possible and in fact quite natural in Navigator 2.0, as the developer has control over Navigator's entire stack of pages.

InMatrix commented 3 years ago

@TahaTesser This is a good example of the Skipping Stacks (#5) use case. We probably should consider incorporating that in our scenario deck. Cc: @jackkim9

HosseinYousefi commented 3 years ago

Hey everyone!

I'm the author of Yeet. I'm focusing on 1. Keeping things extremely simple for first time developers (something that just works!) and 2. Treating web as a first class citizen.

I'm currently teaching a 3 week course on flutter and spending 2 days to really teach something like navigator 2.0 is not a viable option for beginners.

My solution was to create something that feels like yet another tree (we're using flutter after all). I use a simple depth first search to find the first match and populate the page stack using that. It's still quite experimental but the main functionalities do work.

rrousselGit commented 3 years ago

There are use-cases that I think are not covered by the current examples:

InMatrix commented 3 years ago

@rrousselGit These are great considerations. Some of them came up in earlier discussions, especially with the regard to the interplay between routing and state management. We decided to keep it simple in this evaluation, but routing API designers should take the issues you raised into consideration. I'd love to see responses to your questions from package authors.

esDotDev commented 3 years ago

Of these 4 issues, 1 of them is outside the hands of package authors, which is preloading an off-stage widget or routes. Flutter needs better support in general for keeping things alive, or pre-cached when off stage. Short of using IndexedStack, it's cumbersome to keep anything alive in Flutter once it goes off screen, (or before) which is a mobile-first optimization that really has no place on Desktop.

The other 3 issues all point to why state-first navigation is a really bad idea. When you have 2 sources of truth (url path OR state), it gets very messy very fast. Because web urls are a fact of life, we can not get away from them, which means you either choose URL-first approach, or you have to deal with the huge headache that is reconciling state and url.

It becomes clear very quickly in production, that state needs to go, and URL-first is the only sane path forward.

It reminds me of 10yrs ago, before unidirectional app state caught on. It was never exactly clear who was changing the state. Eventually everyone got on page that this was a big headache and mess, and agreed that state-changes should be top down, with the views binding to the models but not changing them directly.

I think it would be a good idea at some point, for the Flutter team to take a step back, understand that state-first nav is actually not a good approach for the majority of apps, and maybe engineer a URL-first approach that is more robust and flexible than Nav 1.

Basically if we have to be on web and have URL's (which we do) then just follow the imperative web-like navigation pattern that is well understood and robust.

rrousselGit commented 3 years ago

Of these 4 issues, 1 of them is outside the hands of package authors, which is preloading an off-stage widget or routes. Flutter needs better support in general for keeping things alive, or pre-cached when off stage.

I disagree

For example with state-first approach, we could potentially support:

SomeNavigator(
  routes: [
     if (page != null) ...[
       MyRoute(path: '/book/$selectedBookId/page/$page'),
       Offstage(child: MyRoute(path: '/book/$selectedBookId/page/${page + 1}')),
     ],
  ]
)
rrousselGit commented 3 years ago

I would also not call url-first as "imperative"

For me the imperative APIs in Navigator 1 are not "pushNamed" functions but rather:

This is in opposition to things like MaterialApp.routes/MaterialApp.home, where you can rebuild the MaterialApp widget with different routes/home

esDotDev commented 3 years ago

Of these 4 issues, 1 of them is outside the hands of package authors, which is preloading an off-stage widget or routes. Flutter needs better support in general for keeping things alive, or pre-cached when off stage.

I disagree

For example with state-first approach, we could potentially support:

SomeNavigator(
  routes: [
     if (page != null) ...[
       MyRoute(path: '/book/$selectedBookId/page/$page'),
       Offstage(child: MyRoute(path: '/book/$selectedBookId/page/${page + 1}')),
     ],
  ]
)

Sure, but this is not very scalable and will quickly turn into a mess. It's much simpler and more flexible to just give MyRoute someway it can cache the next route, and then show that cached route when it needs. But flutter doesn't support this as everything needs to be 'on-stage' before it can be initialized. In AIR, or Unity, I could simply construct my new page, and it would begin loading data, I could then toss it on stage whenever I want.

Though, to be fair, probably more effective anyways to preload the data that the view needs, and then when the view loads in, there is nothing to fetch. model.loadPageData(page + 1)

esDotDev commented 3 years ago

For me the imperative APIs in Navigator 1 are not "pushNamed" functions but rather:

I think you're getting far away from the classic definition of imperative here but this is semantics. I just mean that routes are explicitly navigated to via a function (like pushNamed), rather than implicitly shown based on whatever the current state of the application is, which does not scale well at all, and provides no solution for routes that are not tied to state, you end up with cruft like appModel.isSettingsPageOpen and inevitably clash as you add more and more of these flags.

idkq commented 3 years ago

The other 3 issues all point to why state-first navigation is a really bad idea.

I agree but I would say that much more cons to state navigation exist than reconciling state and URL. By state navigation I mean having several variables controlling which screen should be rendered and be presented as the active screen. The variables are inter-related and inter-dependent. For example if isLoggedIn is false present login screen, if searchingProduct is not null present Search screen. But if isLoggedIn is true and searchingProduct is not null, present Home Screen.

Is this easy to maintain? No.

State navigation is preposterous in 90% of the cases. State navigation is a conditional stacked navigation, that is, the navigation and screen is rebuild based on a series of 'if' conditions.

How many variables do you need to determine which screen is active? Only one. Not one per screen. One can argue that the existing state variables used for other purposes are being re utilized for navigation but, no, that is not a benefit.

Let's compare it to an useful state utilization. Say you have a Switch that filters some results. The state of the switch can modify many aspects of that screen including the color of the switch itself. The switch state is binary but the widget tree can change significantly based on the state.

Going back to navigation the process is similar but useless. The change of navigation state most of the times occur when an event is triggered (press button etc). Like the switch, the screen is either presented or not (other screen in the stack would be presented). The problem here is that absolutely nothing depends on the state of the navigation in 90% of the cases. The entire screen is assumed to be active for any widgets to be shown. The navigation state is never consumed for any other means. It is a waste of time for over engineering it.

So in summary, state navigation uses many more variables than necessary, makes the code hard to maintain, makes state available but for no widget to consume, and requires syncing with URL.

johnpryan commented 3 years ago

The original snippets written for Learning Flutter's new navigation and routing system used a state-driven approach, which is probably not the best-practice for routing, as is being discussed.

There's a new sample in the flutter/samples repo, navigation_and_routing, which demonstrates a general-purpose way to use the current URL path as the app state for navigation.

idkq commented 3 years ago

@johnpryan Excellent job! I really like this way better. Loved it this complete example and implementation overall. Kudos!

Here is what I don't like (no criticism to the code whatsoever, but conceptually, in line with the previous discussion on variables and state manage):

On BookstoreNavigator, selectedBook and selectedAuthor are driving the navigation stack: it dictates which screens will be stacked - this makes no sense to me. First of all, there is no 'selected' book or author concept. Instead, the user is simply 'viewing' a book page or author page. The difference is important because if we treat every (or some) page-parameters as conceptual active/inactive object, things can get messy.

Secondly, the only consumers of selectedBook and selectedAuthor are their respective screens, similarly to a single web-page request, this should be scoped down, and not up. Why do we need to parse and use it in the navigator? We need it only to put the MaterialPage into the stack. So, selectedBook and selectedAuthor are not being used anywhere else in the app except their own screens. It means that as the app grows it would have several useless variables and ifs in this BookstoreNavigator class for no reason. If only BookDetailsScreen cares about param book, it should be limited to that scope and not other parts of the app.

An argument can be made that another process could change the state of that variable hence it would activate the screen. But this is not what is happening in this app and it is an unlikely use case overall. As an analogy, you are currently reading this GitHub webpage. Changing the state of a parameter of another webpage does not rebuild your navigation stack.

A better approach in my view would be the navigation stack be built based on the active RouteStateScope path hierarchy. Think of it as a Site Map structure. For example:

For that to happen a linkage of some sort would need to be implemented, such as 'parent' for each page. So that when pop() is used it moves to the 'parent' route.

That could also be used on the outer Navigator in BookstoreScaffoldBody to replace the ifs. In case there are no parents, show the main view.

Please lmk if I'm missing something... I just can't see a use case in which bringing these variables up to the widget tree is applicable unless rare cases of monitoring, services and sensors.

slovnicki commented 3 years ago

@idkq I agree with your points. Have you seen how beamer works?

The scoping of navigation variables for use cases in large applications is exactly what beamer aims to solve with the concept of BeamLocations. The process looks something like this:

After this is completed and we are at /books/2 with a page stack of [BooksScreen, BookDetailsScreen], we can navigate

Here, the information about "selected book" or "selected article" is localized within the appropriate BeamLocation; above just the relevant page stack, and not above everything. The same can be done for scoping certain additional Providers and such (e.g. CartProvider for all shop-related page stacks), but this is just an additional feature of BeamLocations, not related to navigation.


As for your idea of "1 path segment = 1 page", this is the default behavior of beamer, but can also be overridden if developer needs. When creating a navigation package, some default behavior needs to be picked and this is, in my opinion also, the most sensible one.


NOTE: Some of my statements above might be confusing (especially regarding the types I mention) if compared to the current (v0.14.1) README of beamer. This is because I'm in the middle of implementing these improvements. Basically

idkq commented 3 years ago

@slovnicki I like that beamer builds the page stack purely on the URI info.

Do you see a valid scenario in which the page stack structure changes at runtime? If not, I believe BeamLocation could be further simplified (perhaps a new SimpleBeamLocation?) that does not have the ifs such as if (state.uri.pathSegments.contains('books')). Just an idea.

The build method of the super class would be in charge of determining which pages to put or remove (not put) in the stack.

@override
  List<BeamPage> buildPages(BuildContext context, BeamState state) => [
        BeamPage(
          key: ValueKey('home'),
          child: HomeScreen(),
          path: '/'
        ),
        BeamPage(
          key: ValueKey('books'),
          child: BooksScreen(),
          path: '/books'
        ),
        BeamPage(
          key: ValueKey('book-${state.pathParameters['bookId']}'),
          child: BookDetailsScreen(
            bookId: state.pathParameters['bookId'],
            ),
          path: '/books/:bookid'
      ];

The buildPages would loop trough all BeamPage list and remove the ones not needed and keep others based on BeamPage.path match with current URI. Maybe key could be used for path url if you don't want to create a new attribute.

One problem is when you have a bifurcation, such as '/' -> '/books' and '/' -> '/authors'. For that perhaps use a new BeamLocation or create a new class BeamPageBifurcation that takes a list of BeamPage.

It is a minor thing but just for maintenance leaving the URI resolving at the backstages as opposed to be controlled by the developer.

Potentially even path attribute could be omitted. The logic would be to match the number/count of BeamPage to the level of the URI. For example, '/' = 1, '/xxxx' = 2, '/xxxx/yyyy' = 3 (count of forward slashes plus one if any text). Not sure for readability though.

@override
  List<BeamPage> buildPages(BuildContext context, BeamState state) => [
        BeamPage(  //resolves from '/'
          key: ValueKey('home'),
          child: HomeScreen(),
        ),
        BeamPage( //resolves from '/books'
          key: ValueKey('books'),
          child: BooksScreen(),
        ),
        BeamPage( //resolves from '/books/:bookId'
          key: ValueKey('book-${state.pathParameters['bookId']}'),
          child: BookDetailsScreen(
            bookId: state.pathParameters['bookId'],
            ),
      ];
slovnicki commented 3 years ago

@idkq When you say "SimpleBeamLocation", are you referring to the SimpleBeamLocation that is a part of beamer? This special BeamLocation is used by SimpleLocationBuilder that does exactly what you describe; determines the appropriate BeamPages to put into stack by examining path segments (+ / as first page)

There of course are theoretical scenarios that really require customizing and changing the page stack differently than "path segment = page", so it's good to have flexibility. For example, depending on some context or query parameter, you may wish to show just the top-most page for some "deep URI", or even show a different page for the same path segment string. But from my and users experience, most applications follow the "path segment = page" idea and most users really just use SimpleLocationBuilder.

idkq commented 3 years ago

@slovnicki Got it, exactly.. you are ahead on the game! Just saw in your example here https://github.com/flutter/uxr/blob/master/nav2-usability/scenario_code/lib/deeplink-pathparam/deeplink_pathparam_beamer.dart

johnpryan commented 3 years ago

On BookstoreNavigator, selectedBook and selectedAuthor are driving the navigation stack: it dictates which screens will be stacked - this makes no sense to me. First of all, there is no 'selected' book or author concept. Instead, the user is simply 'viewing' a book page or author page. The difference is important because if we treat every (or some) page-parameters as conceptual active/inactive object, things can get messy.

The if (selectedBook != null) statement could be replaced with if (pathTemplate == '/book/:bookId') - would that make more sense?

Secondly, the only consumers of selectedBook and selectedAuthor are their respective screens, similarly to a single web-page request, this should be scoped down, and not up. Why do we need to parse and use it in the navigator? We need it only to put the MaterialPage into the stack. So, selectedBook and selectedAuthor are not being used anywhere else in the app except their own screens. It means that as the app grows it would have several useless variables and ifs in this BookstoreNavigator class for no reason. If only BookDetailsScreen cares about param book, it should be limited to that scope and not other parts of the app.

You could certainly make BookDetailsScreen and AuthorDetailsScreen obtain the selected book / author, instead of having BookstoreNavigator provide them.

An argument can be made that another process could change the state of that variable hence it would activate the screen. But this is not what is happening in this app and it is an unlikely use case overall. As an analogy, you are currently reading this GitHub webpage. Changing the state of a parameter of another webpage does not rebuild your navigation stack.

I don't think this was the reasoning. The reason it works this ways is that AuthorDetailsScreen requires an Author, so someone needs to provide that. It just so happens that BookstoreNavigator is building the AuthorDetailsScreen. Of course AuthorDetailsScreen could obtain the selected author based on the route ID obtained by routeState.route.parameters['authorId'] and then obtain the selected author from LibraryScope.of(context).allAuthors, but as it's currently written the AuthorsScreen is decoupled from the navigation state.

A better approach in my view would be the navigation stack be built based on the active RouteStateScope path hierarchy. Think of it as a Site Map structure.

This is what packages like vrouter, beamer, and auto_route do.

For that to happen a linkage of some sort would need to be implemented, such as 'parent' for each page. So that when pop() is used it moves to the 'parent' route. That could also be used on the outer Navigator in BookstoreScaffoldBody to replace the ifs. In case there are no parents, show the main view.

I'm not sure what you mean by this. Are you talking about how onPopPage would be implemented?

idkq commented 3 years ago

The if (selectedBook != null) statement could be replaced with if (pathTemplate == '/book/:bookId') - would that make more sense?

Yes. But I rather have it done automatically by another class and getting rid of the conditions entirely. So it can be reused in any app.

Here's an example https://github.com/idkq/samples -> navigation_and_routing

Take a look at the newly created class SimpleNavigator. Few comments:

The reason it works this ways is that AuthorDetailsScreen requires an Author, so someone needs to provide that.

Yeah, in the example I pasted, the AuthorDetailsScreen would require an Author in the constructor even if not being used, which led to a hack as you can see in the example.

johnpryan commented 3 years ago

Take a look at the newly created class SimpleNavigator.

Most of the higher-level packages that were studied abstract away the Page objects too, not just the Navigator (beamer uses BeamPage and vrouter uses VWidget for example). Something to consider, since using the key needs to match the route as currently written.

Yeah, in the example I pasted, the AuthorDetailsScreen would require an Author in the constructor even if not being used, which led to a hack as you can see in the example.

This is why its better to only build the page objects when they are needed. In your version, each page is being constructed because the if (selectedBook) statements were removed.

idkq commented 3 years ago

@johnpryan It was a quick (and honestly dirty) example I posted, just to demonstrate. Here is a better updated version. https://github.com/idkq/samples - no keys dependency, instead abstracting Page.

All logic is automatic and behind the scenes. No need for the ifs and stacking manually. So the user code is cleaner and easier to maintain as the app grows.

StackingLogic can be subclassed and customized to handle complex cases. In this demo, there aren't any L2+ multi level stacks but in the real world almost certainly there are. Here is what I mean by multi level stack:

Here is the entire app Tree Map:

There are two Navigators: Outer / Inner Navigators

The most we have is two stacked screens in one nav on BooksDetail and AuthorDetail. Still they are not really two levels because scaffold is always present by default. No logic is needed to decide to show/not show a scaffold. So for this demo, stacking is straightforward.

A 'truly two levels' would be (three levels in total), for example:

Here '/author/:authorId/profile' must be stacked after '/author/:authorId'. When popped '/author/:authorId/profile' must return to '/author/:authorId'. To automate I see two ways/approaches (open to suggestions on other approaches):

SimpleNavigator requires a StackingLogic class in which it delegates the stacking algo.

SIDE NOTE: when using push & link, things are not working properly on the original code. I'm not sure if that use intentionally to show that push does not have the Route info or if it is a bug. Reproduce:

  1. In Books -> Click 'Left Hand...' book0
  2. Click View Author Link
  3. Click 'Left Hand...' book0 again
  4. Click View Author Link again

Link does not work. This happen because in AuthorDetailsScreen you uses push. When mixing push and link the app does not behave correctly.

MateusAmin commented 3 years ago

Maybe out of place, but how would one use snack bars with the navigation?

A common scenario is you confirm your email after signup by following a link back the app. At that point I notify the person that their email is verified and redirect them to the authenticated app page.

Notifying with a snack bar seems most appropriate. You don't need them to take and action and want to get the started ASAP.

But doing it in nav 2.0 does not seem straight forward. Single data point; I haven't figured it out in a few hours spent.

EDIT 1: Obvously the snackbar messenger seems key but with MaterialApp.router how does one access a build context with materialapp in the routePath method? I guess I will just make another one that is accessible....

EDIT 2: Awkward:

  final delegate = Delegate();

  runApp(
    MaterialApp.router(
      builder: (context, child) {
        delegate.context = context;
        return child!;
      },
      title: 'MyApp',
      routerDelegate: delegate,
      routeInformationParser: Parser(),
    ),
  );
oodavid commented 3 years ago

Regarding pre-loading the next screen, are we mostly interested in pre-rendering that screen, or making sure the data has been loaded?

Rendering should be pretty snappy, so if it's data, then my issue / question about inherited data for child routes might have some overlap:

https://github.com/flutter/uxr/issues/72

InMatrix commented 3 years ago

Closing this issue, since the research has completed.