Skip to content

Conversation

@maRci002
Copy link
Contributor

@maRci002 maRci002 commented Jan 11, 2024

This pull request introduces Android's predictive back feature for routes. By leveraging the OnBackAnimationCallback API, this implementation gathers real-time information about back gestures, enabling the framework to dynamically update route transition animations. The callbacks for this feature are forwarded to the framework as detailed in flutter/engine#49093 PR.

Here's a brief video demo showcasing the Back Preview animation.

predictive_back.mp4
Code sample
import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        brightness: Brightness.light,
        pageTransitionsTheme: const PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
          },
        ),
      ),
      home: const FirstScreen(),
    );
  }
}

class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('First Screen'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(builder: (_) => const SecondScreen()),
            );
          },
          child: const Text('Go to Second Screen'),
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData(brightness: Brightness.dark),
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Second Screen'),
        ),
        body: const Center(
          child: Text('Hello, Predictive back!'),
        ),
      ),
    );
  }
}

Resources

Closes #131961
Depends on engine PR flutter/engine#49093

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is test-exempt.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. f: cupertino flutter/packages/flutter/cupertino repository f: routes Navigator, Router, and related APIs. labels Jan 11, 2024
@maRci002
Copy link
Contributor Author

maRci002 commented Jan 11, 2024

One aspect I'm considering is how to best align with the Predictive Back design, specifically the "Back Preview" experience.

The gesture-controlled route should incorporate two types of transitions. One type of transition would occur when the route is controlled programmatically, such as through the use of Navigator.pop / Navigator.push. Additionally, there should be a distinct transition tailored for the Back Preview when the route is controlled by a gesture.

Considering that various Material components like Bottom sheets, Side sheets, and Search include back gesture animations, it is advisable to develop a generic, reusable component for managing these transitions.

Definitely needed the two different transitions; otherwise, this happens:

predictive_back_single_animation.webm

@maRci002
Copy link
Contributor Author

This pull request seems prepared for review.

Currently, it lacks documentation and tests, which I plan to add once we confirm that the API is satisfactory. This also applies to the engine part flutter/engine#49093

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for the PR and sorry for my delay in getting set up to run this! Overall the approach looks good and it looks and feels good when I try it out locally with the engine PR.

A few thoughts that came up as I was reviewing this:

Currently, a user has to explicitly use AndroidBackGesturePageTransitionsBuilder in order to get the predictive back transition, right? I wonder if it should be built into any of the existing route transitions. That's probably beyond the scope of this PR, though. Do you know if this kind of predictive back transition is the default in native Android?

Do you have any specific native app that you have been using to try to match this to? If I try the predictive back transition on Google Calendar it feels more stiff than this one, and there are probably other small differences. Maybe there should be a fadeout animation. These are small details, though.

What if someone wants to make a route transition that animates itself into some UI element on the parent route? Such as in the predictive back spec (video below). Maybe you should include an example of doing something like that in the examples directory and link it in your docs.

predictive-back-video-1-calendar-event-example.mp4

@goderbauer FYI in case you want to review this PR related to navigation.

@maRci002 maRci002 force-pushed the predictive-back-route branch from 37cc128 to 0c355ce Compare February 6, 2024 12:05
@maRci002
Copy link
Contributor Author

maRci002 commented Feb 6, 2024

Currently, a user has to explicitly use AndroidBackGesturePageTransitionsBuilder in order to get the predictive back transition, right? I wonder if it should be built into any of the existing route transitions. That's probably beyond the scope of this PR, though.

Yes, to enable predictive back transitions, developers should explicitly use AndroidBackGesturePageTransitionsBuilder. This approach ensures compatibility with Android versions below API 34 as well. For these older Android versions or in cases where the back gesture transition is not applicable (e.g., the Android platform is not in use, the Android version is below API 34, or navigation is triggered programmatically through methods like Navigator.pop or Navigator.push), the transition logic falls back to the final PageTransitionsBuilder parent; field. This field, by default, is set to use ZoomPageTransitionsBuilder for transitions which might change in the future #142352.

Do you know if this kind of predictive back transition is the default in native Android?

I don't really know the predictive back transitions, as recommended by Android's Material Design guidelines, have been incorporated in my implementation. However, effects like fading or barrier colors for better visual appeal haven't been added. In my demo, I cheated by applying a high contrast, using a dark page on top of a light page.

What if someone wants to make a route transition that animates itself into some UI element on the parent route? Such as in the predictive back spec (video below). Maybe you should include an example of doing something like that in the examples directory and link it in your docs.

