DISCLAIMER: This article was written in September 2021 for compose 1.0.0 and is definitely not describing a full power and all possible cases of touch event handling, but hopefully will help to understand a bit how touch events are flowing inside Jetpack Compose. And also there will be a lot of code snippets also belonging to compose version 1.0.0 🙂
Custom touch event handling is not always an easy job. In the Android
Views framework you had a pretty complex scheme for how events dispatched to specific views, how to intercept them and what callback you need to implement your own touch events listener. Therefore our beloved StackOverflow has quite a few questions like this one — Touch listener not working.
Jetpack Compose also offers mechanisms to handle custom touch events. There are a high level mechanisms — like clickable or scrollable modifiers and a lower level ones — like pointerInput or pointerFilterInterop for operating with good old MotionEvents.
But how does actual MotionEvent from Android framework become available on specific composable modifier’s callback?
This question can be split into two parts.
- We need to understand how and where Android MotionEvents are crossing the Compose border.
- We need to understand how the framework operating these touch events and deliver specific callbacks to them.
A Compose world starts from either setContent extension method of ComponentActivity or setContent method of a ComposeView. Let’s look inside.
This means that first of all framework will try to find existing ComposeView at the top of view tree if it was already set, if not it will create a new one and execute setContent method of a ComposeView. That means that ComposeView class is responsible for passing touch events into compose world. Let’s go deeper.
ComposeView class actually extends AbstractComposeView, now let’s open it and check if we’ll find anything related to MotionEvents. Nope, nothing related to touch handling not in a ComposeView not in AbstractComposeView. But there is a method called ensureCompositionCreated() in AbstractComposeView which is called from multiple places, and if you look inside it you’ll see another setContent method invocation :), but this time setContent is an extension method of a ViewGroup.
It basically works similar to ComponentActivity.setContentit checks if ViewGroup already has AndroidComposeView as child, if yes it just sets content to existing view, otherwise it will remove all views, create new AndroidComposeView and add it to view tree.
And finally, if you check AndroidComposeView implementation you’ll see that it has overridden dispatchTouchEvent method in which all MotionEvents conversion into Compose world happens. MotionEventAdapter will somehow try to convert MotionEvent into Compose PointerInputEvent and if success will pass this event beyond the Compose world border. And to keep contract to Android View it will return Boolean which will tell AndroidView if someone from Compose world consumed this event.
Motion event path will look like this: Activity.dispatchTouchEvent -> ComposeView.dispatchTouchEvent -> AndroidComposeView.dispatchTouchEvent -> Compose World
Now let’s switch to second part of question and dive into PointerInputEventProcessor.process method. As the first parameter it takes MotionEvent converted to PointerInputEvent.
Then based on received “raw” pointerEvent, PointerInputChangeEventProducer calculates “diff” or “change” between previous pointer event and current one (it helps to determine which event happens down/up or move) operating with PointerInputChange classes. Internally PointerInputChangeEventProducer caches previous PointerInputData and based on it calculates “change” compared to current one.
The most interesting part is coming, based on this pointer input change framework determines if current event changed to down event and then it calls on root LayoutNode hitTest method with position of pointer and hit result — just a mutable list of PointerInputFilters — an interface that has needed onPointerEvent callback.
LayoutNode.hitTest method internally calls hitTest on LayoutNodeWrapperlet’s see docs
LayoutNodeWrapper is an abstract class and it has PointerInputDelegatingWrapper as one of the successors. And inside it we’ll see the logic described in hitTest method documentation. If the pointer input event within layer bounds we will add PointerInputFilter to collection of hitPointerInputFilters, and will call hitTest further on wrapped object.
I know that its really hard to follow the idea when it has only has ripped out random code snippets, but please bear with me, the diagram is coming 🙂
Phew… that was tough, but now its clear when a new down event is coming, framework will:
- Convert Android MotionEvent into internal type
- Calculate changes between current event and previous one, generating instance of PointerInputChanges class
- If this event is new down event for pointer, framework will traverse all LayoutNode tree to find PointerInputFilters interested in this event by determining if event coordinates of touch event are within bounds of LayoutNode
- Collect all this PointerInputFilters in a list
- Then submit this list to HitPathTracker.addHitPath. This will enable future calls to HitPathTracker.dispatchChanges to dispatch the correct PointerInputChanges to the right PointerInputFilters at the right time
One last question needs to be answered is how actually callback from Modifier.pointerInput becomes part of LayoutNodeWrapper?
Inside pointerInput modifier a SuspendingPointerInputFilter object created which implements PointerInputModifier interface. When a modifier applied to LayoutNodemodifier chain is folded out and based on it a new chain of LayoutNodeWrappers created and applied to current LayoutNode.
And the full scheme of touch event flow will look like this:
Hope you enjoyed reading, cheers!