Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document that WillPopScope prevents swipe to go back on MaterialPageRoute #14203

Open
dudeofawesome opened this issue Jan 22, 2018 · 117 comments
Open
Labels
customer: crowd Affects or could affect many people, though not necessarily a specific customer. customer: mulligan (g3) d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos framework flutter/packages/flutter repository. See also f: labels. P2 Important issues not at the top of the work list team-framework Owned by Framework team triaged-framework Triaged by Framework team

Comments

@dudeofawesome
Copy link

dudeofawesome commented Jan 22, 2018

Steps to Reproduce

  1. Add a MaterialPageRoute to your app's page stack
    return new MaterialPageRoute<Null>(
      settings: settings,
      builder: (BuildContext context) => new StoryPage(itemId: itemId),
    );
  2. Wrap that page's Scaffold in a WillPopScope widget
    return new WillPopScope(
      onWillPop: () async {
        return true;
      },
      child: new Scaffold(
        …
      ),
    );
  3. Try to swipe to go back on iOS. The page won't swipe.

This also occurs if you replace the MaterialPageRoute with a CupertinoPageRoute.

I think this might be an unintended side-affect of fixing #8269.

For a live example of this, see my repo here.

Flutter Doctor

[✓] Flutter (on Mac OS X 10.13.2 17C205, locale en-US, channel master)
    • Flutter version 0.0.21-pre.284 at /Users/DudeOfAwesome/Library/Developer/flutter
    • Framework revision 7cdfe6fa0e (2 days ago), 2018-01-20 00:36:00 -0800
    • Engine revision e45eb692b1
    • Tools Dart version 2.0.0-dev.16.0
    • Engine Dart version 2.0.0-edge.93d8c9fe2a2c22dc95ec85866af108cfab71ad06

[✓] Android toolchain - develop for Android devices (Android SDK 27.0.3)
    • Android SDK at /Users/DudeOfAwesome/Library/Android/sdk
    • Android NDK at /Users/DudeOfAwesome/Library/Android/sdk/ndk-bundle
    • Platform android-27, build-tools 27.0.3
    • ANDROID_HOME = /Users/DudeOfAwesome/Library/Android/sdk
    • Java binary at: /Applications/Android Studio.app/Contents/jre/jdk/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-915-b08)

[✓] iOS toolchain - develop for iOS devices (Xcode 9.2)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Xcode 9.2, Build version 9C40b
    • ios-deploy 1.9.2
    • CocoaPods version 1.3.1

[✓] Android Studio (version 3.0)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-915-b08)

[✓] IntelliJ IDEA Ultimate Edition (version 2017.2.5)
    • Flutter plugin version 18.1
    • Dart plugin version 172.4343.25

[✓] Connected devices
    • iPhone X • C927CB75-4D41-426B-9B32-D859B2FC5FFD • ios • iOS 11.2 (simulator)
dudeofawesome added a commit to dudeofawesome/hn_flutter that referenced this issue Jan 22, 2018
dudeofawesome added a commit to dudeofawesome/hn_flutter that referenced this issue Jan 22, 2018
@Hixie
Copy link
Contributor

Hixie commented Jan 22, 2018

This is intended behavior. Is the documentation lacking? Can you elaborate on why you wouldn't want this?

@dudeofawesome
Copy link
Author

The page that I'm trying to watch is a full page, not just a modal like in #8269. I see in the documentation that it sounds like this widget is only intended to wrap a modal.

The goal that I'm trying to achieve is to add a listener for when the page pops, so that I can save the scroll position to the my app's state store. Is there a better way to go about this?

@Hixie
Copy link
Contributor

Hixie commented Jan 22, 2018

WillPopScope is about preventing people from popping.

If you just want to store something when the page pops, I recommend storing it all the time (so that you handle unexpected events like the app crashing). If that doesn't work for you, you could use a custom MaterialPageRoute subclass that exposes the relevant route lifecycle methods like didPush/didPop for you (you could even make that work with a widget like WillPopScope, using a similar implementation strategy). Or you could use a NavigatorObserver.

@Hixie
Copy link
Contributor

Hixie commented Jan 22, 2018

We should make sure we explain WillPopScope in its docs, and either explain how to do the effect you want, or provide some dedicated widget to do so.

