How Gestures Work in Jetpack Compose | by Sherry Yuan

Posted on
Photo by Edvard Alexander Rølvaag on Unsplash

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 Modifiers 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 Modifiers.

How do MotionEvents from the Android framework translate to Modifiers 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 MotionEvents to Compose gestures happens. It uses an instance of MotionEventAdapter to convert MotionEvents into Compose PointerInputEvents, 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.

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:

  1. PointerEventPass.Initial: The event travels down the tree from ancestor to descendant, allowing parents to handle some motions before its children.
  2. PointerEventPass.Main: The event goes back up the tree from descendant to ancestor. This is the primary pass where most gesture-handling happens.
  3. 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 Composables 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.

One limitation of the Compose gesture Modifiers 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 Modifiers 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:

  1. fastAny and fastAll are from the androidx.compose.ui:ui-util package, so it should be included as a dependency in in build.gradle.
  2. In detectTapUnconsumed(), we use awaitFirstDown(requireUnconsumed = false) instead of requireUnconsumed = true like in the original implementation to make sure we get all events.
  3. We don’t call upOrCancel.consumeDownChange() so that the event isn’t marked as consumed.
  4. waitForUpOrCancellationInitial() is nearly identical to the waitForUpOrCancellation() given in the Compose API, except it calls awaitPointerEvent(PointerEventPass.Initial) instead of awaitPointerEvent(PointerEventPass.Main), to get events before they can be consumed in the Main pass.
  5. 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.

Another limitation with the existing Modifiers 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:

  1. awaitFirstDownOnPass() is an internal class in TapGestureDetector.kt, so we have to copy the implementation into our own codebase in order to let our extension functions access it.
  2. 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 Modifiers that address these limitations get added to the API in a future release.

Hope you found this article useful! Dropping the links to the other Android Touch System articles:

Thanks to Russell and Kelvin for their valuable editing and feedback ❤️

Leave a Reply

Your email address will not be published.