These kinds of animations should be implemented using the package available at https://pub.dev/packages/animations. I've made every effort to ensure the solution is as modular and flexible as possible. For instance, there's the AndroidBackGesturePageTransitionsBuilder, which supports parent transitions in the absence of a linear transition. Additionally, we have the AndroidBackGestureDetector for listening to predictive back events, and the AndroidBackGestureTransition which executes the actual transition. In the video referenced from the predictive back spec, the AndroidBackGestureTransition is utilized during an ongoing linear transition. However, a specific technique is required when a commit occurs, at which point the parent transition should be invoked. This means that NavigatorState.didStopUserGesture should be called immediately and using the parent animation to end the transition. Alternatively, the AndroidBackGestureDetectorWidgetBuilder could include an additional parameter indicating the current status (none, start, inProgress, cancel, commit); the latter approach is, I believe, better since it makes it easier to determine whether to use the AndroidBackGestureTransition, the parent transition, or even a secondary transition.

@justinmc
Copy link
Contributor

justinmc commented Feb 6, 2024

That all sounds good, thanks for the detailed response! I'm going to check on 1. the default route transitions on native Android and 2. I'll play around with the animations package and see how easy it would be to tie into predictive back.

@maRci002 maRci002 force-pushed the predictive-back-route branch from 0c355ce to 0c21751 Compare February 7, 2024 00:29
@justinmc
Copy link
Contributor

justinmc commented Feb 7, 2024

I created a quick native Android app to check the route transitions that you get out of the box, gif below. This was after setting android:enableOnBackInvokedCallback="true".

I'll try to make sure that we can do this kind of transition as well as the kind with the animations package.

Gif of default Android pop transition

output

@maRci002
Copy link
Contributor Author

maRci002 commented Feb 8, 2024

I'll try to make sure that we can do this kind of transition as well as the kind with the animations package.

In that case, you would want to add a new PageTransitionsBuilder to AndroidBackGesturePageTransitionsBuilder. Its build method would be invoked when the parent route has a linearTransition, in other words, when the parent (the top most route) is in back preview mode.

Anyway, when I change the AndroidBackGestureDetector's builder logic from final bool linearTransition = CupertinoRouteTransitionMixin.isPopGestureInProgress(route); to final bool linearTransition = startBackEvent != null && currentBackEvent != null;, then, for some reason, the first screen's reverse animation is played. I think this could help. Maybe it happens because userGestureInProgress is set in a later frame.

Here is the video showing the reverse animation of the first screen. It demonstrates the topmost route being popped, which triggers the ZoomPageTransitionsBuilder's reverse animation for the first screen.

Details
predictive_back_parent_reverse_animation.mp4

@justinmc
Copy link
Contributor

You're saying that in your video, the "First Screen" is unexpectedly also animating during the back gesture? I do see that.

I'm currently trying to gather some info on Android's default transition and will post back with more details later.

@justinmc
Copy link
Contributor

I figured out what the transitions should be. The default should be the same default that we see on Android right now, which is a resizing and fading. I don't think there is an official spec for this, but I recorded a gif of it below.

And like I thought, we should make sure that the shared element transition is doable with predictive back. That animation does have a spec, and below is the video from it.

Default transition Possible with animations
unnamed
lbqhhm59-AT1_Calendar_commit_V05_large_alt_221216.mp4

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the main blockers that I see now are:

  1. The use of the protected controller.animation, commented on below. I think this is an architecture problem we'll have to figure out.
  2. The AndroidBackGestureTransition should match the animation that I posted a gif of.

I think we'll also need to make AndroidBackGestureTransition the default transition on Android, but I can do that in a separate PR, because it may be a breaking change.

For the shared element transitions that I mentioned before, I think I can add that ability to the animations package as a separate PR built on top of this. It looks possible using your API.

Also, reminder that this PR will need tests at some point.

I really appreciate you working with me to get this supported!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be the default for Android in PageTransitionsTheme, but I guess we should probably do that in a separate PR, since it could be a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to add docs here and for the public members.

Comment on lines 767 to 802
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are calling addObserver in initState of AndroidBackGestureDetector. Is it guaranteed that the observer that you want will be called here?

I wonder if there is a better way for this use case. Maybe by finding the current route of the navigator or something?

handlePopRoute (above) also suffers from this, though, so this is an existing problem, and I guess it's acceptable if there is no easy way around it right now.

@justinmc
Copy link
Contributor

@maRci002 Are you still available to work on this PR? I'm happy to take over work on this project myself if you're busy.

@maRci002
Copy link
Contributor Author

@maRci002 Are you still available to work on this PR? I'm happy to take over work on this project myself if you're busy.

Yes, this week I am going to address the unresolved conversations.

@justinmc
Copy link
Contributor

Great, thanks and keep me posted!

