Skip to content

Commit ffb9a5f

Browse files
authored
[Android] Refactor Touchable not to rely on GestureDetector (#4342)
## Description Part 1/3 of the `Touchable` refactor (Android; iOS and web follow in stacked PRs). Previously, `Touchable` wrapped the button in a `NativeDetector` and derived `onPress`/`onPressIn`/`onPressOut`/`onLongPress` from gesture callbacks in JS, adding unnecessary overhead. This PR moves the whole press pipeline into the button on Android: - `RNGestureHandlerButtonViewManager` now creates, attaches, and drops a `NativeViewGestureHandler` itself, driven by a new `handlerTag` prop. The handler is attached with the new `ACTION_TYPE_NONE`, so it dispatches no gesture events to JS; its config is kept in sync via the `cancelOnLeave`, `gestureTestID`, `gestureHitSlop`, and `enabled` props (cached on the view, since prop application order isn't guaranteed). - `NativeViewGestureHandlerHook` gains `onHandlerUpdate`/`onHandlerStateChange` hooks, which `ButtonViewGroup` uses to run a press state machine natively — including the long-press timer (`hasLongPressHandler` + `longPressDuration`). - The resulting `press`/`pressIn`/`pressOut`/`longPress`/`interactionFinished` events are dispatched as direct events (`RNGestureHandlerButtonEvent`) carrying pointer data. - The new `Touchable.android.tsx` renders `GestureHandlerButton` directly — no `NativeDetector`, no JS state machine — and only forwards the native events, keeping the keyboard-dismissing-tap suppression in JS. ## Test plan Existing tests in the Example app
1 parent 6b0ca7b commit ffb9a5f

10 files changed

Lines changed: 621 additions & 7 deletions

File tree

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,7 @@ open class GestureHandler {
10691069
const val DIRECTION_LEFT = 2
10701070
const val DIRECTION_UP = 4
10711071
const val DIRECTION_DOWN = 8
1072+
const val ACTION_TYPE_NONE = 0
10721073
const val ACTION_TYPE_REANIMATED_WORKLET = 1
10731074
const val ACTION_TYPE_NATIVE_ANIMATED_EVENT = 2
10741075
const val ACTION_TYPE_JS_FUNCTION_OLD_API = 3

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,15 @@ class NativeViewGestureHandler : GestureHandler() {
212212
}
213213
lastActiveUpdate = snapshot
214214
super.dispatchHandlerUpdate(event)
215+
216+
hook.onHandlerUpdate(this)
215217
}
216218

217219
override fun dispatchStateChange(newState: Int, prevState: Int) {
218220
lastActiveUpdate = null
219221
super.dispatchStateChange(newState, prevState)
222+
223+
hook.onHandlerStateChange(this, newState, prevState)
220224
}
221225

222226
override fun wantsToAttachDirectlyToView() = true
@@ -356,6 +360,16 @@ class NativeViewGestureHandler : GestureHandler() {
356360
* Passes the event down to the underlying view using the correct method.
357361
*/
358362
fun sendTouchEvent(view: View?, event: MotionEvent) = view?.onTouchEvent(event)
363+
364+
/*
365+
* Called when the handler processes a new update event.
366+
*/
367+
fun onHandlerUpdate(handler: NativeViewGestureHandler) = Unit
368+
369+
/*
370+
* Called when the handler moves to a new state.
371+
*/
372+
fun onHandlerStateChange(handler: NativeViewGestureHandler, newState: Int, prevState: Int) = Unit
359373
}
360374

361375
private class TextViewHook : NativeViewGestureHandlerHook {

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@ import android.view.accessibility.AccessibilityNodeInfo
2626
import androidx.core.view.children
2727
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
2828
import com.facebook.react.R
29+
import com.facebook.react.bridge.Arguments
2930
import com.facebook.react.bridge.Dynamic
31+
import com.facebook.react.bridge.ReactContext
3032
import com.facebook.react.bridge.ReadableArray
33+
import com.facebook.react.bridge.ReadableMap
3134
import com.facebook.react.module.annotations.ReactModule
3235
import com.facebook.react.uimanager.BackgroundStyleApplicator
3336
import com.facebook.react.uimanager.LengthPercentage
3437
import com.facebook.react.uimanager.PixelUtil
3538
import com.facebook.react.uimanager.PointerEvents
3639
import com.facebook.react.uimanager.ReactPointerEventsView
3740
import com.facebook.react.uimanager.ThemedReactContext
41+
import com.facebook.react.uimanager.UIManagerHelper
3842
import com.facebook.react.uimanager.ViewGroupManager
3943
import com.facebook.react.uimanager.ViewManagerDelegate
4044
import com.facebook.react.uimanager.ViewProps
@@ -48,6 +52,7 @@ import com.swmansion.gesturehandler.core.GestureHandler
4852
import com.swmansion.gesturehandler.core.HoverGestureHandler
4953
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
5054
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup
55+
import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonEvent
5156

5257
@ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS)
5358
class RNGestureHandlerButtonViewManager :
@@ -63,6 +68,90 @@ class RNGestureHandlerButtonViewManager :
6368

6469
public override fun createViewInstance(context: ThemedReactContext) = ButtonViewGroup(context)
6570

71+
override fun onDropViewInstance(view: ButtonViewGroup) {
72+
view.managedHandlerTag?.let { tag ->
73+
getGestureHandlerModule(view).dropGestureHandler(tag)
74+
}
75+
76+
super.onDropViewInstance(view)
77+
}
78+
79+
private fun getGestureHandlerModule(view: ButtonViewGroup) =
80+
(view.context as ReactContext).getNativeModule(RNGestureHandlerModule::class.java)!!
81+
82+
private fun updateManagedHandlerConfig(view: ButtonViewGroup, config: ReadableMap) {
83+
view.managedHandlerTag?.let { getGestureHandlerModule(view).updateGestureHandlerConfig(it, config) }
84+
}
85+
86+
@ReactProp(name = "handlerTag")
87+
override fun setHandlerTag(view: ButtonViewGroup, handlerTag: Double) {
88+
if (view.managedHandlerTag == handlerTag) {
89+
return
90+
}
91+
92+
view.managedHandlerTag?.let { oldTag ->
93+
getGestureHandlerModule(view).dropGestureHandler(oldTag)
94+
}
95+
96+
view.managedHandlerTag = handlerTag
97+
val module = getGestureHandlerModule(view)
98+
99+
module.createGestureHandler(
100+
"NativeViewGestureHandler",
101+
handlerTag,
102+
Arguments.createMap().apply {
103+
putBoolean("shouldActivateOnStart", false)
104+
putBoolean("disallowInterruption", true)
105+
putBoolean("yieldsToContinuousGestures", true)
106+
putBoolean("enabled", view.isEnabled)
107+
view.managedHandlerCancelOnLeave?.let { putBoolean("shouldCancelWhenOutside", it) }
108+
view.managedHandlerTestID?.let { putString("testID", it) }
109+
view.managedHandlerHitSlop?.let { putMap("hitSlop", it) }
110+
},
111+
)
112+
module.attachGestureHandler(handlerTag, view.id.toDouble(), GestureHandler.ACTION_TYPE_NONE.toDouble())
113+
}
114+
115+
@ReactProp(name = "cancelOnLeave")
116+
override fun setCancelOnLeave(view: ButtonViewGroup, cancelOnLeave: Boolean) {
117+
view.managedHandlerCancelOnLeave = cancelOnLeave
118+
updateManagedHandlerConfig(
119+
view,
120+
Arguments.createMap().apply {
121+
putBoolean("shouldCancelWhenOutside", cancelOnLeave)
122+
},
123+
)
124+
}
125+
126+
@ReactProp(name = "gestureTestID")
127+
override fun setGestureTestID(view: ButtonViewGroup, gestureTestID: String?) {
128+
view.managedHandlerTestID = gestureTestID
129+
updateManagedHandlerConfig(
130+
view,
131+
Arguments.createMap().apply {
132+
putString("testID", gestureTestID)
133+
},
134+
)
135+
}
136+
137+
@ReactProp(name = "gestureHitSlop")
138+
override fun setGestureHitSlop(view: ButtonViewGroup, gestureHitSlop: ReadableMap?) {
139+
view.managedHandlerHitSlop = gestureHitSlop
140+
if (gestureHitSlop != null) {
141+
updateManagedHandlerConfig(
142+
view,
143+
Arguments.createMap().apply {
144+
putMap("hitSlop", gestureHitSlop)
145+
},
146+
)
147+
}
148+
}
149+
150+
@ReactProp(name = "hasLongPressHandler")
151+
override fun setHasLongPressHandler(view: ButtonViewGroup, hasLongPressHandler: Boolean) {
152+
view.hasLongPressHandler = hasLongPressHandler
153+
}
154+
66155
@ReactProp(name = "foreground")
67156
override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) {
68157
view.useDrawableOnForeground = useDrawableOnForeground
@@ -81,6 +170,13 @@ class RNGestureHandlerButtonViewManager :
81170
@ReactProp(name = "enabled")
82171
override fun setEnabled(view: ButtonViewGroup, enabled: Boolean) {
83172
view.isEnabled = enabled
173+
174+
updateManagedHandlerConfig(
175+
view,
176+
Arguments.createMap().apply {
177+
putBoolean("enabled", enabled)
178+
},
179+
)
84180
}
85181

86182
@ReactProp(name = "borderWidth")
@@ -397,7 +493,18 @@ class RNGestureHandlerButtonViewManager :
397493
}
398494
var useBorderlessDrawable = false
399495

