@@ -2,7 +2,11 @@ import * as React from 'react';
22import type { ColorValue , NativeSyntheticEvent , ViewProps } from 'react-native' ;
33import { View } from 'react-native' ;
44
5+ import { ActionType } from '../ActionType' ;
6+ import RNGestureHandlerModule from '../RNGestureHandlerModule.web' ;
57import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent' ;
8+ import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect' ;
9+ import type { PropsRef } from '../web/interfaces' ;
610import { NativeGestureRole } from '../web/interfaces' ;
711import { ButtonEventName } from '../web/tools/ButtonEvents' ;
812import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents' ;
@@ -11,6 +15,8 @@ const prefersReducedMotion = (): boolean =>
1115 typeof window !== 'undefined' &&
1216 ! ! window . matchMedia ?.( '(prefers-reduced-motion: reduce)' ) ?. matches ;
1317
18+ const noopGestureEvent = ( ) => undefined ;
19+
1420type ButtonProps = ViewProps & {
1521 ref ?: React . Ref < React . ComponentRef < typeof View > > ;
1622 enabled ?: boolean ;
@@ -30,6 +36,19 @@ type ButtonProps = ViewProps & {
3036 defaultScale ?: number ;
3137 defaultUnderlayOpacity ?: number ;
3238 underlayColor ?: ColorValue ;
39+ hasLongPressHandler ?: boolean ;
40+ moduleId ?: number ;
41+ handlerTag ?: number ;
42+ cancelOnLeave ?: boolean ;
43+ gestureTestID ?: string ;
44+ gestureHitSlop ?:
45+ | {
46+ top ?: number ;
47+ left ?: number ;
48+ bottom ?: number ;
49+ right ?: number ;
50+ }
51+ | undefined ;
3352 onButtonPress ?:
3453 | ( ( event : NativeSyntheticEvent < ButtonEvent > ) => void )
3554 | undefined ;
@@ -66,6 +85,12 @@ export const ButtonComponent = ({
6685 defaultScale = 1 ,
6786 defaultUnderlayOpacity = 0 ,
6887 underlayColor,
88+ hasLongPressHandler = false ,
89+ moduleId : _moduleId ,
90+ handlerTag,
91+ cancelOnLeave = true ,
92+ gestureTestID,
93+ gestureHitSlop,
6994 onButtonPress,
7095 onButtonPressIn,
7196 onButtonPressOut,
@@ -91,6 +116,37 @@ export const ButtonComponent = ({
91116 ) ;
92117 const gestureEnabledRef = React . useRef ( true ) ;
93118 const viewRef = React . useRef < HTMLElement | null > ( null ) ;
119+ const gesturePropsRef = React . useRef < PropsRef > ( {
120+ // Managed button handlers dispatch their events through DOM CustomEvents,
121+ // so ActionType.NONE prevents these callbacks from being used.
122+ // They remain present because the web module shares its attachment API
123+ // with regular JS-driven gesture handlers.
124+ onGestureHandlerEvent : noopGestureEvent ,
125+ onGestureHandlerStateChange : noopGestureEvent ,
126+ onGestureHandlerTouchEvent : noopGestureEvent ,
127+ } ) ;
128+ const managedGestureConfigRef = React . useRef ( {
129+ enabled,
130+ shouldCancelWhenOutside : cancelOnLeave ,
131+ shouldActivateOnStart : false ,
132+ disallowInterruption : true ,
133+ yieldsToContinuousGestures : true ,
134+ testID : gestureTestID ,
135+ hitSlop : gestureHitSlop ,
136+ hasLongPressHandler,
137+ longPressDuration,
138+ } ) ;
139+ managedGestureConfigRef . current = {
140+ enabled,
141+ shouldCancelWhenOutside : cancelOnLeave ,
142+ shouldActivateOnStart : false ,
143+ disallowInterruption : true ,
144+ yieldsToContinuousGestures : true ,
145+ testID : gestureTestID ,
146+ hitSlop : gestureHitSlop ,
147+ hasLongPressHandler,
148+ longPressDuration,
149+ } ;
94150
95151 const setRef = React . useCallback (
96152 ( node : React . ComponentRef < typeof View > | null ) => {
@@ -104,7 +160,7 @@ export const ButtonComponent = ({
104160 [ externalRef ]
105161 ) ;
106162
107- React . useEffect ( ( ) => {
163+ useIsomorphicLayoutEffect ( ( ) => {
108164 const node = viewRef . current ;
109165
110166 const handleGestureBegan = ( ) => {
@@ -141,67 +197,86 @@ export const ButtonComponent = ({
141197 } ;
142198 } , [ ] ) ;
143199
144- const pressIn = React . useCallback ( ( ) => {
145- if ( ! enabled || ! gestureEnabledRef . current ) {
146- return ;
147- }
200+ const pressIn = React . useCallback (
201+ ( event ?: NativeSyntheticEvent < unknown > ) => {
202+ const isManagedButtonEvent = event === undefined ;
148203
149- if ( pressOutTimer . current != null ) {
150- clearTimeout ( pressOutTimer . current ) ;
151- pressOutTimer . current = null ;
152- }
153- pressInTimestamp . current = performance . now ( ) ;
154- setCurrentDuration ( tapAnimationInDuration ) ;
155- setPressed ( true ) ;
156- } , [ enabled , tapAnimationInDuration ] ) ;
157-
158- const pressOut = React . useCallback ( ( ) => {
159- // Only release if a press-in was actually recorded — guards against
160- // stray pointer events and lets us complete the release cycle even if
161- // `enabled` flipped to false between press-in and press-out.
162- if ( pressInTimestamp . current === 0 || ! gestureEnabledRef . current ) {
163- return ;
164- }
204+ if ( ! enabled || ( ! isManagedButtonEvent && ! gestureEnabledRef . current ) ) {
205+ return ;
206+ }
165207
166- if ( pressOutTimer . current != null ) {
167- clearTimeout ( pressOutTimer . current ) ;
168- pressOutTimer . current = null ;
169- }
170- const elapsed = performance . now ( ) - pressInTimestamp . current ;
171- pressInTimestamp . current = 0 ;
208+ // Managed button events are emitted before the Began lifecycle event.
209+ // Unmanaged buttons are still driven by their wrapping native gesture,
210+ // so they must keep honoring its cancellation state.
211+ if ( isManagedButtonEvent ) {
212+ gestureEnabledRef . current = true ;
213+ }
172214
173- if ( longPressDuration >= 0 && elapsed >= longPressDuration ) {
174- // Long-press release — use the configured long-press out duration.
175- setCurrentDuration ( longPressAnimationOutDuration ) ;
176- setPressed ( false ) ;
177- } else if ( elapsed >= tapAnimationInDuration ) {
178- // Press-in animation fully finished - release with the configured out duration.
179- setCurrentDuration ( tapAnimationOutDuration ) ;
180- setPressed ( false ) ;
181- // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
182- } else if ( elapsed * 2 >= tapAnimationOutDuration ) {
183- setCurrentDuration ( elapsed ) ;
184- setPressed ( false ) ;
185- } else {
186- // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
187- const remaining = tapAnimationInDuration - elapsed ;
188- pressOutTimer . current = setTimeout (
189- ( ) => {
190- pressOutTimer . current = null ;
191- setCurrentDuration ( tapAnimationOutDuration ) ;
192- setPressed ( false ) ;
193- } ,
194- prefersReducedMotion ( ) ? 0 : remaining
195- ) ;
196- }
197- } , [
198- longPressDuration ,
199- longPressAnimationOutDuration ,
200- tapAnimationInDuration ,
201- tapAnimationOutDuration ,
202- ] ) ;
215+ event ?. stopPropagation ( ) ;
216+
217+ if ( pressOutTimer . current != null ) {
218+ clearTimeout ( pressOutTimer . current ) ;
219+ pressOutTimer . current = null ;
220+ }
221+ pressInTimestamp . current = performance . now ( ) ;
222+ setCurrentDuration ( tapAnimationInDuration ) ;
223+ setPressed ( true ) ;
224+ } ,
225+ [ enabled , tapAnimationInDuration ]
226+ ) ;
203227
204- React . useEffect ( ( ) => {
228+ const pressOut = React . useCallback (
229+ ( event ?: NativeSyntheticEvent < unknown > ) => {
230+ // Only release if a press-in was actually recorded — guards against
231+ // stray pointer events and lets us complete the release cycle even if
232+ // `enabled` flipped to false between press-in and press-out.
233+ if ( pressInTimestamp . current === 0 || ! gestureEnabledRef . current ) {
234+ return ;
235+ }
236+
237+ event ?. stopPropagation ( ) ;
238+
239+ if ( pressOutTimer . current != null ) {
240+ clearTimeout ( pressOutTimer . current ) ;
241+ pressOutTimer . current = null ;
242+ }
243+ const elapsed = performance . now ( ) - pressInTimestamp . current ;
244+ pressInTimestamp . current = 0 ;
245+
246+ if ( longPressDuration >= 0 && elapsed >= longPressDuration ) {
247+ // Long-press release — use the configured long-press out duration.
248+ setCurrentDuration ( longPressAnimationOutDuration ) ;
249+ setPressed ( false ) ;
250+ } else if ( elapsed >= tapAnimationInDuration ) {
251+ // Press-in animation fully finished - release with the configured out duration.
252+ setCurrentDuration ( tapAnimationOutDuration ) ;
253+ setPressed ( false ) ;
254+ // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
255+ } else if ( elapsed * 2 >= tapAnimationOutDuration ) {
256+ setCurrentDuration ( elapsed ) ;
257+ setPressed ( false ) ;
258+ } else {
259+ // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time.
260+ const remaining = tapAnimationInDuration - elapsed ;
261+ pressOutTimer . current = setTimeout (
262+ ( ) => {
263+ pressOutTimer . current = null ;
264+ setCurrentDuration ( tapAnimationOutDuration ) ;
265+ setPressed ( false ) ;
266+ } ,
267+ prefersReducedMotion ( ) ? 0 : remaining
268+ ) ;
269+ }
270+ } ,
271+ [
272+ longPressDuration ,
273+ longPressAnimationOutDuration ,
274+ tapAnimationInDuration ,
275+ tapAnimationOutDuration ,
276+ ]
277+ ) ;
278+
279+ useIsomorphicLayoutEffect ( ( ) => {
205280 const node = viewRef . current ;
206281 const wrapEvent = ( event : Event ) : NativeSyntheticEvent < ButtonEvent > =>
207282 ( {
@@ -255,6 +330,49 @@ export const ButtonComponent = ({
255330 pressOut ,
256331 ] ) ;
257332
333+ useIsomorphicLayoutEffect ( ( ) => {
334+ const node = viewRef . current ;
335+ if ( handlerTag === undefined || node === null ) {
336+ return ;
337+ }
338+
339+ RNGestureHandlerModule . createGestureHandler (
340+ 'NativeViewGestureHandler' ,
341+ handlerTag ,
342+ managedGestureConfigRef . current
343+ ) ;
344+ RNGestureHandlerModule . attachGestureHandler (
345+ handlerTag ,
346+ node ,
347+ ActionType . NONE ,
348+ gesturePropsRef
349+ ) ;
350+
351+ return ( ) => {
352+ RNGestureHandlerModule . detachGestureHandler ( handlerTag ) ;
353+ RNGestureHandlerModule . dropGestureHandler ( handlerTag ) ;
354+ } ;
355+ } , [ handlerTag ] ) ;
356+
357+ useIsomorphicLayoutEffect ( ( ) => {
358+ if ( handlerTag === undefined ) {
359+ return ;
360+ }
361+
362+ RNGestureHandlerModule . setGestureHandlerConfig (
363+ handlerTag ,
364+ managedGestureConfigRef . current
365+ ) ;
366+ } , [
367+ cancelOnLeave ,
368+ enabled ,
369+ gestureHitSlop ,
370+ gestureTestID ,
371+ handlerTag ,
372+ hasLongPressHandler ,
373+ longPressDuration ,
374+ ] ) ;
375+
258376 const handlePointerEnter = React . useCallback (
259377 ( event : NativeSyntheticEvent < { pointerType ?: string } > ) => {
260378 if ( ! enabled || event . nativeEvent . pointerType === 'touch' ) {
@@ -271,6 +389,9 @@ export const ButtonComponent = ({
271389
272390 const handlePointerLeave = React . useCallback (
273391 ( event : NativeSyntheticEvent < { pointerType ?: string } > ) => {
392+ if ( handlerTag === undefined ) {
393+ pressOut ( event ) ;
394+ }
274395 if ( event . nativeEvent . pointerType === 'touch' ) {
275396 return ;
276397 }
@@ -279,7 +400,7 @@ export const ButtonComponent = ({
279400 }
280401 setHovered ( false ) ;
281402 } ,
282- [ pressed , hoverAnimationOutDuration ]
403+ [ handlerTag , hoverAnimationOutDuration , pressOut , pressed ]
283404 ) ;
284405
285406 // Mask hover at render rather than clearing the state. Avoids a state
@@ -335,6 +456,9 @@ export const ButtonComponent = ({
335456 } ,
336457 ] }
337458 onPointerEnter = { handlePointerEnter }
459+ onPointerDown = { handlerTag === undefined ? pressIn : undefined }
460+ onPointerUp = { handlerTag === undefined ? pressOut : undefined }
461+ onPointerCancel = { handlerTag === undefined ? pressOut : undefined }
338462 onPointerLeave = { handlePointerLeave } >
339463 { hasUnderlay && (
340464 < View
0 commit comments