@maRci002 maRci002 force-pushed the predictive-back-route branch from 0c21751 to fef8fd3 Compare February 29, 2024 19:25
@maRci002
Copy link
Contributor Author

@justinmc, I am ready for a third review. I have found more documentation about predictive back here.

@maRci002 maRci002 requested a review from justinmc February 29, 2024 20:11
@justinmc
Copy link
Contributor

justinmc commented Mar 4, 2024

@maRci002 Can you push a merge commit? I think a bunch of checks are stuck.

I'll post a review soon, thanks for the quick turnaround!

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maRci002 Thanks so much for the update and for putting in the tedious work to match the animations. Some bugs that I found:

Back button doesn't work

If I try your example code at the top of this PR, just tapping the back button doesn't work. Sometimes nothing happens at all, and sometimes there is some weird jumpiness.

XRecorder_05032024_105350.mp4

No opacity animation

When I swipe back slowly, there is a point at which the top route suddenly disappears. Looking at native Android it seems like there should be an opacity animation. It happens at the point where releasing the gesture would commit the pop Actually it seems to happen after that point. To me it seems to not be driven by the back gesture (so when the back gesture reaches the commit point, the opacity animation runs in a fixed amount of time).

XRecorder_05032024_104929.mp4

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should watch out for this being a breaking change. If so it will be a very easy migration for users, though.

engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2024
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2024
auto-submit bot pushed a commit to flutter/packages that referenced this pull request Mar 28, 2024
Manual roll Flutter from dbdcead to 89ea492 (54 revisions)

Manual roll requested by [email protected]

flutter/flutter@dbdcead...89ea492