496+
var managedHandlerTag: Double? = null
497+
498+
// Config props may be applied before `handlerTag` (prop order is not guaranteed) and
499+
// `updateManagedHandlerConfig` no-ops until the handler exists, so the values are cached
500+
// here and seeded into the config when `setHandlerTag` creates the handler. This also
501+
// re-applies them when the handler is recreated for a new tag.
502+
var managedHandlerCancelOnLeave: Boolean? = null
503+
var managedHandlerTestID: String? = null
504+
var managedHandlerHitSlop: ReadableMap? = null
505+
400506
var exclusive = true
507+
var hasLongPressHandler = false
401508
var tapAnimationInDuration: Int = 50
402509
var tapAnimationOutDuration: Int = 100
403510
var longPressDuration: Int = -1
@@ -436,6 +543,7 @@ class RNGestureHandlerButtonViewManager :
436543
private var underlayDrawable: PaintDrawable? = null
437544
private var pressInTimestamp = 0L
438545
private var pendingPressOut: Runnable? = null
546+
private var pendingLongPress: Runnable? = null
439547
private var pendingHoverOut: Choreographer.FrameCallback? = null
440548
private var isPointerInsideBounds = false
441549
private var isHovered = false
@@ -520,6 +628,92 @@ class RNGestureHandlerButtonViewManager :
520628
}
521629
}
522630

