From 2941beadad4dc826c0b626feae3cfd334b96cdc5 Mon Sep 17 00:00:00 2001 From: Benedict Pregler Date: Mon, 14 Sep 2026 17:20:19 +0200 Subject: [PATCH 1/2] feat(touchable): expose native visual press changes Let custom feedback follow built-in press targets after native scroll deferral while preserving existing interaction callbacks. Cover quick taps, cancellation, disabled state, callback replacement and cleanup. --- .../docs/components/touchable.mdx | 20 ++ .../__typetests__/buttonEventTest.ts | 13 ++ .../RNGestureHandlerButtonViewManager.kt | 46 +++- .../RNGestureHandlerButtonVisualPressEvent.kt | 20 ++ .../apple/RNGestureHandlerButton.h | 3 + .../apple/RNGestureHandlerButton.mm | 38 ++++ .../RNGestureHandlerButtonComponentView.mm | 12 + .../__tests__/touchableVisualPress.test.tsx | 87 +++++++ .../__tests__/webButtonVisualPress.test.tsx | 214 ++++++++++++++++++ .../src/components/GestureHandlerButton.tsx | 5 + .../components/GestureHandlerButton.web.tsx | 57 ++++- .../RNGestureHandlerButtonNativeComponent.ts | 5 + .../src/v3/components/GestureButtonsProps.ts | 1 + .../src/v3/components/Touchable/Touchable.tsx | 12 + .../v3/components/Touchable/TouchableProps.ts | 10 +- 15 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonVisualPressEvent.kt create mode 100644 packages/react-native-gesture-handler/src/__tests__/touchableVisualPress.test.tsx create mode 100644 packages/react-native-gesture-handler/src/__tests__/webButtonVisualPress.test.tsx diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx index 057ff44541..156d3deb83 100644 --- a/packages/docs-gesture-handler/docs/components/touchable.mdx +++ b/packages/docs-gesture-handler/docs/components/touchable.mdx @@ -396,6 +396,26 @@ enum PointerType { Triggered when the button gets pressed (analogous to `onPressIn` in `Pressable` from RN core). +### onVisualPressChange + +```tsx +onVisualPressChange?: (pressed: boolean) => void; +``` + +Called when Touchable selects its built-in pressed (`true`) or resting (`false`) feedback target. Native scroll views can defer `true` while deciding whether a touch starts a scroll. For quick taps, `false` follows the built-in press-in hold and can arrive after `onPressOut` and `onPress`. + +Use this callback for custom feedback when `activeOpacity`, `activeScale`, and `activeUnderlayOpacity` are insufficient. Initialize custom state to `false`. The callback emits changes only, with no initial value or replay when the callback changes. A final callback after unmount is not guaranteed. + +```tsx +const [pressed, setPressed] = useState(false); + + + Select item + +``` + +This callback runs in JavaScript. It reports animation targets, not animation completion, and does not guarantee UI-thread execution or frame synchronization. Existing `onPressIn`, `onPressOut`, and `onPress` callbacks keep their interaction semantics. Hover feedback can remain active after `false`. + ### onPressOut ; + +type TouchableHasVisualPressCallback = StaticAssert< + TouchableProps['onVisualPressChange'] extends + | ((pressed: boolean) => void) + | undefined + ? true + : false +>; + +type TouchableHidesVisualPressTransport = StaticAssert< + 'onButtonVisualPressChange' extends keyof TouchableProps ? false : true +>; 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..90814347b2 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 @@ -53,6 +53,7 @@ import com.swmansion.gesturehandler.core.HoverGestureHandler import com.swmansion.gesturehandler.core.NativeViewGestureHandler import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonEvent +import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonVisualPressEvent @ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS) class RNGestureHandlerButtonViewManager : @@ -489,6 +490,7 @@ class RNGestureHandlerButtonViewManager : private fun dropManagedHandler(view: ButtonViewGroup) { val tag = view.managedHandlerTag ?: return + view.resetVisualPressState() // Cleared even if the drop below doesn't go through — the view must not keep a dangling tag. view.managedHandlerTag = null @@ -606,6 +608,7 @@ class RNGestureHandlerButtonViewManager : private var receivedKeyEvent = false private var currentAnimator: AnimatorSet? = null private var underlayDrawable: PaintDrawable? = null + private var visualPressed = false private var pressInTimestamp = 0L private var pendingPressOut: Runnable? = null private var pendingLongPress: Runnable? = null @@ -1042,12 +1045,35 @@ class RNGestureHandlerButtonViewManager : } } + private fun setVisualPressed(pressed: Boolean) { + if (visualPressed == pressed) { + return + } + visualPressed = pressed + if (managedHandlerTag == null) { + return + } + val reactContext = context as ReactContext + // TODO: deprecated, but its replacement is unavailable before RN 0.85 — drop when possible + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return + eventDispatcher.dispatchEvent(RNGestureHandlerButtonVisualPressEvent(this, pressed)) + } + + fun resetVisualPressState() { + pendingPressOut?.let { handler?.removeCallbacks(it) } + pendingPressOut = null + pressInTimestamp = 0L + setVisualPressed(false) + animateTo(restingOpacity, restingScale, restingUnderlayOpacity, 0) + } + private fun animatePressIn() { pendingPressOut?.let { handler?.removeCallbacks(it) pendingPressOut = null } pressInTimestamp = SystemClock.uptimeMillis() + setVisualPressed(true) animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong()) } @@ -1056,6 +1082,7 @@ class RNGestureHandlerButtonViewManager : return } + setVisualPressed(false) if (effectiveHover) { animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong()) } else { @@ -1194,6 +1221,7 @@ class RNGestureHandlerButtonViewManager : private fun animatePressOut() { pendingPressOut?.let { handler?.removeCallbacks(it) } + pendingPressOut = null val tapInMs = tapAnimationInDuration.toLong() val tapOutMs = tapAnimationOutDuration.toLong() val longPressMs = longPressDuration.toLong() @@ -1202,12 +1230,15 @@ class RNGestureHandlerButtonViewManager : if (longPressMs >= 0 && elapsed >= longPressMs) { // Long-press release - use the configured long-press out duration. + setVisualPressed(false) animateTo(restingOpacity, restingScale, restingUnderlayOpacity, longPressOutMs) } else if (elapsed >= tapInMs) { // Press-in animation fully finished — release with the configured out duration. + setVisualPressed(false) animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs) // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= tapOutMs) { + setVisualPressed(false) animateTo(restingOpacity, restingScale, restingUnderlayOpacity, elapsed) } else { val remaining = tapInMs - elapsed @@ -1215,6 +1246,7 @@ class RNGestureHandlerButtonViewManager : val runnable = Runnable { pendingPressOut = null + setVisualPressed(false) animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs) } pendingPressOut = runnable @@ -1356,6 +1388,8 @@ class RNGestureHandlerButtonViewManager : // and re-inserting, so detaching is not proof the pointer left. A genuine // teardown clears it in `onDropViewInstance` instead. isHovered = false + pressInTimestamp = 0L + setVisualPressed(false) applyStartAnimationState() if (touchResponder === this) { @@ -1494,14 +1528,12 @@ class RNGestureHandlerButtonViewManager : return } - if (!enabled && isPressed) { - setPressed(false) + if (!enabled && (isPressed || visualPressed)) { + if (isPressed) { + setPressed(false) + } - // No finger lifted, so skip the press-out fade: it would overlap the - // restyle that usually comes with disabling and show as a flash. - pendingPressOut?.let { handler?.removeCallbacks(it) } - pendingPressOut = null - animateTo(restingOpacity, restingScale, restingUnderlayOpacity, 0) + resetVisualPressState() } // The managed handler mirrors the button's enabled state. diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonVisualPressEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonVisualPressEvent.kt new file mode 100644 index 0000000000..526fbb59e7 --- /dev/null +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerButtonVisualPressEvent.kt @@ -0,0 +1,20 @@ +package com.swmansion.gesturehandler.react.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager + +class RNGestureHandlerButtonVisualPressEvent( + button: RNGestureHandlerButtonViewManager.ButtonViewGroup, + private val pressed: Boolean, +) : Event(UIManagerHelper.getSurfaceId(button), button.id) { + override fun getEventName() = "onButtonVisualPressChange" + + override fun canCoalesce() = false + + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putBoolean("pressed", pressed) + } +} diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index cd54495d52..b939387477 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -27,6 +27,8 @@ typedef NS_ENUM(NSInteger, RNGHButtonEventType) { - (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(nullable RNGestureHandlerEventExtraData *)extraData; +- (void)dispatchVisualPressChange:(BOOL)pressed; + @end #if TARGET_OS_OSX @@ -100,6 +102,7 @@ typedef NS_ENUM(NSInteger, RNGHButtonEventType) { * and the underlay opacity to neutral values. */ - (void)prepareForRecycle; +- (void)resetVisualPressState; /** * Updates the underlay layer's corner radii with separate horizontal/vertical diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 994b3d38f1..6548d6f272 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -53,6 +53,7 @@ @implementation RNGestureHandlerButton { dispatch_block_t _pendingPressOutBlock; BOOL _isHovered; BOOL _isPressed; + BOOL _visualPressed; dispatch_block_t _pendingHoverOutBlock; // Whether a hover was already open at press-start. The touch stream may only @@ -186,6 +187,7 @@ - (void)prepareForRecycle // prior use leaks into the recycled view, and `updateProps:` won't undo it // when defaults are unchanged between mounts. [self cancelPendingPressOutAnimation]; + [self setVisualPressed:NO]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; _lastEventWasInside = NO; @@ -220,6 +222,7 @@ - (void)viewWillMoveToWindow:(RNGHWindow *)newWindow [super viewWillMoveToWindow:newWindow]; if (newWindow == nil) { [self cancelPendingPressOutAnimation]; + [self setVisualPressed:NO]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; [self applyStartAnimationState]; @@ -244,6 +247,7 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow [super willMoveToWindow:newWindow]; if (newWindow == nil) { [self cancelPendingPressOutAnimation]; + [self setVisualPressed:NO]; [self cancelPendingHoverOut]; [self cancelPendingLongPress]; [self applyStartAnimationState]; @@ -337,6 +341,10 @@ - (void)setUserEnabled:(BOOL)userEnabled } #endif + if (!userEnabled && _visualPressed) { + [self resetVisualPressState]; + } + [self dispatchHoverEventIfNeeded]; if (_isHovered && !_isPressed) { @@ -592,6 +600,29 @@ - (void)animateToOpacity:(CGFloat)opacity } } +- (void)setVisualPressed:(BOOL)pressed +{ + if (_visualPressed == pressed) { + return; + } + _visualPressed = pressed; + if (_managedHandlerTag != nil) { + [self.eventDelegate dispatchVisualPressChange:pressed]; + } +} + +- (void)resetVisualPressState +{ + [self cancelPendingPressOutAnimation]; + _isPressed = NO; + _pressInTimestamp = 0; + [self setVisualPressed:NO]; + [self animateToOpacity:self.restingOpacity + scale:self.restingScale + underlayOpacity:self.restingUnderlayOpacity + duration:0]; +} + - (void)handleAnimatePressIn { if (_pendingPressOutBlock) { @@ -603,6 +634,7 @@ - (void)handleAnimatePressIn [self cancelPendingHoverOut]; _isPressed = YES; _pressInTimestamp = CACurrentMediaTime(); + [self setVisualPressed:YES]; [self animateToOpacity:_activeOpacity scale:_activeScale underlayOpacity:_activeUnderlayOpacity @@ -614,6 +646,7 @@ - (void)handleAnimatePressOut _isPressed = NO; if (_pendingPressOutBlock) { dispatch_block_cancel(_pendingPressOutBlock); + _pendingPressOutBlock = nil; } NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; @@ -621,12 +654,14 @@ - (void)handleAnimatePressOut if (_longPressDuration >= 0 && elapsed >= _longPressDuration) { // Long-press release - use the configured long-press out duration. NSInteger longPressOut = self.longPressAnimationOutDuration; + [self setVisualPressed:NO]; [self animateToOpacity:self.restingOpacity scale:self.restingScale underlayOpacity:self.restingUnderlayOpacity duration:longPressOut]; } else if (elapsed >= _tapAnimationInDuration) { // Press-in animation fully finished - release with the configured out duration. + [self setVisualPressed:NO]; [self animateToOpacity:self.restingOpacity scale:self.restingScale underlayOpacity:self.restingUnderlayOpacity @@ -634,6 +669,7 @@ - (void)handleAnimatePressOut // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= _tapAnimationOutDuration) { // Past minimum but press-in animation still playing, animate out in elapsed time + [self setVisualPressed:NO]; [self animateToOpacity:self.restingOpacity scale:self.restingScale underlayOpacity:self.restingUnderlayOpacity @@ -649,6 +685,7 @@ - (void)handleAnimatePressOut __strong auto strongSelf = weakSelf; if (strongSelf) { strongSelf->_pendingPressOutBlock = nil; + [strongSelf setVisualPressed:NO]; [strongSelf animateToOpacity:strongSelf.restingOpacity scale:strongSelf.restingScale underlayOpacity:strongSelf.restingUnderlayOpacity @@ -888,6 +925,7 @@ - (void)animateHoverState return; } + [self setVisualPressed:NO]; if ([self effectiveHover]) { [self animateToOpacity:self.hoverOpacity scale:self.hoverScale diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index d479d51570..cacaa9bdda 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -361,6 +361,7 @@ - (void)dropManagedHandler return; } + [_buttonView resetVisualPressState]; RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; [manager dropGestureHandler:_managedHandlerTag]; @@ -376,6 +377,17 @@ - (void)dealloc #pragma mark - RNGHButtonEventDelegate +- (void)dispatchVisualPressChange:(BOOL)pressed +{ + if (_eventEmitter == nullptr) { + return; + } + const auto &eventEmitter = static_cast(*_eventEmitter); + RNGestureHandlerButtonEventEmitter::OnButtonVisualPressChange event{}; + event.pressed = pressed; + eventEmitter.onButtonVisualPressChange(event); +} + - (void)dispatchButtonEvent:(RNGHButtonEventType)type withExtraData:(RNGestureHandlerEventExtraData *)extraData { if (_eventEmitter == nullptr) { diff --git a/packages/react-native-gesture-handler/src/__tests__/touchableVisualPress.test.tsx b/packages/react-native-gesture-handler/src/__tests__/touchableVisualPress.test.tsx new file mode 100644 index 0000000000..641b3c6187 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/touchableVisualPress.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { Touchable } from '../v3/components/Touchable/Touchable'; +import type * as ScrollViewInterop from '../v3/scrollViewInterop'; +import { isKeyboardDismissingTap } from '../v3/scrollViewInterop'; + +jest.mock('../v3/scrollViewInterop', () => ({ + ...jest.requireActual('../v3/scrollViewInterop'), + isKeyboardDismissingTap: jest.fn(() => false), +})); + +beforeEach(() => { + jest.mocked(isKeyboardDismissingTap).mockReturnValue(false); +}); + +test('forwards visual targets independently of the existing press callbacks', () => { + const onVisualPressChange = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onPress = jest.fn(); + render( + + Press + + ); + const button = screen.getByTestId('button'); + const event = { nativeEvent: { pointerInside: true } }; + + expect(onVisualPressChange).not.toHaveBeenCalled(); + fireEvent(button, 'buttonPressIn', event); + expect(onPressIn).toHaveBeenCalledWith(event.nativeEvent); + expect(onVisualPressChange).not.toHaveBeenCalled(); + + fireEvent(button, 'buttonVisualPressChange', { + nativeEvent: { pressed: true }, + }); + fireEvent(button, 'buttonPressOut', event); + fireEvent(button, 'buttonPress', event); + expect(onPressOut).toHaveBeenCalledWith(event.nativeEvent); + expect(onPress).toHaveBeenCalledWith(event.nativeEvent); + expect(onVisualPressChange.mock.calls).toEqual([[true]]); + + fireEvent(button, 'buttonVisualPressChange', { + nativeEvent: { pressed: false }, + }); + expect(onVisualPressChange.mock.calls).toEqual([[true], [false]]); + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('does not gate visual targets behind the keyboard tap filter', () => { + jest.mocked(isKeyboardDismissingTap).mockReturnValue(true); + const onPressIn = jest.fn(); + const onVisualPressChange = jest.fn(); + render( + + ); + const button = screen.getByTestId('button'); + fireEvent(button, 'buttonPressIn', { nativeEvent: {} }); + fireEvent(button, 'buttonVisualPressChange', { + nativeEvent: { pressed: true }, + }); + fireEvent(button, 'buttonVisualPressChange', { + nativeEvent: { pressed: false }, + }); + expect(onPressIn).not.toHaveBeenCalled(); + expect(onVisualPressChange.mock.calls).toEqual([[true], [false]]); +}); + +test('leaves the native listener absent when no callback is supplied', () => { + render(); + expect( + screen.getByTestId('button').props.onButtonVisualPressChange + ).toBeUndefined(); +}); diff --git a/packages/react-native-gesture-handler/src/__tests__/webButtonVisualPress.test.tsx b/packages/react-native-gesture-handler/src/__tests__/webButtonVisualPress.test.tsx new file mode 100644 index 0000000000..c8267f09a2 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/webButtonVisualPress.test.tsx @@ -0,0 +1,214 @@ +import { act, render } from '@testing-library/react-native'; +import React from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; +import { StyleSheet } from 'react-native'; + +import { ButtonComponent } from '../components/GestureHandlerButton.web'; +import type { ButtonVisualPressEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; +import { ButtonEventName } from '../web/tools/ButtonEvents'; +import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; + +jest.mock('react-native/Libraries/Components/View/View', () => ({ + __esModule: true, + default: 'View', +})); + +jest.mock('../RNGestureHandlerModule.web', () => ({ + __esModule: true, + default: { + createGestureHandler: jest.fn(), + attachGestureHandler: jest.fn(), + detachGestureHandler: jest.fn(), + dropGestureHandler: jest.fn(), + setGestureHandlerConfig: jest.fn(), + }, +})); + +class ButtonNode { + private listeners = new Map void>>(); + + addEventListener(type: string, listener: (event: unknown) => void) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.get(type)?.delete(listener); + } + + dispatch(type: string) { + this.listeners + .get(type) + ?.forEach((listener) => listener({ detail: { pointerInside: true } })); + } +} + +function mountButton( + props: Partial> = {} +) { + const node = new ButtonNode(); + const onVisualPressChange = jest.fn< + void, + [NativeSyntheticEvent] + >(); + const buttonProps = { + handlerTag: 1, + onButtonVisualPressChange: onVisualPressChange, + ...props, + }; + const result = render(, { + createNodeMock: () => node, + }); + return { + node, + onVisualPressChange, + ...result, + update: (newProps: Partial>) => + result.rerender(), + }; +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(performance, 'now').mockImplementation(() => Date.now()); + jest.setSystemTime(1000); +}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +function targets( + callback: jest.Mock]> +) { + return callback.mock.calls.map(([event]) => event.nativeEvent.pressed); +} + +test('retains the quick-tap target while the existing press callbacks finish', async () => { + const onButtonPressIn = jest.fn(); + const onButtonPressOut = jest.fn(); + const onButtonPress = jest.fn(); + const { node, onVisualPressChange } = mountButton({ + onButtonPressIn, + onButtonPressOut, + onButtonPress, + }); + expect(targets(onVisualPressChange)).toEqual([]); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + jest.advanceTimersByTime(10); + node.dispatch(ButtonEventName.PressOut); + node.dispatch(ButtonEventName.Press); + }); + expect(targets(onVisualPressChange)).toEqual([true]); + expect(onButtonPressIn).toHaveBeenCalledTimes(1); + expect(onButtonPressOut).toHaveBeenCalledTimes(1); + expect(onButtonPress).toHaveBeenCalledTimes(1); + await act(() => jest.advanceTimersByTime(39)); + expect(targets(onVisualPressChange)).toEqual([true]); + await act(() => jest.advanceTimersByTime(1)); + expect(targets(onVisualPressChange)).toEqual([true, false]); +}); + +test('reports both immediate edges even when React batches them', async () => { + const { node, onVisualPressChange } = mountButton({ + tapAnimationInDuration: 0, + }); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + node.dispatch(ButtonEventName.PressOut); + }); + expect(targets(onVisualPressChange)).toEqual([true, false]); +}); + +test('a second press cancels the pending release without repeating true', async () => { + const { node, onVisualPressChange } = mountButton(); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + jest.advanceTimersByTime(10); + node.dispatch(ButtonEventName.PressOut); + jest.advanceTimersByTime(10); + node.dispatch(ButtonEventName.PressIn); + jest.advanceTimersByTime(100); + }); + expect(targets(onVisualPressChange)).toEqual([true]); + await act(() => node.dispatch(ButtonEventName.PressOut)); + expect(targets(onVisualPressChange)).toEqual([true, false]); +}); + +test('cancellation balances once and a subsequent press can re-enter', async () => { + const { node, onVisualPressChange } = mountButton(); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + node.dispatch(ButtonEventName.PressOut); + node.dispatch(GestureLifecycleEvent.Canceled); + node.dispatch(GestureLifecycleEvent.Canceled); + jest.runAllTimers(); + node.dispatch(ButtonEventName.PressIn); + }); + expect(targets(onVisualPressChange)).toEqual([true, false, true]); +}); + +test.each([false, true])( + 'disable ends feedback with pending release %s', + async (releaseFirst) => { + const { node, onVisualPressChange, update, getByTestId } = mountButton({ + testID: 'button', + activeScale: 0.8, + }); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + if (releaseFirst) { + node.dispatch(ButtonEventName.PressOut); + } + }); + update({ enabled: false }); + await act(() => jest.runAllTimers()); + expect(targets(onVisualPressChange)).toEqual([true, false]); + update({ enabled: true }); + expect(targets(onVisualPressChange)).toEqual([true, false]); + expect(StyleSheet.flatten(getByTestId('button').props.style)).toMatchObject( + { + transform: [{ scale: 1 }], + } + ); + await act(() => node.dispatch(ButtonEventName.PressIn)); + expect(StyleSheet.flatten(getByTestId('button').props.style)).toMatchObject( + { + transform: [{ scale: 0.8 }], + } + ); + expect(targets(onVisualPressChange)).toEqual([true, false, true]); + } +); + +test('a pending release uses the latest callback without replaying true', async () => { + const { node, onVisualPressChange, update } = mountButton(); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + node.dispatch(ButtonEventName.PressOut); + }); + const replacement = jest.fn< + void, + [NativeSyntheticEvent] + >(); + update({ onButtonVisualPressChange: replacement }); + expect(targets(replacement)).toEqual([]); + await act(() => jest.runAllTimers()); + expect(targets(onVisualPressChange)).toEqual([true]); + expect(targets(replacement)).toEqual([false]); +}); + +test('unmount cancels a pending release', async () => { + const { node, onVisualPressChange, unmount } = mountButton(); + await act(() => { + node.dispatch(ButtonEventName.PressIn); + node.dispatch(ButtonEventName.PressOut); + }); + unmount(); + await act(() => jest.runAllTimers()); + expect(targets(onVisualPressChange)).toEqual([true]); + expect(jest.getTimerCount()).toBe(0); +}); diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 90a01f429d..1002133fa7 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -9,6 +9,7 @@ import type { ViewStyle, } from 'react-native'; +import type { ButtonVisualPressEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; import RNGestureHandlerButtonNativeComponent from '../specs/RNGestureHandlerButtonNativeComponent'; import type { ButtonEvent } from '../v3/types'; @@ -20,6 +21,10 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ enabled?: boolean | undefined; + onButtonVisualPressChange?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + hasLongPressHandler?: boolean | undefined; moduleId?: number | undefined; handlerTag?: number | undefined; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index d2bd195f29..27bcc465f7 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -6,6 +6,7 @@ import { ActionType } from '../ActionType'; import { normalizeHitSlop } from '../handlers/hitSlop'; import { PointerType } from '../PointerType'; import RNGestureHandlerModule from '../RNGestureHandlerModule.web'; +import type { ButtonVisualPressEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; import type { ButtonEvent } from '../v3/types'; import type { PropsRef } from '../web/interfaces'; @@ -92,6 +93,9 @@ type ButtonProps = ViewProps & { } | null | undefined; + onButtonVisualPressChange?: + | ((event: NativeSyntheticEvent) => void) + | undefined; onButtonPress?: | ((event: NativeSyntheticEvent) => void) | undefined; @@ -141,6 +145,7 @@ export const ButtonComponent = ({ gestureTestID, gestureHitSlop, onButtonPress, + onButtonVisualPressChange, onButtonPressIn, onButtonPressOut, onButtonLongPress, @@ -157,6 +162,30 @@ export const ButtonComponent = ({ hoverUnderlayOpacityProp ?? defaultUnderlayOpacity; const [pressed, setPressed] = React.useState(false); + if (!enabled && pressed) { + setPressed(false); + } + const visualPressedRef = React.useRef(false); + const visualPressCallbackRef = React.useRef(onButtonVisualPressChange); + useIsomorphicLayoutEffect(() => { + visualPressCallbackRef.current = onButtonVisualPressChange; + }); + const reportVisualPressed = React.useCallback((nextPressed: boolean) => { + if (visualPressedRef.current === nextPressed) { + return; + } + visualPressedRef.current = nextPressed; + visualPressCallbackRef.current?.({ + nativeEvent: { pressed: nextPressed }, + } as NativeSyntheticEvent); + }, []); + const setVisualPressed = React.useCallback( + (nextPressed: boolean) => { + setPressed(nextPressed); + reportVisualPressed(nextPressed); + }, + [reportVisualPressed] + ); const [hovered, setHovered] = React.useState(false); const [currentDuration, setCurrentDuration] = React.useState( tapAnimationInDuration @@ -230,7 +259,7 @@ export const ButtonComponent = ({ pressOutTimer.current = null; } pressInTimestamp.current = 0; - setPressed(false); + setVisualPressed(false); }; node?.addEventListener(GestureLifecycleEvent.Began, handleGestureBegan); @@ -252,7 +281,18 @@ export const ButtonComponent = ({ clearTimeout(pressOutTimer.current); } }; - }, []); + }, [setVisualPressed]); + + useIsomorphicLayoutEffect(() => { + if (!enabled) { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + pressInTimestamp.current = 0; + reportVisualPressed(false); + } + }, [enabled, reportVisualPressed]); const pressIn = React.useCallback( (event?: NativeSyntheticEvent) => { @@ -277,9 +317,9 @@ export const ButtonComponent = ({ } pressInTimestamp.current = performance.now(); setCurrentDuration(tapAnimationInDuration); - setPressed(true); + setVisualPressed(true); }, - [enabled, tapAnimationInDuration] + [enabled, tapAnimationInDuration, setVisualPressed] ); const pressOut = React.useCallback( @@ -303,15 +343,15 @@ export const ButtonComponent = ({ if (longPressDuration >= 0 && elapsed >= longPressDuration) { // Long-press release — use the configured long-press out duration. setCurrentDuration(longPressAnimationOutDuration); - setPressed(false); + setVisualPressed(false); } else if (elapsed >= tapAnimationInDuration) { // Press-in animation fully finished - release with the configured out duration. setCurrentDuration(tapAnimationOutDuration); - setPressed(false); + setVisualPressed(false); // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= tapAnimationOutDuration) { setCurrentDuration(elapsed); - setPressed(false); + setVisualPressed(false); } else { // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time. const remaining = tapAnimationInDuration - elapsed; @@ -319,13 +359,14 @@ export const ButtonComponent = ({ () => { pressOutTimer.current = null; setCurrentDuration(tapAnimationOutDuration); - setPressed(false); + setVisualPressed(false); }, prefersReducedMotion() ? 0 : remaining ); } }, [ + setVisualPressed, longPressDuration, longPressAnimationOutDuration, tapAnimationInDuration, diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index ad8f412d04..254e477f8b 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -6,6 +6,8 @@ import type { } from 'react-native'; import { codegenNativeComponent } from 'react-native'; +export type ButtonVisualPressEvent = Readonly<{ pressed: boolean }>; + export type ButtonEvent = Readonly<{ pointerInside: boolean; x: CodegenTypes.Double; @@ -21,6 +23,9 @@ interface NativeProps extends ViewProps { // The events are namespaced with `Button` because the base view config on iOS // already registers `topPress` as a bubbling event — a direct event with the // same top-level name would fail view config validation in dev. + onButtonVisualPressChange?: + | CodegenTypes.DirectEventHandler + | undefined; onButtonPress?: CodegenTypes.DirectEventHandler | undefined; onButtonPressIn?: CodegenTypes.DirectEventHandler | undefined; onButtonPressOut?: CodegenTypes.DirectEventHandler | undefined; diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts index 44e6473b02..3825abd226 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -20,6 +20,7 @@ export interface RawButtonProps | 'onButtonHoverIn' | 'onButtonHoverOut' | 'onButtonInteractionFinished' + | 'onButtonVisualPressChange' | 'defaultOpacity' | 'defaultScale' | 'defaultUnderlayOpacity' 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 2c7ba4bc63..9edd1aca7e 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 @@ -7,6 +7,7 @@ import GestureHandlerButton, { } from '../../../components/GestureHandlerButton'; import { getTVProps } from '../../../components/utils'; import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag'; +import type { ButtonVisualPressEvent } from '../../../specs/RNGestureHandlerButtonNativeComponent'; import { isKeyboardDismissingTap, JSResponderContext, @@ -79,6 +80,7 @@ export const Touchable = (props: TouchableProps) => { onPress, onPressIn, onPressOut, + onVisualPressChange, onHoverIn, onHoverOut, children, @@ -109,6 +111,15 @@ export const Touchable = (props: TouchableProps) => { return false; }, [disabled, jsResponderContext]); + const internalOnVisualPressChange = useMemo( + () => + onVisualPressChange + ? (event: NativeSyntheticEvent) => + onVisualPressChange(event.nativeEvent.pressed) + : undefined, + [onVisualPressChange] + ); + const internalOnPress = useCallback( (e: NativeSyntheticEvent) => { if (dropKeyboardTapRef.current) { @@ -225,6 +236,7 @@ export const Touchable = (props: TouchableProps) => { underlayColor={underlayColor} longPressDuration={resolvedDelayLongPress} hasLongPressHandler={onLongPress !== undefined} + onButtonVisualPressChange={internalOnVisualPressChange} onButtonPress={internalOnPress} onButtonPressIn={internalOnPressIn} onButtonPressOut={internalOnPressOut} 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..c5324c30bd 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 @@ -30,7 +30,8 @@ type InteractionProps = | 'onButtonLongPress' | 'onButtonHoverIn' | 'onButtonHoverOut' - | 'onButtonInteractionFinished'; + | 'onButtonInteractionFinished' + | 'onButtonVisualPressChange'; type DurationProps = | 'tapAnimationInDuration' @@ -110,6 +111,13 @@ export type TouchableProps = Omit< */ onPressOut?: ((event: ButtonEvent) => void) | undefined; + /** + * Called when the platform selects its pressed or resting feedback target. + * Respects native scroll deferral and the built-in hold for quick taps. + * Reports target changes, not animation completion. Runs in JavaScript. + */ + onVisualPressChange?: ((pressed: boolean) => void) | undefined; + /** * Called when a non-touch pointer starts hovering over the component. */ From 64e1da5a47e23a8c943aa6cc49839fc6d2aa5a1d Mon Sep 17 00:00:00 2001 From: Benedict Pregler Date: Mon, 14 Sep 2026 17:20:57 +0200 Subject: [PATCH 2/2] test(touchable): add native visual feedback example Add a card that exposes feedback and activation counters for scroll deferral, quick taps, re-entry and disabling during feedback. --- apps/basic-example/src/App.tsx | 5 + apps/basic-example/src/VisualPress.tsx | 149 +++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 apps/basic-example/src/VisualPress.tsx diff --git a/apps/basic-example/src/App.tsx b/apps/basic-example/src/App.tsx index d22c7a5017..d62c353f00 100644 --- a/apps/basic-example/src/App.tsx +++ b/apps/basic-example/src/App.tsx @@ -7,8 +7,13 @@ import NativeDetector from './NativeDetector'; import Navigator from './Navigator'; import RuntimeDecoration from './RuntimeDecoration'; import Text from './Text'; +import VisualPress from './VisualPress'; const EXAMPLES = [ + { + name: 'Visual Press', + component: VisualPress, + }, { name: 'Text', component: Text, diff --git a/apps/basic-example/src/VisualPress.tsx b/apps/basic-example/src/VisualPress.tsx new file mode 100644 index 0000000000..00b101ed30 --- /dev/null +++ b/apps/basic-example/src/VisualPress.tsx @@ -0,0 +1,149 @@ +import React, { useRef, useState } from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { ScrollView, Touchable } from 'react-native-gesture-handler'; + +type DisableOn = 'never' | 'longPress' | 'press'; + +export default function VisualPress() { + const [pressed, setPressed] = useState(false); + const [disabled, setDisabled] = useState(false); + const [cancelOnLeave, setCancelOnLeave] = useState(true); + const [disableOn, setDisableOn] = useState('never'); + const [events, setEvents] = useState([]); + const [counts, setCounts] = useState({ active: 0, resting: 0, presses: 0 }); + const startedAt = useRef(Date.now()); + + const record = (event: string) => { + const elapsed = Date.now() - startedAt.current; + setEvents((previous) => [...previous.slice(-11), `${elapsed}ms ${event}`]); + }; + + return ( + + + Drag the card to scroll, tap it, or hold then drag outside. Feedback + follows native acceptance. Enable re-entry by turning off cancel on + leave. The log keeps quick-tap transitions visible. + + + Disabled + + + + Cancel on leave + + + + {(['never', 'longPress', 'press'] as const).map((mode) => ( + setDisableOn(mode)}> + + {mode === 'never' ? 'Stay enabled' : `Disable on ${mode}`} + + + ))} + + + Active {counts.active}, resting {counts.resting}, presses{' '} + {counts.presses} + + + Visual pressed {pressed ? 'yes' : 'no'} + + { + setEvents([]); + setCounts({ active: 0, resting: 0, presses: 0 }); + startedAt.current = Date.now(); + setDisabled(false); + }}> + Reset log and enable + + + { + setPressed(nextPressed); + setCounts((previous) => ({ + ...previous, + active: previous.active + (nextPressed ? 1 : 0), + resting: previous.resting + (nextPressed ? 0 : 1), + })); + record(`visual ${nextPressed}`); + }} + onPressIn={() => record('press in')} + onPressOut={() => record('press out')} + onLongPress={() => { + record('long press'); + if (disableOn === 'longPress') { + setDisabled(true); + } + }} + onPress={() => { + record('press'); + setCounts((previous) => ({ + ...previous, + presses: previous.presses + 1, + })); + if (disableOn === 'press') { + setDisabled(true); + } + }}> + + Custom press feedback + The callback controls this card's color and scale. + + + + {events.length === 0 ? 'No events' : events.join('\n')} + + + Scroll space + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 12, gap: 8, backgroundColor: '#fff' }, + instructions: { color: '#333', fontSize: 13 }, + option: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + controls: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + control: { padding: 8, borderRadius: 6, backgroundColor: '#eee' }, + selected: { backgroundColor: '#cbb5ff' }, + status: { fontSize: 14, fontWeight: '600' }, + scrollContent: { padding: 8 }, + card: { padding: 24, borderRadius: 16, backgroundColor: '#e4d9ff', gap: 8 }, + cardTitle: { fontSize: 20, fontWeight: '600' }, + pressed: { backgroundColor: '#b692ff', transform: [{ scale: 0.96 }] }, + disabled: { opacity: 0.4 }, + log: { marginTop: 16, fontSize: 12, minHeight: 180 }, + spacer: { height: 800, paddingTop: 40 }, +});