csells / go_router

The purpose of the go_router for Flutter is to use declarative routes to reduce complexity, regardless of the platform you're targeting (mobile, web, desktop), handling deep linking from Android, iOS and the web while still allowing an easy-to-use developer experience.
https://gorouter.dev
441 stars 97 forks source link

Nested navigation, and push scenarios #270

Closed Miiite closed 2 years ago

Miiite commented 2 years ago

Hello,

I am facing a nested navigation scenario that requires me to "push" child pages, instead of using GoRouter.go. The children widgets (equivalent to the Person widget in the example project) are used in multiple pages, and are unaware of the current context. So it requires me to push the widget onto the navigation stack, no matter where the user is right now in the app.

So if I update the nested_navigation.dart sample file with this kind of changes, it looks like this:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import 'shared/data.dart';

void main() => runApp(App());

class App extends StatelessWidget {
  App({Key? key}) : super(key: key);

  static const title = 'GoRouter Example: Nested Navigation';

  @override
  Widget build(BuildContext context) => MaterialApp.router(
        routeInformationParser: _router.routeInformationParser,
        routerDelegate: _router.routerDelegate,
        title: title,
      );

  late final _router = GoRouter(
    routes: [
      GoRoute(
        path: '/',
        redirect: (_) => '/family/${Families.data[0].id}',
      ),
      **GoRoute(
        path: '/person/:pid',
        builder: (context, state) {
          final family = Families.family(state.params['fid']!);
          final person = family.person(state.params['pid']!);

          return PersonScreen(family: family, person: person);
        },
      ),**
      GoRoute(
        path: '/family/:fid',
        builder: (context, state) => FamilyTabsScreen(
          key: state.pageKey,
          selectedFamily: Families.family(state.params['fid']!),
        ),
        routes: [
          GoRoute(
            path: 'person/:pid',
            builder: (context, state) {
              final family = Families.family(state.params['fid']!);
              final person = family.person(state.params['pid']!);

              return PersonScreen(family: family, person: person);
            },
          ),
        ],
      ),
    ],

    // show the current router location as the user navigates page to page; note
    // that this is not required for nested navigation but it is useful to show
    // the location as it changes
    navigatorBuilder: (context, child) => Material(
      child: Column(
        children: [
          Expanded(child: child!),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Text(_router.location),
          ),
        ],
      ),
    ),
  );
}

class FamilyTabsScreen extends StatefulWidget {
  FamilyTabsScreen({required Family selectedFamily, Key? key})
      : index = Families.data.indexWhere((f) => f.id == selectedFamily.id),
        super(key: key) {
    assert(index != -1);
  }

  final int index;

  @override
  _FamilyTabsScreenState createState() => _FamilyTabsScreenState();
}

class _FamilyTabsScreenState extends State<FamilyTabsScreen>
    with TickerProviderStateMixin {
  late final TabController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TabController(
      length: Families.data.length,
      vsync: this,
      initialIndex: widget.index,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  void didUpdateWidget(FamilyTabsScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    _controller.index = widget.index;
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: const Text(App.title),
          bottom: TabBar(
            controller: _controller,
            tabs: [for (final f in Families.data) Tab(text: f.name)],
            onTap: (index) => _tap(context, index),
          ),
        ),
        body: TabBarView(
          controller: _controller,
          children: [for (final f in Families.data) FamilyView(family: f)],
        ),
      );

  void _tap(BuildContext context, int index) =>
      context.go('/family/${Families.data[index].id}');
}

class FamilyView extends StatefulWidget {
  const FamilyView({required this.family, Key? key}) : super(key: key);
  final Family family;

  @override
  State<FamilyView> createState() => _FamilyViewState();
}

/// Use the [AutomaticKeepAliveClientMixin] to keep the state, like scroll
/// position and text fields when switching tabs, as well as when popping back
/// from sub screens. To use the mixin override [wantKeepAlive] and call
/// `super.build(context)` in build.
///
/// In this example if you make a web build and make the browser window so low
/// that you have to scroll to see the last person on each family tab, you will
/// see that state is kept when you switch tabs and when you open a person
/// screen and pop back to the family.
class _FamilyViewState extends State<FamilyView>
    with AutomaticKeepAliveClientMixin {
  // Override `wantKeepAlive` when using `AutomaticKeepAliveClientMixin`.
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    // Call `super.build` when using `AutomaticKeepAliveClientMixin`.
    super.build(context);
    return ListView(
      children: [
        for (final p in widget.family.people)
          ListTile(
            title: Text(p.name),
            onTap: () => **context.push('/person/${p.id}')**,
          ),
      ],
    );
  }
}

class PersonScreen extends StatelessWidget {
  const PersonScreen({required this.family, required this.person, Key? key})
      : super(key: key);

  final Family family;
  final Person person;

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: Text(person.name)),
        body: Text('${person.name} ${family.name} is ${person.age} years old'),
      );
}

The only things that changed, is that now I push the Person page on click, and therefore I declared a /person/xx route in the Router declaration.

The issue with that scenario is that, when the user swipes left and right in the TabBar, the index of the current tabbar changes, but GoRouter is unaware of those changes, so the URL stays the same.

It looks like we are missing a way of "synchronising" the GoRouter URL with the currently displayed screen. In my scenario, I would need a way to tell GoRouter that the URL is now family/f2 when the user swipes right.

My first reaction was to add a GoRouter.go call, when the user swipes, by reacting to the Controller events. But If I do that, when I will trigger "deeplink" navigation by calling for example context.go('/family/f3/person/p2'), then after this URL will be interpreted and the screen loaded, the family's page Controller will trigger a value change, so I will trigger a GoRouter.go call to try to synchronise my screen's state with GoRouter's state, and that will make me go back to /family/f3.

It's not necessarily an easy problem to describe, so I hope I am making sense here.

Thanks for the help !

noga-dev commented 2 years ago

Remove tabBar's onTap and add a listener to the controller itself. Preserve the state of the views by reusing the keys in the routes.

Ideally GoRoute should have the option to override the current location instead of forcing us to go through the primitive, potentially buggy and boilerplate-y, route of navigating and preserving states. @csells can we introduce a 'overrideCurrentLocation(string newRoute)'?

Miiite commented 2 years ago

Remove tabBar's onTap and add a listener to the controller itself. Preserve the state of the views by reusing the keys in the routes.

Ideally GoRoute should have the option to override the current location instead of forcing us to go through the primitive, potentially buggy and boilerplate-y, route of navigating and preserving states. @csells can we introduce a 'overrideCurrentLocation(string newRoute)'?

I don't think adding à listener to the controller is solving anything, I might be missing something, but I think it introduces its own set of unwanted behaviors when calling the "go" method.

But I like the idea of overriding the current location a lot.

csells commented 2 years ago

context.push() and nested navigation should work just fine together. Check out the books example.