This article is the fifth and final part of my Android Touch System series, and covers how pointer events work in the Jetpack Compose hierarchy, some limitations of gesture detection in Compose, and how to create custom Modifier
s for overcoming the limitations. It assumes readers have some understanding of MotionEvents. If you’re unfamiliar with them, please check out Part 1: Touch Functions and the View Hierarchy. Part 4 goes over the gesture-handling Modifier
s.
From MotionEvents to PointerInputEvents
How do MotionEvent
s from the Android framework translate to Modifier
s under the hood?
We enter the Compose world either from a ComponentActivity
’s setContent()
extension function or ComposeView
’s setContent()
function. Both setContent()
s use AndroidComposeView
for displaying content.
AndroidComposeView.dispatchTouchEvent()
, which overrides View.dispatchTouchEvent()
, is where the conversion from MotionEvent
s to Compose gestures happens. It uses an instance of MotionEventAdapter
to convert MotionEvent
s into Compose PointerInputEvent
s, then passes the event into the Compose world. To adhere to View.dispatchTouchEvent()
’s API contract, it’ll return a Boolean
to let the Android framework know if the event was consumed by a Composable. If it returns false, AndroidComposeView
’s parent views can handle it like a normal MotionEvent
.
The MotionEvent
’s path looks something like this:
→ Activity.dispatchTouchEvent()
→ SomeViewGroup.dispatchTouchEvent()
→ ComposeView.dispatchTouchEvent()
→ AndroidComposeView.dispatchTouchEvent()
→ Compose world
Now let’s see what happens on the Compose side.
The PointerInputEvent
created earlier is passed as a parameter to PointerInputEventProcessor.process()
. process()
calculates the change between the previous pointer event and current one to determine the event type (ie. up or down or move). Finally, process()
performs a hit test on the root node of the Composable tree, then recursively performs the same test on child nodes, dispatching the PointerInputEvent
to nodes that pass the test.
PointerInputEvents and the Compose Hierarchy
Similar to traditional Views, Compose UI consists of Composables in a tree-like hierarchy. Let’s take a look at how pointer events pass through the hierarchy. Fortunately, it’s more intuitive than how it works in the View system!
Events traverse the hierarchy in three passes, which are captured in the PointerEventPass
enum:
PointerEventPass.Initial: The event travels down the tree from ancestor to descendant, allowing parents to handle some motions before its children.
PointerEventPass.Main: The event goes back up the tree from descendant to ancestor. This is the primary pass where most gesture-handling happens.
PointerEventPass.Final: The event travels down from ancestor to descendant again, where children can learn what aspects of the event were consumed by its descendants during the main pass.
For all of the Modifiers
in the Compose API, the event-handling happens during the Main
pass. If a leaf Composable has a Modifier
set for a certain gesture, it’ll consumes the gesture and no other Composable
s get to handle it. If it doesn’t have a Modifier
set, the gesture is sent up towards the root until it reaches a Composable that can handle it.
If there are two overlapping composable functions at the same level of the hierarchy, the one that’s called later will be drawn above the one called earlier and get to consume any gestures first.
For example, given the following code:
Box(modifier = Modifier.align(Alignment.Center) { Box( modifier = Modifier .size(200.dp) .background(Color.LightGray) .clickable { Log.d(TAG, “Light gray box clicked”) } ) Box( modifier = Modifier .size(100.dp) .background(Color.DarkGray) .clickable { Log.d(TAG, “Dark gray box clicked”) } ) }
If there’s a click where the boxes overlap, “Dark gray box clicked” will be logged.
By default, functions are called in the order that they appear in the codebase, but we can useModifier.zIndex()
to explicitly control the drawing order for children of the same parent; the child with the higher zIndex
will be drawn later and get access to gestures first.
Limitations and Custom Modifiers
One limitation of the Compose gesture Modifier
s is there’s currently no way to detect specific gestures without swallowing them, which is needed for use cases like event logging. This is because all the Modifier
s call consumeDownChange()
, which marks the event as consumed. One workaround for this is to create new PointerInputScope
extension functions based on the existing ones.
Here’s an extension function for detecting tap gestures without consuming them, heavily based on this StackOverflow post. It’s very similar to the existing detectTapGesture()
implementation.
suspend fun PointerInputScope.detectTapUnconsumed( onTap: ((Offset) -> Unit) ) { val pressScope = PressGestureScopeImpl(this) forEachGesture { coroutineScope { pressScope.reset() awaitPointerEventScope { awaitFirstDown(requireUnconsumed = false).also { it.consumeDownChange() } val up = waitForUpOrCancellationInitial() if (up == null) { pressScope.cancel() } else { pressScope.release() onTap(up.position) } } } } } suspend fun AwaitPointerEventScope.waitForUpOrCancellationInitial(): PointerInputChange? { while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) if (event.changes.fastAll { it.changedToUp() }) { return event.changes[0] } if (event.changes.fastAny { it.consumed.downChange || it.isOutOfBounds(size,extendedTouchPadding) } ) { return null } // Check for cancel by position consumption. // We can look on the Final pass of the existing // pointer event because it comes after the Main // pass we checked above. val consumeCheck = awaitPointerEvent(PointerEventPass.Final) if (consumeCheck.changes.fastAny { it.positionChangeConsumed() } ) { return null } } }
There are a few things I want to highlight:
fastAny
andfastAll
are from theandroidx.compose.ui:ui-util package, so it should be included as a dependency in in
build.gradle
.- In
detectTapUnconsumed()
, we useawaitFirstDown(requireUnconsumed = false)
instead ofrequireUnconsumed = true
like in the original implementation to make sure we get all events. - We don’t call
upOrCancel.consumeDownChange()
so that the event isn’t marked as consumed. waitForUpOrCancellationInitial()
is nearly identical to thewaitForUpOrCancellation()
given in the Compose API, except it callsawaitPointerEvent(PointerEventPass.Initial)
instead ofawaitPointerEvent(PointerEventPass.Main)
, to get events before they can be consumed in the Main pass.PressGestureScopeImpl
is a private class, so we have to copy the implementation into our own codebase in order to let our extension functions access it.
Job Offers
Another limitation with the existing Modifier
s is none of them let parent Composables intercept and consume events before their children. We can create an extension function for this as well. The code is based on this StackOverflow post.
suspend fun PointerInputScope.detectTapInitialPass( onTap: ((Offset) -> Unit) ) { val pressScope = PressGestureScopeImpl(this) forEachGesture { coroutineScope { pressScope.reset() awaitPointerEventScope { awaitFirstDownOnPass( pass = PointerEventPass.Initial, requireUnconsumed = false ).also { it.consumeDownChange() } val up = waitForUpOrCancellationInitial() if (up == null) { pressScope.cancel() } else { up.consumeDownChange() pressScope.release() onTap(up.position) } } } } }
Things to note here:
awaitFirstDownOnPass()
is an internal class inTapGestureDetector.kt
, so we have to copy the implementation into our own codebase in order to let our extension functions access it.- Unlike
detectTapUnconsumed()
, we do consume the up event, so that it doesn’t get passed to child Composables.
I wrote this based on the Compose 1.1.1 stable release, and wouldn’t be surprised if Modifier
s that address these limitations get added to the API in a future release.
Final Words
Hope you found this article useful! Dropping the links to the other Android Touch System articles:
- Part 1: Touch Functions and the View Hierarchy
- Part 2: Common Touch Event Scenarios
- Part 3: MotionEvent Listeners
- Part 4: Gesture-Handling Modifiers in Jetpack Compose
Thanks to Russell and Kelvin for their valuable editing and feedback ❤️
This article was originally published on proandroiddev.com on July 16, 2022