@Hixie Hixie added framework flutter/packages/flutter repository. See also f: labels. d: api docs Issues with https://api.flutter.dev/ labels Jan 22, 2018
@Hixie Hixie added this to the 4: Next milestone milestone Jan 22, 2018
@dudeofawesome
Copy link
Author

dudeofawesome commented Jan 22, 2018

I got the idea to use WillPopScope from this blog post I found on the flutterdev subreddit. In lieu of that, I've instead added a scroll listener that I debounce so that I'm not writing to the store and SQLite every frame.

I think that it would be beneficial to have an easy way to detect a page pop for similar scenarios. For example, if the Android settings app were made with Flutter, you'd want to be able to turn off Bluetooth searching when the user leaves the Bluetooth page.

@alanrussian
Copy link
Contributor

@Hixie mulligan just noticed this issue. We use WillPopScope, for example, to prevent a user from losing their changes on a form screen. When the user has no changes to lose, we return true.

In the cases where the user has no changes to return, they can press the back button to go to the previous screen; however, they can't use the swipe back gesture. I think from a user's perspective, I wouldn't understand why I can't swipe back.

I'm assuming the onWillPop callback isn't used to determine whether the back gesture should be displayed because it's asynchronous. If so, maybe it would be good to create a synchronous version of it to accomplish this task? In cases like the one I mentioned, we know the value synchronously.

What are your thoughts?

@Hixie
Copy link
Contributor

Hixie commented Oct 25, 2018

Being async isn't necessarily a blocker (so long as it's fast enough to fit within a single frame).

@xster was looking at this recently I believe.

@xster
Copy link
Member

xster commented Oct 25, 2018

@dudeofawesome: I think the WillPopScope is the wrong tool for the scenario you're describing. If you want to conduct cleanup task either at the route level or at the widget level to release resources or stop execution that could have a longer lifecycle than the UI, I'd use the NavigatorObserver like Ian suggested or the dispose() callback on State objects.

@alanrussian: I'd argue WillPopScope is likely the wrong UX pattern to use in your case too. Since the back swipe is from iOS, we can take iOS patterns as prior art reference. When you're in a 'route' on iOS where it's possible to enter data that can be lost by navigating away, that view controller should always be presented rather than pushed. In Flutter-land, that should always translate to a PageRoute<T>.fullscreenDialog being true which will always prevent back swipes regardless of whether there is data to be lost or not.

You can, for instance, reference the native iOS contacts app or calendar app and try to create a new entry. It's always a bottom up transition and a cancel button.

In other words, I think showing a [< Back] button is 'wrong' UX in the first place even without the gesture. In iOS, you should never press the [< Back] button which then triggers an action sheet that asks you do you want to discard data. The [< Back] button should always unconditionally succeed via tap or back swipe.

@alanrussian
Copy link
Contributor

Thanks for the reply, @xster. I'm not super familiar with iOS design patterns these days so cc/ @johnfesa and @jklint-g.

We have one use case today where we use WillPopScope. It's on a screen that we call a "settings" screen but it serves two purposes: displaying detailed information about an entity and then allowing the user to edit a smaller set of details. The user can tap a pencil icon in the app bar which turns the screen turns into a form in place (same route & widget). The app bar left action does turn into an X in this case, but since the two screens are nearly identical, I think we thought it would look bad to have a transition between two screens.

In this case, we're not pushing a new route. In order to selectively use WillPopScope, we'd have to conditionally wrap the contents of the screen with the widget and use a GlobalKey to follow the StatefulWidget performance best practices

Avoid changing the depth of any created subtrees or changing the type of any widgets in the subtree. For example, rather than returning either the child or the child wrapped in an IgnorePointer, always wrap the child widget in an IgnorePointer and control the IgnorePointer.ignoring property. This is because changing the depth of the subtree requires rebuilding, laying out, and painting the entire subtree, whereas just changing the property will require the least possible change to the render tree (in the case of IgnorePointer, for example, no layout or repaint is necessary at all).

If the depth must be changed for some reason, consider wrapping the common parts of the subtrees in widgets that have a GlobalKey that remains consistent for the life of the stateful widget. (The KeyedSubtree widget may be useful for this purpose if no other widget can conveniently be assigned the key.)

