Mobile Computing · UI Frameworks
Hit Testing and Gesture Arbitration Across Overlapping Views
How a touch becomes a gesture, why the framework must decide which recognizer wins before it knows what the user intends, and how touch slop and failure requirements make that decision reversible.
A horizontal carousel sits inside a vertically scrolling page, and a row inside the carousel contains a button. A finger lands on the button and moves diagonally. Four parties want the gesture: the button, which wants a tap; the row, which wants a long press; the carousel, which wants a horizontal drag; and the page, which wants a vertical drag. The system must produce exactly one outcome, and it must produce it while the finger is still moving and before it knows whether the user intends to tap or to scroll.
Every mobile UI framework is an answer to that problem, and the answers differ enough that the same gesture code behaves differently across them. The common structure is a hit test that identifies a candidate view, a dispatch chain that offers the event to a sequence of handlers from the innermost candidate outward, and a set of rules that let one handler claim the gesture and cancel the others.
The hit test and the coordinate transforms
Hit testing answers which view a point belongs to. The obvious approach walks the view tree from the root, transforming the point into each child’s coordinate space and testing containment, recursing into children in reverse z-order so that the topmost view wins. The cost is proportional to the number of views whose bounds contain the point, which for a deep tree is bounded by the depth rather than the total node count, because a child outside the parent’s bounds is not tested.
Two details make the naive version wrong.
First, transforms compose. A view rotated by 30 degrees, scaled by 1.5, or translated by an animated offset has a transform from its own space to its parent’s, and the hit test must invert the full chain to map a screen point into the leaf’s space. A view that is visually rotated but whose hit test uses its axis-aligned bounds will accept touches in the corners it does not cover and reject touches on the parts it does cover. Frameworks therefore hit test against the transformed geometry, and views that do not implement this correctly are the usual cause of a control that only responds when tapped near its centre.
Second, clipping and hit testing are separate decisions. Whether a view draws outside its bounds is controlled by one property on its parent, and whether a touch outside a view’s bounds can still reach it is controlled by a different mechanism entirely, since a container normally does not deliver a touch to a child whose bounds exclude the point. Android provides an explicit delegation object for the second case, which a parent installs to reroute touches from a region of its own space to a smaller child. That indirection is how a small icon gets a comfortable touch target without changing its drawn size, and it is why an enlarged target is a property of the parent’s dispatch rather than of the child’s geometry.
The result of the hit test is not a single view but an ordered list: the deepest candidate, its ancestors, and any views that were asked to participate despite not containing the point, such as gesture detectors attached to a container. That list becomes the dispatch path.
The dispatch chain and its direction
Android’s dispatch is a three-phase protocol that the framework documents as a contract between parent and child. A touch event arrives at the root, which offers it to its own onInterceptTouchEvent, then to the child containing the point via dispatchTouchEvent, and so on down to the leaf, which receives onTouchEvent. A handler that returns true consumes the event and, critically, claims the remainder of the gesture: subsequent events for the same pointer sequence are delivered to it directly, without a fresh hit test.
The downward-then-upward shape is what allows a parent to take over mid-gesture. A parent that did not intercept the initial down event can decide, after seeing a move that exceeds its own threshold, to intercept, at which point the child receives a cancel event and stops receiving the rest of the stream. The child cannot refuse. That asymmetry is deliberate: the parent is the one that knows the scroll container’s state, and a child cannot be allowed to hold a gesture the parent needs for scrolling.
The cancel event is the mechanism that makes the whole thing safe. A child that has started a press animation must revert it; a child that has armed a long-press timer must cancel it; a child that has changed its visual state must restore it. Frameworks that omit the cancel path, or handlers that ignore it, produce the familiar bug where a list item stays highlighted after the user scrolls.
Flutter expresses the same arbitration differently, as an arena. Each pointer has an arena, and every gesture recognizer that is interested in that pointer joins it. Recognizers do not receive a claim-and-cancel stream; they declare interest, and they can resolve in their own favour, reject themselves, or wait. When one recognizer resolves as the winner, the arena rejects all others, and each rejected recognizer runs its own cancellation. The difference from the Android model is that arbitration is explicit and symmetric: no recognizer has an inherent priority by position in the tree, and priority is expressed by the order of registration or by explicit team membership for recognizers that should win or lose together.
The arena model scales better to composed gestures. A recognizer that waits for a second pointer to determine whether the gesture is a two-finger pinch cannot express that as a parent intercepting; it can express it as a member of an arena that resolves only when the second pointer arrives or the first lifts.
Slop, thresholds, and the reversibility of intent
No recognizer can classify a gesture from its first event. A tap and a drag begin identically. The classification therefore waits for evidence, and the evidence is a threshold expressed in density-independent units.
Touch slop is the distance a pointer must move before a press becomes a drag. It exists because a finger’s contact point wanders while it is pressed; a reported movement of a few points is not intent. Platform constants define the slop, and the value differs by device class and by gesture: a vertical scroll slop is typically larger than a tap slop, because scrolling is the more disruptive misclassification. Frameworks expose the constants and generally do not let an application change them, since they are calibrated against the platform’s touch hardware.
The time dimension is symmetric. A long press is a press that persists beyond a duration without exceeding the slop; the recognizer arms a timer on the down event, cancels it on a move beyond the slop, and fires on expiry. A double tap is a tap followed by another within an interval and within a distance. Each of these is a predicate over the same stream, and the recognizers evaluate them concurrently, which is why the arbitration step cannot be reduced to “the innermost view handles it”.
// A recognizer that fails as soon as the pointer leaves its slop, so a
// competing drag recognizer can take over without waiting for a cancel.
final class PressRecognizer: UIGestureRecognizer {
private var origin: CGPoint = .zero
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
guard let touch = touches.first else { return }
origin = touch.location(in: view)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
guard let touch = touches.first else { return }
let p = touch.location(in: view)
let dx = p.x - origin.x, dy = p.y - origin.y
if (dx * dx + dy * dy).squareRoot() > 10 {
state = .failed // refuted: yield to the drag recognizer
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
if state == .possible { state = .recognized }
}
}
The value of failing early rather than waiting is latency. A recognizer that holds the arena until the pointer lifts delays every competing recognizer’s decision until the end of the gesture, so the scroll cannot begin until the finger stops. Failing as soon as the hypothesis is refuted lets the drag begin at the moment the movement is unambiguous.
Where the models disagree in practice
The differences between frameworks show up in three recurring situations.
The first is a gesture that begins inside a scrollable container and then leaves it. Android’s model lets the container intercept once the movement exceeds its slop, so the container wins regardless of where the finger travels afterwards. iOS gives the scroll view a pan recognizer that participates in the same arbitration as the content’s recognizers, with the scroll view’s recognizer typically configured to require the content’s to fail or to be granted priority through the delegate. Flutter’s arena resolves the same conflict by having the scrollable’s drag recognizer accept on slop and the child’s tap recognizer reject on the same event, with the ordering determined by the arena rather than by position in the tree.
The second is simultaneous recognition. A pinch-to-zoom and a two-finger pan can legitimately happen at once, and both platforms need an explicit opt-in: Android’s view system exposes a flag on the parent that lets a child’s and the parent’s handlers receive the stream concurrently, and iOS exposes a delegate callback that permits two recognizers to recognize simultaneously. Without the opt-in the first to resolve wins and the second is cancelled, which is why a photo viewer that zooms and pans in one gesture needs both recognizers coordinated rather than merely both attached.
The third is nested scroll containers. A list inside a bottom sheet, the sheet itself draggable, produces a conflict that neither the intercept model nor the arena model resolves well by default, because both containers have a legitimate claim and the correct winner depends on whether the inner list is at its scroll boundary. Android addresses this with a nested scrolling protocol in which the inner container reports how much of a scroll it consumed and passes the remainder to the outer one; iOS addresses it by adjusting the sheet’s recognizer to require the inner scroll view to be at the top before it begins. Both are cases where the framework cannot decide from geometry alone and the application must supply the missing condition.
Hit testing in a declarative tree
A declarative framework does not have a stable tree of views to hit test at the moment a touch arrives; it has a tree of retained objects that correspond to descriptions, and the correspondence can change between the touch’s down and up events if a rebuild occurs. The rebuild itself is the reconciliation pass covered in Reconciliation: Turning a Declarative Description into Minimal Mutations, and the fact that it can run mid-gesture is what makes pointer routing a separate concern from view identity. The frameworks handle this by attaching recognizers to retained objects rather than to descriptions, and by holding the pointer’s routing for the duration of the gesture so that a rebuild in the middle of a drag does not re-route the event to a different node.
That stability has a cost when the rebuild removes the node that owns the gesture. A row that is removed from a list while its press is in progress leaves the recognizer attached to an object that is no longer in the tree, and the gesture either continues against a detached object or is cancelled. Frameworks generally cancel, on the principle that a gesture cannot outlive the node that owns it, and the visible consequence is a row that disappears from under the finger and a tap that does nothing. Preventing that is a matter of deferring the removal until the gesture completes, which is why destructive actions in lists are usually triggered on release rather than on press.
The relationship between the hit test and the accessibility tree is close and asymmetric. A touch target is defined by geometry and clipping; an accessibility element is defined by semantics and can be larger, smaller, or differently shaped than the visual node, and the two are produced by different passes. The semantics layer is where the two meet, and it is the subject of Semantics Trees and the Platform Accessibility Bridge.