diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index f964945a54..fc8f627ae9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -79,6 +79,7 @@ class RNGestureHandlerButtonViewManager : view.managedHandlerTestID = null view.managedHandlerHitSlop = null view.moduleId = null + view.pressOutAfterAnimation = false view.resetHoverState() // Has to come last — every setter above flags the view as needing a managed handler update. view.managedHandlerNeedsUpdate = false @@ -357,6 +358,11 @@ class RNGestureHandlerButtonViewManager : view.longPressDuration = value } + @ReactProp(name = "pressOutAfterAnimation") + override fun setPressOutAfterAnimation(view: ButtonViewGroup, value: Boolean) { + view.pressOutAfterAnimation = value + } + @ReactProp(name = "longPressAnimationOutDuration") override fun setLongPressAnimationOutDuration(view: ButtonViewGroup, value: Int) { view.longPressAnimationOutDuration = value @@ -567,6 +573,7 @@ class RNGestureHandlerButtonViewManager : var tapAnimationInDuration: Int = 50 var tapAnimationOutDuration: Int = 100 var longPressDuration: Int = -1 + var pressOutAfterAnimation = false var longPressAnimationOutDuration: Int = -1 get() = if (field < 0) tapAnimationOutDuration else field var activeOpacity: Float = 1.0f @@ -608,6 +615,12 @@ class RNGestureHandlerButtonViewManager : private var underlayDrawable: PaintDrawable? = null private var pressInTimestamp = 0L private var pendingPressOut: Runnable? = null + + // PressOut event held back by `pressOutAfterAnimation` (the animation has its own hold above). + private var pendingPressOutEvent: Runnable? = null + + // InteractionFinished that arrived while PressOut was held; delivered right after it. + private var pendingInteractionFinishedEvent: RNGestureHandlerButtonEvent? = null private var pendingLongPress: Runnable? = null private var pendingHoverOut: Choreographer.FrameCallback? = null private var isPointerInsideBounds = false @@ -721,15 +734,21 @@ class RNGestureHandlerButtonViewManager : // ancestor intercepted it — the orchestrator still delivers events then. private var receivedNativeDown = false + // Whether the framework deferred the press on the DOWN (tap timeout inside a scrolling + // container) instead of pressing right away. + private var pressDeferredByPlatform = false + override fun onHandlerUpdate(handler: NativeViewGestureHandler) { if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) { return } if (handler.isWithinBounds) { + // Re-entry while a held PressOut from leaving is pending (cancelOnLeave off). + flushPendingPressOut() dispatchJSEvent(EventType.PressIn, handler) } else { - dispatchJSEvent(EventType.PressOut, handler) + dispatchPressOut(handler) pendingLongPress?.let { this.handler?.removeCallbacks(it) @@ -753,6 +772,7 @@ class RNGestureHandlerButtonViewManager : // terminal state may never come and the stale value would survive. BEGAN precedes both the // native dispatch of the same DOWN and the sweep that reads the flag. receivedNativeDown = false + flushPendingPressOut() dispatchJSEvent(EventType.PressIn, handler) longPressDetected = false @@ -772,7 +792,7 @@ class RNGestureHandlerButtonViewManager : newState == GestureHandler.STATE_CANCELLED ) { if (localLastEventWasInside) { - dispatchJSEvent(EventType.PressOut, handler) + dispatchPressOut(handler) } pendingLongPress?.let { @@ -789,15 +809,18 @@ class RNGestureHandlerButtonViewManager : newState == GestureHandler.STATE_FAILED || newState == GestureHandler.STATE_CANCELLED ) { - dispatchJSEvent(EventType.InteractionFinished, handler) + val interactionFinishedEvent = RNGestureHandlerButtonEvent.obtain(this, handler, EventType.InteractionFinished) + + if (pendingPressOutEvent != null) { + pendingInteractionFinishedEvent = interactionFinishedEvent + } else { + dispatchEvent(interactionFinishedEvent) + } } } private fun dispatchJSEvent(type: EventType, handler: NativeViewGestureHandler) { - val reactContext = context as? ReactContext ?: return - // TODO: deprecated, but its replacement is unavailable before RN 0.85 — drop when possible - val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, this.id) ?: return - eventDispatcher.dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, handler, type)) + dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, handler, type)) if (type == EventType.PressIn) { lastEventWasInside = true @@ -806,6 +829,45 @@ class RNGestureHandlerButtonViewManager : } } + private fun dispatchEvent(event: RNGestureHandlerButtonEvent) { + val reactContext = context as? ReactContext ?: return + // TODO: deprecated, but its replacement is unavailable before RN 0.85 — drop when possible + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, this.id) ?: return + eventDispatcher.dispatchEvent(event) + } + + // With `pressOutAfterAnimation`, a press the platform deferred (tap timeout inside a scrolling + // container) holds PressOut until the press-in animation has played, like `animatePressOut` + // holds the animation. An immediate press keeps its native timing. Press is never delayed, so + // on such taps PressOut follows it. The event is built now so it carries the release position. + private fun dispatchPressOut(handler: NativeViewGestureHandler) { + val remaining = tapAnimationInDuration - (SystemClock.uptimeMillis() - pressInTimestamp) + if (!pressOutAfterAnimation || !pressDeferredByPlatform || remaining <= 0) { + dispatchJSEvent(EventType.PressOut, handler) + return + } + + val event = RNGestureHandlerButtonEvent.obtain(this, handler, EventType.PressOut) + lastEventWasInside = false + val runnable = Runnable { + pendingPressOutEvent = null + dispatchEvent(event) + + pendingInteractionFinishedEvent?.let { + pendingInteractionFinishedEvent = null + dispatchEvent(it) + } + } + pendingPressOutEvent = runnable + this.handler?.postDelayed(runnable, remaining) + } + + private fun flushPendingPressOut() { + val runnable = pendingPressOutEvent ?: return + handler?.removeCallbacks(runnable) + runnable.run() + } + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { super.requestDisallowInterceptTouchEvent(disallowIntercept) if (!disallowIntercept) { @@ -930,6 +992,10 @@ class RNGestureHandlerButtonViewManager : val handled = super.onTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + pressDeferredByPlatform = !isPressed + } + // Replay press-in / press-out animations across drag transitions. if (handled && canRespondToTouches()) { when (event.actionMasked) { @@ -1347,6 +1413,8 @@ class RNGestureHandlerButtonViewManager : override fun onDetachedFromWindow() { pendingPressOut?.let { handler?.removeCallbacks(it) } pendingPressOut = null + // Deliver a held PressOut now rather than drop it: JS already saw the PressIn. + flushPendingPressOut() pendingLongPress?.let { handler?.removeCallbacks(it) } pendingLongPress = null cancelPendingHoverOut() diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 90a01f429d..912b810ee9 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -145,6 +145,14 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ longPressDuration?: number | undefined; + /** + * When the platform deferred the press (e.g. the tap timeout inside a + * scrolling container), hold the press-out event until the press-in + * animation has played, so feedback driven from the callbacks stays + * visible. The press event is not delayed. + */ + pressOutAfterAnimation?: boolean | undefined; + /** * Duration of the press-out animation, in milliseconds, when the * button is released after being held past `longPressDuration`. diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index ad8f412d04..2a8725a89c 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -60,6 +60,7 @@ interface NativeProps extends ViewProps { tapAnimationInDuration?: CodegenTypes.WithDefault; tapAnimationOutDuration?: CodegenTypes.WithDefault; longPressDuration?: CodegenTypes.WithDefault; + pressOutAfterAnimation?: CodegenTypes.WithDefault; longPressAnimationOutDuration?: CodegenTypes.WithDefault< CodegenTypes.Int32, -1 diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index 6f800fcec3..05995cd71c 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -21,6 +21,9 @@ const IS_TEST_ENV = isTestEnv(); // RN's Pressable default. Touchable's own default is 600ms const DEFAULT_LONG_PRESS_DURATION = 500; +// Pressable has no native opacity/scale animation, so the press-in duration only sets how long a +// platform-deferred quick tap keeps `pressed` (and holds onPressOut): RN's minPressDuration, 130 ms. +const PRESSABLE_ANIMATION_DURATION = { in: 50, out: 100, tap: { in: 130 } }; type Timers = { press: ReturnType | null; @@ -192,8 +195,9 @@ const PressableWithTouchable = (props: PressableProps) => { }; const handlePressOut = (event: ButtonEvent) => { - // Not cleared here: onPress fires after onPressOut and must stay suppressed - // too; isActive resets on the next press-in. + // Not cleared here: onPress can fire on either side of onPressOut (a quick + // tap defers onPressOut) and must stay suppressed too; isActive resets on + // the next press-in. if (!isActive.current) { return; } @@ -312,6 +316,8 @@ const PressableWithTouchable = (props: PressableProps) => { touchSoundDisabled={android_disableSound ?? undefined} delayLongPress={resolvedDelayLongPress} style={[pointerStyle, resolvedStyle]} + animationDuration={PRESSABLE_ANIMATION_DURATION} + pressOutAfterAnimation onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={handlePress} diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 40469658c7..e3a1a0e5d9 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -85,6 +85,7 @@ export const Touchable = (props: TouchableProps) => { children, disabled = false, cancelOnLeave = true, + pressOutAfterAnimation = false, ref, ...rest } = props; @@ -225,6 +226,7 @@ export const Touchable = (props: TouchableProps) => { activeUnderlayOpacity={activeUnderlayOpacity} underlayColor={underlayColor} longPressDuration={resolvedDelayLongPress} + pressOutAfterAnimation={pressOutAfterAnimation} hasLongPressHandler={onLongPress !== undefined} onButtonPress={internalOnPress} onButtonPressIn={internalOnPressIn} diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts index a2925c03f7..c58509c292 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts @@ -130,4 +130,11 @@ export type TouchableProps = Omit< * On web this prop doesn't have any effect and behaves as if `true` was set. */ cancelOnLeave?: boolean | undefined; + + /** + * When the platform deferred the press (e.g. inside a scrolling container), holds `onPressOut` + * until the press-in animation has finished, so a quick tap still shows its feedback. + * `onPress` is not delayed and may precede `onPressOut` on such taps. By default set to false. + */ + pressOutAfterAnimation?: boolean | undefined; };