slovnicki / beamer

A routing package built on top of Router and Navigator's pages API, supporting arbitrary nested navigation, guards and more.
MIT License
584 stars 128 forks source link

[BottomTabNavigation + deep link into nested route inside tab] Q: How can I ensure state of other tabs is preserved? #596

Open annawidera opened 1 year ago

annawidera commented 1 year ago

Hey @slovnicki ! Thank you for the fantastic navigation package! I've tried a couple of them already 🙈 and Beamer appears to have a well-thought-through structure. It also has the balance between automating things and not being a pure 🔮 black box. I also appreciate an extensive catalogue of examples. I found a few solutions for the navigation I need: the bottom tab bar.

Background: requirements

preserving tabs state

An essential requirement, in my case, is to preserve the state of the tabs (their nested navigation) while switching between them. So I based my experiments on bottom_navigation_multiple_beamers example.

deep linking to nested pages

Another feature that we need is deep linking that navigates to the nested pages in the tabs (like for example books/2 or articles/3).

Changes to the example

I added deep link support by adding the following:

            <!-- Deep linking -->
            <meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="bmr" android:host="beamer.dev" />
            </intent-filter>
        </activity>

to the examples/bottom_navigation_multiple_beamers/android/app/src/main/AndroidManifest.xml.

Full AndroidManifest.xml content ``` XML ```

Current behaviour

On Android, when sending an intent from the terminal:

adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "bmr://beamer.dev/books/1"'

Makes the first tab (Books) present the Details page with the 1st book. 👍🏻 Unfortunately, the state of the other tab (Articles) is lost. 👎🏻

What I have already found

So far, I discovered that it happens becase the intent /books/1 is sent to the main Beamer, and it passes it (?) down to both BeamerDelegates (that are part of the AppScreenState):

  final routerDelegates = [
    BeamerDelegate(
      initialPath: '/books',
      locationBuilder: (routeInformation, _) {
        if (routeInformation.location!.contains('books')) {
          return BooksLocation(routeInformation);
        }
        print("Books, not found");
        return NotFound(path: routeInformation.location!);
      },
    ),
    BeamerDelegate(
      initialPath: '/articles',
      locationBuilder: (routeInformation, _) {
        if (routeInformation.location!.contains('articles')) {
          return ArticlesLocation(routeInformation);
        }
        print("Articles, not found");
        return NotFound(path: routeInformation.location!);
      },
    ),
  ];

Both locationBuilders are called. For /books/1, the first BeamerDelegate shows the detail page, but the second one has to return NotFound(path: routeInformation.location!); and its state is lost.

Expected behaviour + question

How can I ensure that only relevant BeamerDelegate's locationBuilder is called when handling a deep link intent? In the case of my example, this would be BeamerDelegate for /books.

Shall I try another configuration, and specify the tabs' routes in the top-level routerDelegate?

late final routerDelegate = BeamerDelegate(
    initialPath: initialDeepLink ?? '/books',
    locationBuilder: RoutesLocationBuilder(
      routes: {
        '*': (context, state, data) => AppScreen(),
       // 'books` => ???
       // 'articles' => ???
      },
    ),
  );

Please let me know if I can provide any further details! Thanks for any advice!

annawidera commented 1 year ago

Update: I discovered that using uni_link for reading in the deep link intent + opting out from flutter_deeplinking_enabled made it possible to orchestrate the traffic between tabs.

<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
class _AppScreenState extends State<AppScreen> {
  late int currentIndex;
  StreamSubscription? _sub;

  @override
  void initState() {
    super.initState();

    // listening to links stream from uni_links was added
    _sub = uriLinkStream.listen(
      (Uri? uri) {
        if (uri != null) {
          // Handcrafted uri parsing, and handling forwarded to the **right** delegate 
          if (uri.path.contains('books')) {
            routerDelegates[0].beamToNamed(uri.path);
            setState(() => currentIndex = 0);
          } else if (uri.path.contains('articles')) {
            routerDelegates[1].beamToNamed(uri.path);
            setState(() => currentIndex = 1);
          }

        }
      },
      onError: (err) {
        print("uriLinkStream error! $err");
        // Handle exception by warning the user their action did not succeed
      },
    );
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  // exactly as they were in example: 'bottom_navigation_multiple_beamers'
  final routerDelegates = [
    BeamerDelegate(
      initialPath: '/books',
      locationBuilder: (routeInformation, _) {
        if (routeInformation.location!.contains('books')) {
          return BooksLocation(routeInformation);
        }
        return NotFound(path: routeInformation.location!);
      },
    ),
    BeamerDelegate(
      initialPath: '/articles',
      locationBuilder: (routeInformation, _) {
        if (routeInformation.location!.contains('articles')) {
          return ArticlesLocation(routeInformation);
        }
        return NotFound(path: routeInformation.location!);
      },
    ),
  ];

  @override
  void didChangeDependencies() {
    // exactly as in example: 'bottom_navigation_multiple_beamers'
  }

  @override
  Widget build(BuildContext context) {
    // exactly as in example: 'bottom_navigation_multiple_beamers'
  }
}

Is this the way to do deep linking with multiple Beamers on Android? On iOS, I didn't have to opt-out from FlutterDeepLinkingEnabled to make this work even without uni_links and manual URI parsing and picking the right routerDelegate for the deep link to handle.

Does anybody know where the difference is between delivering the deep links when FlutterDeepLinkingEnabled is enabled on iOS and Android? How may this confuse Beamer?

dleurs commented 1 year ago

Thank you that solved my problem ! Only difference is that I am using Cubit instead of Stateful widget to update index

slovnicki commented 1 year ago

Hey @annawidera :wave: Thanks for creating a wonderfully elaborated issue and sorry for my absence lately. Also, thanks for the kind words :slightly_smiling_face:

This is a good solution. Nevertheless, working with tabs often causes confusion because they are tricky from routing perspective. That's why I'm planing to add additional support for tab control - #618

Another thing I can additionally suggest for preserving the state of tabs while the app is running and receives the link is the following:

Instead of

 routes: {
        '*': (context, state, data) => AppScreen(),
      },

we can do

 routes: {
        '*': (context, state, data) => BeamPage(
          key: ValueKey('app'),
          child: AppScreen(),
        ),
      },

which will prevent the rebuild of the entire app and therefore losing what the state of tabs (scroll, navigation, ...). This works because Navigator will only rebuild the page if the key has changed. If we keep it constant, then we will never rebuild AppScreen, but will react to routes because we have listeners on root Beamer.