8

I'm making an Android app in Flutter and I want to add an interactive widget that listens to raw pointer events and reacts to them. This is easy to do using the Listener widget:

class _MyWidget extends State<MyWidget> {
  @override
  build(BuildContext ctx) {
    return Listener(
      onPointerMove: (event) {
        event.delta // use this to do some magic...
      },
      child: ...
    );
  }
}

However, this widget is rendered inside a PageView, which allows the user to swipe in order to change pages. This is a behavior I don't want to get rid of – except for the time when the user swipes over MyWidget. Since the MyWidget requires the user to touch-and-drag, I don't want them to accidentaly nagivate to a different page.

In a browser, this would be extremely simple to implement, I'd just call event.stopPropagation() and the event wouldn't propagate (ie. bubble) from MyWidget to its ancestor PageView. How do I stop propagation of an event in Flutter?

I could, in theory, make MyWidget set the application state, and use that to disable PageView while I'm swiping over MyWidget. However, that would go against the natural dataflow of Flutter's widgets: it would require me to add callbacks on multiple places and make all the widgets more intertwined and less reusable. I would much prefer to prevent the event from bubbling, locally.


EDIT: I've tried using AbsorbPointer, however it seems to be "blocking propagation" in the wrong direction. It blocks all children of AbsorbPointer from recieving pointer events. What I want is quite the opposite – stop pointer events on a child from propagating to its ancestors.

m93a
  • 8,866
  • 9
  • 40
  • 58
  • [Related question](https://stackoverflow.com/q/57952331/1137334). However, their setting is slightly different and the recommended walkaround there doesn't apply to my question. – m93a Mar 25 '22 at 16:50
  • Earlier I thought you were just asking about stopping notification events. I just re-read your question, deleted my previous answer, and wrote a new one, it's actually a non-trivial question that deserves more upvotes lol. – WSBT Mar 25 '22 at 19:34

2 Answers2

12

In Flutter, usually there is only 1 widget that would respond to user touch events, instead of everything at the touch location responding together. To determine which widget should be the one answering a touch event, Flutter uses something called "gesture arena" to determine a "winner".

Listener is a raw listening widget that does not compete in the "gesture arena", so the underlying PageView will still "win" in the "gesture arena" even though Listener is technically closer to the user (and would've won).

On the other hand, more advanced (less raw) widgets such as GestureDetector does enter the "gesture arena" competition, and will "win", thus causing the PageView to "lose" in the arena, so the page view would not move.

For example, try putting this code inside a PageView and drag on it:

GestureDetector(
  onHorizontalDragUpdate: (DragUpdateDetails details) {
    print('on Horizontal Drag Update');
  },
  onVerticalDragUpdate: (DragUpdateDetails details) {
    print('on Vertical Drag Update');
  },
  child: Container(
    width: 100,
    height: 100,
    color: Colors.red,
  ),
)

You should notice that the PageView no longer scrolls.

However, depending on the scroll axis of the PageView (whether it's scrolling horizontally or vertically), removing one of the event on GestureDetector above (e.g. remove onHorizontalDragUpdate event listener on a horizontally scrolling PageView) would enable the PageView to scroll again. This is because horizontal swiping and vertical swiping are "non-conflicting events" that enter different gesture arenas.

So, go back to your original question, can you rewrite your business logic using these "more advanced" widgets, instead of dealing with the raw level data Listener? If so, the problem will be solved by the framework for you.

If you must use Listener for some reason, I have another possible workaround for you: In PageView widget, you can pass in physics: const NeverScrollableScrollPhysics() to make it stop scrolling. So you can perhaps monitor touch events, and set/clear property accordingly. Essentially, at "touch down", lock the PageView, and at "touch up", free it.

WSBT
  • 33,033
  • 18
  • 128
  • 133
  • Thank you for a great in-depth explanation! Setting the correct listeners on the `GestureDetector` does indeed prevent the propagation, as it "wins" in the gesture arena. – m93a Mar 25 '22 at 19:49
2

Based on my testing, Flutter sends events to the most deeply-nested widget first. You can use this to your advantage:

class MyWidget extends StatefulWidget {
  // ...
}

class _MyWidgetState extends State<MyWidget> {
  bool _pointerDownInner = false;

  @override
  Widget build(BuildContext context) {
    // Outer listener
    return Listener(
      onPointerDown: (event) {
        // If the mouse is over both listeners when the event 
        // happens then this flag will be true; otherwise it will
        // be false.
        if (!_pointerDownInner) {
          // ...
        }

        _pointerDownInner = false;
      },
      // Inner listener
      child: Listener(
        onPointerDown: (event) {
          // This is called first, so we set a flag here.
          //
          // There's no need to setState() here since we don't
          // want the widget to re-render.
          _pointerDownInner = true;
        },
      ),
    );
  }
}

You can also do this across widgets:

class EventFlags {
  bool pointerDownInner = false;
  // ...
}

class Outer extends StatefulWidget {
  const Outer({Key? key}) : super(key: key);

  @override
  State<Outer> createState() => _OuterState();
}

class _OuterState extends State<Outer> {
  final EventFlags eventFlags = EventFlags();

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: (event) {
        // If the mouse is over both listeners when the event 
        // happens then this flag will be true; otherwise it will
        // be false.
        if (!eventFlags.pointerDownInner) {
          // Do something...
        }

        eventFlags.pointerDownInner = false;
      },
      child: Inner(eventFlags: eventFlags),
    );
  }
}

class Inner extends StatelessWidget {
  final EventFlags eventFlags;

  const Inner({Key? key, required this.eventFlags}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: (event) {
        eventFlags.pointerDownInner = true;
      },
    );
  }
}
Joshua Wade
  • 4,755
  • 2
  • 24
  • 44