The Dispatch Mechanism of Touch Events
Where Do Events Originate, and Where Do They Go?
Every time a finger physically contacts the screen, the underlying driver synthesizes a MotionEvent object. This object must be routed to the specific View that "cares" about it. However, the screen may contain dozens of overlapping Views across multiple hierarchical layers. How does the operating system mathematically determine the correct recipient?
This is the exact problem the Event Dispatch Mechanism solves: During a single touch, what vector does the event traverse, and who ultimately processes the payload?
The entire architectural flow is a "game of hot potato" played across a strict tree topology: The payload is passed down from the root toward the leaves, and if no leaf consumes it, it bounces back up.
The Three Core Methods and Their Relationships
To master event dispatch, you must memorize the architectural responsibilities of three specific methods:
| Method | Resides In | Architectural Responsibility |
|---|---|---|
dispatchTouchEvent() |
View, ViewGroup | The absolute entry point. Controls the decision: "Should this event continue propagating downward?" |
onInterceptTouchEvent() |
ViewGroup Only | The interception gate. Controls the decision: "Should I hijack this event right here and process it myself?" |
onTouchEvent() |
View, ViewGroup | The terminal processing node. Executes the actual logic and returns a boolean declaring if the event was "consumed." |
Their interplay can be mapped via this abstracted pseudocode:
// ViewGroup's dispatchTouchEvent Pseudocode (Abstracted Architecture)
fun dispatchTouchEvent(ev: MotionEvent): Boolean {
var consumed = false
// ① Phase 1: Ask myself - Should I intercept?
val intercepted = onInterceptTouchEvent(ev)
if (!intercepted) {
// ② Phase 2: No interception → Route the payload to the appropriate Child View
for (child in children.reversed()) {
if (child is physically under the touch coordinates) {
consumed = child.dispatchTouchEvent(ev)
if (consumed) break // Payload consumed; terminate the routing loop
}
}
}
// ③ Phase 3: No child consumed it OR I intercepted it → Process it myself
if (!consumed) {
consumed = onTouchEvent(ev)
}
return consumed
}
Critical Architectural Conclusion: dispatchTouchEvent acts as the grand dispatcher. It either propagates the payload downstream (if onInterceptTouchEvent returns false) or hijacks it for self-processing (if onInterceptTouchEvent returns true). The ultimate fallback execution always lands on onTouchEvent.
The Complete Event Transmission Vector
Tracing the execution flow of a complete hardware ACTION_DOWN event:
Hardware Driver synthesizes MotionEvent(ACTION_DOWN)
│
▼
ViewRootImpl.dispatchInputEvent()
│
▼
DecorView.dispatchTouchEvent() ← The absolute Root View of the Window
│
▼
Activity.dispatchTouchEvent() ← The Activity itself holds interception capabilities!
│
▼
PhoneWindow.superDispatchTouchEvent()
│
▼
DecorView (Operating as a ViewGroup)
└── onInterceptTouchEvent()
│ Returns False (No Interception)
▼
Child ViewGroup (e.g., LinearLayout)
└── onInterceptTouchEvent()
│ Returns False (No Interception)
▼
Target View (e.g., Button)
└── onTouchEvent() ← Consumes payload, Returns True
← Execution result (True) propagates back up the exact same vector
Note: The Activity's dispatchTouchEvent() is the outermost layer. If the entire View tree rejects the event (all nodes return false), the event ultimately bounces all the way back to Activity.onTouchEvent() for final unhandled processing.
ACTION_DOWN is the "Admission Ticket" to the Sequence
A touch event is never isolated; it is a mathematical sequence: ACTION_DOWN → ACTION_MOVE × N → ACTION_UP.
The Iron Rule: If a View returns false (refuses to consume) during the initial ACTION_DOWN, it is permanently blacklisted from receiving any subsequent events in that specific sequence (the MOVEs and the UP).
This rule is enforced physically via the mFirstTouchTarget pointer:
ACTION_DOWN consumed by View → ViewGroup assigns mFirstTouchTarget pointer to that View.
Subsequent ACTION_MOVE / ACTION_UP → ViewGroup routes directly to mFirstTouchTarget, bypassing the discovery loop entirely.
Therefore, ACTION_DOWN is the absolute "admission ticket" to the entire event lifecycle. If you drop the DOWN, you are mathematically excluded from the rest of the gesture.
Interception Timing and Architectural Traps
onInterceptTouchEvent exists exclusively within ViewGroups. Standard Views physically lack this method (they have no children to intercept from).
Post-Interception Behavior
The microsecond a ViewGroup elects to intercept (returns true):
- The Current Event is violently rerouted to its own
onTouchEvent(). - Subsequent Events bypass child queries entirely. They are routed directly to the ViewGroup's
onTouchEvent(Note: Subsequent events typically bypassonInterceptTouchEventaltogether).
The child View that previously held the touch focus will instantly receive an ACTION_CANCEL event. This is the OS broadcasting an "Abort Operation" signal. Upon receiving CANCEL, the child View is architecturally obligated to purge its state (e.g., clearing the highlighted/pressed visual state).
The Canonical Interception Conflict: ScrollView vs. Button
When a finger drags vertically inside a ScrollView beyond a specific threshold (Touch Slop), the ScrollView forcefully intercepts the ACTION_MOVE. This prevents the child Button from mistakenly interpreting the drag as a click.
The industrial-standard resolution for this class of conflict is the "Internal Interception Vector." The child View aggressively invokes requestDisallowInterceptTouchEvent(true) when necessary, broadcasting a strict command to its parent: "Do not intercept my events":
// Child View's (e.g., ViewPager) dispatchTouchEvent implementation
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
// Command Parent to disable interception
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
if (isHorizontalScroll) {
// Drag is horizontal. Maintain parent interception blackout.
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Drag is vertical. Relinquish control; allow parent (ScrollView) to intercept.
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return super.dispatchTouchEvent(ev)
}
Under the hood, requestDisallowInterceptTouchEvent(true) flips the FLAG_DISALLOW_INTERCEPT bit within the Parent's mGroupFlags. As long as this bit remains active, the Parent's onInterceptTouchEvent is mathematically bypassed by the dispatcher.
The Execution Priority: onTouchListener vs. OnClickListener
A View supports three distinct layers of event callbacks, executing in this strict priority hierarchy:
1. onTouchListener.onTouch() ← Absolute Priority. If returns true, downstream execution is aborted.
2. onTouchEvent() ← Native View logic (handles visual states and CLICK synthesis).
3. onClickListener.onClick() ← Triggered internally by onTouchEvent during the UP sequence.
onClick is NOT an independent hardware event. It is a semantic payload synthesized deep within the ACTION_UP branch of onTouchEvent (Mathematically: Pressed + Not Timed Out + No Movement = Click).
The Critical Payloads of MotionEvent
Engineering robust touch logic requires analyzing more than just the action. The following fields represent the core data vectors utilized in production:
ev.action // Event type: DOWN / MOVE / UP / CANCEL / POINTER_DOWN, etc.
ev.x, ev.y // Coordinates relative to the top-left of the CURRENT View.
ev.rawX, ev.rawY // Absolute coordinates relative to the top-left of the physical screen.
ev.pointerCount // Number of fingers currently touching the glass (Multi-touch).
ev.getX(i) // X coordinate of the i-th finger relative to the current View.
ev.pressure // Pressure payload (0f - 1f, hardware dependent).
ev.eventTime // Timestamp of the event (milliseconds since system boot).
ev.downTime // Timestamp of the initial ACTION_DOWN (utilized for Long-Press detection).
In multi-touch topologies, the action field physically encodes the pointer index. It must be extracted via bitmasking:
val action = ev.actionMasked // Pure event type (Pointer index stripped).
val index = ev.actionIndex // The specific finger index triggering this event.
val id = ev.getPointerId(index) // Stable Finger ID (Index mutates as fingers lift; ID is permanent).
The Three Frameworks for Resolving Sliding Conflicts
Topology 1: Parent Vertical, Child Horizontal (Most Common)
Canonical Scenario: An outer ScrollView wrapping an inner horizontal ViewPager.
External Interception Vector (Resolving directionality within the Parent's onInterceptTouchEvent):
// Parent ViewGroup (Vertical ScrollView)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.x; lastY = ev.y
return false // DOWN MUST NEVER be intercepted, or the Child starves instantly.
}
MotionEvent.ACTION_MOVE -> {
val dx = abs(ev.x - lastX)
val dy = abs(ev.y - lastY)
// Vertical delta exceeds horizontal delta → Parent assumes control (Intercepts)
return dy > dx
}
else -> return false
}
}
Topology 2: Parent and Child Co-Directional, Differing Semantics
Canonical Scenario: An outer RecyclerView wrapping an inner RecyclerView (both scrolling vertically).
This topology possesses no universal mathematical solution. It requires bespoke business logic to determine "who owns the scroll vector at this exact millisecond." The modern architectural solution is deploying the NestedScrolling mechanism (NestedScrollingChild + NestedScrollingParent) to continuously negotiate velocity and displacement.
Topology 3: Deep Nesting (Exceeding Two Layers)
Nesting scrollable containers beyond two layers is an architectural anti-pattern indicating flawed UI design. The definitive, declarative solution is refactoring to deploy a CoordinatorLayout coupled with custom Behavior classes, leveraging the official Material Design orchestration architecture.
The Performance Implications of Event Dispatch
Every single ACTION_MOVE forces an execution pass through the dispatchTouchEvent → onInterceptTouchEvent pipeline. If the View hierarchy is catastrophically deep (e.g., 15 layers of nesting), a drag event might trigger 60 times per second, forcing a traversal of 15 nodes on every frame. This generates severe CPU overhead.
Optimization Vectors:
- Flatten the Hierarchy: Deploy
ConstraintLayoutto eradicate nestedLinearLayoutsandRelativeLayouts. - Eradicate Heavy Calculus: Never execute memory allocations or complex math within
onInterceptTouchEvent. - Short-circuit the Pipeline: If a leaf View requires zero touch interaction, explicitly set
android:clickable="false"to force the dispatcher to ignore it immediately.
Deconstructing the Canonical Event Routing Problem
"ViewGroup A wraps View B. The user taps B. What is the exact execution sequence?"
Finger Down (ACTION_DOWN):
A.dispatchTouchEvent(DOWN)
→ A.onInterceptTouchEvent(DOWN) → Returns False (Does not intercept)
→ B.dispatchTouchEvent(DOWN)
→ B.onTouchEvent(DOWN) → Returns True (Consumes the payload; B has a clickListener)
Finger Lift (ACTION_UP):
A.dispatchTouchEvent(UP)
→ A.onInterceptTouchEvent(UP) → Bypassed entirely (mFirstTouchTarget already points to B)
→ B.dispatchTouchEvent(UP)
→ B.onTouchEvent(UP) → Synthesizes onClick, Returns True
Terminal Result: B.onClick() is invoked.
"If A intercepts during ACTION_MOVE, what happens to B?"
View B instantly receives an ACTION_CANCEL. For the remainder of this touch sequence, all subsequent events are routed directly to A's onTouchEvent, and B receives nothing further until the next finger press.