Combining multiple GestureDetectors in Flutter
In this article I will present a use case where multiple GestureDetectors can interfere with each other and how by implementing our own GestureDetector we can modify the default precedences.
Da problem!
In one of my past articles I explained how I built a circular slider widget in Flutter, which is also published in https://pub.dartlang.org/packages/flutter_circular_slider. Life was good and I was happy, until some unscrupulous user opened an issue claiming problems when the widget was used inside a scroll.
My CircularSlider widget listens to drag events, both horizontal and vertical. When placing it inside a scrollable view we can see that while I can still slide when when I drag the handlers horizontally, as soon as I try moving vertically the scroll takes over and the widget becomes unusable. It’s clear that there is an issue with gestures and precedences.
A bit of research
Spoiler altert!
I fixed the problem.
I found the following article https://medium.com/flutter-community/flutter-deep-dive-gestures-c16203b3434f which was really helpful giving me the entry point for the problem. That and a bit of reading through Flutter’s documentation (and source code) did the rest.
How does all this work?
A GestureDetector creates a GestureRecognizerFactory. This factory can handle different types of gestures, and for each one needs a GestureRecognizer. Every time one of those recognizes a gesture they can deal with, it’s sent to the gesture arena. There, all those recognizers will fight for the victory, and only one shall be victorious.
Wait… what?
Long story short, when multiple gestures are detected for the same widget (tree), Flutter will decide which GestureDetector will handle the event based on a combination of factors.
This should be fine for many situations, but we can alter this behavior if we want.
Fixing the issue
A bit of debugging
The first thing we can do to see what’s happening is enabling a debug property in the code which will allows us to see what gestures are conflicting.
I modified the code in my main.dart to add a couple of big Container widgets and thus causing the screen exceed the size of the viewport. Then I wrapped everything in a SingleChildScrollView. This way I can scroll the view and reproduce the problem. Finally, I enabled the property debugPrintGestureArenaDiagnostics in the main() method.
Now when I try to drag the handlers vertically I get this in the console:
I/flutter ( 4013): Gesture arena 3 ❙ ★ Opening new gesture arena.I/flutter ( 4013): Gesture arena 3 ❙ Adding: PanGestureRecognizer#1efbf(debugOwner: GestureDetector, start behavior: down)I/flutter ( 4013): Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#f0577(start behavior: down)I/flutter ( 4013): Gesture arena 3 ❙ Closing with 2 members.I/flutter ( 4013): Gesture arena 3 ❙ Accepting: VerticalDragGestureRecognizer#f0577(start behavior: down)I/flutter ( 4013): Gesture arena 3 ❙ Self-declared winner: VerticalDragGestureRecognizer#f0577(start behavior: down)
We can see two things clearly:
- This cool concept of the gesture arena was not my invention.
- PanGestureRecognizer (the one from my slider) and VerticalDragGestureRecognizer (from SingleChildScrollView) are fighting, and clearly the last one is winning.
I would expect the inner widget (GestureDetector) to take precedence but hey, there’s probably a reason why this is not happening.
Please welcome GestureRecognizers
If you have not read my previous article you will be asking yourself where this PanGestureRecognizer is coming from. My CircularSlider widget contains a GestureDetector with callbacks for pan events.
So, GestureDetector is internally creating a GestureRecognizerFactory which initializes GestureRecognizers for many different events (tap, drag, long press, etc). Every time a pointer is detected, all recognizers that could be handling the event enter the arena and observe how this pointer moves, and subsequently only one of them will proclaim victory. At this point, as the event has been uniquely identified, the related handler (if any) will be executed.
By using GestureDetector we don’t need to worry about any of this, but if we want to alter this default behavior we need to use or own GestureDetector and GestureRecognizer.
What do I want to achieve?
As we saw earlier, right now the only way to interact with my slider when this is inside a vertical scroll, is by dragging the handlers horizontally. What I want to happen would be:
- if the user taps down (and then drags) in any of the handlers, my widget will deal with the event, whatever this is.
- if the user taps down (and then drags) anywhere else than my handlers, then the scroll will deal with the event.
In order to do that, what I need is:
- implement my own GestureDetector, using RawGestureDetector
- add my CustomPanGestureRecognizer to the GestureRecognizerFactory in my RawGestureDetector to deal with the pointers.
- when a pointer is detected, CustomPanGestureRecognizer will check if this is a tap down event and if it has happened in one of the handlers; if so, CustomPanGestureRecognizer will declare victory in the arena and the pointer will be added to the track; otherwise, we don’t need to do anything, the default behavior will take over and the VerticalDragGestureRecognizer from the scroll will deal with the pointer and the event.
- finally, once the pointer is being tracked by CustomPanGestureRecognizer, any moving event will be deal with by our callback _onPanUpdate(), and an up event will be deal with by _onPanEnd() and also we will need to stop tracking the pointer.
What’s all this nonsense? show me some code!
First, we need to use our own GestureDetector, create our factory and our GestureRecognizer.
Everything else in our widget stays the same for now. Now let’s create CustomPanGestureRecognizer.
We need to extend OneSequenceGestureRecognizer, as we only need to deal with one gesture at a time. We also need to provide the callbacks to deal with the events, as the information (radius, center) we need to check if the pointer affects our slider handlers is in CircularSliderPaint.
Then, when a pointer is detected we verify that is affecting our handlers and only then we declare victory with resolve(GestureDisposition.accepted) and start tracking the pointer. Once that’s done, subsequent movements in the event will be dealt with by handleEvent().
Is that it? Does it work?
It does indeed.
Some recap
So basically, Flutter implements its own system to deal with multiple GestureDetectors and that’s usually all we need.
A GestureDetector initializes a GestureRecognizerFactory with as many GestureRecognizers as needed, depending on the events supported. Once a pointer is identified, all recognizers can track the event and add themselves to the gesture arena. Depending on the movement of the pointer a specific event will be identified and a GestureRecognizer will be declared winner to deal with the event.
If we want, we can change that behavior by implementing our own GestureRecognizer.
That’s all. You can check the complete code in my github repository https://github.com/davidanaya/flutter-circular-slider.
Thanks for reading!