631+
private var longPressDetected = false
632+
633+
// Cannot rely on isPointerInsideBounds because it's set to false during motion event processing
634+
// which happens before the final state change is handled (state change is triggered by the motion
635+
// event).
636+
private var lastEventWasInside = false
637+
638+
override fun onHandlerUpdate(handler: NativeViewGestureHandler) {
639+
if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) {
640+
return
641+
}
642+
643+
if (handler.isWithinBounds) {
644+
dispatchJSEvent(EventType.PressIn, handler)
645+
} else {
646+
dispatchJSEvent(EventType.PressOut, handler)
647+
648+
pendingLongPress?.let {
649+
this.handler?.removeCallbacks(it)
650+
pendingLongPress = null
651+
}
652+
}
653+
}
654+
655+
override fun onHandlerStateChange(handler: NativeViewGestureHandler, newState: Int, prevState: Int) {
656+
if (managedHandlerTag == null) {
657+
return
658+
}
659+
660+
// Capture local copy, since lastEventWasInside can change during this method
661+
// Specifically PressOut -> Press scenario on STATE_END
662+
val localLastEventWasInside = lastEventWasInside
663+
664+
if (newState == GestureHandler.STATE_BEGAN) {
665+
dispatchJSEvent(EventType.PressIn, handler)
666+
longPressDetected = false
667+
668+
if (hasLongPressHandler && longPressDuration >= 0) {
669+
val runnable = Runnable {
670+
pendingLongPress = null
671+
longPressDetected = true
672+
dispatchJSEvent(EventType.LongPress, handler)
673+
}
674+
pendingLongPress = runnable
675+
this.handler?.postDelayed(runnable, longPressDuration.toLong())
676+
}
677+
}
678+
679+
if (newState == GestureHandler.STATE_END ||
680+
newState == GestureHandler.STATE_FAILED ||
681+
newState == GestureHandler.STATE_CANCELLED
682+
) {
683+
if (localLastEventWasInside) {
684+
dispatchJSEvent(EventType.PressOut, handler)
685+
}
686+
687+
pendingLongPress?.let {
688+
this.handler?.removeCallbacks(it)
689+
pendingLongPress = null
690+
}
691+
}
692+
693+
if (newState == GestureHandler.STATE_END && !longPressDetected && localLastEventWasInside) {
694+
dispatchJSEvent(EventType.Press, handler)
695+
}
696+
697+
if (newState == GestureHandler.STATE_END ||
698+
newState == GestureHandler.STATE_FAILED ||
699+
newState == GestureHandler.STATE_CANCELLED
700+
) {
701+
dispatchJSEvent(EventType.InteractionFinished, handler)
702+
}
703+
}
704+
705+
private fun dispatchJSEvent(type: EventType, handler: NativeViewGestureHandler) {
706+
val reactContext = context as? ReactContext ?: return
707+
val eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext) ?: return
708+
eventDispatcher.dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, handler, type))
709+
710+
if (type == EventType.PressIn) {
711+
lastEventWasInside = true
712+
} else if (type == EventType.PressOut) {
713+
lastEventWasInside = false
714+
}
715+
}
716+
523717
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
524718
if (super.onInterceptTouchEvent(event)) {
525719
return true
@@ -912,6 +1106,8 @@ class RNGestureHandlerButtonViewManager :
9121106
override fun onDetachedFromWindow() {
9131107
pendingPressOut?.let { handler?.removeCallbacks(it) }
9141108
pendingPressOut = null
1109+
pendingLongPress?.let { handler?.removeCallbacks(it) }
1110+
pendingLongPress = null
9151111
cancelPendingHoverOut()
9161112
currentAnimator?.cancel()
9171113
currentAnimator = null
@@ -1104,6 +1300,14 @@ class RNGestureHandlerButtonViewManager :
11041300
}
11051301
}
11061302
}
1303+
1304+
enum class EventType {
1305+
Press,
1306+
PressIn,
1307+
PressOut,
1308+
LongPress,
1309+
InteractionFinished,
1310+
}
11071311
}
11081312

11091313
companion object {

0 commit comments

Comments
 (0)