Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
m-bert marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pendingLongPress?.let {
this.handler?.removeCallbacks(it)
Expand All @@ -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

Expand All @@ -772,7 +792,7 @@ class RNGestureHandlerButtonViewManager :
newState == GestureHandler.STATE_CANCELLED
) {
if (localLastEventWasInside) {
dispatchJSEvent(EventType.PressOut, handler)
dispatchPressOut(handler)
}

pendingLongPress?.let {
Expand All @@ -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
Expand All @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of the name of this prop, though I cannot say what exactly feels wrong. Maybe something along the lines of delayPressOutForAnimation?

Not a blocker. We can also brainstorm later.

@m-bert m-bert Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest delayPressOutUntilAnimationEnds, but delayPressOutForAnimation sounds ok. We can decide later.


/**
* Duration of the press-out animation, in milliseconds, when the
* button is released after being held past `longPressDuration`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface NativeProps extends ViewProps {
tapAnimationInDuration?: CodegenTypes.WithDefault<CodegenTypes.Int32, 50>;
tapAnimationOutDuration?: CodegenTypes.WithDefault<CodegenTypes.Int32, 100>;
longPressDuration?: CodegenTypes.WithDefault<CodegenTypes.Int32, -1>;
pressOutAfterAnimation?: CodegenTypes.WithDefault<boolean, false>;
longPressAnimationOutDuration?: CodegenTypes.WithDefault<
CodegenTypes.Int32,
-1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const Touchable = (props: TouchableProps) => {
children,
disabled = false,
cancelOnLeave = true,
pressOutAfterAnimation = false,
ref,
...rest
} = props;
Expand Down Expand Up @@ -225,6 +226,7 @@ export const Touchable = (props: TouchableProps) => {
activeUnderlayOpacity={activeUnderlayOpacity}
underlayColor={underlayColor}
longPressDuration={resolvedDelayLongPress}
pressOutAfterAnimation={pressOutAfterAnimation}
Comment thread
m-bert marked this conversation as resolved.
hasLongPressHandler={onLongPress !== undefined}
onButtonPress={internalOnPress}
onButtonPressIn={internalOnPressIn}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading