Touch dispatch in the View tree
DOWN goes down the tree, everything else goes up. onInterceptTouchEvent, onTouchEvent, the touch target, and the cancel that fires when an ancestor changes its mind.
The view dispatch rules are three methods and one rule. The methods are dispatchTouchEvent, onInterceptTouchEvent
and onTouchEvent; the rule is that a gesture has exactly one owner, and it is chosen on the first event and kept until
the gesture ends.
TL;DR#
ACTION_DOWNis offered down the tree. A ViewGroup can intercept it, or pass it to children in reverse draw order.- The first view that returns
truefromonTouchEventbecomes the touch target for the rest of the gesture. - Subsequent
ACTION_MOVEandACTION_UPgo straight to the target — they do not re-traverse the tree. - If an ancestor starts intercepting mid-gesture, the target receives
ACTION_CANCELand loses the gesture. performClick()fires only if the view consumedACTION_DOWNand the pointer is still inside its bounds onACTION_UP.
Mental model#
Imagine a company where a customer request arrives at the front desk.
The request goes down the org chart: the front desk asks each department whether it wants to handle it. A department can take it immediately (intercept) or forward it to its own team.
The first team that says "we'll handle it" owns the request for the rest of its life. Later updates go directly to that team, not back through the org chart.
If a manager later decides to take over, the team gets a message saying "stop working on this" — that is the cancel.
Where the analogy breaks#
An org chart is hierarchical and stable. A view tree changes while a gesture is in progress: views can be removed,
added, or moved, and a view that was the target can be detached. The framework handles this with the cancel mechanism
and with mFirstTouchTarget, a linked list that survives re-layout. The mental model also hides that a ViewGroup can
intercept after the gesture starts, which has no clean corporate equivalent.
The big picture#
Text description of this diagram
An interactive view tree: a DecorView containing a Content group and an Overlay, with Title and Button inside Content. Each node has an 'intercept' toggle for ViewGroups and a 'consume' toggle for any node. Pressing the toggles and reading the trace shows the dispatch order: ViewGroups are asked onInterceptTouchEvent first, children are offered the event in reverse draw order, and the first node to consume becomes the touch target. Toggling all consumes off results in no click; toggling the parent's intercept on routes the event to the parent instead.
The simulator is the rules made executable. Try three configurations and read the trace each time:
- Only the Button consumes. The Button becomes the target and the click fires.
- The Content group intercepts and consumes. The Button never sees the event; Content becomes the target.
- Nothing consumes. The trace shows every node returning false, and the gesture is dropped.
Deep dive#
The three methods#
// ViewGroup
override fun dispatchTouchEvent(ev: MotionEvent): Boolean
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean
// View (and ViewGroup, which can also consume)
override fun onTouchEvent(event: MotionEvent): BooleandispatchTouchEvent is the entry point. onInterceptTouchEvent exists only on ViewGroup. onTouchEvent exists on
everything. The default View.onTouchEvent returns true only if the view is clickable; a plain view with no click
listener ignores touches.
The DOWN descent#
On ACTION_DOWN, ViewGroup.dispatchTouchEvent does this:1
- Ask itself: should I intercept? (
onInterceptTouchEvent) - If not, iterate children in reverse draw order — topmost first — and call
dispatchTouchEventon each. - The first child that returns true becomes
mFirstTouchTarget. - If no child takes it, call the group's own
onTouchEvent.
Reverse draw order is the key detail. The view drawn last is the one visually on top, so it must get the first chance to handle the touch. This is why an overlay sibling above a button steals its touches.
The MOVE and UP ascent#
Once a target exists, subsequent events for the gesture go directly to it. ViewGroup.dispatchTouchEvent checks
mFirstTouchTarget and, if set, calls dispatchTransformedTouchEvent on that target rather than re-traversing.
This is why changing the tree mid-gesture is dangerous: the target is a specific view, and if it is removed, the
gesture has nowhere to go. The framework sends ACTION_CANCEL when the target is detached.
Intercepting mid-gesture#
A ViewGroup may decide to intercept after the gesture has started — a ScrollView that detects a vertical drag, for example. When it does:
- The current target receives
ACTION_CANCEL. - The ViewGroup becomes the target for the rest of the gesture.
- Any state the child had built up for the gesture should be reset.
This is the mechanism behind every "the parent steals the scroll" behaviour, and the reason child views must handle
ACTION_CANCEL correctly. A child that only resets on ACTION_UP will be left in a stuck state.
performClick and the click contract#
View.performClick() is called from View.onTouchEvent on ACTION_UP if:
- The view is clickable.
- The view received and consumed
ACTION_DOWN. - The pointer was still within the view's bounds at
ACTION_UP(or the view hasisPressedstate consistent with that).
If you override onTouchEvent and consume events yourself, you must call performClick() yourself or the click
listener and accessibility click action will not fire. This is why custom views often override
performClick() and call super.performClick().
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> return true
MotionEvent.ACTION_UP -> {
if (isInside(event)) performClick()
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick()
// your action
return true
}Multi-touch and pointer IDs#
A gesture is not necessarily one pointer. Each finger gets a pointer ID, and the event stream includes
ACTION_POINTER_DOWN and ACTION_POINTER_UP for additional fingers. getActionMasked() strips the pointer index
from the action, and getActionIndex() tells you which pointer changed.
The dispatch rules do not change: the target is chosen on the first ACTION_DOWN, and every subsequent event for
that gesture — including additional pointers — goes to the same target. A view that wants per-pointer behaviour must
track pointer IDs itself.
Compose's model#
Compose does not use onInterceptTouchEvent. Its pointer input system is a tree of PointerInputModifierNodes, and
"interception" is expressed by consuming a change at a node, which makes it invisible to ancestors. The mental model
is inverted — consumption propagates upward rather than an explicit intercept propagating downward — but the
observable behaviour is similar: the first consumer at the deepest relevant node wins.
Accessibility is dispatched differently#
An accessibility click does not travel through the touch pipeline at all. It calls performAccessibilityAction on a
node, which the view maps to performClick() or its own action handling.2 This is
why a custom view that consumes touches but never calls performClick() is also unusable with TalkBack: the same
missing call breaks both paths.
Why the tree is traversed in reverse draw order#
Children are stored in draw order: index 0 is drawn first, at the bottom; the last index is drawn last, on top. The dispatch loop runs from the last index down, so the topmost child gets the first chance to handle the event.
This is the same rule that makes a FrameLayout with two overlapping children behave intuitively: the one declared
last, drawn on top, receives the touch. An app that wants a transparent overlay to not steal touches must either
make it not clickable or set its visibility so it does not participate.
What mFirstTouchTarget actually is#
ViewGroup keeps a linked list of touch targets, normally with one entry. The list exists because a single gesture
can have multiple pointers down at once, and in some configurations different children can own different pointers.
For almost all app code, the list has one entry and the behaviour is "the child that consumed DOWN". The name matters
because it appears in traces and in dumpsys output when debugging why a touch went to the wrong place.
The pressed state and the click#
View maintains a pressed state derived from the touch. On ACTION_DOWN inside the bounds it becomes pressed, and
on ACTION_UP inside the bounds the click fires and the state clears. On ACTION_CANCEL, or on ACTION_UP outside
the bounds, the state clears without a click.
That is why a button dragged off and released does not click: the up event is outside the bounds, so the click is suppressed. Custom views that implement their own touch handling must reproduce that behaviour or they will feel different from every other control on the screen.
Nested scrolling, and why it is a separate protocol#
A RecyclerView inside a NestedScrollView is two scrollable views in a parent-child relationship. The touch
dispatch rules alone cannot express "the child scrolls until it reaches its end, then the parent takes over" — the
parent would have to intercept, which would cancel the child's gesture.
Nested scrolling is a separate protocol layered on top: the child reports its scroll to the parent through
NestedScrollingChild, and the parent can consume the unconsumed part through NestedScrollingParent. It is a
cooperative extension of the dispatch model rather than a replacement for it.
Testing dispatch#
The dispatch rules are deterministic, which makes them testable without a device. Espresso performs clicks through
the accessibility action path rather than raw touch, so it exercises performClick — a useful reminder that a view
which is only clickable through touch is not clickable at all for a large class of users.
For raw touch, an instrumentation test can dispatch a MotionEvent sequence directly to the root view and assert
which handler fired. That is the fastest way to test a custom gesture recogniser without a physical device.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
Under the hood#
- ViewGroup.javadispatchTouchEvent, onInterceptTouchEvent, mFirstTouchTarget
- View.javaonTouchEvent, performClick and the click contract
- ViewRootImpl.javathe boundary between the input channel and the view tree
- MotionEvent.javapointer IDs, action masking and historical points
Curiosity corner#
onInterceptTouchEventis not called for every event. Once a group has a touch target, it is still asked on subsequent events, but the default implementation returns false and most groups never override it.- A click can fire without a touch.
performClick()is public, and accessibility services call it directly. - The dispatch rules predate Android. They are close to the Java AWT/Swing event model, which is not a coincidence: early Android UI was built by people who had worked on desktop toolkits.
Try it#
adb shell dumpsys activity top | grep -A20 "View Hierarchy"Example output
DecorView
LinearLayout
FrameLayout
TextView (id/title)
Button (id/action)adb shell perfetto -o /data/misc/perfetto-traces/touch -t 5s -a com.example.app input viewExample output
viewroot_dispatch → ViewGroup.dispatchTouchEvent → View.onTouchEventCheck yourself#
Check yourself
Why does the system deliver input to an app through a socket pair rather than a Binder call?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsHandle input eventsdeveloper.android.comaccessed 2026-10-27↩
- docsAccessibility on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsInput overviewsource.android.comaccessed 2026-10-27↩
- docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
- docsWinscopesource.android.comaccessed 2026-10-27↩