Blog Infos
Author
Published
Topics
Published

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

View touch event handling

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.

  1. We need to understand how and where Android MotionEvents are crossing the Compose border.
  2. 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.

public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
val existingComposeView = window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView
if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else ComposeView(this).apply {
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
// Set the view tree owners before setting the content view so that the inflation process
// and attach listeners will see them already present
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
}

setContent ext method of a ComponentActivity

 

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.

internal fun ViewGroup.setContent(
parent: CompositionContext,
content: @Composable () -> Unit
): Composition {
GlobalSnapshotManager.ensureStarted()
val composeView =
if (childCount > 0) {
getChildAt(0) as? AndroidComposeView
} else {
removeAllViews(); null
} ?: AndroidComposeView(context).also { addView(it.view, DefaultLayoutParams) }
return doSetContent(composeView, parent, content)
}

setContent ext method of a view group

 

It basically works similar to ComponentActivity.setContent, it 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 succeed 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.

override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
...
try {
...
val pointerInputEvent =
motionEventAdapter.convertToPointerInputEvent(motionEvent)
if (pointerInputEvent != null) {
pointerInputEventProcessor.process(pointerInputEvent)
} else {
pointerInputEventProcessor.processCancel()
ProcessResult(
dispatchedToAPointerInputModifier = false,
anyMovementConsumed = false
)
}
if (processResult.anyMovementConsumed) {
parent.requestDisallowInterceptTouchEvent(true)
}
return processResult.dispatchedToAPointerInputModifier
} finally {
...
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

Simplified implementation of a AndroidComposeView.dispatchTouchEvent

 

Motion event path will look like this: Activity.dispatchTouchEvent -> ComposeView.dispatchTouchEvent -> AndroidComposeView.dispatchTouchEvent -> Compose World

 

Touch event scheme

 

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.

fun process(
pointerEvent: PointerInputEvent,
...
): ProcessResult {
// Gets a new PointerInputChangeEvent with the PointerInputEvent.
val internalPointerEvent =
pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
...
}
view raw MainActivity.kt hosted with ❤ by GitHub

Job Offers

Job Offers


    Kotlin Backend Developer and Mobile Enthusiast (m/f/d)

    Axel Springer National Media & Tech
    Berlin
    • Full Time
    apply now

    Senior Android Developer

    Komoot
    remote
    • Full Time
    apply now

    Android Developer

    Yoti Ltd
    Anywhere
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

, ,

What does Recomposition mean to your app?

You’ve heard a lot that Jetpack Compose is a declarative UI toolkit and it recomposes only the components that changed. But what does it exactly mean? How does it apply not only in the scale…
Watch Video

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engi ...
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

Jobs

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.

class PointerInputChange(
val id: PointerId,
val uptimeMillis: Long,
val position: Offset,
val pressed: Boolean,
val previousUptimeMillis: Long,
val previousPosition: Offset,
val previousPressed: Boolean,
val consumed: ConsumedData,
val type: PointerType = PointerType.Touch
)
view raw PointerEvent.kt hosted with ❤ by GitHub

pointer input change class

 

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.

private val hitResult: MutableList<PointerInputFilter> = mutableListOf()
fun process(
pointerEvent: PointerInputEvent,
...
): ProcessResult {
// Gets a new PointerInputChangeEvent with the PointerInputEvent.
val internalPointerEvent =
pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
// Add new hit paths to the tracker due to down events.
internalPointerEvent.changes.values.forEach { pointerInputChange ->
if (pointerInputChange.changedToDownIgnoreConsumed()) {
root.hitTest(
pointerInputChange.position,
hitResult
)
...
}
}
...
}
view raw MainActivity.kt hosted with ❤ by GitHub

LayoutNode.hitTest method internally calls hitTest on LayoutNodeWrapper, let’s see docs

/**
* Executes a hit test on any appropriate type associated with this [LayoutNodeWrapper].
*
* Override appropriately to either add a [PointerInputFilter] to [hitPointerInputFilters] or
* to pass the execution on.
*
* @param pointerPosition The tested pointer position, which is relative to
* the [LayoutNodeWrapper].
* @param hitPointerInputFilters The collection that the hit [PointerInputFilter]s will be
* added to if hit.
*/
abstract fun hitTest(
pointerPosition: Offset,
hitPointerInputFilters: MutableList<PointerInputFilter>
)

Hit test documentation

 

LayoutNodeWrapper is an abstract class and it has PointerInputDelegatingWrapper as one of 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.

override fun hitTest(
pointerPosition: Offset,
hitPointerInputFilters: MutableList<PointerInputFilter>
) {
if (isPointerInBounds(pointerPosition) && withinLayerBounds(pointerPosition)) {
// If the pointer is in bounds, we hit the pointer input filter, so add it!
hitPointerInputFilters.add(modifier.pointerInputFilter)
// Also, keep looking to see if we also might hit any children.
// This avoids checking layer bounds twice as when we call super.hitTest()
val positionInWrapped = wrapped.fromParentPosition(pointerPosition)
wrapped.hitTest(positionInWrapped, hitPointerInputFilters)
}
}

hitTest implemenation

 

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 new down event is coming, framework will:

  1. Convert Android MotionEvent into internal type
  2. Calculate changes between current event and previous one, generating instance of PointerInputChanges class
  3. 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
  4. Collect all this PointerInputFilters in a list
  5. 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
fun process(
pointerEvent: PointerInputEvent,
positionCalculator: PositionCalculator
): ProcessResult {
// Gets a new PointerInputChangeEvent with the PointerInputEvent.
val internalPointerEvent =
pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
// Add new hit paths to the tracker due to down events.
internalPointerEvent.changes.values.forEach { pointerInputChange ->
if (pointerInputChange.changedToDownIgnoreConsumed()) {
root.hitTest(
pointerInputChange.position,
hitResult
)
if (hitResult.isNotEmpty()) {
hitPathTracker.addHitPath(pointerInputChange.id, hitResult)
hitResult.clear()
}
}
}
// Remove [PointerInputFilter]s that are no longer valid and refresh the offset information
// for those that are.
hitPathTracker.removeDetachedPointerInputFilters()
// Dispatch to PointerInputFilters
val dispatchedToSomething = hitPathTracker.dispatchChanges(internalPointerEvent)
var anyMovementConsumed = false
// Remove hit paths from the tracker due to up events, and calculate if we have consumed
// any movement
internalPointerEvent.changes.values.forEach { pointerInputChange ->
if (pointerInputChange.changedToUpIgnoreConsumed()) {
hitPathTracker.removeHitPath(pointerInputChange.id)
}
if (pointerInputChange.positionChangeConsumed()) {
anyMovementConsumed = true
}
}
return ProcessResult(dispatchedToSomething, anyMovementConsumed)
}
view raw MainActivity.kt hosted with ❤ by GitHub

Full code of process method

 

One last question needs to be answered is how actually callback from Modifier.pointerInput becomes part of LayoutNodeWrapper?

fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
...
) {
val density = LocalDensity.current
val viewConfiguration = LocalViewConfiguration.current
remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
LaunchedEffect(this, key1) {
block()
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

internals of pointerInput modifier

 

Inside pointerInput modifier a SuspendingPointerInputFilter object created which implements PointerInputModifier interface. When a modifier applied to LayoutNode, modifier chain is folded out and based on it a new chain of LayoutNodeWrappers created and applied to current LayoutNode.

override var modifier: Modifier = Modifier
set(value) {
if (value == field) return
...
field = value
...
// Create a new chain of LayoutNodeWrappers, reusing existing ones from wrappers
// when possible.
val outerWrapper = modifier.foldOut(innerLayoutNodeWrapper) { mod, toWrap ->
var wrapper = toWrap
...
if (mod is PointerInputModifier) {
wrapper = PointerInputDelegatingWrapper(wrapper, mod).assignChained(toWrap)
}
...
}
wrapper
}
outerWrapper.wrappedBy = parent?.innerLayoutNodeWrapper
outerMeasurablePlaceable.outerWrapper = outerWrapper
...
}
view raw MainActivity.kt hosted with ❤ by GitHub

modifier change code

 

And the full scheme of touch event flow will look like this:

 

full scheme of touch event flow

 

Hope you enjoyed reading, cheers!

This article was originally published on proandroiddev.com on May 24, 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

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu