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.
- 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.
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 { | |
... | |
} | |
} |
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) | |
... | |
} |
Job Offers
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 | |
) |
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 | |
) | |
... | |
} | |
} | |
... | |
} |
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:
- 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
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) | |
} |
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() | |
} | |
} | |
} |
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 | |
... | |
} |
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!