I'd argue that if the pattern we have is common, this should be easier to do.

@xster
Copy link
Member

xster commented Oct 26, 2018

The last usage you just described is correct right? In edit mode with the X button, you get a conditional close on the X tap and you can never swipe and native iOS would behave the same way (for instance when going into edit mode for a contact which turns the [< Back] button into Cancel).

Are you asking about a different question / feature request in this case than back swipe gestures?

A Navigator.of(context).pushReplacement seems acceptable in this case too. iOS does it with an in-place fade transition.

A semi-side-discussion, I wouldn't overly depend on all the data being UI state data in this case either (re: reshaping trees with GlobalKeys). Since the user might cancel the edit etc, you'll want your non-Flutter Dart application logic to harmonize between the 2 modes anyway (at which point it's cheap to recreate the UI on 2 different routes based on your application logic source of truth).

@Hixie Hixie changed the title WillPopScope prevents swipe to go back on MaterialPageRoute Document that WillPopScope prevents swipe to go back on MaterialPageRoute Oct 30, 2018
@kf6gpe
Copy link
Contributor

kf6gpe commented Nov 7, 2018

@tvolkert Could we see about getting a volunteer to do a doc update for this as part of the doc fixit this week? Thanks!

@tvolkert
Copy link
Contributor

tvolkert commented Nov 7, 2018

It should get picked up in a sweep of issues tagged as api docs.

@dark-chocolate
Copy link

How to achieve back swipe when using WillPopScope? Have you guys mentioned that too somewhere?

@sakchhams
Copy link

We use WillPopScope to prevent users from going back to the previous page in a multipage survey.

However, at the end of the survey, we want the user to be able to the home screen to take another one if they wish so.

We're pushing routes in agreement to the abovementioned behavior so a pop on the final page would navigate users back to the home screen. The same works for Android when the user presses the back button, and we'd like the user to be able to do the same on iOS using the swipe gesture.

Any suggestions?

@skela
Copy link

skela commented May 28, 2019

I think many people just want to be able to set a navigator result so we don't have to set create our own backbutton, or disable swipe to go back. Having the ability to just do Navigator.onPopResult(context,whatever); which then would be used for the back gesture and the default back button, could go a long way. I'm just using WillPopScope as it gets me 80% of the way, the only downside is that the back swipe stops working, which isnt ideal.

@cubissimo
Copy link

Maybe instead of just prevent the gesture, a way to detect and handle the swipe back inside the WillPopScope give us more flexibility to accomplish more use cases

@mhstoller
Copy link

mhstoller commented Jul 21, 2019

@Hixie @xster Is there any chance that we can get a WillPopScope property that can enable/disable the swipe-back gesture? I want to be able to use the widget so that I can send data back to a previous page using Navigator.pop(context, data); but don't want to lose the gesture to achieve this.

@Hixie
Copy link
Contributor

Hixie commented Jul 21, 2019

you can set the route's data without using pop, which would avoid the issue

@mhstoller
Copy link

@dark-chocolate I found the way to do this without using WillPopScope, you can use the addScopedWillPopCallback method.

There's some more info here: https://api.flutter.dev/flutter/widgets/ModalRoute/addScopedWillPopCallback.html

The first section shows how to use the WillPopScope widget, but after that it shows how to register the ModalRoute callback manually.

@jamesdixon
Copy link

@Hixie @xster while using WillPopScope does prevent swiping back on iOS, I've noticed that the onWillPop callback is never actually called when performing a swipe. Conversely, if you tap on the back button in the app bar, onWillPop is triggered. Is this intended behavior?

@w3ggy
Copy link

w3ggy commented Oct 10, 2019

To achieve this behavior you can override hasScopedWillPopCallback getter in the MaterialPageRoute.

class CustomMaterialPageRoute extends MaterialPageRoute {
  @protected
  bool get hasScopedWillPopCallback {
    return false;
  }
  CustomMaterialPageRoute({
    @required WidgetBuilder builder,
    RouteSettings settings,
    bool maintainState = true,
    bool fullscreenDialog = false,
  }) : super(
          builder: builder,
          settings: settings,
          maintainState: maintainState,
          fullscreenDialog: fullscreenDialog,
        );
}

And then replace MaterialPageRoute on CustomMaterialPageRoute in your Router.
Then swipe on iOS will work and the WillPopScope widget will work.

@nastaran-mohammadi

This comment was marked as spam.

@Faamica

This comment was marked as spam.

1 similar comment
@sst4tic

This comment was marked as spam.

@DhavalRKansara
Copy link

I am adding one solution which is a kind of workaround not an ideal solution for the problem.

Use the below widget 'MyWillPopScope' on behalf of 'WillPopScope'...

Note: This will fix the issue of swipe to back with iOS but the problem will be you will not get the actual gesture of iOS where you can see the previous screen while starting the back using swipe to back.

class MyWillPopScope extends StatelessWidget {
  const MyWillPopScope({
    required this.child,
    this.onWillPop,
    super.key,
  });

  final Widget child;
  final WillPopCallback? onWillPop;

  @override
  Widget build(BuildContext context) {
    return Platform.isIOS
        ? GestureDetector(
            onHorizontalDragUpdate: (details) {
              if (details.delta.dx > 0 && onWillPop != null) {
                onWillPop!.call();
              }
            },
            child: WillPopScope(
              onWillPop: onWillPop,
              child: child,
            ),
          )
        : WillPopScope(
            onWillPop: onWillPop,
            child: child,
          );
  }
}

@Faamica
Copy link

Faamica commented Jul 3, 2023

Good. Thank you DhavalRKansara
I am using below code.

 GestureDetector(
              onHorizontalDragEnd: Platform.isIOS
                  ? (details) async {
                      if (details.primaryVelocity! > 0 &&
                          details.primaryVelocity!.abs() > 500) {
                        try {
                          //do
                        } catch (e) {
                          log('error: $e');
                        }
                        Navigator.pop(context);
                      }
                    }
                  : null,
                 

When used on a real device, this code gives a pretty natural result.
Of course, you can't do things like make the swipe very slow like a real swipe : )

@flutter-triage-bot flutter-triage-bot bot added team-framework Owned by Framework team triaged-framework Triaged by Framework team labels Jul 8, 2023
@fzyzcjy
Copy link
Contributor

fzyzcjy commented Jul 8, 2023

In my case, I have a TextField in the page, and want to save the content to disk when the user leaves the page, and will restore the data when entering the page. Since "save to disk" (and other operations) is asynchronous, I cannot simply use dispose(), because in the old days when I used dispose() I do observe a bug: When the page is popped and then quickly re-pushed, the content is lost. This is because the timing is like: LeavePage - start persisting - EnterPage - start restoring - end restoring (with old data) - end persisting (now writes the new data but too late).

@tanmeifang
Copy link

import 'dart:io';

import 'package:flutter/cupertino.dart';

class IosBackGestureDetector extends StatelessWidget {
  const IosBackGestureDetector({
    Key? key,
    required this.child,
    required this.onBackGesture,
  }) : super(key: key);

  final Widget child;
  final VoidCallback onBackGesture;

  @override
  Widget build(BuildContext context) {
    bool checkValue = false;
    return GestureDetector(
      onHorizontalDragStart: (DragStartDetails details) {
        checkValue = false;
        checkValue = details.localPosition.dx < 50 || details.localPosition.dx > (MediaQuery.of(context).size.width - 70);
      },
      onHorizontalDragEnd: (DragEndDetails details) {
        if (checkValue && Platform.isIOS) {
          onBackGesture();
        }
      },
      child: child,
    );
  }
}

wrap this with onWillPopScope it will detect gesture detector on iOS

I use it works for back swipe ,but when I use it in webview page, the webview content will can not scroll

@mattrltrent
Copy link

So basically the Flutter team has no idea how to fix this... lol.

This is a serious problem.

@fzyzcjy
Copy link
Contributor

fzyzcjy commented Jul 29, 2023

I created a simple widget that mimics the standard android behavior - when you swipe from the side, an arrow will appear, and when you end dragging, the arrow disappears and WillPopScope will be called.

Code: https://gist.github.com/fzyzcjy/4d9238c5be3f49ab45de9a00436b4743

(Used my own internal code, so you may need a bit of tweaking, e.g. remove the Log line)

@hanstarj
Copy link

hanstarj commented Aug 21, 2023

Sharing my workaround using a callback, without using WillPopScope, if you need to get a result back from the pushed page with all back behaviors working.

The pushed page takes a callback, which is then called for any update happening. It's not efficient as it can be called multiple times (potentially triggering rebuild of the parent page, depending on you have to do setState or not), but it gets the job done.

class PushedPage extends StatelessWidget {
  final void Function(int updated)? onUpdate;
  const PushedPage({super.key, this.onChange});
  ...

  onPress: () { onUpdate?.call(42); },
  ...
}

///

class HomePage extends StatefulWidget {
  ...
}

class _HomePageState extends State<HomePage> {
  ...
  Navigator.push(
    context,
    MaterialPageRoute(
    builder: (context) => PostPage(
      onUpdate: (updated) {
        setState(() { _pushedPageResult = updated; });
      },
    ),
  ).then((_) { /* use _pushedPageResult ... */ });
}

@pro100svitlo

This comment was marked as duplicate.

@kofkuiper
Copy link

import 'dart:io';

import 'package:flutter/cupertino.dart';

class IosBackGestureDetector extends StatelessWidget {
  const IosBackGestureDetector({
    Key? key,
    required this.child,
    required this.onBackGesture,
  }) : super(key: key);

  final Widget child;
  final VoidCallback onBackGesture;

  @override
  Widget build(BuildContext context) {
    bool checkValue = false;
    return GestureDetector(
      onHorizontalDragStart: (DragStartDetails details) {
        checkValue = false;
        checkValue = details.localPosition.dx < 50 || details.localPosition.dx > (MediaQuery.of(context).size.width - 70);
      },
      onHorizontalDragEnd: (DragEndDetails details) {
        if (checkValue && Platform.isIOS) {
          onBackGesture();
        }
      },
      child: child,
    );
  }
}

wrap this with onWillPopScope it will detect gesture detector on iOS

I use it works for back swipe ,but when I use it in webview page, the webview content will can not scroll

🔥 I encountered the same issue and tried using a Listener widget with the onPointerMove callback. 🚀 It works for back swipe and webview content scrolling. Hope it works for you!

   Listener(
     key: widget.key,
     onPointerMove: (event) {
       if (event.delta.dx > 10 &&
           event.delta.dy >= 0 &&
           event.delta.dy <= 10) {
         // Do something
       }
     },
     child: webViewWidget,
   );

More : https://github.com/kofkuiper/swipe-iOS/blob/main/lib/main.dart

@abdur-rohman
Copy link

Just sharing my simple widget to enable ios gesture:

using will pop scope:
https://pub.dev/packages/adaptive_will_pop_scope

using pop scope:
https://pub.dev/packages/adaptive_pop_scope

@mzelldev
Copy link

Can we please get an update from the Flutter team on this issue?

I am not super familiar with iOS UI/UX, but can someone elaborate on why using PopScope/WillPopScope as a mechanism to intercept the back gesture and display a dialog when the user has pending form changes is not recommended for iOS? On Android this is perfectly acceptable UI/UX, and is used extensively.

If the Flutter team is unable (or unwilling) to fix this open issue, then what is the iOS alternative to the workflow above? I read through this entire thread, and am still not clear on this. (sorry if I missed it)

@whitte-h
Copy link

After reading this I did a tricky fast solution:

Widget WillPopOrChild( // could be moved to a class tho
      {required bool shouldConfirmLeave,
      required Widget child,
      required Future<bool> Function()? onWillPop}) {
    if (shouldConfirmLeave) {
      return WillPopScope(onWillPop: onWillPop, child: child);
    }
    return child;
  }

I have a flag in state when there's edition and changes, so if that's true I will pass that to the function and it will show a confirmation and prevent swiping back, if not, it will just swipe back, I think combined with some form of telling the user that still have pending changes will do the trick, hope this helps someone

@giordy16
Copy link

@darshankawar any comment for this? it's here since years

@thanoofayoob
Copy link

any update on this issue.? or any feasible solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
customer: crowd Affects or could affect many people, though not necessarily a specific customer. customer: mulligan (g3) d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos framework flutter/packages/flutter repository. See also f: labels. P2 Important issues not at the top of the work list team-framework Owned by Framework team triaged-framework Triaged by Framework team
Projects
None yet
Development

No branches or pull requests