2024-03-28 [email protected] Roll Flutter Engine from f71e5ad8586b to a396dc1a03a9 (3 revisions) (flutter/flutter#145928)
2024-03-28 [email protected] Roll Flutter Engine from 043af350ae85 to f71e5ad8586b (1 revision) (flutter/flutter#145919)
2024-03-28 [email protected] Refactor skp_generator_tests (flutter/flutter#145871)
2024-03-28 [email protected] Roll Flutter Engine from 7c9d5adb6ff8 to 043af350ae85 (2 revisions) (flutter/flutter#145917)
2024-03-28 [email protected] Update `TabBar` and `TabBar.secondary` to use indicator height/color M3 tokens (flutter/flutter#145753)
2024-03-28 [email protected] Roll Packages from e6b3e11 to 924c7e6 (5 revisions) (flutter/flutter#145915)
2024-03-28 [email protected] Add `viewId` to `TextInputConfiguration` (flutter/flutter#145708)
2024-03-28 [email protected] Roll Flutter Engine from 9df2d3a0778e to 7c9d5adb6ff8 (3 revisions) (flutter/flutter#145909)
2024-03-28 [email protected] Manual roll Flutter Engine from c602abdbae16 to 9df2d3a0778e (10 revisions) (flutter/flutter#145903)
2024-03-28 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Roll Flutter Engine from c602abdbae16 to 922c7b133bc2 (7 revisions) (#145877)" (flutter/flutter#145901)
2024-03-28 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Roll Flutter Engine from 922c7b133bc2 to b3516c4c5683 (1 revision) (#145879)" (flutter/flutter#145900)
2024-03-28 [email protected] Roll Flutter Engine from 922c7b133bc2 to b3516c4c5683 (1 revision) (flutter/flutter#145879)
2024-03-28 [email protected] Roll Flutter Engine from c602abdbae16 to 922c7b133bc2 (7 revisions) (flutter/flutter#145877)
2024-03-28 [email protected] Remove deprecated `TextTheme` members (flutter/flutter#139255)
2024-03-27 [email protected] [WIP] Predictive back support for routes (flutter/flutter#141373)
2024-03-27 [email protected] Roll Flutter Engine from 73c145c9ac3a to c602abdbae16 (1 revision) (flutter/flutter#145865)
2024-03-27 [email protected] Roll Flutter Engine from d65662541682 to 73c145c9ac3a (8 revisions) (flutter/flutter#145862)
2024-03-27 [email protected] Roll Flutter Engine from b7dddee939f2 to d65662541682 (2 revisions) (flutter/flutter#145851)
2024-03-27 [email protected] Roll Flutter Engine from 00dab0d9d310 to b7dddee939f2 (2 revisions) (flutter/flutter#145841)
2024-03-27 [email protected] Roll Packages from ab1630b to e6b3e11 (6 revisions) (flutter/flutter#145833)
2024-03-27 [email protected] Refactor web long running tests (flutter/flutter#145776)
2024-03-27 [email protected] Roll Flutter Engine from da64c6bcbbb6 to 00dab0d9d310 (1 revision) (flutter/flutter#145830)
2024-03-27 [email protected] Roll Flutter Engine from d6c6ba5aa157 to da64c6bcbbb6 (1 revision) (flutter/flutter#145811)
2024-03-27 [email protected] Roll Flutter Engine from 064a4f5d9042 to d6c6ba5aa157 (1 revision) (flutter/flutter#145807)
2024-03-27 [email protected] Roll Flutter Engine from cad8e7a9ad70 to 064a4f5d9042 (1 revision) (flutter/flutter#145805)
2024-03-27 [email protected] Roll Flutter Engine from 441005698702 to cad8e7a9ad70 (1 revision) (flutter/flutter#145804)
2024-03-27 [email protected] Roll Flutter Engine from d872d50e53f4 to 441005698702 (1 revision) (flutter/flutter#145803)
2024-03-27 [email protected] Roll Flutter Engine from 92ebd47dd8a8 to d872d50e53f4 (6 revisions) (flutter/flutter#145801)
2024-03-27 [email protected] Update localization files. (flutter/flutter#145780)
2024-03-27 [email protected] Roll Flutter Engine from 026d8902e3b5 to 92ebd47dd8a8 (1 revision) (flutter/flutter#145788)
2024-03-26 [email protected] [web] Add BackgroundIsolateBinaryMessenger.ensureInitialized to web. (flutter/flutter#145786)
2024-03-26 49699333+dependabot[bot]@users.noreply.github.com Bump codecov/codecov-action from 4.1.0 to 4.1.1 (flutter/flutter#145787)
2024-03-26 [email protected] Roll pub packages and regenerate gradle lockfiles (flutter/flutter#145727)
2024-03-26 [email protected] Roll Flutter Engine from 5c7aea6f20fc to 026d8902e3b5 (1 revision) (flutter/flutter#145785)
2024-03-26 [email protected] Roll Flutter Engine from baede78d2352 to 5c7aea6f20fc (2 revisions) (flutter/flutter#145784)
2024-03-26 [email protected] Roll Flutter Engine from cffd1dcfe6a5 to baede78d2352 (2 revisions) (flutter/flutter#145778)
2024-03-26 [email protected] Correct typo: "Free" to "Three" in comments (flutter/flutter#145689)
2024-03-26 [email protected] Fix disabled `DropdownMenu` doesn't defer the mouse cursor (flutter/flutter#145686)
2024-03-26 [email protected] Roll Flutter Engine from b2d93a64cbc7 to cffd1dcfe6a5 (9 revisions) (flutter/flutter#145773)
2024-03-26 [email protected] Roll Packages from 28d126c to ab1630b (1 revision) (flutter/flutter#145755)
2024-03-26 [email protected] Memory leaks clean up 2 (flutter/flutter#145757)
2024-03-26 [email protected] Fix memory leak in Overlay.wrap. (flutter/flutter#145744)
2024-03-26 [email protected] Be tolerant of backticks around directory name in `pub` output. (flutter/flutter#145768)
2024-03-26 [email protected] Fix `ExpansionTile` Expanded/Collapsed announcement is interrupted by VoiceOver (flutter/flutter#143936)
...
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request May 14, 2024
@PaulGrandperrin
Copy link

Hi @maRci002 and @justinmc !

I might be interested in helping with fixing the back gesture animation (if I have enough free time).

At first, I just wanted to copy and customize PredictiveBackPageTransitionsBuilder to fix the front widget abruptly disappearing mid-animation (at 35% more precisely), but now I realize that it's impossible without changing the flutter SDK itself.

Maybe I'm mistaken because I'm a flutter newbie, but it seems that it's currently impossible to access the required attributes from Backevent and the current phase of the animation from the transition code (the PredictiveBackPageTransition class).

Here's what I think we need to successfully implement the back gesture animation:

  • a way to know in which phase we are in: preCommit / postCommit / postCancel
  • backEvent.swipeEdge
  • backEvent.touchOffset

Then to implement the preCommit phase, compute the front page's offset from backEvent.swipeEdge and backEvent.touchOffset. This way, we can move the front page from the correct side, and move it up and down as it is done natively.

For the postCommit phase, we can use backEvent.swipeEdge, backEvent.touchOffset and backEvent.progress to implement the sliding animation starting from the backEvent.touchOffset at commit time, to the backEvent.swipeEdge where the gesture started, controlled by backEvent.progress.

Finally, for the postCancel phase, we can use the same general design as the postCommit phase but with the same animation as the preCommit phase.

Right now, it seems only (1 - backEvent.progress) is available and therefor used for everything, without any real distinction between the 3 phases, making it impossible to solve the current issues.

@PaulGrandperrin
Copy link

For reference, I'm talking about this issue that was already discussed, but it seems not yet addressed: #141373 (review)

@PaulGrandperrin
Copy link

PaulGrandperrin commented Aug 15, 2024

Ah, I actually found a hacky way to fix the animation!
It still can't distinguish if the gesture started from the left or right, or up and down gestures, but at least the animation is fluid and continuous at all time.

Here my code, copied and adapted from the SDK, but doesn't to change the SDK itself:
(There are 4 comments indicating where changes have been made)

class MyPageTransition extends PageTransitionsBuilder {
  @override
  Widget buildTransitions<T>(
    PageRoute<T> route,
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    return MyPredictiveBackGestureDetector(
      route: route,
      builder: (BuildContext context) {
        // Only do a predictive back transition when the user is performing a
        // pop gesture. Otherwise, for things like button presses or other
        // programmatic navigation, fall back to ZoomPageTransitionsBuilder.
        if (route.popGestureInProgress) {
          return MyPredictiveBackPageTransition(
            animation: animation,
            secondaryAnimation: secondaryAnimation,
            getIsCurrent: () => route.isCurrent,
            child: child,
          );
        }

        return const ZoomPageTransitionsBuilder().buildTransitions(
          route,
          context,
          animation,
          secondaryAnimation,
          child,
        );
      },
    );
  }
}

class MyPredictiveBackGestureDetector extends StatefulWidget {
  const MyPredictiveBackGestureDetector({super.key, 
    required this.route,
    required this.builder,
  });

  final WidgetBuilder builder;
  final PredictiveBackRoute route;

  @override
  State<MyPredictiveBackGestureDetector> createState() =>
      MyPredictiveBackGestureDetectorState();
}

class MyPredictiveBackGestureDetectorState extends State<MyPredictiveBackGestureDetector>
    with WidgetsBindingObserver {
  /// True when the predictive back gesture is enabled.
  bool get _isEnabled {
    return widget.route.isCurrent
        && widget.route.popGestureEnabled;
  }

  /// The back event when the gesture first started.
  PredictiveBackEvent? get startBackEvent => _startBackEvent;
  PredictiveBackEvent? _startBackEvent;
  set startBackEvent(PredictiveBackEvent? startBackEvent) {
    if (_startBackEvent != startBackEvent && mounted) {
      setState(() {
        _startBackEvent = startBackEvent;
      });
    }
  }

  /// The most recent back event during the gesture.
  PredictiveBackEvent? get currentBackEvent => _currentBackEvent;
  PredictiveBackEvent? _currentBackEvent;
  set currentBackEvent(PredictiveBackEvent? currentBackEvent) {
    if (_currentBackEvent != currentBackEvent && mounted) {
      setState(() {
        _currentBackEvent = currentBackEvent;
      });
    }
  }

  // Begin WidgetsBindingObserver.

  @override
  bool handleStartBackGesture(PredictiveBackEvent backEvent) {
    final bool gestureInProgress = !backEvent.isButtonEvent && _isEnabled;
    if (!gestureInProgress) {
      return false;
    }

    widget.route.handleStartBackGesture(progress: 1 - backEvent.progress / 10); // SOME CHANGE HERE
    startBackEvent = currentBackEvent = backEvent;
    return true;
  }

  @override
  void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
    widget.route.handleUpdateBackGestureProgress(progress: 1 - backEvent.progress / 10); // SOME CHANGE HERE
    currentBackEvent = backEvent;
  }

  @override
  void handleCancelBackGesture() {
    widget.route.handleCancelBackGesture();
    startBackEvent = currentBackEvent = null;
  }

  @override
  void handleCommitBackGesture() {
    widget.route.handleCommitBackGesture();
    startBackEvent = currentBackEvent = null;
  }

  // End WidgetsBindingObserver.

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return widget.builder(context);
  }
}

class MyPredictiveBackPageTransition extends StatelessWidget {
  const MyPredictiveBackPageTransition({
    super.key, 
    required this.animation,
    required this.secondaryAnimation,
    required this.getIsCurrent,
    required this.child,
  });

  final Animation<double> animation;
  final Animation<double> secondaryAnimation;
  final ValueGetter<bool> getIsCurrent;
  final Widget child;

  Widget _primaryAnimatedBuilder(BuildContext context, Widget? child) { // SOME CHANGE HERE
    final Size size = MediaQuery.sizeOf(context);

    return Transform.translate(
      offset: Offset(Tween(begin: size.width, end: 0.0).animate(animation).value, 0),
      child: Transform.scale(
        scale: Tween(begin: 0.5, end: 1.0).animate(animation).value,
        child: Opacity(
          opacity: Tween(begin: 0.5, end: 1.0).animate(animation).value,
          child: child,
        ),
      ),
    );
  }
  @override
  Widget build(BuildContext context) { // SOME CHANGE HERE
    return AnimatedBuilder(
      animation: animation,
      builder: _primaryAnimatedBuilder,
      child: child,
    );
  }
}

@PaulGrandperrin
Copy link

PaulGrandperrin commented Aug 16, 2024

I continued trying to make the animation work, and I found a few things:

  • on android, there's no postCancel phase to handle. when the user stops the back gesture, the system will continue to send backEvents to the app with the progress attribute progressively going back to 0.
  • this means, the current code in _handleDragEnd is based on the wrong assumptions and incorrectly tries to run the animation controller when it shouldn't.

Also,

  • when the gesture is committed, _handleDragEnd incorrectly assumes that this means that it should continue the same animation and just simulate that BackEvent.progress is continuing to 1.0, just like if the user's finder was continuing on its way to the opposite screen edge.
  • in the native back gesture animation, the commit animation actually goes back to the where the gesture was started but with a different scale and opacity parameters.
  • it's therefor a new and different animation and can't just be the preCommit animation either run to its completion nor run backward to its origin.

Looking at this PR, it seems the code that is now used to drive android back gestures was mostly taken from the iOS back gesture code.

I don't own an iOS device, but I therefore suppose they work very differently from android.

Judging by the issues I had with this code on android, I infer that on iOS:

  • it's the app responsibility to drive the cancel animation (unlike android)
  • the native commit animation is just a continuation of the gesture animation. (unlike android)
  • the back gesture animation is only dependent on the finger's position on the horizontal axis (when on android it's dependent on both axis)
  • the back gesture can only be done in one direction (on android, it can start from both screen edges in opposite direction)

To conclude, I think the current implementation is based on the wrong assumption that iOS and Android back gestures are similar.

I'm trying very hard to write some code that works around those issues to provide the best animationI can without changing the SDK itself.

However, to implement the material spec fully, I think the SDK's back gesture design will need to be changed.

@PaulGrandperrin
Copy link

I'm giving up making a transition that works with an unmodified SDK.

The one last problem I encountered, and that I can't work around, is this line: https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/routes.dart#L541

It sets the animation curve for the commit transition, but the issue is that it is not in the PredictiveBackPageTransition class but in the TransitionRoute class.

This means that we can't change it or avoid it.

Unfortunately, this specific curve is ill-suited for the commit transition animation.

It starts extremely fast and finishes extremely slowly, when we would need the opposite to have a smooth animation meeting the material design spec.

It goes so fast to a very small value that the animation goes from 1.0 to almost 0 in a small fraction of the animation duration: At most, the animation will last for 800ms (ui.lerpDouble(0, 800, _controller!.value)) but the controller.value will reach almost 0 in about 100ms.
This means that, at best, the animation will look 3 times too fast compared to normal (300ms) and will then block all user interactions for the remaining 700ms (800-100ms). During this time, it seems the app is unresponsive. This happens with PredictiveBackPageTransitionsBuilder and cannot be worked around.

@PaulGrandperrin
Copy link

I'm posting my playground code in case I want someday to go back to it or if it can help someone else.

It solves a few issues, but it's still far from being usable.

import 'dart:math';

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

class MyPageTransition extends PageTransitionsBuilder {
  @override
  Widget buildTransitions<T>(route, context, animation, secondaryAnimation, child) {
    // if (route.popGestureInProgress) {
      return MyPredictiveBackGestureDetector(
        route: route,
        animation: animation,
        secondaryAnimation: secondaryAnimation,
        child: child,
      );
    // } else {
    //   return const ZoomPageTransitionsBuilder().buildTransitions(
    //     route,
    //     context,
    //     animation,
    //     secondaryAnimation,
    //     child,
    //   );
    // }
  }
}

class MyPredictiveBackGestureDetector extends StatefulWidget {
  const MyPredictiveBackGestureDetector({
    super.key, 
    required this.route,
    required this.animation,
    required this.secondaryAnimation,
    required this.child,
  });

  final PageRoute route;
  final Animation<double> animation;
  final Animation<double> secondaryAnimation;
  final Widget child;

  @override
  State<MyPredictiveBackGestureDetector> createState() => MyPredictiveBackGestureDetectorState();
}

enum Phase {
  preCommit,
  postCommit,
}

class MyPredictiveBackGestureDetectorState extends State<MyPredictiveBackGestureDetector>
    with WidgetsBindingObserver {


  /// True when the predictive back gesture is enabled.
  bool get _isEnabled {
    return widget.route.isCurrent
        && widget.route.popGestureEnabled;
  }

  Phase? _phase;
  PredictiveBackEvent? _startBackEvent;
  PredictiveBackEvent? _lastBackEvent;

  // Begin WidgetsBindingObserver.

  @override
  bool handleStartBackGesture(PredictiveBackEvent backEvent) {
    if (backEvent.isButtonEvent || !_isEnabled) {
      return false;
    }

    // Here we just call this function so that the navigator knows the gesture started but we don't actually use the progress value because the code
    // in `_handleDragUpdate` is too broken to be able to use it.
    widget.route.handleStartBackGesture(progress: 0.5); // abitrary number strictly between 0 and 1 so that the controller is tricked into thinking the animation is ongoing

    setState(() {
      _phase = Phase.preCommit;
      _startBackEvent = backEvent;
      _lastBackEvent = backEvent;
    });
    return true;
  }

  @override
  void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
    // Here would have been nice to update the animation progress to make the back page move in sync with the front page but the code is too broken for that
    // and it would create problems when running the commit animation.
    // widget.route.handleUpdateBackGestureProgress(progress: );
    setState(() {
      _lastBackEvent = backEvent;
    });
  }

  @override
  void handleCancelBackGesture() {
    // This function is not called when the user cancels the gesture, but after the animation is complete.
    // It's the system that will drive the animation backward through the BackEvent.progress value.
    // Then, when the animation is complete and the front page has reached its original position, the system will call this function.
    
    // Unfortunately, `widget.route.handleCancelBackGesture()` thinks it's his responsability to drive the animation backward, but it's not.
    // I am guessing this mistake is due to the fact that the code might by inspired by the iOS implementation where I suppose the system doesn't drive the animation backward.

    // So, for android, we have to trick it into thinking the animation is complete by telling it the progress is at 100% already.
    
    widget.route.handleUpdateBackGestureProgress(progress: 1.0);

    widget.route.handleCancelBackGesture();
  }

  @override
  void handleCommitBackGesture() {
    // This function is called when the user releases the gesture and the system decides to commit the back gesture.
    // The commit animation is not just a continuation of the pre-commit animation that would simulate the user's finger finishing crossing the screen.
    // Actually, in the Clock app, the commit animation goes back in the direction of the origin edge and overshoots it.
    
    // Unfortunatly, the code run by `widget.route.handleCommitBackGesture()` is probably inspired the iOS implementation where I suppose the animation is supposed
    // to continue in the same direction as the pre-commit animation.
    
    // So, for android, we want a new animation to start and so we need its controller to start at 0.
    // In order to do that, we have to trick `_handleDragEnd` into thinking we are at 0% of the animation.
    // However, to complete the progress of the commit animation `_handleDragEnd` sets up the controller in a backward motion, so instead of telling him that the progress
    // is now at 0%, we have to tell him that the progress is at 100%.
    // I don't know why `_handleDragEnd` mixes up frontward and backward motions but I guess it comes from the original iOS implementation.
    
    widget.route.handleUpdateBackGestureProgress(progress: 0.5);

    // This only works because we decided to not use the progress value to animate the back page.
    // If we tried to animate the back page in sync with the front page, we would either have a jump in the backpage animation or an insanly fast front page commit animation.
    // It is not resolvable without a complete rewrite of the code of `_handleDragEnd`.

    widget.route.handleCommitBackGesture();


    setState(() {
      _phase = Phase.postCommit;
    });
  }

  // End WidgetsBindingObserver.

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final Size size = MediaQuery.sizeOf(context);
    const slideRatio = 0.06;
    const scaleRatio = slideRatio * 3;
    const commitOvershootFactor = 1.2;

    if (_phase != null) { // animation for the front page
      final progress = _lastBackEvent!.progress;
      final yOffset = (_lastBackEvent!.touchOffset!.dy - _startBackEvent!.touchOffset!.dy);
      final fromLeftEdge = _lastBackEvent!.swipeEdge == SwipeEdge.left;

      switch (_phase!) {
        case Phase.preCommit:
          return Transform.translate(
            offset: Offset(
              progress * slideRatio * size.width * (fromLeftEdge ? 1 : -1),
              progress * slideRatio * yOffset
            ),
            child: Transform.scale(
              scale: 1 - progress * scaleRatio,
              child: widget.child
            ),
          );
        case Phase.postCommit:
          final animationValue = sqrt(sqrt(widget.animation.value)); // try to correct a little the `fastLinearToSlowEaseIn` curve in `_handleDragEnd`
          return Transform.translate(
            offset: Offset(
              progress * slideRatio * size.width * (fromLeftEdge ? 1 : -1) * animationValue * animationValue + (1 - animationValue) * commitOvershootFactor * size.width,
              progress * slideRatio * yOffset                              * animationValue,
            ),
            child: Transform.scale(
              scale: 1 - progress * scaleRatio, // stays the same
              child: Opacity(
                opacity: animationValue,
                child: widget.child
              )
            ),
          );
      }
    } else { // animation for the back page
      // Unfortunatly, the code in `_handleDragEnd` is too broken to be able to use the progress value to animate the back page in sync with the front page.
      return widget.child;

      // final progress = Tween(begin: 0.0, end: 1.0).animate(widget.secondaryAnimation).value;
      // return Transform.translate(
      //   offset: Offset(-slideRatio*progress*size.width, 0.0),
      //   child: Transform.scale(
      //     scale: 1-progress*slideRatio,
      //     child: Opacity(
      //       opacity: 1-progress*0.5,
      //       child: widget.child
      //     )
      //   ),
      // );
    }
  }
}

@Adnanmnsour

This comment was marked as spam.

@Adnanmnsour

This comment was marked as spam.

@justinmc
Copy link
Contributor

@PaulGrandperrin Thanks for doing all this investigation here. Are you interested in opening a PR to improve the transition in the framework? I noticed some of these problems and had intended to do a round of improvements myself (old draft PR: #146789).

@PaulGrandperrin
Copy link

PaulGrandperrin commented Aug 20, 2024

Hi @justinmc !
I opened an issue #153577 and a draft PR already #153635 .

I can't say how much and at what pace I'll work on it, I can't really afford to spend too much time on this, but it's a big itch I'd like to scratch.

I really like the concept of predictive back gestures as it makes it worth it to really create beautiful transitions that users can then explore at their own pace.

So it was so frustrating to encounter a blocker that couldn't be worked around!

Anyway, right now, in my PR, there's the strict minimum to fix the SDK API and provide a marginally better transition animation.
The code is not well though out and lacking the proper use of Tweens, a nice curve to smooth out the postCommit phase and the rounding of corners.

I took some time to investigate how the classic back button events are dispatched, and I was thinking that in theory, the back gesture events should follow the same path. What do you think?

Since the two top-most routes should be animated by those gesture events, I thought it would make the architecture cleaner: events would go top to bottom in the router(s) and navigator(s) in similar manner to the back button events.

Anyway, that was what I thought before spending some time in the 6000+ lines of navigator.dart ;) Now I'm not so sure -_-

What's your opinion? (Maybe you can answer on the PR)

@maRci002
Copy link
Contributor Author

Hi @maRci002 and @justinmc !

I might be interested in helping with fixing the back gesture animation (if I have enough free time).

I have written down my thoughts regarding this in the new issue you created.

@justinmc
Copy link
Contributor

justinmc commented Nov 15, 2024

Adding a quick post about how to enable predictive back as of now (2024.11.15).

  1. Use Flutter v3.24.5 or above (stable is fine now).
  2. Add android:enableOnBackInvokedCallback="true" to AndroidManfiest.xml.
  3. If you want predictive back between routes, use PredictiveBackPageTransitionsBuilder:
    return MaterialApp(
      theme: ThemeData(
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
          },
        ),
      ...
      ),
    );
  1. Don't use WillPopScope in your app. Use PopScope instead.
  2. Run your app on an Android 15 device, or if on an older device, set "use predictive back" to true in the device's developer settings.

That's it. You should see predictive back occur both when swiping back to exit the app and when swiping back between routes within the app.

@aleripe
Copy link

aleripe commented Jan 24, 2025

Adding a quick post about how to enable predictive back as of now (2024.11.15).

  1. Use Flutter v3.24.5 or above (stable is fine now).
  2. Add android:enableOnBackInvokedCallback="true" to AndroidManfiest.xml.
  3. If you want predictive back between routes, use PredictiveBackPageTransitionsBuilder:
    return MaterialApp(
      theme: ThemeData(
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
          },
        ),
      ...
      ),
    );
  1. Don't use WillPopScope in your app. Use PopScope instead.
  2. Run your app on an Android 15 device, or if on an older device, set "use predictive back" to true in the device's developer settings.

That's it. You should see predictive back occur both when swiping back to exit the app and when swiping back between routes within the app.

Hi Justin,
does this work with dialogs? When I swipe back with a dialog opened, it pops the underlying route instead of closing the dialog...

Thanks!

@justinmc
Copy link
Contributor

It should definitely work with dialogs, if not it's a bug. Can you file an issue with a reproduction?

@NicholasFlamy
Copy link

Hi Justin,
does this work with dialogs? When I swipe back with a dialog opened, it pops the underlying route instead of closing the dialog...

I've had this same issue. I'm pretty sure it's just that I need to implement a lot more of the PopScope stuff.

@NicholasFlamy
Copy link

Definitely needed the two different transitions; otherwise, this happens:

It appears as though that never happened and it is still one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android predictive back route transitions

9 participants