Repository navigation
[General] Press buttons from hardware keys, accessibility services and a TV's Select press - #4614
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Summary
Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to
|
Device evidence — before / afterUNFIXED — FIXED — each activation presses its control once: Enter on a
API 34 x86_64 emulator (
Press feedback — Enter held 1.5 s on the Tab-focused |
There was a problem hiding this comment.
🟡 Changes recommended
Stale touch tracking can suppress keyboard activation, and transformed buttons report incorrect absolute coordinates.
3 open issues
What changed in this PR
Enables Android hardware-key activation of focused gesture-handler buttons, extending existing accessibility-click handling.
Changes:
- Tracks held confirm keys and activates native handlers.
- Centers pointerless activations and handles them in both state-machine Pressable engines.
- Adds keyboard flows and regression tests.
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx | Handles successful clicks without touches. |
| packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx | Adds equivalent legacy Pressable handling. |
| packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx | Tests pointerless, touch, and accessibility activation. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt | Tracks confirm keys and permits keyboard presses. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | Centers pointerless native activations. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt | Adds center-coordinate placement. |
| .argent/flows/keyboard-touchable-test.yaml | Checks nested Touchable activation with Enter and Space. |
| .argent/flows/keyboard-pressable-test.yaml | Checks focused nested Pressable activation with Enter. |
Review effort: Balanced
- ButtonViewGroup records a click's source while View's own onKeyDown, onKeyUp or performAccessibilityAction runs, admits that press while no child button is pressed, activates its native gesture as KEY or OTHER, and calls super.performClick(), which plays the click sound and sends TYPE_VIEW_CLICKED; receivedKeyEvent and the touch-exploration branch are gone. - An activation no pointer drives reports no pointer and numberOfPointers 0, at the view's visible center mapped through every view's transform, the root view's included; makeActive asks React Native to cancel its touch only for an activation reporting a pointer, and activateNativeHandlersForView skips a disabled handler and takes POINTER_TYPE_OTHER by default. - LegacyPressable and StatefulPressable take a native gesture reporting no pointer around their touch-based state machine, at the position it reports, while a tvOS focus press keeps the layout center; NativeViewGestureHandlerPayload declares the coordinates software-mansion#4302 emits. - isKeyboardDismissingTap takes the press's pointer count, and StatefulPressable's long press clears a touch's keyboard verdict as it finalizes. - The Keyboard activation example with keyboard-activation-test.yaml and keyboard-touchable-test.yaml, pressableClickWithoutTouch.test.tsx and the api_v3 cases.
2c7700e to
cd01505
Compare
…sture's first touch A key or an accessibility service's click pressed nothing while a finger rested or a mouse hovered elsewhere in the root: the recorded handlers' check from software-mansion#4127, which decides between handlers competing for the touch being dispatched, cancelled the button's handler against the root's own. The check now runs only inside touch dispatch, and `performClick` clicks only for a press a handler took. `makeActive` reads `hasTrackedPointers` instead of `numberOfPointers`, which `handle()` sets after the touch-down callbacks, so a gesture the state manager activates from its first `onTouchesDown` cancels React Native's responder again. The Keyboard activation example and its flow hold that case. The native gesture's payload docs name each platform's position for a press no pointer drives.
…e hit slop with none A key or an accessibility service's click on a button a finger already held ended the finger's gesture: both state-machine Pressables saw its touches cancelled and pressed nothing, and the button kept the touch responder, so every other exclusive button refused touches until it was touched again. A native handler a pointer drives is now left to that pointer, whose release presses, and the activation walks a snapshot of the view's handlers taken under the registry's lock. software-mansion#4127's check runs where a pointer's touch records a handler, rather than wherever a handler is recorded during touch dispatch. A handler takes its pointer type from the pointer that begins its gesture, so one a second finger begins reports that finger's type rather than the last gesture's, a key's included. A key's press beside another button's touch moves the ripple to the view's center, and LegacyPressable and the default Pressable press for a press no pointer drives whatever their layout or hit slop, as StatefulPressable does. The native gesture's docs list its position fields with what each platform reports with no pointer, and cancelsJSResponder's docs name the activations that take no touch.
…and cancel the JS responder for every touch A confirm key on a button a finger held unpressed it, since `View.onKeyUp` unpresses a pressed view before it clicks: the ripple went out under the finger, and the finger's release neither clicked, sounded nor reported `TYPE_VIEW_CLICKED`. A press no pointer drives now shows only where its click would activate a native gesture, and its release unpresses the button only while no pointer presses it. A key on a button whose touch waited on another gesture's failure overwrote that touch's pointer count, type and position, which `makeActive` dispatched once the other gesture failed. The activation now leaves a handler in a gesture of its own to that gesture. `makeActive` skipped the JS-responder cancel for every handler tracking no pointer, so a touch handler activating after its pointers lifted left React Native's Pressable beside it to press too. The handler now records that an activation no pointer drives prepared it, and only that activation skips the cancel. The button reaches its root view's orchestrator directly, as `requestDisallowInterceptTouchEvent` does, so the root view's and the root helper's forwarding `activateNativeHandlers` go. The native gesture's docs note the pointer count and type of a press no pointer drives on Android and web.
…for a press its handler kept A key's or an accessibility service's activation that waited on another gesture dispatched its delayed ACTIVE and END with `pointerInside` false, since the activation marked the handler in bounds only while it ran, so a legacy BaseButton waiting on such a gesture never pressed. The preparation now marks the pointer inside the view for the whole gesture, and `withMarkedAsInBounds`, which nothing else called, goes. The click sounded and reported `TYPE_VIEW_CLICKED` once the handler began, even when a gesture it waits on cancelled it as it activated. A press now counts as taken when its handler ends, waiting on another gesture or not. The press the button shows and the activation its click runs read one selection of the view's native handlers.
…ouchless press at the view's center on Apple On tvOS a focus Select press reaches the native gesture with no touch. Apple's native view handler reported it at (0, 0) as a TOUCH pointer, so the default Pressable dropped it at the hit-slop check and LegacyPressable waited for a touch that never came; only StatefulPressable's TV branch pressed, at the layout center. isActivatedWithoutPointer now takes every press on a TV, as that branch did, so all three engines go around the state machine and the hit-slop check for it and press at the position the native gesture reports. Apple reports a press with no touch at the view's center, as handleSwitch: reports a switch's, and as KEY, as Android reports a key's press. The docs and the payload type say a press no pointer drives reports the view's center on every platform. A click test case also pairs a screen reader with a hit slop that leaves out the center, which fails if either state-machine engine lets the screen reader's branch take a click with no pointer first.
…ort a touch's own pointers to an activation before it is handled isActivatedWithoutPointer took every press on a TV around the touch state machine and the hit-slop check, so on Android TV a mouse's press or a touch-capable box's touch beginning in the press retention zone, or at a center the hit slop leaves out, pressed in all three engines. On Android, Android TV included, a press now goes around them only when it reports no pointer; on tvOS, where nothing points at the screen, every press still does. The orchestrator dispatches a touch's down to the handler's touch callbacks before handle() reads it, so a callback that activates the handler from onTouchesDown on the UI thread dispatched the pointer count and type the handler held from before: 0 and OTHER for its first touch, 0 and KEY after a key's press. updatePointerData now sets both from the pointers the handler tracks before it dispatches the down. The Keyboard activation example gains a RawButton whose touch activates it that way, and the keyboard flow taps it after Enter. The native gesture docs say a key's or an accessibility service's activation reaches a Gesture Handler button's own native gesture only, and what a tvOS Select press on a button reports.
… handled, and press an iOS press no touch drives in every engine updatePointerData set the pointer count and type of a handler's down before its touch callbacks ran, but not where the pointer is, so a gesture its UI-thread onTouchesDown activates still reported the last position and pointerInside the handler held: (0, 0) and outside on its first touch, the view's center and inside after a key's press. handle() reads the count, position, bounds, last position and pointer type through helpers, and updatePointerData's down branch reads them through the same helpers from the same adapted events before it dispatches the touch. The example's touch-activated RawButton logs whether its activation lands on its touch, and the keyboard flow taps it first on a fresh screen. Apple's native view handler reports a press no touch drives at the view's center with no pointer on iPhone and iPad as well as on tvOS, and the default Pressable pressed for it there while StatefulPressable and LegacyPressable sent it into the touch state machine, which never presses it. isActivatedWithoutPointer now takes such a press on iOS as it does on Android; on tvOS every press still goes around the state machine, and web, which reports no pointer once a press's pointers lift, keeps its own path. The native gesture docs say iOS reports such a press with no pointer and as a key.
Web drops a pointer from its tracker before its native gesture ends, so every web press ends reporting no pointer, which isActivatedWithoutPointer leaves to web's own path. A case pins it: LegacyPressable's web press goes out where its press gesture saw the touch, not where its native gesture ends.
A press's last event counts only the pointers still down: none once a web press's pointers lift, and none on iOS for an action a button sends with no event, as it sends a press's cancel when it is disabled under a finger. LegacyPressable and StatefulPressable read isActivatedWithoutPointer at their native gesture's onBegin and end the press the way it began, as PressableWithTouchable keeps its press-in verdict, so a touch press whose native gesture ends reporting no pointer presses where the touch was. Read only at a press's first event, the rule needs no platform but tvOS: web counts a key's press as a pointer, so no web press begins without one.
…ycle a handler's adapted events on every exit isKeyboardDismissingTap took a press with no pointer to be one counting 0 pointers, while the press path asks isActivatedWithoutPointer, which takes every press on tvOS, so a tvOS press reporting a pointer went around the state machine and was still dropped as the tap dismissing the keyboard. It now takes the press and asks the same rule, in StatefulPressable and in the Touchable under the default Pressable. handle() returned from its shouldCancelWhenOutside failure without recycling the events it adapted, as on main; it recycles them in a finally, as the orchestrator releases its pooled lists. The orchestrator's comment and the recording hook's KDoc say the recorded-handler check runs where a pointer's touch records a handler, and StatefulPressable's comment names iOS beside Android and tvOS.
There was a problem hiding this comment.
🔵 Needs a closer look
Shared native gesture arbitration and cross-platform press lifecycle changes warrant human review, particularly because tvOS lacks native build and device validation.
No open issues 🎉
3 resolved since last review
Review effort: Balanced






Description
On Android, an activation no pointer drives presses no gesture-handler button: a hardware Enter or Space on a focused button, a Switch Access or Voice Access click, and a TalkBack double tap on a default
Pressablethat no finger has touched. Enter and Space activate a focused button (WAI-ARIA APG, Button), and an activation by keyboard or voice fires the click a pointer would (HTML §6.5), as React Native'sPressabledoes, and gesture-handler on web for a key.ButtonViewGroup.setPressedadmits a press only whentouchResponder === this || canBePressedAlongsideOther, so an exclusive button, the default, refuses the pressView.onKeyDownmakes for a confirm key and never clicks — the RectButton doesn't trigger onPress callback when pressed with keyboard Enter/Space key #2204 regression from Fix grabbing delegate insetPressed#3742 — andperformClickactivated the native gesture for a click no touch produced only while touch exploration was on or a key's up had setreceivedKeyEvent. The button now records a click's source whileView's ownonKeyDown,onKeyUporperformAccessibilityActionruns, admits that press without taking the responder where its click would activate a native gesture, and activates the gesture from it, then callsView.performClickfor the click sound andTYPE_VIEW_CLICKEDonce a handler took the press.receivedKeyEvent, the touch-exploration check and the root view's forwardingactivateNativeHandlersare gone.maindoes for a key on a button a finger holds, cancels the finger's touches and leaves the button the touch responder.mainan activation with no pointer reports the last pointer's count, type and position: (0, 0) on a button no pointer has reached, whichPressableWithTouchable'sisTouchWithinInsetrejects.prepareActivationWithoutPointerreports no pointer, as Pointer Events requires of a click a non-pointing device generates (§4.2.12.1), at the view's center mapped to the screen, whereViewtakes a key event to be (View.java); where that section asks for an empty type, it reportsKEYfor a key, as web does, andOTHERfor an accessibility service.handle()now reads a down's pointer before the touch callbacks run, so a handler a UI-threadonTouchesDownactivates, or a second finger begins, reports that touch rather than the last gesture's.makeActiveno longer asks React Native to cancel a touch for an activation no pointer drives, which loggedCan't cancel already finished gesture(JSTouchDispatcher).LegacyPressableandStatefulPressablepress in only onLONG_PRESS_TOUCHES_DOWN, which only a touch sends. A native gesture that begins reporting no pointer now goes around their state machine, pressing in as it begins and out as it ends at the position it reports, asStatefulPressable's TV branch did. Every engine, the defaultPressable's included, presses it before its first layout and under a hit slop that leaves out its center, andisKeyboardDismissingTapno longer takes it, inside a gesture-handlerScrollView, for the tap dismissing the keyboard.NativeViewGestureHandlerPayloaddeclares thex,y,absoluteXandabsoluteYthat Emit coordinates inNativegesture events #4302 emits, and the docs describe such a press.TOUCH, so the defaultPressabledrops it. It now reports the view's center, ashandleSwitch:does, with no pointer andKEY, so the same rule presses it. No CI job builds tvOS and no device here runs it, so that half rests on the iOS build and jest.While a key is down the button shows its native press feedback (recording). Android clicks on the key's up, so
onPressInandonPressarrive together, and a held key presses once, on release, as React Native'sPressabledoes (Pressability.onClick).Test plan
Tested on the following code, as the example app's Empty example:
main, Enter and Space on a Tab-focused button count only on React Native'sPressable; with this PR each key counts once on every button. With TalkBack on, a double tap on gesture-handler'sPressablebefore any touch counts nothing onmainand once with this PR, whileRectButtonand React Native'sPressablecount theirs on both..argent/flows/keyboard-activation-test.yaml, and.argent/flows/keyboard-touchable-test.yaml, which presses aTouchablewith Enter, then Space, fail onmainand pass here (before/after).pressableClickWithoutTouch.test.tsxandapi_v3.test.tsx: every engine presses once for a key, an accessibility click, an Android TV key, a tvOS focus press and an iOS press no touch drives, while a touch keeps the touch path. Withmain's engines every no-pointer case fails but a screen reader's click.ACTION_CLICKwith touch exploration off, as Switch Access and Voice Access click, presses nothing onmainand each control once here.