Android Touch Event Dispatch: A Deep Dive
Android Touch Event Dispatch: A Deep Dive
Introduction
Every time you tap a button, swipe a list, or pinch to zoom, a chain of method calls decides which view finally consumes that gesture. Understanding this chain — the Android touch event dispatch mechanism — is essential for building custom views, nested scrolling, and any UI where gestures compete. Get it wrong and your scrolling container eats your child’s click, or your custom view swallows every touch in the region.
This article unpacks the full dispatch flow, the three core methods, the sequence of calls, and the common patterns and pitfalls you will meet in real apps.
1. The MotionEvent
Touch input arrives as MotionEvent objects. Each event carries an action code and coordinates:
ACTION_DOWN— a finger has touched the screen. This is the start of a touch sequence.ACTION_MOVE— the finger has moved. Many of these follow aDOWN.ACTION_UP— the finger has lifted. This ends the sequence.ACTION_CANCEL— the sequence was aborted, typically because a parent decided to intercept.ACTION_POINTER_DOWN/ACTION_POINTER_UP— a secondary finger joined or left (multi-touch).
A single touch gesture is always a sequence beginning with one ACTION_DOWN, followed by zero or more ACTION_MOVEs, and ending with either ACTION_UP or ACTION_CANCEL. This sequence property is the foundation of the whole dispatch model: the view that consumes the DOWN is the one that will receive the rest of the sequence, unless a parent intercepts.
2. The Three Core Methods
The dispatch mechanism is built on three methods, each with a clear role. Every View and ViewGroup participates.
2.1 dispatchTouchEvent(MotionEvent) — boolean
This is the entry point. When a touch event reaches a view or view group, dispatchTouchEvent is called first. It is responsible for deciding what happens next:
- For a
View, it callsonTouchEventand returns its result. - For a
ViewGroup, it asks each child (in reverse Z-order) whether it wants the event, and if none does, handles it itself.
The return value signals consumption: true means “I handled this event,” false means “I did not.”
2.2 onInterceptTouchEvent(MotionEvent) — boolean
This exists only on ViewGroup. It is the parent’s chance to steal the event from its children before they see it. Returning true means “I am intercepting — do not dispatch to children, send the rest of this sequence to my own onTouchEvent.”
Most of the time it returns false. The classic use case is a scrolling container that lets children handle taps but takes over once the finger moves far enough to look like a scroll.
Once a parent intercepts, the child currently receiving the sequence gets an ACTION_CANCEL so it can reset its state.
2.3 onTouchEvent(MotionEvent) — boolean
This is where a view actually reacts to the event — updating state, calling listeners, triggering animations. Returning true means the view consumes the event and wants the rest of the sequence. Returning false on ACTION_DOWN tells the parent “I am not interested,” and the parent will not deliver subsequent events to this view.
3. The Full Dispatch Sequence
Here is the precise order of calls when a MotionEvent arrives at the root of a view hierarchy. Assume a ViewGroup containing a child View.
3.1 ACTION_DOWN
ViewGroup.dispatchTouchEvent
-> ViewGroup.onInterceptTouchEvent? (returns false — let children try)
-> child.dispatchTouchEvent
-> child.onTouchEvent? (returns true — child consumes)
<- true
<- true
If the child returns true for the DOWN, it is recorded as the “touch target” for this sequence. All subsequent MOVE and UP events in this sequence will be delivered directly to the child, short-circuiting the child-search loop — but the parent still gets a chance to intercept on every event.
3.2 ACTION_MOVE (no interception)
ViewGroup.dispatchTouchEvent
-> ViewGroup.onInterceptTouchEvent? (returns false)
-> child.dispatchTouchEvent (delivered directly to the known target)
-> child.onTouchEvent
<- true
<- true
3.3 ACTION_MOVE (parent intercepts)
ViewGroup.dispatchTouchEvent
-> ViewGroup.onInterceptTouchEvent (returns true — parent steals)
-> child.dispatchTouchEvent(CANCEL) (child gets a chance to clean up)
-> ViewGroup.onTouchEvent(MOVE) (parent now handles the rest)
<- true
From this point on, the parent’s onTouchEvent receives the remaining MOVEs and the final UP. The child is out of the loop.
3.4 ACTION_UP
Delivered to whoever is the current target — the original child if no interception happened, or the parent if it intercepted. After UP, the touch target is cleared and the next DOWN starts a fresh sequence.
4. The Consumption Rules, Precisely
The behavior follows a few rules that, once internalized, make the whole system predictable:
- Only the view that consumes
ACTION_DOWNreceives the rest of the sequence. If a view returnsfalsefromonTouchEventforDOWN, it will not seeMOVEorUPfor this gesture. - A parent can intercept mid-sequence.
onInterceptTouchEventis called for every event afterDOWN, not just the first one. This is how a scroll container waits for movement before deciding to take over. - Interception sends
ACTION_CANCELto the child. The child receives exactly oneCANCELand then no more events from this sequence. Always handleCANCELsymmetrically withUP— release resources, reset pressed state, end animations. - A child can request its parent not to intercept. Calling
parent.requestDisallowInterceptTouchEvent(true)sets a flag that causes the parent to skiponInterceptTouchEventfor the rest of the sequence. This is how a child that is certain it wants the gesture (e.g., a button mid-press) can prevent a scroll parent from stealing it. - Z-order matters. When multiple children overlap, the child drawn last (highest in the Z-order, last in the child list) is asked first. The first child to consume wins; siblings underneath never see the event.
5. Worked Example: A Scroll Container with Tappable Children
This is the scenario every Android developer eventually debugs. You have a vertical scroll container (ScrollView-like) holding buttons. You want taps to reach the buttons, but drags to scroll the container.
The container implements onInterceptTouchEvent roughly like this:
class VerticalScrollContainer @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {
private var touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downY = 0f
private var isDragging = false
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
ACTION_DOWN -> {
downY = ev.y
isDragging = false
}
ACTION_MOVE -> {
if (abs(ev.y - downY) > touchSlop) {
isDragging = true
return true // steal the sequence from the child
}
}
ACTION_CANCEL, ACTION_UP -> {
isDragging = false
}
}
return false
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
// Handle actual scrolling here once isDragging is true
return true
}
}
The flow in practice:
DOWNarrives. The container’sonInterceptTouchEventrecords the start Y and returnsfalse, so the event goes to the child button. The button consumes it (shows pressed state).MOVEevents arrive. As long as the finger has not moved beyondtouchSlop, the container keeps returningfalseand the button keeps receiving the moves — it still thinks it is being tapped.- The finger moves past
touchSlop. The container’sonInterceptTouchEventreturnstrue. The button receives anACTION_CANCEL(it clears its pressed state). The container’sonTouchEventtakes over and scrolls.
This touchSlop threshold is critical. It is the platform’s way of distinguishing a tap from a drag, and it is tuned per device to match the touchscreen’s noise floor. Never hardcode a pixel value for this — always use ViewConfiguration.get(context).scaledTouchSlop.
6. requestDisallowInterceptTouchEvent
Sometimes the child knows best. A horizontally swipeable child inside a vertical scroll container wants to keep the gesture once it has started a horizontal drag, even though the vertical container might also want it.
The child calls:
parent.requestDisallowInterceptTouchEvent(true)
This sets a flag on the parent that causes onInterceptTouchEvent to be skipped for the remainder of the sequence. The flag is automatically cleared on the next ACTION_DOWN, so you typically call it from the child’s onTouchEvent when it detects the start of its own gesture.
This is the mechanism behind NestedScrollingChild and most nested-scrolling support: the inner view coordinates with the outer view rather than simply fighting over interception.
7. Nested Scrolling
The intercept/cancel model is blunt: when a parent intercepts, the child is fully cut off. This works for simple cases but breaks down for nested scrolling, where a parent and child need to cooperate — the child scrolls until it hits its edge, then the parent takes the overflow.
Android provides the nested scrolling APIs to solve this without fighting over interception:
NestedScrollingChild3/NestedScrollingParent3— the modern interfaces.NestedScrollView— a ready-made implementation.
The child dispatches scroll deltas to the parent before consuming them itself, letting the parent consume the portion it wants. This cooperative model is how a vertically scrolling list inside a vertically scrolling parent (like a NestedScrollView containing a RecyclerView) produces the smooth handoff users expect.
If you are building a custom scrolling container that should nest inside others, implement NestedScrollingParent3 and delegate to a NestedScrollingParentHelper. If you are building a scrollable child, implement NestedScrollingChild3 and use a NestedScrollingChildHelper. The helpers handle the bookkeeping; you implement the actual scrolling.
8. GestureDetector and ScaleGestureDetector
For common gestures — tap, double tap, long press, fling, scroll, scale — do not roll your own detection in onTouchEvent. Use the platform detectors:
GestureDetector— detects taps, double taps, long presses, scrolls, and flings.ScaleGestureDetector— detects pinch-to-scale.
These detectors consume MotionEvents and call back to listener methods with interpreted gestures. They handle the fiddly timing and threshold math correctly across devices.
class PinchZoomView(context: Context) : View(context) {
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
scaleFactor *= detector.scaleFactor
invalidate()
return true
}
})
override fun onTouchEvent(ev: MotionEvent): Boolean {
scaleDetector.onTouchEvent(ev)
return true
}
}
A subtle point: a detector’s onTouchEvent returns true whenever it has a gesture in progress, but you should generally return true from your view’s onTouchEvent for the whole sequence once you have consumed the DOWN, so that you keep receiving the events the detector needs.
9. Common Pitfalls
9.1 Forgetting to Handle ACTION_CANCEL
If you track pressed state, animations, or any transient touch state in onTouchEvent, you must reset it on both ACTION_UP and ACTION_CANCEL. A child that only resets on UP will stay “pressed” forever after a parent intercepts, because it received CANCEL instead of UP.
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
ACTION_DOWN -> isPressed = true
ACTION_UP, ACTION_CANCEL -> isPressed = false
}
return true
}
9.2 Consuming DOWN but Not the Rest
If you return true for ACTION_DOWN but false for ACTION_MOVE, you will receive the moves (because you consumed the down) but signal that you do not want them. The parent will then handle the moves itself, leading to split behavior where half the gesture goes to the child and half to the parent. If you consume the DOWN, commit to consuming the whole sequence unless you deliberately want to hand off.
9.3 Intercepting Too Early
If a parent’s onInterceptTouchEvent returns true on ACTION_DOWN, the children never see any event at all — the parent grabs the entire sequence from the start. This is almost never what you want for a container that should pass taps through. Intercept on MOVE, after you have evidence of a drag, not on DOWN.
9.4 Not Using touchSlop
Hardcoding a movement threshold in pixels makes your view feel inconsistent across devices with different densities and touch hardware. Always derive thresholds from ViewConfiguration.
9.5 Breaking the Z-Order Assumption
If you reorder children or change visibility during a touch sequence, the recorded touch target may point at a view that is no longer on top or even attached. Avoid mutating the view hierarchy during an active touch sequence. If you must, be prepared to clear state on CANCEL.
9.6 Returning false from dispatchTouchEvent Unintentionally
If you override dispatchTouchEvent and forget to call super.dispatchTouchEvent in some branch, you can silently drop events. Only override dispatchTouchEvent when you have a specific reason (e.g., forwarding events to a different view), and always defer to super for the cases you do not handle.
10. Debugging Event Dispatch
When gestures misbehave, a few techniques pin down the culprit:
- Log the three methods. Add temporary logs in
dispatchTouchEvent,onInterceptTouchEvent, andonTouchEventat each level of the hierarchy, printing the action and the return value. The call sequence becomes immediately visible. - Check the touch target. In a
ViewGroup, the fieldmFirstTouchTarget(accessible via reflection in debug, or inferred from behavior) tells you which child currently owns the sequence. - Use the hierarchy inspector. Overlapping or invisible siblings are a common cause of stolen events. Confirm which view is actually on top at the touch coordinates.
- Verify requestDisallow flags. A stale
disallowInterceptflag can make a parent stop intercepting when it should. The flag resets onDOWN, but if you set it manually outside the normal flow you can leave it in a surprising state. - Test on a real device. Emulators sometimes deliver touch events with different timing and precision than real hardware, masking or introducing timing-sensitive bugs.
11. A Mental Model to Keep
Think of the dispatch as a conversation with three phases:
- Delivery. The event walks down the hierarchy from the root, with each
ViewGroupconsultingonInterceptTouchEventto decide whether to keep going or stop. The deepest view that consumes theDOWNbecomes the target. - Steady state. Subsequent events flow to the target, with parents still checking interception on each one. This is where mid-sequence interception happens.
- Resolution. Either the target gets
UPand the sequence ends cleanly, or a parent intercepts and the target getsCANCELwhile the parent takes over.
Every dispatch question reduces to: who is the target, who is trying to intercept, and who has called requestDisallowInterceptTouchEvent. Keep those three facts in mind and the behavior is always explicable.
Conclusion
The Android touch event dispatch mechanism is a deterministic set of rules, not a black box. Three methods — dispatchTouchEvent, onInterceptTouchEvent, and onTouchEvent — cooperate through a small set of conventions: consume the DOWN to own the sequence, intercept to steal mid-sequence, cancel to tell the child to reset, and disallow intercept to protect a gesture you have started.
Master these rules and you can build any gesture interaction with confidence: scroll containers that cooperate with their children, custom views that compete correctly for touches, and nested scrolling that feels native. The key is always to handle CANCEL, respect touchSlop, and let the platform detectors do the gesture math for you.
- 点赞
- 收藏
- 关注作者
评论(0)