14

I have a vertically scrolling WebView inside a horizontally scrolling PageView. Something like this:

PageView.builder(
  itemCount: 5,
  itemBuilder: (context, index) {
    return WebView(
      initialUrl: 'https://flutter.dev/docs',
      gestureRecognizers: [
        Factory(() => VerticalDragGestureRecognizer()),
      ].toSet(),
    );
  },
);

With the previous stable version of Flutter (1.5.4), this worked as expected - scrolling vertically would move the content inside the WebView and scrolling horizontally would move the PageView.

This broke after upgrading to Flutter v1.7.8+hotfix.3. Now horizontal scrolling seems always to win, even if the gesture is very clearly almost entirely vertical. If the page gets scrolled vertically at all, it is only after the gesture stops (i.e., when I stop touching the screen after a gesture) - there is no vertical scrolling while the gesture is happening.

Adding and removing VerticalDragGestureRecognizer from gestureRecognizers have no effect now - either way the program works as if the recognizer was not on the list (although it's not that gestureRecognizers are completely ignored because adding EagerGestureRecognizer DOES have an effect).

Here is the debug output of the gesture arena (keep in mind that I was trying to keep my gesture as vertical as possible, but even a slight finger movement to the sides was enough for the HorizontalDragGestureRecognizer to win, even though I also was moving vertically the entire time):

I/flutter (30125): Gesture arena 14   ❙ ★ Opening new gesture arena.
I/flutter (30125): Gesture arena 14   ❙ Adding: Instance of '_CombiningGestureArenaMember'
I/flutter (30125): Gesture arena 14   ❙ Adding: LongPressGestureRecognizer#9cad1(debugOwner: GestureDetector, state: ready)
I/flutter (30125): Gesture arena 14   ❙ Adding: HorizontalDragGestureRecognizer#69b8f(start behavior: start)
I/flutter (30125): Gesture arena 14   ❙ Closing with 3 members.
I/flutter (30125): Gesture arena 14   ❙ Rejecting: LongPressGestureRecognizer#9cad1(debugOwner: GestureDetector, state: possible)
I/flutter (30125): Gesture arena 14   ❙ Accepting: HorizontalDragGestureRecognizer#69b8f(start behavior: start)
I/flutter (30125): Gesture arena 14   ❙ Self-declared winner: HorizontalDragGestureRecognizer#69b8f(start behavior: start)

And this is what happens when you do manage to keep your gesture entirely vertical (seems to be easier on an emulator with a mouse), while the drag gesture is in process:

flutter: Gesture arena 30   ❙ ★ Opening new gesture arena.
flutter: Gesture arena 30   ❙ Adding: Instance of '_CombiningGestureArenaMember'
flutter: Gesture arena 30   ❙ Adding: HorizontalDragGestureRecognizer#11e7f(start behavior: down)
flutter: Gesture arena 30   ❙ Closing with 2 members.

Even a slight vertical move will make the HorizontalDragGestureRecognizer announce a win, but the VerticalDragGestureRecognizer (which seems to be wrapped inside the _CombiningGestureArenaMember) never claims a victory. It seems to be completely ignored in fact - the gesture arena output with VerticalDragGestureRecognizer in gestureRecognizers and without it is absolutely identical.

It may be a bug in Flutter so I also created an issue on Flutter's GitHub. But either way - how do I achieve this effect with the current version of Flutter? Any workarounds or canonical solutions would be highly appreciated.

Ludwik Trammer
  • 24,602
  • 6
  • 66
  • 90
  • Could make a designated drag area for vertical. Like a scrollbar. Less elegant of course. But if youre in trouble deadline wise it might be useful at least temporarily. – Doh09 Jul 21 '19 at 09:26
  • @Doh09 Thanks for the suggestion. My "less elegant solution for now" is sticking to Flutter 1.5.4. I just don't want to be stuck with an old version forever. – Ludwik Trammer Jul 21 '19 at 09:29

3 Answers3

34

It seems that the rules of the arena have changed. Now the arena declares wins for gestures that have active receivers. That indeed increases the responsiveness of the gestures even more. However, as the native views do not claim the gestures and only consume them when no other active detector/receiver claims them, I suspect that the vertical drag doesn't even enter the arena as a gesture from the WebView. That is why any slight horizontal drag causes horizontal drag gesture to win - because simply no other widgets claim any gesture.

You can extend VerticalDragGestureRecognizer, so it accepts gestures:

class PlatformViewVerticalGestureRecognizer
    extends VerticalDragGestureRecognizer {
  PlatformViewVerticalGestureRecognizer({PointerDeviceKind kind})
      : super(kind: kind);

  Offset _dragDistance = Offset.zero;

  @override
  void addPointer(PointerEvent event) {
    startTrackingPointer(event.pointer);
  }

  @override
  void handleEvent(PointerEvent event) {
    _dragDistance = _dragDistance + event.delta;
    if (event is PointerMoveEvent) {
      final double dy = _dragDistance.dy.abs();
      final double dx = _dragDistance.dx.abs();

      if (dy > dx && dy > kTouchSlop) {
        // vertical drag - accept
        resolve(GestureDisposition.accepted);
        _dragDistance = Offset.zero;
      } else if (dx > kTouchSlop && dx > dy) {
        // horizontal drag - stop tracking
        stopTrackingPointer(event.pointer);
        _dragDistance = Offset.zero;
      }
    }
  }

  @override
  String get debugDescription => 'horizontal drag (platform view)';

  @override
  void didStopTrackingLastPointer(int pointer) {}
}

After that, you can use the new class in gestureRecognizers:

PageView.builder(
  itemCount: 5,
  itemBuilder: (context, index) {
    return WebView(
      initialUrl: 'https://flutter.dev/docs',
      gestureRecognizers: [
        Factory(() => PlatformViewVerticalGestureRecognizer()),
      ].toSet(),
    );
  },
);
Ludwik Trammer
  • 24,602
  • 6
  • 66
  • 90
Saed Nabil
  • 6,705
  • 1
  • 14
  • 36
  • Thank you! I just tested it. At first it didn't work, but it started to work after I removed the `else { stopTrackingPointer(event.pointer); }` part - it seems that at the very beginning of every gesture both `delta.dx` and `delta.dy` equals 0 and if you stop tracking a pointer at this point you lose a chance to notice their further changes. This may be related to me testing on emulator (gestures made in real world with fingers may be too "messy" to start with a 0). – Ludwik Trammer Jul 22 '19 at 20:01
  • Would you be willing to update to remove the `stopTrackingPointer(event.pointer);` from your answer? Or do you see potential problems with doing so and want to propose an alternative solution? – Ludwik Trammer Jul 22 '19 at 20:04
  • @LudwikTrammer Which emulator you tested with ? – Saed Nabil Jul 23 '19 at 00:01
  • iOS emulator, the one bundled with XCode – Ludwik Trammer Jul 23 '19 at 06:02
  • I actually tested the same logic with both android and ios and its working without issues, can you test it with a real device ? – Saed Nabil Jul 23 '19 at 06:05
  • I tested on Android (both simulator and real device) and haven't observed the problem. I don't have an iOS device with me right now, but if it works for you then I'm sure it would work for me. Still, I would like this to work everywhere, including the simulator. Also, even philosophically, making a decision about a gesture before it even fully started seems premature. The real `VerticalDragGestureRecognizer` have a logic of waiting for `kTouchSlop` pixels before making a decision about drag. I made a version that uses similar logic: https://pastebin.com/3FKtuRFi. What do you think? – Ludwik Trammer Jul 23 '19 at 09:16
  • Another problem (that both your version and my modified version share) is that it works great if the drag is fully vertical. If a drag is mostly vertical but also have a horizontal movement to it (even if it is much less) than the horizontal gesture seems to win. While playing on a real device and trying to scroll vertically with my thumb I accidentally switched a page in the `PageView` multiple times. – Ludwik Trammer Jul 23 '19 at 09:20
  • I tested your version but it is not working as expected, the gesture is not resolving smoothly and most of the time the horizontal wins, this not the case with my version for some reason , it seams that kTouchSlop is too big !! – Saed Nabil Jul 23 '19 at 11:33
  • Wait, I figured it out , it seams that the drag update comes in discrete so we need to accumulate it to get the correct value , it is working fine now , check the edit. – Saed Nabil Jul 23 '19 at 11:41
  • 2
    This version works perfectly. I went ahead and cleaned your answer a little (so it consists a single, canonical, ready to use answer - I hope you don't mind), accepted it and awarded you the bounty. Thank you for all the help! – Ludwik Trammer Jul 23 '19 at 12:47
  • 2
    Thank you, It was very interesting question, I learned a lot ,today. – Saed Nabil Jul 23 '19 at 19:20
2

I upgraded my sdk only to have this problem you described. The issue is too annoying and I came up with this rather ugly hack.

This CustomGestureRecognizer will ignore the unwanted behavior when the event is occurring in the middle (usually where we scroll). This does come with some over-scrolling shadows which I believe can be handled, might take some more time that's it.

class CustomGestureRecognizer extends OneSequenceGestureRecognizer {
  double maxScreenOffsetX;
  final double edgeMargin = 20.0;

  CustomGestureRecognizer({@required this.maxScreenOffsetX});

  @override
  void addAllowedPointer(PointerDownEvent event) {

    print("CustomGestureRecognizer: Screen Width: "+ maxScreenOffsetX.toString());
    print("CustomGestureRecognizer: dx: "+event.position.dx.toString());

    if (event.position.dx < edgeMargin || event.position.dx > (maxScreenOffsetX - edgeMargin)) {
      print("CustomGestureRecognizer: At the Edge.");
      return;
    }
    print("CustomGestureRecognizer: Inside Safe Zone");
    startTrackingPointer(event.pointer, event.transform);
    resolve(GestureDisposition.accepted);
    stopTrackingPointer(event.pointer);
  }

PageView Widget

PageView.builder(
        itemCount: 5,
        physics: CustomScrollPhysics(),
        itemBuilder: (context, index) {
          return WebView(
            initialUrl: 'https://flutter.dev/docs',
            gestureRecognizers: [
              Factory(() => CustomGestureRecognizer(maxScreenOffsetX: screenWidth)),
            ].toSet(),
          );
        });

Screen Width

  @override
  Widget build(BuildContext context) {
    screenWidth = MediaQuery.of(context).size.width;
    return Scaffold(//...
shb
  • 5,957
  • 2
  • 15
  • 32
  • 3
    That workaround is quite unintuitive for the user so I don't think I would be able to use this in production but it's the first indication that I'm not the only one grappling with this and looking for a solution, so thank you! – Ludwik Trammer Jul 22 '19 at 13:26
  • No problem mate! – shb Jul 22 '19 at 14:37
0

Modified with help of above two answers

class PlatformViewHorizontalGestureRecognizer
    extends OneSequenceGestureRecognizer {

    final Function()? onToLeftSwipe;
    final Function()? onToRightSwipe;

    final double edgeMargin = 20.0;

    PlatformViewHorizontalGestureRecognizer({
   

         this.onToLeftSwipe,
    this.onToRightSwipe,
  });

  Offset _dragDistance = Offset.zero;

  @override
  void addPointer(PointerEvent event) {
    startTrackingPointer(event.pointer, event.transform);
  }

  @override
  String get debugDescription => 'horizontal drag (platform view)';

  @override
  void didStopTrackingLastPointer(int pointer) {}

  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerDownEvent) {
      _dragDistance = Offset.zero;
    } else if (event is PointerMoveEvent) {
      _dragDistance = _dragDistance + event.delta;
    } else if (event is PointerUpEvent) {
      final dy = _dragDistance.dy.abs();
      final dx = _dragDistance.dx.abs();

      if (dx > kTouchSlop && dx > dy) {
        final xDistance = _dragDistance.dx;
        if (xDistance > 0) {
          if (onToLeftSwipe != null) {
            onToRightSwipe!();
          }
        } else {
          if (onToLeftSwipe != null) {
            onToLeftSwipe!();
          }
        }

        resolve(GestureDisposition.accepted);
        stopTrackingPointer(event.pointer);
        _dragDistance = Offset.zero;
      }
    }
  }
}
Vinod Sutar
  • 101
  • 6