Blog Infos
Author
Published
Topics
,
Published

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 HierarchyPart 4 goes over the gesture-handling Modifiers.

From MotionEvents to PointerInputEvents

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.

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:

  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.

Limitations and Custom Modifiers

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.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Jetpack Compose: Drawing without pain and recomposition

This is a talk on recomposition in Jetpack Compose and the myths of too many calls it is followed by. I’ll briefly explain the reasons behind recompositions and why they are not as problematic as…
Watch Video

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jetpack Compose: Drawing without pain and recomposition

Vitalii Markus
Android Engineer
Flo Health Inc.

Jobs

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.

Final Words

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 ❤️

This article was originally published on proandroiddev.com on July 16, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu