From 9fb1120aabcde07953cf9252965939fef0dbed41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 10:36:45 +0100 Subject: [PATCH 01/51] Base implementation --- .../src/v3/components/ClickableAG.tsx | 214 ++++++++++++++++++ .../src/v3/components/index.ts | 3 + .../src/v3/index.ts | 2 + 3 files changed, 219 insertions(+) create mode 100644 packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx new file mode 100644 index 0000000000..3eda648a3d --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -0,0 +1,214 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { Animated, Platform, StyleSheet } from 'react-native'; +import { RawButton } from './GestureButtons'; +import type { BaseButtonProps } from './GestureButtonsProps'; +import type { GestureEvent } from '../types'; +import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; + +type CallbackEventType = GestureEvent; + +export enum ClickableBehavior { + NONE = 'none', + RECT = 'rect', + BORDERLESS = 'borderless', +} + +export interface ClickableProps extends BaseButtonProps { + /** + * Background color that will be dimmed when button is in active state. + * Only applicable when behavior is RECT. + */ + underlayColor?: string | undefined; + + /** + * Opacity applied to the underlay or button when it is in an active state. + * Defaults to 0.105 for RECT behavior and 0.3 for BORDERLESS behavior. + */ + activeOpacity?: number | undefined; + + /** + * Defines how the button visually reacts to being pressed. + * Defaults to NONE. + */ + behavior?: ClickableBehavior | undefined; +} + +const AnimatedRawButton = Animated.createAnimatedComponent(RawButton as any); + +const btnStyles = StyleSheet.create({ + underlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 0, + }, +}); + +export const Clickable = (props: ClickableProps) => { + const { + underlayColor = 'black', + activeOpacity, + behavior = ClickableBehavior.NONE, + delayLongPress = 600, + onLongPress, + onPress, + onActiveStateChange, + style, + children, + ...rest + } = props; + + const resolvedActiveOpacity = + activeOpacity ?? (behavior === ClickableBehavior.BORDERLESS ? 0.3 : 0.105); + + const longPressDetected = useRef(false); + const longPressTimeout = useRef | undefined>( + undefined + ); + const activeState = useRef(new Animated.Value(0)).current; + + const wrappedLongPress = useCallback(() => { + longPressDetected.current = true; + onLongPress?.(); + }, [onLongPress]); + + const onBegin = useCallback( + (e: CallbackEventType) => { + if (Platform.OS === 'android' && e.pointerInside) { + longPressDetected.current = false; + if (onLongPress) { + longPressTimeout.current = setTimeout( + wrappedLongPress, + delayLongPress + ); + } + } + }, + [delayLongPress, onLongPress, wrappedLongPress] + ); + + const onActivate = useCallback( + (e: CallbackEventType) => { + onActiveStateChange?.(true); + + const canAnimate = + behavior === ClickableBehavior.BORDERLESS + ? Platform.OS === 'ios' // Borderless animates on iOS + : Platform.OS !== 'android'; // Rect animates everywhere except Android (where native ripple takes over) + + if (behavior !== ClickableBehavior.NONE && canAnimate) { + activeState.setValue(1); + } + + if (Platform.OS !== 'android' && e.pointerInside) { + longPressDetected.current = false; + if (onLongPress) { + longPressTimeout.current = setTimeout( + wrappedLongPress, + delayLongPress + ); + } + } + + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [ + behavior, + delayLongPress, + onActiveStateChange, + onLongPress, + wrappedLongPress, + activeState, + ] + ); + + const onDeactivate = useCallback( + (e: CallbackEventType, success: boolean) => { + onActiveStateChange?.(false); + + const canAnimate = + behavior === ClickableBehavior.BORDERLESS + ? Platform.OS === 'ios' + : Platform.OS !== 'android'; + + if (behavior !== ClickableBehavior.NONE && canAnimate) { + activeState.setValue(0); + } + + if (success && !longPressDetected.current) { + onPress?.(e.pointerInside); + } + }, + [behavior, onActiveStateChange, onPress, activeState] + ); + + const onFinalize = useCallback((_e: CallbackEventType) => { + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, []); + + const resolvedStyle = useMemo(() => StyleSheet.flatten(style ?? {}), [style]); + + const underlayAnimatedStyle = useMemo(() => { + if (behavior !== ClickableBehavior.RECT) { + return {}; + } + + return { + opacity: activeState.interpolate({ + inputRange: [0, 1], + outputRange: [0, resolvedActiveOpacity], + }), + backgroundColor: underlayColor, + borderRadius: resolvedStyle.borderRadius, + borderTopLeftRadius: resolvedStyle.borderTopLeftRadius, + borderTopRightRadius: resolvedStyle.borderTopRightRadius, + borderBottomLeftRadius: resolvedStyle.borderBottomLeftRadius, + borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, + }; + }, [ + behavior, + activeState, + resolvedActiveOpacity, + underlayColor, + resolvedStyle, + ]); + + const buttonAnimatedStyle = useMemo(() => { + if (behavior !== ClickableBehavior.BORDERLESS || Platform.OS !== 'ios') { + return {}; + } + + return { + opacity: activeState.interpolate({ + inputRange: [0, 1], + outputRange: [1, resolvedActiveOpacity], + }), + }; + }, [behavior, activeState, resolvedActiveOpacity]); + + return ( + + {behavior === ClickableBehavior.RECT && ( + + )} + {children} + + ); +}; diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index bf4bbc5526..f16e70a4b9 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -22,3 +22,6 @@ export { } from './GestureComponents'; export { default as Pressable } from './Pressable'; + +export { Clickable, ClickableBehavior } from './ClickableAG'; +export type { ClickableProps } from './ClickableAG'; diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index 3c720e10dd..b557c15f09 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -73,6 +73,8 @@ export { RectButton, BorderlessButton, Pressable, + Clickable, + ClickableBehavior, ScrollView, Switch, TextInput, From f1134cb50e04ebb555706884b9770ea75c75f67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 10:50:09 +0100 Subject: [PATCH 02/51] Do not use animated button if not necessary --- .../src/v3/components/ClickableAG.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 3eda648a3d..c0c552b836 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -193,12 +193,15 @@ export const Clickable = (props: ClickableProps) => { }; }, [behavior, activeState, resolvedActiveOpacity]); + const ButtonComponent = + behavior === ClickableBehavior.NONE ? RawButton : AnimatedRawButton; + return ( - { )} {children} - + ); }; From 9461ecc7645dacb9cc7507caabcf9adfa9c3b5d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 12:24:58 +0100 Subject: [PATCH 03/51] Use props --- .../src/v3/components/ClickableAG.tsx | 88 +++++++++++-------- .../src/v3/components/index.ts | 2 +- .../src/v3/index.ts | 1 - 3 files changed, 51 insertions(+), 40 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index c0c552b836..7d933e4f0f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -7,33 +7,32 @@ import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; type CallbackEventType = GestureEvent; -export enum ClickableBehavior { - NONE = 'none', - RECT = 'rect', - BORDERLESS = 'borderless', -} - export interface ClickableProps extends BaseButtonProps { /** * Background color that will be dimmed when button is in active state. - * Only applicable when behavior is RECT. */ underlayColor?: string | undefined; /** * Opacity applied to the underlay or button when it is in an active state. - * Defaults to 0.105 for RECT behavior and 0.3 for BORDERLESS behavior. + * If not provided, no visual feedback will be applied. */ activeOpacity?: number | undefined; /** - * Defines how the button visually reacts to being pressed. - * Defaults to NONE. + * If true, the whole component's opacity will be decreased when pressed. + * If false (default), an underlay with underlayColor will be shown. + */ + shouldDecreaseOpacity?: boolean | undefined; + + /** + * If true, the button will have a borderless ripple effect on Android. + * On iOS, this has no effect. */ - behavior?: ClickableBehavior | undefined; + borderless?: boolean | undefined; } -const AnimatedRawButton = Animated.createAnimatedComponent(RawButton as any); +const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); const btnStyles = StyleSheet.create({ underlay: { @@ -49,18 +48,18 @@ export const Clickable = (props: ClickableProps) => { const { underlayColor = 'black', activeOpacity, - behavior = ClickableBehavior.NONE, + shouldDecreaseOpacity = false, delayLongPress = 600, onLongPress, onPress, onActiveStateChange, style, children, + borderless, ...rest } = props; - const resolvedActiveOpacity = - activeOpacity ?? (behavior === ClickableBehavior.BORDERLESS ? 0.3 : 0.105); + const hasFeedback = activeOpacity !== undefined; const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -92,12 +91,11 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType) => { onActiveStateChange?.(true); - const canAnimate = - behavior === ClickableBehavior.BORDERLESS - ? Platform.OS === 'ios' // Borderless animates on iOS - : Platform.OS !== 'android'; // Rect animates everywhere except Android (where native ripple takes over) + const canAnimate = shouldDecreaseOpacity + ? Platform.OS === 'ios' // Borderless-like animates on iOS + : Platform.OS !== 'android'; // Rect-like animates everywhere except Android (ripple takes over) - if (behavior !== ClickableBehavior.NONE && canAnimate) { + if (hasFeedback && canAnimate) { activeState.setValue(1); } @@ -117,7 +115,8 @@ export const Clickable = (props: ClickableProps) => { } }, [ - behavior, + shouldDecreaseOpacity, + hasFeedback, delayLongPress, onActiveStateChange, onLongPress, @@ -130,12 +129,11 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType, success: boolean) => { onActiveStateChange?.(false); - const canAnimate = - behavior === ClickableBehavior.BORDERLESS - ? Platform.OS === 'ios' - : Platform.OS !== 'android'; + const canAnimate = shouldDecreaseOpacity + ? Platform.OS === 'ios' + : Platform.OS !== 'android'; - if (behavior !== ClickableBehavior.NONE && canAnimate) { + if (hasFeedback && canAnimate) { activeState.setValue(0); } @@ -143,7 +141,13 @@ export const Clickable = (props: ClickableProps) => { onPress?.(e.pointerInside); } }, - [behavior, onActiveStateChange, onPress, activeState] + [ + shouldDecreaseOpacity, + hasFeedback, + onActiveStateChange, + onPress, + activeState, + ] ); const onFinalize = useCallback((_e: CallbackEventType) => { @@ -156,14 +160,14 @@ export const Clickable = (props: ClickableProps) => { const resolvedStyle = useMemo(() => StyleSheet.flatten(style ?? {}), [style]); const underlayAnimatedStyle = useMemo(() => { - if (behavior !== ClickableBehavior.RECT) { + if (shouldDecreaseOpacity || !hasFeedback || activeOpacity === undefined) { return {}; } return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [0, resolvedActiveOpacity], + outputRange: [0, activeOpacity], }), backgroundColor: underlayColor, borderRadius: resolvedStyle.borderRadius, @@ -173,42 +177,50 @@ export const Clickable = (props: ClickableProps) => { borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, }; }, [ - behavior, + shouldDecreaseOpacity, + hasFeedback, + activeOpacity, activeState, - resolvedActiveOpacity, underlayColor, resolvedStyle, ]); const buttonAnimatedStyle = useMemo(() => { - if (behavior !== ClickableBehavior.BORDERLESS || Platform.OS !== 'ios') { + if ( + !shouldDecreaseOpacity || + !hasFeedback || + activeOpacity === undefined || + Platform.OS !== 'ios' + ) { return {}; } return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [1, resolvedActiveOpacity], + outputRange: [1, activeOpacity], }), }; - }, [behavior, activeState, resolvedActiveOpacity]); + }, [shouldDecreaseOpacity, hasFeedback, activeOpacity, activeState]); - const ButtonComponent = - behavior === ClickableBehavior.NONE ? RawButton : AnimatedRawButton; + const ButtonComponent = ( + hasFeedback ? AnimatedRawButton : RawButton + ) as React.ElementType; return ( - {behavior === ClickableBehavior.RECT && ( + {!shouldDecreaseOpacity && hasFeedback && ( )} {children} diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index f16e70a4b9..5db04aa256 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -23,5 +23,5 @@ export { export { default as Pressable } from './Pressable'; -export { Clickable, ClickableBehavior } from './ClickableAG'; +export { Clickable } from './ClickableAG'; export type { ClickableProps } from './ClickableAG'; diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index b557c15f09..f0d9ab46cb 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -74,7 +74,6 @@ export { BorderlessButton, Pressable, Clickable, - ClickableBehavior, ScrollView, Switch, TextInput, From ab6ced1ac29673545533df7df31fdbd9c6715a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 12:59:40 +0100 Subject: [PATCH 04/51] Types --- .../src/v3/components/ClickableAG.tsx | 31 +++++++------------ .../src/v3/components/GestureButtons.tsx | 9 ++++-- .../src/v3/types/NativeWrapperType.ts | 2 +- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 7d933e4f0f..ab27c1bc24 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -33,7 +33,6 @@ export interface ClickableProps extends BaseButtonProps { } const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); - const btnStyles = StyleSheet.create({ underlay: { position: 'absolute', @@ -56,11 +55,18 @@ export const Clickable = (props: ClickableProps) => { style, children, borderless, + ref, ...rest } = props; const hasFeedback = activeOpacity !== undefined; + const canAnimate = useMemo(() => { + return shouldDecreaseOpacity + ? Platform.OS === 'ios' // Borderless-like animates on iOS + : Platform.OS !== 'android'; // Rect-like animates everywhere except Android (ripple takes over) + }, [shouldDecreaseOpacity]); + const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( undefined @@ -91,10 +97,6 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType) => { onActiveStateChange?.(true); - const canAnimate = shouldDecreaseOpacity - ? Platform.OS === 'ios' // Borderless-like animates on iOS - : Platform.OS !== 'android'; // Rect-like animates everywhere except Android (ripple takes over) - if (hasFeedback && canAnimate) { activeState.setValue(1); } @@ -115,8 +117,8 @@ export const Clickable = (props: ClickableProps) => { } }, [ - shouldDecreaseOpacity, hasFeedback, + canAnimate, delayLongPress, onActiveStateChange, onLongPress, @@ -129,10 +131,6 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType, success: boolean) => { onActiveStateChange?.(false); - const canAnimate = shouldDecreaseOpacity - ? Platform.OS === 'ios' - : Platform.OS !== 'android'; - if (hasFeedback && canAnimate) { activeState.setValue(0); } @@ -141,13 +139,7 @@ export const Clickable = (props: ClickableProps) => { onPress?.(e.pointerInside); } }, - [ - shouldDecreaseOpacity, - hasFeedback, - onActiveStateChange, - onPress, - activeState, - ] + [hasFeedback, canAnimate, onActiveStateChange, onPress, activeState] ); const onFinalize = useCallback((_e: CallbackEventType) => { @@ -203,9 +195,7 @@ export const Clickable = (props: ClickableProps) => { }; }, [shouldDecreaseOpacity, hasFeedback, activeOpacity, activeState]); - const ButtonComponent = ( - hasFeedback ? AnimatedRawButton : RawButton - ) as React.ElementType; + const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; return ( { ]} borderless={borderless ?? shouldDecreaseOpacity} {...rest} + ref={ref ?? null} onBegin={onBegin} onActivate={onActivate} onDeactivate={onDeactivate} diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx index fc6eee6ebc..13ed2291c7 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx @@ -5,6 +5,7 @@ import GestureHandlerButton from '../../components/GestureHandlerButton'; import type { BaseButtonProps, BorderlessButtonProps, + RawButtonProps, RectButtonProps, } from './GestureButtonsProps'; @@ -13,7 +14,10 @@ import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; type CallbackEventType = GestureEvent; -export const RawButton = createNativeWrapper(GestureHandlerButton, { +export const RawButton = createNativeWrapper< + ReturnType, + RawButtonProps +>(GestureHandlerButton, { shouldCancelWhenOutside: false, shouldActivateOnStart: false, }); @@ -151,11 +155,12 @@ export const BorderlessButton = (props: BorderlessButtonProps) => { props.onActiveStateChange?.(active); }; - const { children, style, ...rest } = props; + const { children, style, ref, ...rest } = props; return ( {children} diff --git a/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts b/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts index c7cb30c78f..7ffc10ecd8 100644 --- a/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts +++ b/packages/react-native-gesture-handler/src/v3/types/NativeWrapperType.ts @@ -7,7 +7,7 @@ import { } from '../hooks/gestures/native/NativeTypes'; export type WrapperSpecificProperties = { - ref?: React.Ref; + ref?: React.Ref | undefined; onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER?: ( gesture: NativeGesture ) => void; From 27b3d0c825e2133d57bb828175e0493a76d96ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 14:14:54 +0100 Subject: [PATCH 05/51] Additional props --- .../src/v3/components/ClickableAG.tsx | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index ab27c1bc24..4e88bada90 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -20,10 +20,18 @@ export interface ClickableProps extends BaseButtonProps { activeOpacity?: number | undefined; /** - * If true, the whole component's opacity will be decreased when pressed. - * If false (default), an underlay with underlayColor will be shown. + * Determines what should be animated. + * - 'underlay' (default): an additional view rendered behind children. + * - 'component': the whole button. */ - shouldDecreaseOpacity?: boolean | undefined; + feedbackTarget?: 'underlay' | 'component' | undefined; + + /** + * Determines the direction of the animation. + * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. + * - 'opacity-decrease': opacity goes from 1 to activeOpacity. + */ + feedbackType?: 'opacity-increase' | 'opacity-decrease' | undefined; /** * If true, the button will have a borderless ripple effect on Android. @@ -33,6 +41,7 @@ export interface ClickableProps extends BaseButtonProps { } const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); + const btnStyles = StyleSheet.create({ underlay: { position: 'absolute', @@ -47,7 +56,8 @@ export const Clickable = (props: ClickableProps) => { const { underlayColor = 'black', activeOpacity, - shouldDecreaseOpacity = false, + feedbackTarget = 'underlay', + feedbackType = 'opacity-increase', delayLongPress = 600, onLongPress, onPress, @@ -62,10 +72,10 @@ export const Clickable = (props: ClickableProps) => { const hasFeedback = activeOpacity !== undefined; const canAnimate = useMemo(() => { - return shouldDecreaseOpacity - ? Platform.OS === 'ios' // Borderless-like animates on iOS - : Platform.OS !== 'android'; // Rect-like animates everywhere except Android (ripple takes over) - }, [shouldDecreaseOpacity]); + return feedbackTarget === 'component' + ? Platform.OS === 'ios' + : Platform.OS !== 'android'; + }, [feedbackTarget]); const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -152,14 +162,20 @@ export const Clickable = (props: ClickableProps) => { const resolvedStyle = useMemo(() => StyleSheet.flatten(style ?? {}), [style]); const underlayAnimatedStyle = useMemo(() => { - if (shouldDecreaseOpacity || !hasFeedback || activeOpacity === undefined) { + if ( + feedbackTarget !== 'underlay' || + !hasFeedback || + activeOpacity === undefined + ) { return {}; } + const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; + return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [0, activeOpacity], + outputRange: [startOpacity, activeOpacity], }), backgroundColor: underlayColor, borderRadius: resolvedStyle.borderRadius, @@ -169,7 +185,8 @@ export const Clickable = (props: ClickableProps) => { borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, }; }, [ - shouldDecreaseOpacity, + feedbackTarget, + feedbackType, hasFeedback, activeOpacity, activeState, @@ -179,7 +196,7 @@ export const Clickable = (props: ClickableProps) => { const buttonAnimatedStyle = useMemo(() => { if ( - !shouldDecreaseOpacity || + feedbackTarget !== 'component' || !hasFeedback || activeOpacity === undefined || Platform.OS !== 'ios' @@ -187,13 +204,15 @@ export const Clickable = (props: ClickableProps) => { return {}; } + const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; + return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [1, activeOpacity], + outputRange: [startOpacity, activeOpacity], }), }; - }, [shouldDecreaseOpacity, hasFeedback, activeOpacity, activeState]); + }, [feedbackTarget, feedbackType, hasFeedback, activeOpacity, activeState]); const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; @@ -202,16 +221,18 @@ export const Clickable = (props: ClickableProps) => { style={[ resolvedStyle, Platform.OS === 'ios' && { cursor: undefined }, - hasFeedback && buttonAnimatedStyle, + feedbackTarget === 'component' && hasFeedback + ? buttonAnimatedStyle + : undefined, ]} - borderless={borderless ?? shouldDecreaseOpacity} + borderless={borderless ?? feedbackTarget === 'component'} {...rest} ref={ref ?? null} onBegin={onBegin} onActivate={onActivate} onDeactivate={onDeactivate} onFinalize={onFinalize}> - {!shouldDecreaseOpacity && hasFeedback && ( + {feedbackTarget === 'underlay' && hasFeedback && ( )} {children} From c0d4960ba75dfb721edec862fb12c4c914b22ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 16:01:20 +0100 Subject: [PATCH 06/51] Working component --- .../src/v3/components/ClickableAG.tsx | 99 +++++++++---------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 4e88bada90..515e1990a0 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -54,7 +54,7 @@ const btnStyles = StyleSheet.create({ export const Clickable = (props: ClickableProps) => { const { - underlayColor = 'black', + underlayColor, activeOpacity, feedbackTarget = 'underlay', feedbackType = 'opacity-increase', @@ -71,11 +71,26 @@ export const Clickable = (props: ClickableProps) => { const hasFeedback = activeOpacity !== undefined; + const shouldUseNativeRipple = useMemo(() => { + return ( + hasFeedback && + Platform.OS === 'android' && + feedbackTarget === 'underlay' && + feedbackType === 'opacity-increase' + ); + }, [hasFeedback, feedbackTarget, feedbackType]); + const canAnimate = useMemo(() => { - return feedbackTarget === 'component' - ? Platform.OS === 'ios' - : Platform.OS !== 'android'; - }, [feedbackTarget]); + if (!hasFeedback) { + return false; + } + + if (shouldUseNativeRipple) { + return false; + } + + return true; + }, [hasFeedback, shouldUseNativeRipple]); const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -88,37 +103,36 @@ export const Clickable = (props: ClickableProps) => { onLongPress?.(); }, [onLongPress]); + const startLongPressTimer = useCallback(() => { + if (onLongPress && !longPressTimeout.current) { + longPressDetected.current = false; + longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); + } + }, [delayLongPress, onLongPress, wrappedLongPress]); + const onBegin = useCallback( (e: CallbackEventType) => { if (Platform.OS === 'android' && e.pointerInside) { - longPressDetected.current = false; - if (onLongPress) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - delayLongPress - ); + startLongPressTimer(); + + if (canAnimate) { + activeState.setValue(1); } } }, - [delayLongPress, onLongPress, wrappedLongPress] + [startLongPressTimer, canAnimate, activeState] ); const onActivate = useCallback( (e: CallbackEventType) => { onActiveStateChange?.(true); - if (hasFeedback && canAnimate) { + if (canAnimate && Platform.OS !== 'android') { activeState.setValue(1); } if (Platform.OS !== 'android' && e.pointerInside) { - longPressDetected.current = false; - if (onLongPress) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - delayLongPress - ); - } + startLongPressTimer(); } if (!e.pointerInside && longPressTimeout.current !== undefined) { @@ -126,22 +140,14 @@ export const Clickable = (props: ClickableProps) => { longPressTimeout.current = undefined; } }, - [ - hasFeedback, - canAnimate, - delayLongPress, - onActiveStateChange, - onLongPress, - wrappedLongPress, - activeState, - ] + [canAnimate, onActiveStateChange, activeState, startLongPressTimer] ); const onDeactivate = useCallback( (e: CallbackEventType, success: boolean) => { onActiveStateChange?.(false); - if (hasFeedback && canAnimate) { + if (canAnimate) { activeState.setValue(0); } @@ -149,7 +155,7 @@ export const Clickable = (props: ClickableProps) => { onPress?.(e.pointerInside); } }, - [hasFeedback, canAnimate, onActiveStateChange, onPress, activeState] + [canAnimate, onActiveStateChange, onPress, activeState] ); const onFinalize = useCallback((_e: CallbackEventType) => { @@ -162,11 +168,7 @@ export const Clickable = (props: ClickableProps) => { const resolvedStyle = useMemo(() => StyleSheet.flatten(style ?? {}), [style]); const underlayAnimatedStyle = useMemo(() => { - if ( - feedbackTarget !== 'underlay' || - !hasFeedback || - activeOpacity === undefined - ) { + if (feedbackTarget !== 'underlay' || !canAnimate) { return {}; } @@ -175,9 +177,9 @@ export const Clickable = (props: ClickableProps) => { return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity], + outputRange: [startOpacity, activeOpacity as number], }), - backgroundColor: underlayColor, + backgroundColor: underlayColor ?? 'black', borderRadius: resolvedStyle.borderRadius, borderTopLeftRadius: resolvedStyle.borderTopLeftRadius, borderTopRightRadius: resolvedStyle.borderTopRightRadius, @@ -187,7 +189,7 @@ export const Clickable = (props: ClickableProps) => { }, [ feedbackTarget, feedbackType, - hasFeedback, + canAnimate, activeOpacity, activeState, underlayColor, @@ -195,12 +197,7 @@ export const Clickable = (props: ClickableProps) => { ]); const buttonAnimatedStyle = useMemo(() => { - if ( - feedbackTarget !== 'component' || - !hasFeedback || - activeOpacity === undefined || - Platform.OS !== 'ios' - ) { + if (feedbackTarget !== 'component' || !canAnimate) { return {}; } @@ -209,10 +206,10 @@ export const Clickable = (props: ClickableProps) => { return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity], + outputRange: [startOpacity, activeOpacity as number], }), }; - }, [feedbackTarget, feedbackType, hasFeedback, activeOpacity, activeState]); + }, [feedbackTarget, feedbackType, canAnimate, activeOpacity, activeState]); const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; @@ -220,19 +217,21 @@ export const Clickable = (props: ClickableProps) => { - {feedbackTarget === 'underlay' && hasFeedback && ( + {feedbackTarget === 'underlay' && canAnimate && ( )} {children} From 25ed517abf03084b81db6b56de86248c306322af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 16:06:01 +0100 Subject: [PATCH 07/51] Small refactor --- .../src/v3/components/ClickableAG.tsx | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 515e1990a0..0701eff2e4 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -71,26 +71,21 @@ export const Clickable = (props: ClickableProps) => { const hasFeedback = activeOpacity !== undefined; - const shouldUseNativeRipple = useMemo(() => { - return ( + const shouldUseNativeRipple = useMemo( + () => hasFeedback && Platform.OS === 'android' && feedbackTarget === 'underlay' && - feedbackType === 'opacity-increase' - ); - }, [hasFeedback, feedbackTarget, feedbackType]); - - const canAnimate = useMemo(() => { - if (!hasFeedback) { - return false; - } + feedbackType === 'opacity-increase', + [hasFeedback, feedbackTarget, feedbackType] + ); - if (shouldUseNativeRipple) { - return false; - } + const canAnimate = useMemo( + () => hasFeedback && !shouldUseNativeRipple, + [hasFeedback, shouldUseNativeRipple] + ); - return true; - }, [hasFeedback, shouldUseNativeRipple]); + const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -114,7 +109,6 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType) => { if (Platform.OS === 'android' && e.pointerInside) { startLongPressTimer(); - if (canAnimate) { activeState.setValue(1); } @@ -172,8 +166,6 @@ export const Clickable = (props: ClickableProps) => { return {}; } - const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; - return { opacity: activeState.interpolate({ inputRange: [0, 1], @@ -188,10 +180,10 @@ export const Clickable = (props: ClickableProps) => { }; }, [ feedbackTarget, - feedbackType, canAnimate, activeOpacity, activeState, + startOpacity, underlayColor, resolvedStyle, ]); @@ -201,15 +193,13 @@ export const Clickable = (props: ClickableProps) => { return {}; } - const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; - return { opacity: activeState.interpolate({ inputRange: [0, 1], outputRange: [startOpacity, activeOpacity as number], }), }; - }, [feedbackTarget, feedbackType, canAnimate, activeOpacity, activeState]); + }, [feedbackTarget, canAnimate, activeOpacity, activeState, startOpacity]); const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; From 5e4262b977783c956365b6879766a941148f7025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 6 Mar 2026 16:11:59 +0100 Subject: [PATCH 08/51] Add example --- .../new_api/components/clickable/index.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 apps/common-app/src/new_api/components/clickable/index.tsx diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx new file mode 100644 index 0000000000..021bf94d9e --- /dev/null +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -0,0 +1,174 @@ +import React, { RefObject, useRef } from 'react'; +import { StyleSheet, Text, View, Platform, ScrollView } from 'react-native'; +import { + GestureHandlerRootView, + Clickable, +} from 'react-native-gesture-handler'; +import { COLORS, Feedback, FeedbackHandle } from '../../../common'; + +type ButtonWrapperProps = { + name: string; + color: string; + feedback: RefObject; + [key: string]: any; +}; + +function ButtonWrapper({ name, color, feedback, ...rest }: ButtonWrapperProps) { + return ( + feedback.current?.showMessage(`[${name}] onPress`)} + onLongPress={() => feedback.current?.showMessage(`[${name}] onLongPress`)} + {...rest}> + {name} + + ); +} + +export default function ClickableExample() { + const feedbackRef = useRef(null); + + return ( + + + + BaseButton Replacement + No visual feedback by default + + + + + + + RectButton Replacement + Underlay + Opacity Increase + + + + + + + BorderlessButton Replacement + Component + Opacity Decrease + + + + + + + Custom: Underlay + Decrease + Hides background on press + + + + + + + Custom: Component + Increase + + Hidden at rest, visible on press + + + + + + + + Styled Underlay + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + section: { + padding: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ccc', + alignItems: 'center', + }, + sectionHeader: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + }, + description: { + fontSize: 12, + color: '#666', + marginBottom: 12, + }, + row: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + button: { + width: 200, + height: 60, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + ...Platform.select({ + ios: { cursor: 'pointer' }, + android: { elevation: 3 }, + }), + }, + buttonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, +}); From 56467757c6e8557ba99988d5acbb6613d9f418b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 09:55:32 +0100 Subject: [PATCH 09/51] Looks good --- .../src/v3/components/ClickableAG.tsx | 134 ++++++++++++++---- 1 file changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 0701eff2e4..38400d6126 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -85,7 +85,12 @@ export const Clickable = (props: ClickableProps) => { [hasFeedback, shouldUseNativeRipple] ); - const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; + const startOpacity = + feedbackType === 'opacity-increase' + ? feedbackTarget === 'component' + ? 0.01 + : 0 + : 1; const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -159,10 +164,81 @@ export const Clickable = (props: ClickableProps) => { } }, []); - const resolvedStyle = useMemo(() => StyleSheet.flatten(style ?? {}), [style]); + const { shellStyle, visualStyle } = useMemo(() => { + const flattened = StyleSheet.flatten(style ?? {}) as any; + if (feedbackTarget !== 'component') { + return { shellStyle: flattened, visualStyle: {} }; + } + + const { + margin, + marginVertical, + marginHorizontal, + marginTop, + marginBottom, + marginLeft, + marginRight, + position, + top, + bottom, + left, + right, + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + flex, + flexGrow, + flexShrink, + flexBasis, + alignSelf, + ...visuals + } = flattened; + + return { + shellStyle: { + margin, + marginVertical, + marginHorizontal, + marginTop, + marginBottom, + marginLeft, + marginRight, + position, + top, + bottom, + left, + right, + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + flex, + flexGrow, + flexShrink, + flexBasis, + alignSelf, + }, + visualStyle: visuals, + }; + }, [style, feedbackTarget]); + + const backgroundDecorationColor = useMemo(() => { + if (underlayColor) { + return underlayColor; + } + if (feedbackTarget === 'component') { + return (visualStyle.backgroundColor as string) ?? 'transparent'; + } + return 'black'; + }, [underlayColor, feedbackTarget, visualStyle.backgroundColor]); - const underlayAnimatedStyle = useMemo(() => { - if (feedbackTarget !== 'underlay' || !canAnimate) { + const backgroundAnimatedStyle = useMemo(() => { + if (!canAnimate || feedbackTarget !== 'underlay') { return {}; } @@ -171,29 +247,30 @@ export const Clickable = (props: ClickableProps) => { inputRange: [0, 1], outputRange: [startOpacity, activeOpacity as number], }), - backgroundColor: underlayColor ?? 'black', - borderRadius: resolvedStyle.borderRadius, - borderTopLeftRadius: resolvedStyle.borderTopLeftRadius, - borderTopRightRadius: resolvedStyle.borderTopRightRadius, - borderBottomLeftRadius: resolvedStyle.borderBottomLeftRadius, - borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, + backgroundColor: backgroundDecorationColor, + borderRadius: shellStyle.borderRadius, + borderTopLeftRadius: shellStyle.borderTopLeftRadius, + borderTopRightRadius: shellStyle.borderTopRightRadius, + borderBottomLeftRadius: shellStyle.borderBottomLeftRadius, + borderBottomRightRadius: shellStyle.borderBottomRightRadius, }; }, [ - feedbackTarget, canAnimate, + feedbackTarget, activeOpacity, activeState, startOpacity, - underlayColor, - resolvedStyle, + backgroundDecorationColor, + shellStyle, ]); - const buttonAnimatedStyle = useMemo(() => { + const componentAnimatedStyle = useMemo(() => { if (feedbackTarget !== 'component' || !canAnimate) { return {}; } return { + flex: 1, opacity: activeState.interpolate({ inputRange: [0, 1], outputRange: [startOpacity, activeOpacity as number], @@ -205,26 +282,33 @@ export const Clickable = (props: ClickableProps) => { return ( - {feedbackTarget === 'underlay' && canAnimate && ( - + {feedbackTarget === 'component' && canAnimate ? ( + + {children} + + ) : ( + <> + {feedbackTarget === 'underlay' && canAnimate && ( + + )} + {children} + )} - {children} ); }; From 202a999cfcccabe7a545dfc117ed741d30aa008d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 12:34:41 +0100 Subject: [PATCH 10/51] Unified, without ripple --- .../src/v3/components/ClickableAG.tsx | 154 ++++++++---------- 1 file changed, 72 insertions(+), 82 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index 38400d6126..de9e66b9f5 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -38,6 +38,11 @@ export interface ClickableProps extends BaseButtonProps { * On iOS, this has no effect. */ borderless?: boolean | undefined; + + /** + * If true, ripple will be enabled on Android. + */ + enableRipple?: boolean | undefined; } const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); @@ -56,8 +61,9 @@ export const Clickable = (props: ClickableProps) => { const { underlayColor, activeOpacity, - feedbackTarget = 'underlay', - feedbackType = 'opacity-increase', + feedbackTarget, + feedbackType, + enableRipple = false, delayLongPress = 600, onLongPress, onPress, @@ -69,34 +75,10 @@ export const Clickable = (props: ClickableProps) => { ...rest } = props; - const hasFeedback = activeOpacity !== undefined; - - const shouldUseNativeRipple = useMemo( - () => - hasFeedback && - Platform.OS === 'android' && - feedbackTarget === 'underlay' && - feedbackType === 'opacity-increase', - [hasFeedback, feedbackTarget, feedbackType] - ); - - const canAnimate = useMemo( - () => hasFeedback && !shouldUseNativeRipple, - [hasFeedback, shouldUseNativeRipple] - ); - - const startOpacity = - feedbackType === 'opacity-increase' - ? feedbackTarget === 'component' - ? 0.01 - : 0 - : 1; - const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( undefined ); - const activeState = useRef(new Animated.Value(0)).current; const wrappedLongPress = useCallback(() => { longPressDetected.current = true; @@ -110,16 +92,39 @@ export const Clickable = (props: ClickableProps) => { } }, [delayLongPress, onLongPress, wrappedLongPress]); + const hasFeedback = activeOpacity !== undefined; + const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; + + const shouldAnimateOverlay = useMemo( + () => hasFeedback && feedbackTarget === 'underlay', + [feedbackTarget, hasFeedback] + ); + + const shouldAnimateComponent = useMemo( + () => hasFeedback && feedbackTarget === 'component', + [hasFeedback, feedbackTarget] + ); + + const shouldUseNativeRipple = useMemo( + () => Platform.OS === 'android' && enableRipple, + [enableRipple] + ); + + const canAnimate = shouldAnimateComponent || shouldAnimateOverlay; + + const activeState = useRef(new Animated.Value(0)).current; + const onBegin = useCallback( (e: CallbackEventType) => { if (Platform.OS === 'android' && e.pointerInside) { startLongPressTimer(); - if (canAnimate) { + + if (canAnimate || shouldAnimateOverlay) { activeState.setValue(1); } } }, - [startLongPressTimer, canAnimate, activeState] + [startLongPressTimer, canAnimate, activeState, shouldAnimateOverlay] ); const onActivate = useCallback( @@ -146,7 +151,7 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType, success: boolean) => { onActiveStateChange?.(false); - if (canAnimate) { + if (canAnimate || shouldAnimateOverlay) { activeState.setValue(0); } @@ -154,7 +159,13 @@ export const Clickable = (props: ClickableProps) => { onPress?.(e.pointerInside); } }, - [canAnimate, onActiveStateChange, onPress, activeState] + [ + canAnimate, + onActiveStateChange, + onPress, + activeState, + shouldAnimateOverlay, + ] ); const onFinalize = useCallback((_e: CallbackEventType) => { @@ -165,10 +176,7 @@ export const Clickable = (props: ClickableProps) => { }, []); const { shellStyle, visualStyle } = useMemo(() => { - const flattened = StyleSheet.flatten(style ?? {}) as any; - if (feedbackTarget !== 'component') { - return { shellStyle: flattened, visualStyle: {} }; - } + const flattened = StyleSheet.flatten(style ?? {}); const { margin, @@ -225,43 +233,32 @@ export const Clickable = (props: ClickableProps) => { }, visualStyle: visuals, }; - }, [style, feedbackTarget]); + }, [style]); - const backgroundDecorationColor = useMemo(() => { - if (underlayColor) { - return underlayColor; - } - if (feedbackTarget === 'component') { - return (visualStyle.backgroundColor as string) ?? 'transparent'; - } - return 'black'; - }, [underlayColor, feedbackTarget, visualStyle.backgroundColor]); + const backgroundDecorationColor = underlayColor ?? 'black'; const backgroundAnimatedStyle = useMemo(() => { - if (!canAnimate || feedbackTarget !== 'underlay') { - return {}; - } - - return { - opacity: activeState.interpolate({ - inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity as number], - }), - backgroundColor: backgroundDecorationColor, - borderRadius: shellStyle.borderRadius, - borderTopLeftRadius: shellStyle.borderTopLeftRadius, - borderTopRightRadius: shellStyle.borderTopRightRadius, - borderBottomLeftRadius: shellStyle.borderBottomLeftRadius, - borderBottomRightRadius: shellStyle.borderBottomRightRadius, - }; + return shouldAnimateOverlay + ? { + opacity: activeState.interpolate({ + inputRange: [0, 1], + outputRange: [startOpacity, activeOpacity as number], + }), + backgroundColor: backgroundDecorationColor, + borderRadius: visualStyle.borderRadius, + borderTopLeftRadius: visualStyle.borderTopLeftRadius, + borderTopRightRadius: visualStyle.borderTopRightRadius, + borderBottomLeftRadius: visualStyle.borderBottomLeftRadius, + borderBottomRightRadius: visualStyle.borderBottomRightRadius, + } + : {}; }, [ - canAnimate, - feedbackTarget, activeOpacity, - activeState, startOpacity, backgroundDecorationColor, - shellStyle, + visualStyle, + shouldAnimateOverlay, + activeState, ]); const componentAnimatedStyle = useMemo(() => { @@ -270,7 +267,6 @@ export const Clickable = (props: ClickableProps) => { } return { - flex: 1, opacity: activeState.interpolate({ inputRange: [0, 1], outputRange: [startOpacity, activeOpacity as number], @@ -285,30 +281,24 @@ export const Clickable = (props: ClickableProps) => { {...rest} style={[ shellStyle, - feedbackTarget === 'component' && - canAnimate && { backgroundColor: 'transparent' }, + visualStyle, + feedbackTarget === 'component' && canAnimate && componentAnimatedStyle, ]} borderless={borderless ?? feedbackTarget === 'component'} - rippleColor={underlayColor as any} + rippleColor={shouldUseNativeRipple ? underlayColor : 'transparent'} ref={ref ?? null} onBegin={onBegin} onActivate={onActivate} onDeactivate={onDeactivate} onFinalize={onFinalize}> - {feedbackTarget === 'component' && canAnimate ? ( - - {children} - - ) : ( - <> - {feedbackTarget === 'underlay' && canAnimate && ( - - )} - {children} - - )} + <> + {feedbackTarget === 'underlay' ? ( + + ) : null} + {children} + ); }; From dd2c3bfbcbe29256b112d15afd04180312caea86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 13:06:44 +0100 Subject: [PATCH 11/51] Rippleeeee --- .../src/v3/components/ClickableAG.tsx | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index de9e66b9f5..f0d4e21258 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -32,45 +32,26 @@ export interface ClickableProps extends BaseButtonProps { * - 'opacity-decrease': opacity goes from 1 to activeOpacity. */ feedbackType?: 'opacity-increase' | 'opacity-decrease' | undefined; - - /** - * If true, the button will have a borderless ripple effect on Android. - * On iOS, this has no effect. - */ - borderless?: boolean | undefined; - - /** - * If true, ripple will be enabled on Android. - */ - enableRipple?: boolean | undefined; } const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); -const btnStyles = StyleSheet.create({ - underlay: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - top: 0, - }, -}); - export const Clickable = (props: ClickableProps) => { const { underlayColor, activeOpacity, feedbackTarget, feedbackType, - enableRipple = false, + borderless, + foreground, + rippleColor, + rippleRadius, delayLongPress = 600, onLongPress, onPress, onActiveStateChange, style, children, - borderless, ref, ...rest } = props; @@ -106,8 +87,13 @@ export const Clickable = (props: ClickableProps) => { ); const shouldUseNativeRipple = useMemo( - () => Platform.OS === 'android' && enableRipple, - [enableRipple] + () => + Platform.OS === 'android' && + (borderless !== undefined || + foreground !== undefined || + rippleColor !== undefined || + rippleRadius !== undefined), + [borderless, foreground, rippleColor, rippleRadius] ); const canAnimate = shouldAnimateComponent || shouldAnimateOverlay; @@ -274,6 +260,17 @@ export const Clickable = (props: ClickableProps) => { }; }, [feedbackTarget, canAnimate, activeOpacity, activeState, startOpacity]); + const rippleProps = shouldUseNativeRipple + ? { + rippleColor: rippleColor ?? 'black', + rippleRadius, + borderless, + foreground, + } + : { + rippleColor: 'transparent', + }; + const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; return ( @@ -284,8 +281,7 @@ export const Clickable = (props: ClickableProps) => { visualStyle, feedbackTarget === 'component' && canAnimate && componentAnimatedStyle, ]} - borderless={borderless ?? feedbackTarget === 'component'} - rippleColor={shouldUseNativeRipple ? underlayColor : 'transparent'} + {...rippleProps} ref={ref ?? null} onBegin={onBegin} onActivate={onActivate} @@ -302,3 +298,13 @@ export const Clickable = (props: ClickableProps) => { ); }; + +const btnStyles = StyleSheet.create({ + underlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 0, + }, +}); From 2cf923b7a7b4c7aa9bd9be6d53ab6e4021cdfdd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 13:07:31 +0100 Subject: [PATCH 12/51] clear activeState also in onFinalize --- .../src/v3/components/ClickableAG.tsx | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index f0d4e21258..af0097d705 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -105,12 +105,12 @@ export const Clickable = (props: ClickableProps) => { if (Platform.OS === 'android' && e.pointerInside) { startLongPressTimer(); - if (canAnimate || shouldAnimateOverlay) { + if (canAnimate) { activeState.setValue(1); } } }, - [startLongPressTimer, canAnimate, activeState, shouldAnimateOverlay] + [startLongPressTimer, canAnimate, activeState] ); const onActivate = useCallback( @@ -137,29 +137,26 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType, success: boolean) => { onActiveStateChange?.(false); - if (canAnimate || shouldAnimateOverlay) { - activeState.setValue(0); - } - if (success && !longPressDetected.current) { onPress?.(e.pointerInside); } }, - [ - canAnimate, - onActiveStateChange, - onPress, - activeState, - shouldAnimateOverlay, - ] + [onActiveStateChange, onPress] ); - const onFinalize = useCallback((_e: CallbackEventType) => { - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, []); + const onFinalize = useCallback( + (_e: CallbackEventType) => { + if (canAnimate) { + activeState.setValue(0); + } + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [activeState, canAnimate] + ); const { shellStyle, visualStyle } = useMemo(() => { const flattened = StyleSheet.flatten(style ?? {}); From 8059e407d83cb6ca8f68f6ebb648e7481265855e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 16:43:23 +0100 Subject: [PATCH 13/51] Fix borderless --- .../src/components/GestureHandlerButton.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 178293b68a..58f7507111 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -105,7 +105,10 @@ export const ButtonComponent = RNGestureHandlerButtonNativeComponent as HostComponent; export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { - const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); + const flattenedStyle = useMemo( + () => StyleSheet.flatten(style) ?? {}, + [style] + ); const { // Layout properties @@ -157,6 +160,17 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { end, overflow, + // Native button visual properties + backgroundColor, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderWidth, + borderColor, + borderStyle, + // Visual properties ...restStyle } = flattenedStyle; @@ -210,7 +224,22 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { left, start, end, - overflow, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [flattenedStyle] + ); + + const buttonStyle = useMemo( + () => ({ + backgroundColor, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderWidth, + borderColor, + borderStyle, }), // eslint-disable-next-line react-hooks/exhaustive-deps [flattenedStyle] @@ -225,7 +254,7 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { (!overflow || overflow === 'hidden') && styles.overflowHidden, restStyle, ]}> - + ); From 6b043a9897164b6c6e7c1d91f233d2d8d8d7200b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 17:00:21 +0100 Subject: [PATCH 14/51] Default ripple color --- .../src/v3/components/ClickableAG.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx index af0097d705..216a5293a1 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx @@ -259,7 +259,7 @@ export const Clickable = (props: ClickableProps) => { const rippleProps = shouldUseNativeRipple ? { - rippleColor: rippleColor ?? 'black', + rippleColor, rippleRadius, borderless, foreground, From 1eb680e2f3b9aa384039be1efeb1869a611b5f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 17:02:03 +0100 Subject: [PATCH 15/51] Rename file --- .../src/v3/components/{ClickableAG.tsx => Clickable.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-native-gesture-handler/src/v3/components/{ClickableAG.tsx => Clickable.tsx} (100%) diff --git a/packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable.tsx similarity index 100% rename from packages/react-native-gesture-handler/src/v3/components/ClickableAG.tsx rename to packages/react-native-gesture-handler/src/v3/components/Clickable.tsx From b2f7e0599241000c72e01833683951c564c60557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 17:14:47 +0100 Subject: [PATCH 16/51] Fix index --- .../react-native-gesture-handler/src/v3/components/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index 5db04aa256..d1eca10387 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -23,5 +23,5 @@ export { export { default as Pressable } from './Pressable'; -export { Clickable } from './ClickableAG'; -export type { ClickableProps } from './ClickableAG'; +export { Clickable } from './Clickable'; +export type { ClickableProps } from './Clickable'; From 44bbcc650bd7ed3154ca4998929fec94b951a61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 9 Mar 2026 17:28:28 +0100 Subject: [PATCH 17/51] Split component and types --- .../components/{ => Clickable}/Clickable.tsx | 60 ++++++------------- .../v3/components/Clickable/ClickableProps.ts | 42 +++++++++++++ .../src/v3/components/index.ts | 8 ++- 3 files changed, 65 insertions(+), 45 deletions(-) rename packages/react-native-gesture-handler/src/v3/components/{ => Clickable}/Clickable.tsx (78%) create mode 100644 packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx similarity index 78% rename from packages/react-native-gesture-handler/src/v3/components/Clickable.tsx rename to packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 216a5293a1..1da51e442b 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -1,38 +1,12 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { Animated, Platform, StyleSheet } from 'react-native'; -import { RawButton } from './GestureButtons'; -import type { BaseButtonProps } from './GestureButtonsProps'; -import type { GestureEvent } from '../types'; -import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; - -type CallbackEventType = GestureEvent; - -export interface ClickableProps extends BaseButtonProps { - /** - * Background color that will be dimmed when button is in active state. - */ - underlayColor?: string | undefined; - - /** - * Opacity applied to the underlay or button when it is in an active state. - * If not provided, no visual feedback will be applied. - */ - activeOpacity?: number | undefined; - - /** - * Determines what should be animated. - * - 'underlay' (default): an additional view rendered behind children. - * - 'component': the whole button. - */ - feedbackTarget?: 'underlay' | 'component' | undefined; - - /** - * Determines the direction of the animation. - * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. - * - 'opacity-decrease': opacity goes from 1 to activeOpacity. - */ - feedbackType?: 'opacity-increase' | 'opacity-decrease' | undefined; -} +import { RawButton } from '../GestureButtons'; +import { + CallbackEventType, + ClickableAnimationMode, + ClickableOpacityMode, + ClickableProps, +} from './ClickableProps'; const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); @@ -74,15 +48,15 @@ export const Clickable = (props: ClickableProps) => { }, [delayLongPress, onLongPress, wrappedLongPress]); const hasFeedback = activeOpacity !== undefined; - const startOpacity = feedbackType === 'opacity-increase' ? 0 : 1; + const startOpacity = feedbackType === ClickableOpacityMode.INCREASE ? 0 : 1; const shouldAnimateOverlay = useMemo( - () => hasFeedback && feedbackTarget === 'underlay', + () => hasFeedback && feedbackTarget === ClickableAnimationMode.UNDERLAY, [feedbackTarget, hasFeedback] ); const shouldAnimateComponent = useMemo( - () => hasFeedback && feedbackTarget === 'component', + () => hasFeedback && feedbackTarget === ClickableAnimationMode.COMPONENT, [hasFeedback, feedbackTarget] ); @@ -245,7 +219,7 @@ export const Clickable = (props: ClickableProps) => { ]); const componentAnimatedStyle = useMemo(() => { - if (feedbackTarget !== 'component' || !canAnimate) { + if (feedbackTarget !== ClickableAnimationMode.COMPONENT || !canAnimate) { return {}; } @@ -276,7 +250,9 @@ export const Clickable = (props: ClickableProps) => { style={[ shellStyle, visualStyle, - feedbackTarget === 'component' && canAnimate && componentAnimatedStyle, + feedbackTarget === ClickableAnimationMode.COMPONENT && + canAnimate && + componentAnimatedStyle, ]} {...rippleProps} ref={ref ?? null} @@ -285,10 +261,8 @@ export const Clickable = (props: ClickableProps) => { onDeactivate={onDeactivate} onFinalize={onFinalize}> <> - {feedbackTarget === 'underlay' ? ( - + {feedbackTarget === ClickableAnimationMode.UNDERLAY ? ( + ) : null} {children} @@ -296,7 +270,7 @@ export const Clickable = (props: ClickableProps) => { ); }; -const btnStyles = StyleSheet.create({ +const styles = StyleSheet.create({ underlay: { position: 'absolute', left: 0, diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts new file mode 100644 index 0000000000..3eee0ed9ae --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -0,0 +1,42 @@ +import type { BaseButtonProps } from '../GestureButtonsProps'; +import type { GestureEvent } from '../../types'; +import type { NativeHandlerData } from '../../hooks/gestures/native/NativeTypes'; + +export type CallbackEventType = GestureEvent; + +export enum ClickableOpacityMode { + INCREASE, + DECREASE, +} + +export enum ClickableAnimationMode { + COMPONENT, + UNDERLAY, +} + +export interface ClickableProps extends BaseButtonProps { + /** + * Background color that will be dimmed when button is in active state. + */ + underlayColor?: string | undefined; + + /** + * Opacity applied to the underlay or button when it is in an active state. + * If not provided, no visual feedback will be applied. + */ + activeOpacity?: number | undefined; + + /** + * Determines what should be animated. + * - 'underlay' (default): an additional view rendered behind children. + * - 'component': the whole button. + */ + feedbackTarget?: ClickableAnimationMode | undefined; + + /** + * Determines the direction of the animation. + * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. + * - 'opacity-decrease': opacity goes from 1 to activeOpacity. + */ + feedbackType?: ClickableOpacityMode | undefined; +} diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index d1eca10387..180d26437e 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -23,5 +23,9 @@ export { export { default as Pressable } from './Pressable'; -export { Clickable } from './Clickable'; -export type { ClickableProps } from './Clickable'; +export { Clickable } from './Clickable/Clickable'; +export { + ClickableAnimationMode, + ClickableOpacityMode, +} from './Clickable/ClickableProps'; +export type { ClickableProps } from './Clickable/ClickableProps'; From f4d895e7128732a8ca12a39351d2cadc99c2e484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 12:39:44 +0100 Subject: [PATCH 18/51] Add presets --- .../new_api/components/clickable/index.tsx | 71 ++----------------- apps/common-app/src/new_api/index.tsx | 2 + .../src/v3/components/Clickable/Clickable.tsx | 55 ++++++++++---- .../v3/components/Clickable/ClickableProps.ts | 22 ++++-- .../src/v3/components/index.ts | 3 +- .../src/v3/index.ts | 7 +- 6 files changed, 72 insertions(+), 88 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index 021bf94d9e..026ed8fa0e 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -3,6 +3,7 @@ import { StyleSheet, Text, View, Platform, ScrollView } from 'react-native'; import { GestureHandlerRootView, Clickable, + ClickablePreset, } from 'react-native-gesture-handler'; import { COLORS, Feedback, FeedbackHandle } from '../../../common'; @@ -33,10 +34,9 @@ export default function ClickableExample() { BaseButton Replacement - No visual feedback by default @@ -45,14 +45,11 @@ export default function ClickableExample() { RectButton Replacement - Underlay + Opacity Increase @@ -60,62 +57,11 @@ export default function ClickableExample() { BorderlessButton Replacement - Component + Opacity Decrease - - - - - Custom: Underlay + Decrease - Hides background on press - - - - - - - Custom: Component + Increase - - Hidden at rest, visible on press - - - - - - - - Styled Underlay - - @@ -145,11 +91,6 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginBottom: 4, }, - description: { - fontSize: 12, - color: '#666', - marginBottom: 12, - }, row: { flexDirection: 'row', justifyContent: 'center', diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 3c706cf91b..58b99b3878 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -27,6 +27,7 @@ import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; import ButtonsExample from './components/buttons'; +import ClickableExample from './components/clickable'; import ReanimatedDrawerLayout from './components/drawer'; import FlatListExample from './components/flatlist'; import ScrollViewExample from './components/scrollview'; @@ -105,6 +106,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'FlatList example', component: FlatListExample }, { name: 'ScrollView example', component: ScrollViewExample }, { name: 'Buttons example', component: ButtonsExample }, + { name: 'Clickable example', component: ClickableExample }, { name: 'Switch & TextInput', component: SwitchTextInputExample }, { name: 'Reanimated Swipeable', component: Swipeable }, { name: 'Reanimated Drawer Layout', component: ReanimatedDrawerLayout }, diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 1da51e442b..8c6b528e3c 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -3,8 +3,9 @@ import { Animated, Platform, StyleSheet } from 'react-native'; import { RawButton } from '../GestureButtons'; import { CallbackEventType, - ClickableAnimationMode, + ClickableAnimationTarget, ClickableOpacityMode, + ClickablePreset, ClickableProps, } from './ClickableProps'; @@ -15,7 +16,8 @@ export const Clickable = (props: ClickableProps) => { underlayColor, activeOpacity, feedbackTarget, - feedbackType, + opacityMode, + preset, borderless, foreground, rippleColor, @@ -47,17 +49,40 @@ export const Clickable = (props: ClickableProps) => { } }, [delayLongPress, onLongPress, wrappedLongPress]); - const hasFeedback = activeOpacity !== undefined; - const startOpacity = feedbackType === ClickableOpacityMode.INCREASE ? 0 : 1; + let targetComponent; + let targetOpacity; + let targetOpacityMode; + + switch (preset) { + case ClickablePreset.RECT: + targetComponent = ClickableAnimationTarget.UNDERLAY; + targetOpacity = 0.105; + targetOpacityMode = ClickableOpacityMode.INCREASE; + break; + case ClickablePreset.BORDERLESS: + targetComponent = ClickableAnimationTarget.COMPONENT; + targetOpacity = 0.3; + targetOpacityMode = ClickableOpacityMode.DECREASE; + break; + default: + targetOpacity = activeOpacity; + targetComponent = feedbackTarget; + targetOpacityMode = opacityMode; + break; + } + + const hasFeedback = targetOpacity !== undefined; + const startOpacity = + targetOpacityMode === ClickableOpacityMode.INCREASE ? 0 : 1; const shouldAnimateOverlay = useMemo( - () => hasFeedback && feedbackTarget === ClickableAnimationMode.UNDERLAY, - [feedbackTarget, hasFeedback] + () => hasFeedback && targetComponent === ClickableAnimationTarget.UNDERLAY, + [targetComponent, hasFeedback] ); const shouldAnimateComponent = useMemo( - () => hasFeedback && feedbackTarget === ClickableAnimationMode.COMPONENT, - [hasFeedback, feedbackTarget] + () => hasFeedback && targetComponent === ClickableAnimationTarget.COMPONENT, + [hasFeedback, targetComponent] ); const shouldUseNativeRipple = useMemo( @@ -199,7 +224,7 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity as number], + outputRange: [startOpacity, targetOpacity as number], }), backgroundColor: backgroundDecorationColor, borderRadius: visualStyle.borderRadius, @@ -210,7 +235,7 @@ export const Clickable = (props: ClickableProps) => { } : {}; }, [ - activeOpacity, + targetOpacity, startOpacity, backgroundDecorationColor, visualStyle, @@ -219,17 +244,17 @@ export const Clickable = (props: ClickableProps) => { ]); const componentAnimatedStyle = useMemo(() => { - if (feedbackTarget !== ClickableAnimationMode.COMPONENT || !canAnimate) { + if (targetComponent !== ClickableAnimationTarget.COMPONENT || !canAnimate) { return {}; } return { opacity: activeState.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity as number], + outputRange: [startOpacity, targetOpacity as number], }), }; - }, [feedbackTarget, canAnimate, activeOpacity, activeState, startOpacity]); + }, [targetComponent, canAnimate, targetOpacity, activeState, startOpacity]); const rippleProps = shouldUseNativeRipple ? { @@ -250,7 +275,7 @@ export const Clickable = (props: ClickableProps) => { style={[ shellStyle, visualStyle, - feedbackTarget === ClickableAnimationMode.COMPONENT && + targetComponent === ClickableAnimationTarget.COMPONENT && canAnimate && componentAnimatedStyle, ]} @@ -261,7 +286,7 @@ export const Clickable = (props: ClickableProps) => { onDeactivate={onDeactivate} onFinalize={onFinalize}> <> - {feedbackTarget === ClickableAnimationMode.UNDERLAY ? ( + {targetComponent === ClickableAnimationTarget.UNDERLAY ? ( ) : null} {children} diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 3eee0ed9ae..bb3b441d12 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -4,12 +4,17 @@ import type { NativeHandlerData } from '../../hooks/gestures/native/NativeTypes' export type CallbackEventType = GestureEvent; +export enum ClickablePreset { + RECT, + BORDERLESS, +} + export enum ClickableOpacityMode { INCREASE, DECREASE, } -export enum ClickableAnimationMode { +export enum ClickableAnimationTarget { COMPONENT, UNDERLAY, } @@ -26,17 +31,22 @@ export interface ClickableProps extends BaseButtonProps { */ activeOpacity?: number | undefined; + /** + * Determines the direction of the animation. + * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. + * - 'opacity-decrease': opacity goes from 1 to activeOpacity. + */ + opacityMode?: ClickableOpacityMode | undefined; + /** * Determines what should be animated. * - 'underlay' (default): an additional view rendered behind children. * - 'component': the whole button. */ - feedbackTarget?: ClickableAnimationMode | undefined; + feedbackTarget?: ClickableAnimationTarget | undefined; /** - * Determines the direction of the animation. - * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. - * - 'opacity-decrease': opacity goes from 1 to activeOpacity. + * Determines the preset style of the button. */ - feedbackType?: ClickableOpacityMode | undefined; + preset?: ClickablePreset | undefined; } diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index 180d26437e..45cd5139b7 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -25,7 +25,8 @@ export { default as Pressable } from './Pressable'; export { Clickable } from './Clickable/Clickable'; export { - ClickableAnimationMode, + ClickableAnimationTarget, ClickableOpacityMode, + ClickablePreset, } from './Clickable/ClickableProps'; export type { ClickableProps } from './Clickable/ClickableProps'; diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index f0d9ab46cb..d0f58c0930 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -66,19 +66,24 @@ export type { BaseButtonProps, RectButtonProps, BorderlessButtonProps, + ClickableProps, } from './components'; + export { RawButton, BaseButton, RectButton, BorderlessButton, Pressable, - Clickable, ScrollView, Switch, TextInput, FlatList, RefreshControl, + Clickable, + ClickableAnimationTarget, + ClickableOpacityMode, + ClickablePreset, } from './components'; export type { ComposedGesture } from './types'; From 491c27abe2a25bdfb1689e9dd2d71614c8f3382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 12:40:13 +0100 Subject: [PATCH 19/51] Update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8f4dff35ec..c6c013e0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ DerivedData # Android/IntelliJ # +bin/ build/ .idea .gradle From e404f8a1ec5ff3adda6aba355e5f3aaa264f85bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 12:51:07 +0100 Subject: [PATCH 20/51] Renames --- .../src/v3/components/Clickable/Clickable.tsx | 230 +++++++++--------- 1 file changed, 118 insertions(+), 112 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 8c6b528e3c..212fcee143 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -32,6 +32,66 @@ export const Clickable = (props: ClickableProps) => { ...rest } = props; + const { layoutStyle, visualStyle } = useMemo(() => { + const flattened = StyleSheet.flatten(style ?? {}); + + const { + margin, + marginVertical, + marginHorizontal, + marginTop, + marginBottom, + marginLeft, + marginRight, + position, + top, + bottom, + left, + right, + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + flex, + flexGrow, + flexShrink, + flexBasis, + alignSelf, + ...visuals + } = flattened; + + return { + layoutStyle: { + margin, + marginVertical, + marginHorizontal, + marginTop, + marginBottom, + marginLeft, + marginRight, + position, + top, + bottom, + left, + right, + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + flex, + flexGrow, + flexShrink, + flexBasis, + alignSelf, + }, + visualStyle: visuals, + }; + }, [style]); + const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( undefined @@ -75,7 +135,7 @@ export const Clickable = (props: ClickableProps) => { const startOpacity = targetOpacityMode === ClickableOpacityMode.INCREASE ? 0 : 1; - const shouldAnimateOverlay = useMemo( + const shouldAnimateUnderlay = useMemo( () => hasFeedback && targetComponent === ClickableAnimationTarget.UNDERLAY, [targetComponent, hasFeedback] ); @@ -95,29 +155,29 @@ export const Clickable = (props: ClickableProps) => { [borderless, foreground, rippleColor, rippleRadius] ); - const canAnimate = shouldAnimateComponent || shouldAnimateOverlay; + const usesJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; - const activeState = useRef(new Animated.Value(0)).current; + const animatedValue = useRef(new Animated.Value(0)).current; const onBegin = useCallback( (e: CallbackEventType) => { if (Platform.OS === 'android' && e.pointerInside) { startLongPressTimer(); - if (canAnimate) { - activeState.setValue(1); + if (usesJSAnimation) { + animatedValue.setValue(1); } } }, - [startLongPressTimer, canAnimate, activeState] + [startLongPressTimer, usesJSAnimation, animatedValue] ); const onActivate = useCallback( (e: CallbackEventType) => { onActiveStateChange?.(true); - if (canAnimate && Platform.OS !== 'android') { - activeState.setValue(1); + if (usesJSAnimation && Platform.OS !== 'android') { + animatedValue.setValue(1); } if (Platform.OS !== 'android' && e.pointerInside) { @@ -129,7 +189,7 @@ export const Clickable = (props: ClickableProps) => { longPressTimeout.current = undefined; } }, - [canAnimate, onActiveStateChange, activeState, startLongPressTimer] + [usesJSAnimation, onActiveStateChange, animatedValue, startLongPressTimer] ); const onDeactivate = useCallback( @@ -145,8 +205,8 @@ export const Clickable = (props: ClickableProps) => { const onFinalize = useCallback( (_e: CallbackEventType) => { - if (canAnimate) { - activeState.setValue(0); + if (usesJSAnimation) { + animatedValue.setValue(0); } if (longPressTimeout.current !== undefined) { @@ -154,107 +214,53 @@ export const Clickable = (props: ClickableProps) => { longPressTimeout.current = undefined; } }, - [activeState, canAnimate] + [animatedValue, usesJSAnimation] ); - const { shellStyle, visualStyle } = useMemo(() => { - const flattened = StyleSheet.flatten(style ?? {}); - - const { - margin, - marginVertical, - marginHorizontal, - marginTop, - marginBottom, - marginLeft, - marginRight, - position, - top, - bottom, - left, - right, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - alignSelf, - ...visuals - } = flattened; - - return { - shellStyle: { - margin, - marginVertical, - marginHorizontal, - marginTop, - marginBottom, - marginLeft, - marginRight, - position, - top, - bottom, - left, - right, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - alignSelf, - }, - visualStyle: visuals, - }; - }, [style]); - - const backgroundDecorationColor = underlayColor ?? 'black'; - - const backgroundAnimatedStyle = useMemo(() => { - return shouldAnimateOverlay - ? { - opacity: activeState.interpolate({ - inputRange: [0, 1], - outputRange: [startOpacity, targetOpacity as number], - }), - backgroundColor: backgroundDecorationColor, - borderRadius: visualStyle.borderRadius, - borderTopLeftRadius: visualStyle.borderTopLeftRadius, - borderTopRightRadius: visualStyle.borderTopRightRadius, - borderBottomLeftRadius: visualStyle.borderBottomLeftRadius, - borderBottomRightRadius: visualStyle.borderBottomRightRadius, - } - : {}; - }, [ - targetOpacity, - startOpacity, - backgroundDecorationColor, - visualStyle, - shouldAnimateOverlay, - activeState, - ]); - - const componentAnimatedStyle = useMemo(() => { - if (targetComponent !== ClickableAnimationTarget.COMPONENT || !canAnimate) { - return {}; - } + const underlayAnimatedStyle = useMemo( + () => + shouldAnimateUnderlay + ? { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [startOpacity, targetOpacity as number], + }), + backgroundColor: underlayColor ?? 'black', + borderRadius: visualStyle.borderRadius, + borderTopLeftRadius: visualStyle.borderTopLeftRadius, + borderTopRightRadius: visualStyle.borderTopRightRadius, + borderBottomLeftRadius: visualStyle.borderBottomLeftRadius, + borderBottomRightRadius: visualStyle.borderBottomRightRadius, + } + : {}, + [ + targetOpacity, + startOpacity, + underlayColor, + visualStyle, + shouldAnimateUnderlay, + animatedValue, + ] + ); - return { - opacity: activeState.interpolate({ - inputRange: [0, 1], - outputRange: [startOpacity, targetOpacity as number], - }), - }; - }, [targetComponent, canAnimate, targetOpacity, activeState, startOpacity]); + const componentAnimatedStyle = useMemo( + () => + targetComponent === ClickableAnimationTarget.COMPONENT && usesJSAnimation + ? { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [startOpacity, targetOpacity as number], + }), + } + : {}, + [ + targetComponent, + usesJSAnimation, + targetOpacity, + animatedValue, + startOpacity, + ] + ); const rippleProps = shouldUseNativeRipple ? { @@ -273,10 +279,10 @@ export const Clickable = (props: ClickableProps) => { { onFinalize={onFinalize}> <> {targetComponent === ClickableAnimationTarget.UNDERLAY ? ( - + ) : null} {children} From 583e8644238f33b7be424030d11693fc8aa4763d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 15:09:20 +0100 Subject: [PATCH 21/51] Remove preset --- .../new_api/components/clickable/index.tsx | 118 +++++++++--------- .../src/v3/components/Clickable/Clickable.tsx | 55 +++----- .../v3/components/Clickable/ClickableProps.ts | 12 +- .../src/v3/components/index.ts | 1 - .../src/v3/index.ts | 1 - 5 files changed, 76 insertions(+), 111 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index 026ed8fa0e..e9c5e32784 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -1,73 +1,83 @@ -import React, { RefObject, useRef } from 'react'; -import { StyleSheet, Text, View, Platform, ScrollView } from 'react-native'; +import React from 'react'; +import { StyleSheet, Text, View, ScrollView } from 'react-native'; import { GestureHandlerRootView, Clickable, - ClickablePreset, + ClickableProps, + ClickableOpacityMode, + ClickableAnimationTarget, } from 'react-native-gesture-handler'; -import { COLORS, Feedback, FeedbackHandle } from '../../../common'; -type ButtonWrapperProps = { +type ButtonWrapperProps = ClickableProps & { name: string; color: string; - feedback: RefObject; [key: string]: any; }; -function ButtonWrapper({ name, color, feedback, ...rest }: ButtonWrapperProps) { +export const COLORS = { + PURPLE: '#7d63d9', + NAVY: '#17327a', + RED: '#b53645', + YELLOW: '#c98d1f', + GREEN: '#167a5f', + GRAY: '#7f879b', + KINDA_RED: '#d97973', + KINDA_YELLOW: '#d6b24a', + KINDA_GREEN: '#4f9a84', + KINDA_BLUE: '#5f97c8', +}; + +function ButtonWrapper({ name, color, ...rest }: ButtonWrapperProps) { return ( - feedback.current?.showMessage(`[${name}] onPress`)} - onLongPress={() => feedback.current?.showMessage(`[${name}] onLongPress`)} - {...rest}> - {name} - + + {name} + + console.log(`[${name}] onPress`)} + onLongPress={() => console.log(`[${name}] onLongPress`)} + {...rest}> + Click me! + + ); } export default function ClickableExample() { - const feedbackRef = useRef(null); - return ( - - BaseButton Replacement - - - - + + + - - RectButton Replacement - - - - + - - BorderlessButton Replacement - - - - + - + ); @@ -91,21 +101,13 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginBottom: 4, }, - row: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - }, + button: { width: 200, height: 60, borderRadius: 12, alignItems: 'center', justifyContent: 'center', - ...Platform.select({ - ios: { cursor: 'pointer' }, - android: { elevation: 3 }, - }), }, buttonText: { color: 'white', diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 212fcee143..690c79f2b1 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -5,7 +5,6 @@ import { CallbackEventType, ClickableAnimationTarget, ClickableOpacityMode, - ClickablePreset, ClickableProps, } from './ClickableProps'; @@ -15,9 +14,8 @@ export const Clickable = (props: ClickableProps) => { const { underlayColor, activeOpacity, - feedbackTarget, + animationTarget, opacityMode, - preset, borderless, foreground, rippleColor, @@ -109,40 +107,17 @@ export const Clickable = (props: ClickableProps) => { } }, [delayLongPress, onLongPress, wrappedLongPress]); - let targetComponent; - let targetOpacity; - let targetOpacityMode; - - switch (preset) { - case ClickablePreset.RECT: - targetComponent = ClickableAnimationTarget.UNDERLAY; - targetOpacity = 0.105; - targetOpacityMode = ClickableOpacityMode.INCREASE; - break; - case ClickablePreset.BORDERLESS: - targetComponent = ClickableAnimationTarget.COMPONENT; - targetOpacity = 0.3; - targetOpacityMode = ClickableOpacityMode.DECREASE; - break; - default: - targetOpacity = activeOpacity; - targetComponent = feedbackTarget; - targetOpacityMode = opacityMode; - break; - } - - const hasFeedback = targetOpacity !== undefined; - const startOpacity = - targetOpacityMode === ClickableOpacityMode.INCREASE ? 0 : 1; + const hasFeedback = activeOpacity !== undefined; + const startOpacity = opacityMode === ClickableOpacityMode.INCREASE ? 0 : 1; const shouldAnimateUnderlay = useMemo( - () => hasFeedback && targetComponent === ClickableAnimationTarget.UNDERLAY, - [targetComponent, hasFeedback] + () => hasFeedback && animationTarget === ClickableAnimationTarget.UNDERLAY, + [animationTarget, hasFeedback] ); const shouldAnimateComponent = useMemo( - () => hasFeedback && targetComponent === ClickableAnimationTarget.COMPONENT, - [hasFeedback, targetComponent] + () => hasFeedback && animationTarget === ClickableAnimationTarget.COMPONENT, + [hasFeedback, animationTarget] ); const shouldUseNativeRipple = useMemo( @@ -223,7 +198,7 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, targetOpacity as number], + outputRange: [startOpacity, activeOpacity as number], }), backgroundColor: underlayColor ?? 'black', borderRadius: visualStyle.borderRadius, @@ -234,7 +209,7 @@ export const Clickable = (props: ClickableProps) => { } : {}, [ - targetOpacity, + activeOpacity, startOpacity, underlayColor, visualStyle, @@ -245,18 +220,18 @@ export const Clickable = (props: ClickableProps) => { const componentAnimatedStyle = useMemo( () => - targetComponent === ClickableAnimationTarget.COMPONENT && usesJSAnimation + animationTarget === ClickableAnimationTarget.COMPONENT && usesJSAnimation ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, targetOpacity as number], + outputRange: [startOpacity, activeOpacity as number], }), } : {}, [ - targetComponent, + animationTarget, usesJSAnimation, - targetOpacity, + activeOpacity, animatedValue, startOpacity, ] @@ -281,7 +256,7 @@ export const Clickable = (props: ClickableProps) => { style={[ layoutStyle, visualStyle, - targetComponent === ClickableAnimationTarget.COMPONENT && + animationTarget === ClickableAnimationTarget.COMPONENT && usesJSAnimation && componentAnimatedStyle, ]} @@ -292,7 +267,7 @@ export const Clickable = (props: ClickableProps) => { onDeactivate={onDeactivate} onFinalize={onFinalize}> <> - {targetComponent === ClickableAnimationTarget.UNDERLAY ? ( + {animationTarget === ClickableAnimationTarget.UNDERLAY ? ( ) : null} {children} diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index bb3b441d12..4d6e3a7eb3 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -4,11 +4,6 @@ import type { NativeHandlerData } from '../../hooks/gestures/native/NativeTypes' export type CallbackEventType = GestureEvent; -export enum ClickablePreset { - RECT, - BORDERLESS, -} - export enum ClickableOpacityMode { INCREASE, DECREASE, @@ -43,10 +38,5 @@ export interface ClickableProps extends BaseButtonProps { * - 'underlay' (default): an additional view rendered behind children. * - 'component': the whole button. */ - feedbackTarget?: ClickableAnimationTarget | undefined; - - /** - * Determines the preset style of the button. - */ - preset?: ClickablePreset | undefined; + animationTarget?: ClickableAnimationTarget | undefined; } diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index 45cd5139b7..1020e12755 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -27,6 +27,5 @@ export { Clickable } from './Clickable/Clickable'; export { ClickableAnimationTarget, ClickableOpacityMode, - ClickablePreset, } from './Clickable/ClickableProps'; export type { ClickableProps } from './Clickable/ClickableProps'; diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index d0f58c0930..d546f2d9b7 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -83,7 +83,6 @@ export { Clickable, ClickableAnimationTarget, ClickableOpacityMode, - ClickablePreset, } from './components'; export type { ComposedGesture } from './types'; From 769adb4368ae04411ccb95c1a9f876c14ea70228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 15:30:42 +0100 Subject: [PATCH 22/51] Ripple --- .../new_api/components/clickable/index.tsx | 18 +++++++++------ .../src/v3/components/Clickable/Clickable.tsx | 22 ++++++------------- .../v3/components/Clickable/ClickableProps.ts | 17 +++++++++++++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index e9c5e32784..950049c590 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -25,6 +25,8 @@ export const COLORS = { KINDA_YELLOW: '#d6b24a', KINDA_GREEN: '#4f9a84', KINDA_BLUE: '#5f97c8', + ANDROID: '#34a853', + WEB: '#1067c4', }; function ButtonWrapper({ name, color, ...rest }: ButtonWrapperProps) { @@ -51,7 +53,7 @@ export default function ClickableExample() { diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 690c79f2b1..7f29a77a8f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -16,10 +16,7 @@ export const Clickable = (props: ClickableProps) => { activeOpacity, animationTarget, opacityMode, - borderless, - foreground, - rippleColor, - rippleRadius, + androidRipple, delayLongPress = 600, onLongPress, onPress, @@ -121,13 +118,8 @@ export const Clickable = (props: ClickableProps) => { ); const shouldUseNativeRipple = useMemo( - () => - Platform.OS === 'android' && - (borderless !== undefined || - foreground !== undefined || - rippleColor !== undefined || - rippleRadius !== undefined), - [borderless, foreground, rippleColor, rippleRadius] + () => Platform.OS === 'android' && androidRipple !== undefined, + [androidRipple] ); const usesJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; @@ -239,10 +231,10 @@ export const Clickable = (props: ClickableProps) => { const rippleProps = shouldUseNativeRipple ? { - rippleColor, - rippleRadius, - borderless, - foreground, + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, } : { rippleColor: 'transparent', diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 4d6e3a7eb3..46bb4b5676 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -1,3 +1,4 @@ +import type { PressableAndroidRippleConfig as RNPressableAndroidRippleConfig } from 'react-native'; import type { BaseButtonProps } from '../GestureButtonsProps'; import type { GestureEvent } from '../../types'; import type { NativeHandlerData } from '../../hooks/gestures/native/NativeTypes'; @@ -14,7 +15,16 @@ export enum ClickableAnimationTarget { UNDERLAY, } -export interface ClickableProps extends BaseButtonProps { +type PressableAndroidRippleConfig = { + [K in keyof RNPressableAndroidRippleConfig]?: Exclude< + RNPressableAndroidRippleConfig[K], + null + >; +}; + +type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; + +export interface ClickableProps extends Omit { /** * Background color that will be dimmed when button is in active state. */ @@ -39,4 +49,9 @@ export interface ClickableProps extends BaseButtonProps { * - 'component': the whole button. */ animationTarget?: ClickableAnimationTarget | undefined; + + /** + * Configuration for ripple effect on Android. + */ + androidRipple?: PressableAndroidRippleConfig | undefined; } From e4bb348cb650c3c592ef617c03530ca942d9a29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 15:50:48 +0100 Subject: [PATCH 23/51] Example --- .../new_api/components/clickable/index.tsx | 155 ++++++++++++------ 1 file changed, 108 insertions(+), 47 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index 950049c590..3cb2da0487 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -31,17 +31,13 @@ export const COLORS = { function ButtonWrapper({ name, color, ...rest }: ButtonWrapperProps) { return ( - - {name} - - console.log(`[${name}] onPress`)} - onLongPress={() => console.log(`[${name}] onLongPress`)} - {...rest}> - Click me! - - + console.log(`[${name}] onPress`)} + onLongPress={() => console.log(`[${name}] onLongPress`)} + {...rest}> + {name} + ); } @@ -49,39 +45,97 @@ export default function ClickableExample() { return ( - - - - - - - - - + + Buttons replacements + New component that replaces all buttons and pressables. + + + + + + + + + + + + Custom animations + Animated overlay. + + + + + + + + Animated component. + + + + + + + + + + Android ripple + Configurable ripple effect on Clickable component. + + + + + + + ); @@ -100,15 +154,22 @@ const styles = StyleSheet.create({ borderBottomColor: '#ccc', alignItems: 'center', }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 10, + marginTop: 20, + marginBottom: 20, + }, sectionHeader: { fontSize: 16, fontWeight: 'bold', marginBottom: 4, }, - button: { - width: 200, - height: 60, + width: 110, + height: 50, borderRadius: 12, alignItems: 'center', justifyContent: 'center', From 96c016b3c1ce4c368348f864792f13932c38ef5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 16:26:36 +0100 Subject: [PATCH 24/51] Rename in example --- .../new_api/components/clickable/index.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index 3cb2da0487..ac88122353 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -11,7 +11,6 @@ import { type ButtonWrapperProps = ClickableProps & { name: string; color: string; - [key: string]: any; }; export const COLORS = { @@ -29,7 +28,7 @@ export const COLORS = { WEB: '#1067c4', }; -function ButtonWrapper({ name, color, ...rest }: ButtonWrapperProps) { +function ClickableWrapper({ name, color, ...rest }: ButtonWrapperProps) { return ( New component that replaces all buttons and pressables. - + - - Animated overlay. - - Animated component. - - Configurable ripple effect on Clickable component. - - Date: Tue, 10 Mar 2026 16:28:40 +0100 Subject: [PATCH 25/51] Opacity fix --- .../src/components/GestureHandlerButton.tsx | 2 ++ .../src/v3/components/Clickable/Clickable.tsx | 6 ++++-- .../src/v3/components/Clickable/ClickableProps.ts | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 58f7507111..3c34d651ae 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -170,6 +170,7 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { borderWidth, borderColor, borderStyle, + opacity, // Visual properties ...restStyle @@ -240,6 +241,7 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { borderWidth, borderColor, borderStyle, + opacity, }), // eslint-disable-next-line react-hooks/exhaustive-deps [flattenedStyle] diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 7f29a77a8f..53bc67a76e 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -13,9 +13,10 @@ const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); export const Clickable = (props: ClickableProps) => { const { underlayColor, + initialOpacity, activeOpacity, - animationTarget, opacityMode, + animationTarget, androidRipple, delayLongPress = 600, onLongPress, @@ -105,7 +106,8 @@ export const Clickable = (props: ClickableProps) => { }, [delayLongPress, onLongPress, wrappedLongPress]); const hasFeedback = activeOpacity !== undefined; - const startOpacity = opacityMode === ClickableOpacityMode.INCREASE ? 0 : 1; + const startOpacity = + initialOpacity ?? (opacityMode === ClickableOpacityMode.INCREASE ? 0 : 1); const shouldAnimateUnderlay = useMemo( () => hasFeedback && animationTarget === ClickableAnimationTarget.UNDERLAY, diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 46bb4b5676..70c9880277 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -36,6 +36,11 @@ export interface ClickableProps extends Omit { */ activeOpacity?: number | undefined; + /** + * Initial opacity of the underlay or button. + */ + initialOpacity?: number | undefined; + /** * Determines the direction of the animation. * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. From aca162b283175681a9cc55a1a57530d37345e659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 16:33:39 +0100 Subject: [PATCH 26/51] JSDocs --- .../src/v3/components/Clickable/ClickableProps.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 70c9880277..18a4b29d55 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -26,7 +26,7 @@ type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; export interface ClickableProps extends Omit { /** - * Background color that will be dimmed when button is in active state. + * Background color of underlay. Works only when `animationTarget` is set to `UNDERLAY`. */ underlayColor?: string | undefined; @@ -42,21 +42,19 @@ export interface ClickableProps extends Omit { initialOpacity?: number | undefined; /** - * Determines the direction of the animation. - * - 'opacity-increase' (default): opacity goes from 0 to activeOpacity. - * - 'opacity-decrease': opacity goes from 1 to activeOpacity. + * Determines whether opacity should increase or decrease when the button is active. */ opacityMode?: ClickableOpacityMode | undefined; /** * Determines what should be animated. - * - 'underlay' (default): an additional view rendered behind children. + * - 'underlay': an additional view rendered behind children. * - 'component': the whole button. */ animationTarget?: ClickableAnimationTarget | undefined; /** - * Configuration for ripple effect on Android. + * Configuration for the ripple effect on Android. */ androidRipple?: PressableAndroidRippleConfig | undefined; } From c6f27e745a3b3704d195f73aa2567cd908da4de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 17:12:52 +0100 Subject: [PATCH 27/51] Vibe stress test --- .../components/clickable_stress/index.tsx | 639 ++++++++++++++++++ apps/common-app/src/new_api/index.tsx | 2 + 2 files changed, 641 insertions(+) create mode 100644 apps/common-app/src/new_api/components/clickable_stress/index.tsx diff --git a/apps/common-app/src/new_api/components/clickable_stress/index.tsx b/apps/common-app/src/new_api/components/clickable_stress/index.tsx new file mode 100644 index 0000000000..8db9cb8a12 --- /dev/null +++ b/apps/common-app/src/new_api/components/clickable_stress/index.tsx @@ -0,0 +1,639 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + BaseButton, + BorderlessButton, + Clickable, + GestureHandlerRootView, + RectButton, +} from 'react-native-gesture-handler'; + +const COLORS = { + PURPLE: '#7d63d9', + NAVY: '#17327a', + GREEN: '#167a5f', + GRAY: '#7f879b', + RED: '#b53645', + BLUE: '#1067c4', +}; + +const CLICKABLE_MODE_INCREASE = 0; +const CLICKABLE_MODE_DECREASE = 1; +const CLICKABLE_TARGET_COMPONENT = 0; +const CLICKABLE_TARGET_UNDERLAY = 1; + +const STRESS_ITEM_COUNT = 2000; +const STRESS_RUN_COUNT = 12; +const STRESS_TRIM_COUNT = 2; + +const STRESS_DATA = Array.from({ length: STRESS_ITEM_COUNT }, (_, index) => ({ + id: `stress-${index}`, + label: `Button ${index + 1}`, +})); + +function now() { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function getTrimmedStats(results: number[], trimCount: number) { + if (results.length === 0) { + return { + average: null, + trimmedAverage: null, + }; + } + + const sortedResults = [...results].sort((left, right) => left - right); + const safeTrimCount = Math.min( + trimCount, + Math.max(0, Math.floor((sortedResults.length - 1) / 2)) + ); + const trimmedResults = + safeTrimCount > 0 + ? sortedResults.slice(safeTrimCount, sortedResults.length - safeTrimCount) + : sortedResults; + + return { + average: results.reduce((sum, value) => sum + value, 0) / results.length, + trimmedAverage: + trimmedResults.reduce((sum, value) => sum + value, 0) / + trimmedResults.length, + }; +} + +type ImplementationKey = 'reference' | 'clickable'; +type ScenarioKey = 'base' | 'rect' | 'borderless'; + +type ScenarioResult = Record; +type ResultsByScenario = Record; +type StatusByScenario = Record; + +type StressScenario = { + key: ScenarioKey; + title: string; + description: string; + referenceName: string; + clickableName: string; + renderReference: (label: string, key: string) => React.ReactElement; + renderClickable: (label: string, key: string) => React.ReactElement; +}; + +type ActiveBenchmark = { + scenarioKey: ScenarioKey; + implementationKey: ImplementationKey; + runToken: number; +}; + +const INITIAL_RESULTS: ResultsByScenario = { + base: { reference: [], clickable: [] }, + rect: { reference: [], clickable: [] }, + borderless: { reference: [], clickable: [] }, +}; + +const INITIAL_STATUS: StatusByScenario = { + base: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, + rect: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, + borderless: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, +}; + +const STRESS_SCENARIOS: StressScenario[] = [ + { + key: 'base', + title: 'BaseButton vs Clickable', + description: 'Clickable with no visual feedback compared to BaseButton.', + referenceName: 'BaseButton', + clickableName: 'Clickable', + renderReference: (label, key) => ( + + {label} + + ), + renderClickable: (label, key) => ( + + {label} + + ), + }, + { + key: 'rect', + title: 'RectButton vs Clickable', + description: + 'Clickable configured with underlay opacity increase to match RectButton.', + referenceName: 'RectButton', + clickableName: 'Clickable (Rect)', + renderReference: (label, key) => ( + + {label} + + ), + renderClickable: (label, key) => ( + + {label} + + ), + }, + { + key: 'borderless', + title: 'BorderlessButton vs Clickable', + description: + 'Clickable configured with component opacity decrease to match BorderlessButton.', + referenceName: 'BorderlessButton', + clickableName: 'Clickable (Borderless)', + renderReference: (label, key) => ( + + {label} + + ), + renderClickable: (label, key) => ( + + {label} + + ), + }, +]; + +function getScenarioByKey(key: ScenarioKey) { + const scenario = STRESS_SCENARIOS.find((item) => item.key === key); + + if (scenario === undefined) { + throw new Error(`Unknown stress scenario: ${key}`); + } + + return scenario; +} + +type StressListProps = { + benchmark: ActiveBenchmark; + onReady: (runToken: number) => void; +}; + +function StressList({ benchmark, onReady }: StressListProps) { + const scenario = getScenarioByKey(benchmark.scenarioKey); + const renderButton = + benchmark.implementationKey === 'reference' + ? scenario.renderReference + : scenario.renderClickable; + + return ( + + {STRESS_DATA.map((item, index) => { + const button = renderButton(item.label, item.id); + + if (index !== STRESS_DATA.length - 1) { + return button; + } + + return ( + onReady(benchmark.runToken)}> + {button} + + ); + })} + + ); +} + +export default function ClickableStressExample() { + const [activeBenchmark, setActiveBenchmark] = + useState(null); + const [resultsByScenario, setResultsByScenario] = + useState(INITIAL_RESULTS); + const [statusByScenario, setStatusByScenario] = + useState(INITIAL_STATUS); + + const runStartRef = useRef(null); + const lastCompletedTokenRef = useRef(null); + const activeBenchmarkRef = useRef(null); + const resultsRef = useRef(INITIAL_RESULTS); + + const scheduleRun = useCallback( + ( + scenarioKey: ScenarioKey, + implementationKey: ImplementationKey, + runToken: number + ) => { + requestAnimationFrame(() => { + runStartRef.current = now(); + lastCompletedTokenRef.current = null; + const nextBenchmark = { scenarioKey, implementationKey, runToken }; + + activeBenchmarkRef.current = nextBenchmark; + setActiveBenchmark(nextBenchmark); + setStatusByScenario((previousStatus) => ({ + ...previousStatus, + [scenarioKey]: `${ + getScenarioByKey(scenarioKey)[ + implementationKey === 'reference' + ? 'referenceName' + : 'clickableName' + ] + } ${runToken}/${STRESS_RUN_COUNT}...`, + })); + }); + }, + [] + ); + + const clearMountedList = useCallback((callback: () => void) => { + activeBenchmarkRef.current = null; + setActiveBenchmark(null); + requestAnimationFrame(callback); + }, []); + + const finalizeScenario = useCallback((scenarioKey: ScenarioKey) => { + const scenario = getScenarioByKey(scenarioKey); + const scenarioResults = resultsRef.current[scenarioKey]; + const referenceStats = getTrimmedStats( + scenarioResults.reference, + STRESS_TRIM_COUNT + ); + const clickableStats = getTrimmedStats( + scenarioResults.clickable, + STRESS_TRIM_COUNT + ); + + activeBenchmarkRef.current = null; + setActiveBenchmark(null); + setStatusByScenario((previousStatus) => ({ + ...previousStatus, + [scenarioKey]: + referenceStats.trimmedAverage === null || + clickableStats.trimmedAverage === null + ? 'Benchmark finished with no results.' + : `${scenario.referenceName}: ${referenceStats.trimmedAverage.toFixed( + 2 + )} ms, ${scenario.clickableName}: ${clickableStats.trimmedAverage.toFixed( + 2 + )} ms`, + })); + }, []); + + const beginBenchmark = useCallback( + ( + scenarioKey: ScenarioKey, + implementationKey: ImplementationKey, + runToken: number + ) => { + clearMountedList(() => { + scheduleRun(scenarioKey, implementationKey, runToken); + }); + }, + [clearMountedList, scheduleRun] + ); + + const handleListReady = useCallback( + (runToken: number) => { + const benchmark = activeBenchmarkRef.current; + + if (benchmark === null || runStartRef.current === null) { + return; + } + + if (lastCompletedTokenRef.current === runToken) { + return; + } + + lastCompletedTokenRef.current = runToken; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const runStart = runStartRef.current; + if (runStart === null) { + return; + } + + const duration = now() - runStart; + const nextScenarioResults = { + ...resultsRef.current[benchmark.scenarioKey], + [benchmark.implementationKey]: [ + ...resultsRef.current[benchmark.scenarioKey][ + benchmark.implementationKey + ], + duration, + ], + }; + const nextResultsByScenario = { + ...resultsRef.current, + [benchmark.scenarioKey]: nextScenarioResults, + }; + + resultsRef.current = nextResultsByScenario; + setResultsByScenario(nextResultsByScenario); + + if (runToken < STRESS_RUN_COUNT) { + beginBenchmark( + benchmark.scenarioKey, + benchmark.implementationKey, + runToken + 1 + ); + return; + } + + if (benchmark.implementationKey === 'reference') { + beginBenchmark(benchmark.scenarioKey, 'clickable', 1); + return; + } + + finalizeScenario(benchmark.scenarioKey); + }); + }); + }, + [beginBenchmark, finalizeScenario] + ); + + const startScenarioBenchmark = useCallback( + (scenarioKey: ScenarioKey) => { + if (activeBenchmarkRef.current !== null) { + return; + } + + runStartRef.current = null; + lastCompletedTokenRef.current = null; + const nextResults = { + ...resultsRef.current, + [scenarioKey]: { + reference: [], + clickable: [], + }, + }; + + resultsRef.current = nextResults; + setResultsByScenario(nextResults); + setStatusByScenario((previousStatus) => ({ + ...previousStatus, + [scenarioKey]: `Preparing benchmark with ${STRESS_ITEM_COUNT} buttons...`, + })); + + requestAnimationFrame(() => { + scheduleRun(scenarioKey, 'reference', 1); + }); + }, + [scheduleRun] + ); + + return ( + + + + + Buttons vs Clickable stress tests + + + Each comparison mounts {STRESS_ITEM_COUNT} items for both the + original button and the matching Clickable configuration, runs + {` ${STRESS_RUN_COUNT} `} + samples per side, and drops the {STRESS_TRIM_COUNT} fastest and + slowest runs before averaging. + + + + {STRESS_SCENARIOS.map((scenario) => { + const scenarioResults = resultsByScenario[scenario.key]; + const referenceStats = getTrimmedStats( + scenarioResults.reference, + STRESS_TRIM_COUNT + ); + const clickableStats = getTrimmedStats( + scenarioResults.clickable, + STRESS_TRIM_COUNT + ); + const trimmedDelta = + referenceStats.trimmedAverage !== null && + clickableStats.trimmedAverage !== null + ? clickableStats.trimmedAverage - referenceStats.trimmedAverage + : null; + const isScenarioRunning = + activeBenchmark?.scenarioKey === scenario.key; + + return ( + + {scenario.title} + + {scenario.description} + + + startScenarioBenchmark(scenario.key)} + enabled={activeBenchmark === null} + activeOpacity={0.2} + opacityMode={CLICKABLE_MODE_INCREASE} + animationTarget={CLICKABLE_TARGET_UNDERLAY} + underlayColor={COLORS.NAVY}> + + {isScenarioRunning + ? 'Benchmark running...' + : `Run ${scenario.title}`} + + + + + {statusByScenario[scenario.key]} + + + {(scenarioResults.reference.length > 0 || + scenarioResults.clickable.length > 0) && ( + + Results + + {scenario.referenceName}:{' '} + {scenarioResults.reference + .map((value) => value.toFixed(1)) + .join(', ') || '-'}{' '} + ms + + + {scenario.clickableName}:{' '} + {scenarioResults.clickable + .map((value) => value.toFixed(1)) + .join(', ') || '-'}{' '} + ms + + + {scenario.referenceName} avg:{' '} + {referenceStats.average?.toFixed(2) ?? '-'} ms + + + {scenario.referenceName} trimmed avg:{' '} + {referenceStats.trimmedAverage?.toFixed(2) ?? '-'} ms + + + {scenario.clickableName} avg:{' '} + {clickableStats.average?.toFixed(2) ?? '-'} ms + + + {scenario.clickableName} trimmed avg:{' '} + {clickableStats.trimmedAverage?.toFixed(2) ?? '-'} ms + + + Trimmed delta:{' '} + {trimmedDelta === null + ? '-' + : `${trimmedDelta.toFixed(2)} ms`} + + + )} + + {isScenarioRunning ? ( + + ) : ( + + + Active benchmark list renders here while{' '} + {scenario.title.toLowerCase()} is running. + + + )} + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + section: { + padding: 20, + alignItems: 'center', + }, + screenHeader: { + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + }, + sectionHeader: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + }, + sectionDescription: { + textAlign: 'center', + color: '#4a5368', + marginTop: 4, + }, + benchmarkButton: { + width: 240, + minHeight: 52, + marginTop: 20, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLORS.GREEN, + paddingHorizontal: 16, + }, + benchmarkButtonBusy: { + backgroundColor: COLORS.GRAY, + }, + benchmarkButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '700', + textAlign: 'center', + }, + benchmarkStatus: { + marginTop: 12, + textAlign: 'center', + color: COLORS.NAVY, + fontSize: 13, + }, + metricsCard: { + width: '100%', + marginTop: 16, + borderRadius: 16, + backgroundColor: '#eef3fb', + padding: 16, + gap: 8, + }, + metricsHeadline: { + fontSize: 15, + fontWeight: '700', + color: COLORS.NAVY, + }, + metricsText: { + color: '#33415c', + fontSize: 13, + }, + stressList: { + width: '100%', + height: 320, + marginTop: 16, + borderRadius: 16, + backgroundColor: '#f3f6fb', + }, + stressGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + padding: 12, + }, + stressButton: { + width: 96, + height: 42, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLORS.PURPLE, + }, + rectButton: { + backgroundColor: COLORS.BLUE, + }, + borderlessButton: { + backgroundColor: COLORS.RED, + }, + stressButtonText: { + color: 'white', + fontSize: 12, + fontWeight: '600', + }, + stressPlaceholder: { + width: '100%', + height: 120, + marginTop: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: '#d8dfec', + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, + }, + stressPlaceholderText: { + textAlign: 'center', + color: '#5b6478', + }, +}); diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 58b99b3878..602cb6ceab 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -28,6 +28,7 @@ import TapExample from './simple/tap'; import ButtonsExample from './components/buttons'; import ClickableExample from './components/clickable'; +import ClickableStressExample from './components/clickable_stress'; import ReanimatedDrawerLayout from './components/drawer'; import FlatListExample from './components/flatlist'; import ScrollViewExample from './components/scrollview'; @@ -107,6 +108,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'ScrollView example', component: ScrollViewExample }, { name: 'Buttons example', component: ButtonsExample }, { name: 'Clickable example', component: ClickableExample }, + { name: 'Clickable stress test', component: ClickableStressExample }, { name: 'Switch & TextInput', component: SwitchTextInputExample }, { name: 'Reanimated Swipeable', component: Swipeable }, { name: 'Reanimated Drawer Layout', component: ReanimatedDrawerLayout }, From 4200868f5e4c460b2214fa343189004b887ff959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 10 Mar 2026 18:12:25 +0100 Subject: [PATCH 28/51] First refactor --- .../src/v3/components/Clickable/Clickable.tsx | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 53bc67a76e..c3c76f2855 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -9,6 +9,7 @@ import { } from './ClickableProps'; const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); +const isAndroid = Platform.OS === 'android'; export const Clickable = (props: ClickableProps) => { const { @@ -109,20 +110,11 @@ export const Clickable = (props: ClickableProps) => { const startOpacity = initialOpacity ?? (opacityMode === ClickableOpacityMode.INCREASE ? 0 : 1); - const shouldAnimateUnderlay = useMemo( - () => hasFeedback && animationTarget === ClickableAnimationTarget.UNDERLAY, - [animationTarget, hasFeedback] - ); - - const shouldAnimateComponent = useMemo( - () => hasFeedback && animationTarget === ClickableAnimationTarget.COMPONENT, - [hasFeedback, animationTarget] - ); - - const shouldUseNativeRipple = useMemo( - () => Platform.OS === 'android' && androidRipple !== undefined, - [androidRipple] - ); + const shouldAnimateUnderlay = + hasFeedback && animationTarget === ClickableAnimationTarget.UNDERLAY; + const shouldAnimateComponent = + hasFeedback && animationTarget === ClickableAnimationTarget.COMPONENT; + const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; const usesJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; @@ -130,7 +122,7 @@ export const Clickable = (props: ClickableProps) => { const onBegin = useCallback( (e: CallbackEventType) => { - if (Platform.OS === 'android' && e.pointerInside) { + if (isAndroid && e.pointerInside) { startLongPressTimer(); if (usesJSAnimation) { @@ -145,11 +137,11 @@ export const Clickable = (props: ClickableProps) => { (e: CallbackEventType) => { onActiveStateChange?.(true); - if (usesJSAnimation && Platform.OS !== 'android') { + if (usesJSAnimation && !isAndroid) { animatedValue.setValue(1); } - if (Platform.OS !== 'android' && e.pointerInside) { + if (!isAndroid && e.pointerInside) { startLongPressTimer(); } @@ -192,7 +184,7 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity as number], + outputRange: [startOpacity, activeOpacity], }), backgroundColor: underlayColor ?? 'black', borderRadius: visualStyle.borderRadius, @@ -218,7 +210,7 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity as number], + outputRange: [startOpacity, activeOpacity], }), } : {}, @@ -231,16 +223,24 @@ export const Clickable = (props: ClickableProps) => { ] ); - const rippleProps = shouldUseNativeRipple - ? { - rippleColor: androidRipple?.color, - rippleRadius: androidRipple?.radius, - borderless: androidRipple?.borderless, - foreground: androidRipple?.foreground, - } - : { - rippleColor: 'transparent', - }; + const rippleProps = useMemo( + () => + shouldUseNativeRipple + ? { + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, + } + : { rippleColor: 'transparent' as const }, + [ + shouldUseNativeRipple, + androidRipple?.color, + androidRipple?.radius, + androidRipple?.borderless, + androidRipple?.foreground, + ] + ); const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; @@ -260,12 +260,10 @@ export const Clickable = (props: ClickableProps) => { onActivate={onActivate} onDeactivate={onDeactivate} onFinalize={onFinalize}> - <> - {animationTarget === ClickableAnimationTarget.UNDERLAY ? ( - - ) : null} - {children} - + {shouldAnimateUnderlay ? ( + + ) : null} + {children} ); }; From beb6710b3e1dbce1dfaa8993af87f0c50abbcdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 11 Mar 2026 12:09:30 +0100 Subject: [PATCH 29/51] Second optimization --- .../src/v3/components/Clickable/Clickable.tsx | 270 ++++++------------ 1 file changed, 88 insertions(+), 182 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index c3c76f2855..a555982a81 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Animated, Platform, StyleSheet } from 'react-native'; import { RawButton } from '../GestureButtons'; import { @@ -10,6 +10,7 @@ import { const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); const isAndroid = Platform.OS === 'android'; +const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; export const Clickable = (props: ClickableProps) => { const { @@ -29,82 +30,11 @@ export const Clickable = (props: ClickableProps) => { ...rest } = props; - const { layoutStyle, visualStyle } = useMemo(() => { - const flattened = StyleSheet.flatten(style ?? {}); - - const { - margin, - marginVertical, - marginHorizontal, - marginTop, - marginBottom, - marginLeft, - marginRight, - position, - top, - bottom, - left, - right, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - alignSelf, - ...visuals - } = flattened; - - return { - layoutStyle: { - margin, - marginVertical, - marginHorizontal, - marginTop, - marginBottom, - marginLeft, - marginRight, - position, - top, - bottom, - left, - right, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - alignSelf, - }, - visualStyle: visuals, - }; - }, [style]); - const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( undefined ); - - const wrappedLongPress = useCallback(() => { - longPressDetected.current = true; - onLongPress?.(); - }, [onLongPress]); - - const startLongPressTimer = useCallback(() => { - if (onLongPress && !longPressTimeout.current) { - longPressDetected.current = false; - longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); - } - }, [delayLongPress, onLongPress, wrappedLongPress]); + const animatedValue = useRef(new Animated.Value(0)).current; const hasFeedback = activeOpacity !== undefined; const startOpacity = @@ -118,151 +48,127 @@ export const Clickable = (props: ClickableProps) => { const usesJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; - const animatedValue = useRef(new Animated.Value(0)).current; - - const onBegin = useCallback( - (e: CallbackEventType) => { - if (isAndroid && e.pointerInside) { - startLongPressTimer(); + const wrappedLongPress = () => { + longPressDetected.current = true; + onLongPress?.(); + }; - if (usesJSAnimation) { - animatedValue.setValue(1); - } - } - }, - [startLongPressTimer, usesJSAnimation, animatedValue] - ); + const startLongPressTimer = () => { + if (onLongPress && !longPressTimeout.current) { + longPressDetected.current = false; + longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); + } + }; - const onActivate = useCallback( - (e: CallbackEventType) => { - onActiveStateChange?.(true); + const onBegin = (e: CallbackEventType) => { + if (isAndroid && e.pointerInside) { + startLongPressTimer(); - if (usesJSAnimation && !isAndroid) { + if (usesJSAnimation) { animatedValue.setValue(1); } + } + }; - if (!isAndroid && e.pointerInside) { - startLongPressTimer(); - } + const onActivate = (e: CallbackEventType) => { + onActiveStateChange?.(true); - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, - [usesJSAnimation, onActiveStateChange, animatedValue, startLongPressTimer] - ); + if (usesJSAnimation && !isAndroid) { + animatedValue.setValue(1); + } + + if (!isAndroid && e.pointerInside) { + startLongPressTimer(); + } - const onDeactivate = useCallback( - (e: CallbackEventType, success: boolean) => { - onActiveStateChange?.(false); + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }; - if (success && !longPressDetected.current) { - onPress?.(e.pointerInside); - } - }, - [onActiveStateChange, onPress] - ); + const onDeactivate = (e: CallbackEventType, success: boolean) => { + onActiveStateChange?.(false); - const onFinalize = useCallback( - (_e: CallbackEventType) => { - if (usesJSAnimation) { - animatedValue.setValue(0); - } + if (success && !longPressDetected.current) { + onPress?.(e.pointerInside); + } + }; - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, - [animatedValue, usesJSAnimation] - ); + const onFinalize = (_e: CallbackEventType) => { + if (usesJSAnimation) { + animatedValue.setValue(0); + } - const underlayAnimatedStyle = useMemo( - () => - shouldAnimateUnderlay - ? { - opacity: animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity], - }), - backgroundColor: underlayColor ?? 'black', - borderRadius: visualStyle.borderRadius, - borderTopLeftRadius: visualStyle.borderTopLeftRadius, - borderTopRightRadius: visualStyle.borderTopRightRadius, - borderBottomLeftRadius: visualStyle.borderBottomLeftRadius, - borderBottomRightRadius: visualStyle.borderBottomRightRadius, - } - : {}, - [ - activeOpacity, - startOpacity, - underlayColor, - visualStyle, - shouldAnimateUnderlay, - animatedValue, - ] - ); + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }; + + const underlayAnimatedStyle = useMemo(() => { + if (!shouldAnimateUnderlay) { + return undefined; + } + const resolvedStyle = StyleSheet.flatten(style ?? {}); + return { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [startOpacity, activeOpacity], + }), + backgroundColor: underlayColor ?? 'black', + borderRadius: resolvedStyle.borderRadius, + borderTopLeftRadius: resolvedStyle.borderTopLeftRadius, + borderTopRightRadius: resolvedStyle.borderTopRightRadius, + borderBottomLeftRadius: resolvedStyle.borderBottomLeftRadius, + borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, + }; + }, [ + shouldAnimateUnderlay, + style, + startOpacity, + activeOpacity, + underlayColor, + animatedValue, + ]); const componentAnimatedStyle = useMemo( () => - animationTarget === ClickableAnimationTarget.COMPONENT && usesJSAnimation + shouldAnimateComponent ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [startOpacity, activeOpacity], }), } - : {}, - [ - animationTarget, - usesJSAnimation, - activeOpacity, - animatedValue, - startOpacity, - ] + : undefined, + [shouldAnimateComponent, activeOpacity, animatedValue, startOpacity] ); - const rippleProps = useMemo( - () => - shouldUseNativeRipple - ? { - rippleColor: androidRipple?.color, - rippleRadius: androidRipple?.radius, - borderless: androidRipple?.borderless, - foreground: androidRipple?.foreground, - } - : { rippleColor: 'transparent' as const }, - [ - shouldUseNativeRipple, - androidRipple?.color, - androidRipple?.radius, - androidRipple?.borderless, - androidRipple?.foreground, - ] - ); + const rippleProps = shouldUseNativeRipple + ? { + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, + } + : TRANSPARENT_RIPPLE; const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; return ( - {shouldAnimateUnderlay ? ( + {underlayAnimatedStyle && ( - ) : null} + )} {children} ); From 1a23339be348e98921e5f5673008a14bd95d22c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 11 Mar 2026 14:35:45 +0100 Subject: [PATCH 30/51] Change props --- .../new_api/components/clickable/index.tsx | 25 ++------- .../components/clickable_stress/index.tsx | 17 +----- .../src/v3/components/Clickable/Clickable.tsx | 56 +++++++++---------- .../v3/components/Clickable/ClickableProps.ts | 29 +++------- .../src/v3/components/index.ts | 4 -- .../src/v3/index.ts | 2 - 6 files changed, 45 insertions(+), 88 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index ac88122353..e47c2e09be 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -4,8 +4,6 @@ import { GestureHandlerRootView, Clickable, ClickableProps, - ClickableOpacityMode, - ClickableAnimationTarget, } from 'react-native-gesture-handler'; type ButtonWrapperProps = ClickableProps & { @@ -54,16 +52,12 @@ export default function ClickableExample() { @@ -77,18 +71,14 @@ export default function ClickableExample() { @@ -101,16 +91,13 @@ export default function ClickableExample() { color={COLORS.KINDA_BLUE} initialOpacity={0.3} activeOpacity={0.7} - opacityMode={ClickableOpacityMode.INCREASE} - animationTarget={ClickableAnimationTarget.COMPONENT} /> diff --git a/apps/common-app/src/new_api/components/clickable_stress/index.tsx b/apps/common-app/src/new_api/components/clickable_stress/index.tsx index 8db9cb8a12..2f0e90301a 100644 --- a/apps/common-app/src/new_api/components/clickable_stress/index.tsx +++ b/apps/common-app/src/new_api/components/clickable_stress/index.tsx @@ -17,13 +17,8 @@ const COLORS = { BLUE: '#1067c4', }; -const CLICKABLE_MODE_INCREASE = 0; -const CLICKABLE_MODE_DECREASE = 1; -const CLICKABLE_TARGET_COMPONENT = 0; -const CLICKABLE_TARGET_UNDERLAY = 1; - const STRESS_ITEM_COUNT = 2000; -const STRESS_RUN_COUNT = 12; +const STRESS_RUN_COUNT = 20; const STRESS_TRIM_COUNT = 2; const STRESS_DATA = Array.from({ length: STRESS_ITEM_COUNT }, (_, index) => ({ @@ -134,9 +129,7 @@ const STRESS_SCENARIOS: StressScenario[] = [ {label} @@ -163,8 +156,6 @@ const STRESS_SCENARIOS: StressScenario[] = [ key={key} style={[styles.stressButton, styles.borderlessButton]} activeOpacity={0.3} - opacityMode={CLICKABLE_MODE_DECREASE} - animationTarget={CLICKABLE_TARGET_COMPONENT} androidRipple={{ borderless: true }}> {label} @@ -441,9 +432,7 @@ export default function ClickableStressExample() { ]} onPress={() => startScenarioBenchmark(scenario.key)} enabled={activeBenchmark === null} - activeOpacity={0.2} - opacityMode={CLICKABLE_MODE_INCREASE} - animationTarget={CLICKABLE_TARGET_UNDERLAY} + underlayActiveOpacity={0.2} underlayColor={COLORS.NAVY}> {isScenarioRunning diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index a555982a81..a8b30fe27f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -1,12 +1,7 @@ import React, { useMemo, useRef } from 'react'; import { Animated, Platform, StyleSheet } from 'react-native'; import { RawButton } from '../GestureButtons'; -import { - CallbackEventType, - ClickableAnimationTarget, - ClickableOpacityMode, - ClickableProps, -} from './ClickableProps'; +import { CallbackEventType, ClickableProps } from './ClickableProps'; const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); const isAndroid = Platform.OS === 'android'; @@ -15,10 +10,10 @@ const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; export const Clickable = (props: ClickableProps) => { const { underlayColor, + underlayInitialOpacity, + underlayActiveOpacity, initialOpacity, activeOpacity, - opacityMode, - animationTarget, androidRipple, delayLongPress = 600, onLongPress, @@ -30,23 +25,21 @@ export const Clickable = (props: ClickableProps) => { ...rest } = props; - const longPressDetected = useRef(false); - const longPressTimeout = useRef | undefined>( - undefined - ); const animatedValue = useRef(new Animated.Value(0)).current; - const hasFeedback = activeOpacity !== undefined; - const startOpacity = - initialOpacity ?? (opacityMode === ClickableOpacityMode.INCREASE ? 0 : 1); + const underlayStartOpacity = underlayInitialOpacity ?? 0; + const componentStartOpacity = initialOpacity ?? 1; + + const shouldAnimateUnderlay = underlayActiveOpacity !== undefined; + const shouldAnimateComponent = activeOpacity !== undefined; - const shouldAnimateUnderlay = - hasFeedback && animationTarget === ClickableAnimationTarget.UNDERLAY; - const shouldAnimateComponent = - hasFeedback && animationTarget === ClickableAnimationTarget.COMPONENT; const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + const shouldUseJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; - const usesJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; + const longPressDetected = useRef(false); + const longPressTimeout = useRef | undefined>( + undefined + ); const wrappedLongPress = () => { longPressDetected.current = true; @@ -64,7 +57,7 @@ export const Clickable = (props: ClickableProps) => { if (isAndroid && e.pointerInside) { startLongPressTimer(); - if (usesJSAnimation) { + if (shouldUseJSAnimation) { animatedValue.setValue(1); } } @@ -73,7 +66,7 @@ export const Clickable = (props: ClickableProps) => { const onActivate = (e: CallbackEventType) => { onActiveStateChange?.(true); - if (usesJSAnimation && !isAndroid) { + if (shouldUseJSAnimation && !isAndroid) { animatedValue.setValue(1); } @@ -96,7 +89,7 @@ export const Clickable = (props: ClickableProps) => { }; const onFinalize = (_e: CallbackEventType) => { - if (usesJSAnimation) { + if (shouldUseJSAnimation) { animatedValue.setValue(0); } @@ -114,7 +107,7 @@ export const Clickable = (props: ClickableProps) => { return { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity], + outputRange: [underlayStartOpacity, underlayActiveOpacity], }), backgroundColor: underlayColor ?? 'black', borderRadius: resolvedStyle.borderRadius, @@ -126,8 +119,8 @@ export const Clickable = (props: ClickableProps) => { }, [ shouldAnimateUnderlay, style, - startOpacity, - activeOpacity, + underlayStartOpacity, + underlayActiveOpacity, underlayColor, animatedValue, ]); @@ -138,11 +131,16 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [startOpacity, activeOpacity], + outputRange: [componentStartOpacity, activeOpacity], }), } : undefined, - [shouldAnimateComponent, activeOpacity, animatedValue, startOpacity] + [ + shouldAnimateComponent, + activeOpacity, + animatedValue, + componentStartOpacity, + ] ); const rippleProps = shouldUseNativeRipple @@ -154,7 +152,7 @@ export const Clickable = (props: ClickableProps) => { } : TRANSPARENT_RIPPLE; - const ButtonComponent = hasFeedback ? AnimatedRawButton : RawButton; + const ButtonComponent = shouldUseJSAnimation ? AnimatedRawButton : RawButton; return ( ; -export enum ClickableOpacityMode { - INCREASE, - DECREASE, -} - -export enum ClickableAnimationTarget { - COMPONENT, - UNDERLAY, -} - type PressableAndroidRippleConfig = { [K in keyof RNPressableAndroidRippleConfig]?: Exclude< RNPressableAndroidRippleConfig[K], @@ -31,27 +21,26 @@ export interface ClickableProps extends Omit { underlayColor?: string | undefined; /** - * Opacity applied to the underlay or button when it is in an active state. + * Opacity applied to the underlay when it is in an active state. * If not provided, no visual feedback will be applied. */ - activeOpacity?: number | undefined; + underlayActiveOpacity?: number | undefined; /** - * Initial opacity of the underlay or button. + * Opacity applied to the component when it is in an active state. + * If not provided, no visual feedback will be applied. */ - initialOpacity?: number | undefined; + activeOpacity?: number | undefined; /** - * Determines whether opacity should increase or decrease when the button is active. + * Initial opacity of the underlay. */ - opacityMode?: ClickableOpacityMode | undefined; + underlayInitialOpacity?: number | undefined; /** - * Determines what should be animated. - * - 'underlay': an additional view rendered behind children. - * - 'component': the whole button. + * Initial opacity of the component. */ - animationTarget?: ClickableAnimationTarget | undefined; + initialOpacity?: number | undefined; /** * Configuration for the ripple effect on Android. diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index 1020e12755..30adc491c4 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -24,8 +24,4 @@ export { export { default as Pressable } from './Pressable'; export { Clickable } from './Clickable/Clickable'; -export { - ClickableAnimationTarget, - ClickableOpacityMode, -} from './Clickable/ClickableProps'; export type { ClickableProps } from './Clickable/ClickableProps'; diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index d546f2d9b7..50655f9cbe 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -81,8 +81,6 @@ export { FlatList, RefreshControl, Clickable, - ClickableAnimationTarget, - ClickableOpacityMode, } from './components'; export type { ComposedGesture } from './types'; From 0e5b735c3cd337750e892d394ddafa8c277dd7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 11 Mar 2026 16:16:48 +0100 Subject: [PATCH 31/51] Bring back use callback --- .../src/v3/components/Clickable/Clickable.tsx | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index a8b30fe27f..59b6c5abba 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Animated, Platform, StyleSheet } from 'react-native'; import { RawButton } from '../GestureButtons'; import { CallbackEventType, ClickableProps } from './ClickableProps'; @@ -41,63 +41,80 @@ export const Clickable = (props: ClickableProps) => { undefined ); - const wrappedLongPress = () => { + const wrappedLongPress = useCallback(() => { longPressDetected.current = true; onLongPress?.(); - }; + }, [onLongPress]); - const startLongPressTimer = () => { + const startLongPressTimer = useCallback(() => { if (onLongPress && !longPressTimeout.current) { longPressDetected.current = false; longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); } - }; + }, [onLongPress, delayLongPress, wrappedLongPress]); - const onBegin = (e: CallbackEventType) => { - if (isAndroid && e.pointerInside) { - startLongPressTimer(); + const onBegin = useCallback( + (e: CallbackEventType) => { + if (isAndroid && e.pointerInside) { + startLongPressTimer(); - if (shouldUseJSAnimation) { - animatedValue.setValue(1); + if (shouldUseJSAnimation) { + animatedValue.setValue(1); + } } - } - }; + }, + [startLongPressTimer, shouldUseJSAnimation, animatedValue] + ); - const onActivate = (e: CallbackEventType) => { - onActiveStateChange?.(true); + const onActivate = useCallback( + (e: CallbackEventType) => { + onActiveStateChange?.(true); - if (shouldUseJSAnimation && !isAndroid) { - animatedValue.setValue(1); - } + if (shouldUseJSAnimation && !isAndroid) { + animatedValue.setValue(1); + } - if (!isAndroid && e.pointerInside) { - startLongPressTimer(); - } + if (!isAndroid && e.pointerInside) { + startLongPressTimer(); + } - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }; + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [ + onActiveStateChange, + shouldUseJSAnimation, + animatedValue, + startLongPressTimer, + ] + ); - const onDeactivate = (e: CallbackEventType, success: boolean) => { - onActiveStateChange?.(false); + const onDeactivate = useCallback( + (e: CallbackEventType, success: boolean) => { + onActiveStateChange?.(false); - if (success && !longPressDetected.current) { - onPress?.(e.pointerInside); - } - }; + if (success && !longPressDetected.current) { + onPress?.(e.pointerInside); + } + }, + [onActiveStateChange, onPress] + ); - const onFinalize = (_e: CallbackEventType) => { - if (shouldUseJSAnimation) { - animatedValue.setValue(0); - } + const onFinalize = useCallback( + (_e: CallbackEventType) => { + if (shouldUseJSAnimation) { + animatedValue.setValue(0); + } - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }; + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [shouldUseJSAnimation, animatedValue] + ); const underlayAnimatedStyle = useMemo(() => { if (!shouldAnimateUnderlay) { From d6113c6441d7940a48e46ef47b9872dd64052c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 13:00:25 +0100 Subject: [PATCH 32/51] Tests --- .../src/__tests__/api_v3.test.tsx | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index f3f8758f0c..c99a8b7323 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -3,8 +3,9 @@ import { render, renderHook } from '@testing-library/react-native'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; -import { RectButton } from '../v3/components'; +import { RectButton, Clickable } from '../v3/components'; import { act } from 'react'; +import type { SingleGesture } from '../v3/types'; describe('[API v3] Hooks', () => { test('Pan gesture', () => { @@ -57,4 +58,152 @@ describe('[API v3] Components', () => { expect(pressFn).toHaveBeenCalledTimes(1); }); + + describe('Clickable', () => { + test('calls onPress on successful press', () => { + const pressFn = jest.fn(); + + const Example = () => ( + + + + ); + + render(); + const gesture = getByGestureTestId('clickable'); + + act(() => { + fireGestureHandler(gesture, [ + { oldState: State.UNDETERMINED, state: State.BEGAN }, + { oldState: State.BEGAN, state: State.ACTIVE }, + { oldState: State.ACTIVE, state: State.END }, + ]); + }); + + expect(pressFn).toHaveBeenCalledTimes(1); + }); + + test('does not call onPress on cancelled gesture', () => { + const pressFn = jest.fn(); + + const Example = () => ( + + + + ); + + render(); + const gesture = getByGestureTestId('clickable'); + + act(() => { + fireGestureHandler(gesture, [ + { oldState: State.UNDETERMINED, state: State.BEGAN }, + { oldState: State.BEGAN, state: State.ACTIVE }, + { oldState: State.ACTIVE, state: State.FAILED }, + ]); + }); + + expect(pressFn).not.toHaveBeenCalled(); + }); + + test('calls onActiveStateChange with correct values', () => { + const activeStateFn = jest.fn(); + + const Example = () => ( + + + + ); + + render(); + const gesture = getByGestureTestId('clickable'); + + act(() => { + fireGestureHandler(gesture, [ + { oldState: State.UNDETERMINED, state: State.BEGAN }, + { oldState: State.BEGAN, state: State.ACTIVE }, + { oldState: State.ACTIVE, state: State.END }, + ]); + }); + + expect(activeStateFn).toHaveBeenCalledTimes(2); + expect(activeStateFn).toHaveBeenNthCalledWith(1, true); + expect(activeStateFn).toHaveBeenNthCalledWith(2, false); + }); + + test('calls onLongPress after delayLongPress and suppresses onPress', () => { + jest.useFakeTimers(); + + const pressFn = jest.fn(); + const longPressFn = jest.fn(); + const DELAY = 800; + + const Example = () => ( + + + + ); + + render(); + + const gesture = getByGestureTestId('clickable') as SingleGesture< + any, + any, + any + >; + const { jsEventHandler } = gesture.detectorCallbacks; + + // Fire BEGAN + act(() => { + jsEventHandler?.({ + oldState: State.UNDETERMINED, + state: State.BEGAN, + handlerTag: gesture.handlerTag, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlerData: { pointerInside: true, numberOfPointers: 1 } as any, + }); + }); + + // Fire ACTIVE — long press timer starts here (on iOS / non-Android) + act(() => { + jsEventHandler?.({ + oldState: State.BEGAN, + state: State.ACTIVE, + handlerTag: gesture.handlerTag, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlerData: { pointerInside: true, numberOfPointers: 1 } as any, + }); + }); + + expect(longPressFn).not.toHaveBeenCalled(); + + // Advance fake timers past delayLongPress + act(() => { + jest.advanceTimersByTime(DELAY); + }); + + expect(longPressFn).toHaveBeenCalledTimes(1); + expect(pressFn).not.toHaveBeenCalled(); + + // Fire END — onPress should be suppressed because long press was detected + act(() => { + jsEventHandler?.({ + oldState: State.ACTIVE, + state: State.END, + handlerTag: gesture.handlerTag, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlerData: { pointerInside: true, numberOfPointers: 1 } as any, + }); + }); + + expect(pressFn).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + }); }); From ac02b275b954d96d886b5ff4aa79024eec294260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 13:00:55 +0100 Subject: [PATCH 33/51] Reset longpress outside of if --- .../src/v3/components/Clickable/Clickable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 59b6c5abba..c7c1029654 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -47,8 +47,9 @@ export const Clickable = (props: ClickableProps) => { }, [onLongPress]); const startLongPressTimer = useCallback(() => { + longPressDetected.current = false; + if (onLongPress && !longPressTimeout.current) { - longPressDetected.current = false; longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); } }, [onLongPress, delayLongPress, wrappedLongPress]); From 8864e8976216445fdc30c0f228465676ba2f0139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 13:01:26 +0100 Subject: [PATCH 34/51] Easier stress test --- .../components/clickable_stress/index.tsx | 714 ++++-------------- 1 file changed, 133 insertions(+), 581 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable_stress/index.tsx b/apps/common-app/src/new_api/components/clickable_stress/index.tsx index 2f0e90301a..9cf0c835a9 100644 --- a/apps/common-app/src/new_api/components/clickable_stress/index.tsx +++ b/apps/common-app/src/new_api/components/clickable_stress/index.tsx @@ -1,628 +1,180 @@ -import React, { useCallback, useRef, useState } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; -import { - BaseButton, - BorderlessButton, - Clickable, - GestureHandlerRootView, - RectButton, -} from 'react-native-gesture-handler'; - -const COLORS = { - PURPLE: '#7d63d9', - NAVY: '#17327a', - GREEN: '#167a5f', - GRAY: '#7f879b', - RED: '#b53645', - BLUE: '#1067c4', -}; - -const STRESS_ITEM_COUNT = 2000; -const STRESS_RUN_COUNT = 20; -const STRESS_TRIM_COUNT = 2; - -const STRESS_DATA = Array.from({ length: STRESS_ITEM_COUNT }, (_, index) => ({ - id: `stress-${index}`, - label: `Button ${index + 1}`, -})); - -function now() { - return typeof performance !== 'undefined' ? performance.now() : Date.now(); -} - -function getTrimmedStats(results: number[], trimCount: number) { - if (results.length === 0) { - return { - average: null, - trimmedAverage: null, - }; - } - - const sortedResults = [...results].sort((left, right) => left - right); - const safeTrimCount = Math.min( - trimCount, - Math.max(0, Math.floor((sortedResults.length - 1) / 2)) +import { Profiler, useCallback, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Clickable, ScrollView } from 'react-native-gesture-handler'; + +const CLICK_COUNT = 2000; +const N = 25; +const DROPOUT = 3; + +const STRESS_DATA = Array.from( + { length: CLICK_COUNT }, + (_, i) => `stress-${i}` +); + +type BenchmarkState = + | { phase: 'idle' } + | { phase: 'running'; run: number } + | { phase: 'done'; results: number[] }; + +function getTrimmedAverage(results: number[], dropout: number): number { + const sorted = [...results].sort((a, b) => a - b); + const trimCount = Math.min( + dropout, + Math.max(0, Math.floor((sorted.length - 1) / 2)) ); - const trimmedResults = - safeTrimCount > 0 - ? sortedResults.slice(safeTrimCount, sortedResults.length - safeTrimCount) - : sortedResults; - - return { - average: results.reduce((sum, value) => sum + value, 0) / results.length, - trimmedAverage: - trimmedResults.reduce((sum, value) => sum + value, 0) / - trimmedResults.length, - }; + const trimmed = + trimCount > 0 ? sorted.slice(trimCount, sorted.length - trimCount) : sorted; + return trimmed.reduce((sum, v) => sum + v, 0) / trimmed.length; } -type ImplementationKey = 'reference' | 'clickable'; -type ScenarioKey = 'base' | 'rect' | 'borderless'; - -type ScenarioResult = Record; -type ResultsByScenario = Record; -type StatusByScenario = Record; - -type StressScenario = { - key: ScenarioKey; - title: string; - description: string; - referenceName: string; - clickableName: string; - renderReference: (label: string, key: string) => React.ReactElement; - renderClickable: (label: string, key: string) => React.ReactElement; +type ClickableListProps = { + run: number; + onMountDuration: (duration: number) => void; }; -type ActiveBenchmark = { - scenarioKey: ScenarioKey; - implementationKey: ImplementationKey; - runToken: number; -}; +function ClickableList({ run, onMountDuration }: ClickableListProps) { + const reportedRef = useRef(-1); -const INITIAL_RESULTS: ResultsByScenario = { - base: { reference: [], clickable: [] }, - rect: { reference: [], clickable: [] }, - borderless: { reference: [], clickable: [] }, -}; - -const INITIAL_STATUS: StatusByScenario = { - base: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, - rect: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, - borderless: `Ready to mount ${STRESS_ITEM_COUNT} buttons.`, -}; - -const STRESS_SCENARIOS: StressScenario[] = [ - { - key: 'base', - title: 'BaseButton vs Clickable', - description: 'Clickable with no visual feedback compared to BaseButton.', - referenceName: 'BaseButton', - clickableName: 'Clickable', - renderReference: (label, key) => ( - - {label} - - ), - renderClickable: (label, key) => ( - - {label} - - ), - }, - { - key: 'rect', - title: 'RectButton vs Clickable', - description: - 'Clickable configured with underlay opacity increase to match RectButton.', - referenceName: 'RectButton', - clickableName: 'Clickable (Rect)', - renderReference: (label, key) => ( - - {label} - - ), - renderClickable: (label, key) => ( - - {label} - - ), - }, - { - key: 'borderless', - title: 'BorderlessButton vs Clickable', - description: - 'Clickable configured with component opacity decrease to match BorderlessButton.', - referenceName: 'BorderlessButton', - clickableName: 'Clickable (Borderless)', - renderReference: (label, key) => ( - - {label} - - ), - renderClickable: (label, key) => ( - - {label} - - ), - }, -]; - -function getScenarioByKey(key: ScenarioKey) { - const scenario = STRESS_SCENARIOS.find((item) => item.key === key); - - if (scenario === undefined) { - throw new Error(`Unknown stress scenario: ${key}`); - } - - return scenario; -} - -type StressListProps = { - benchmark: ActiveBenchmark; - onReady: (runToken: number) => void; -}; - -function StressList({ benchmark, onReady }: StressListProps) { - const scenario = getScenarioByKey(benchmark.scenarioKey); - const renderButton = - benchmark.implementationKey === 'reference' - ? scenario.renderReference - : scenario.renderClickable; + const handleRender = useCallback( + (_id: string, phase: string, actualDuration: number) => { + if (phase === 'mount' && reportedRef.current !== run) { + reportedRef.current = run; + onMountDuration(actualDuration); + } + }, + [run, onMountDuration] + ); return ( - - {STRESS_DATA.map((item, index) => { - const button = renderButton(item.label, item.id); - - if (index !== STRESS_DATA.length - 1) { - return button; - } - - return ( - onReady(benchmark.runToken)}> - {button} - - ); - })} - + + + {STRESS_DATA.map((id) => ( + // + + + // + // + + // + // + ))} + + ); } -export default function ClickableStressExample() { - const [activeBenchmark, setActiveBenchmark] = - useState(null); - const [resultsByScenario, setResultsByScenario] = - useState(INITIAL_RESULTS); - const [statusByScenario, setStatusByScenario] = - useState(INITIAL_STATUS); - - const runStartRef = useRef(null); - const lastCompletedTokenRef = useRef(null); - const activeBenchmarkRef = useRef(null); - const resultsRef = useRef(INITIAL_RESULTS); - - const scheduleRun = useCallback( - ( - scenarioKey: ScenarioKey, - implementationKey: ImplementationKey, - runToken: number - ) => { - requestAnimationFrame(() => { - runStartRef.current = now(); - lastCompletedTokenRef.current = null; - const nextBenchmark = { scenarioKey, implementationKey, runToken }; - - activeBenchmarkRef.current = nextBenchmark; - setActiveBenchmark(nextBenchmark); - setStatusByScenario((previousStatus) => ({ - ...previousStatus, - [scenarioKey]: `${ - getScenarioByKey(scenarioKey)[ - implementationKey === 'reference' - ? 'referenceName' - : 'clickableName' - ] - } ${runToken}/${STRESS_RUN_COUNT}...`, - })); - }); - }, - [] - ); - - const clearMountedList = useCallback((callback: () => void) => { - activeBenchmarkRef.current = null; - setActiveBenchmark(null); - requestAnimationFrame(callback); - }, []); - - const finalizeScenario = useCallback((scenarioKey: ScenarioKey) => { - const scenario = getScenarioByKey(scenarioKey); - const scenarioResults = resultsRef.current[scenarioKey]; - const referenceStats = getTrimmedStats( - scenarioResults.reference, - STRESS_TRIM_COUNT - ); - const clickableStats = getTrimmedStats( - scenarioResults.clickable, - STRESS_TRIM_COUNT - ); +export default function ClickableStress() { + const [state, setState] = useState({ phase: 'idle' }); + const resultsRef = useRef([]); - activeBenchmarkRef.current = null; - setActiveBenchmark(null); - setStatusByScenario((previousStatus) => ({ - ...previousStatus, - [scenarioKey]: - referenceStats.trimmedAverage === null || - clickableStats.trimmedAverage === null - ? 'Benchmark finished with no results.' - : `${scenario.referenceName}: ${referenceStats.trimmedAverage.toFixed( - 2 - )} ms, ${scenario.clickableName}: ${clickableStats.trimmedAverage.toFixed( - 2 - )} ms`, - })); + const start = useCallback(() => { + resultsRef.current = []; + setState({ phase: 'running', run: 1 }); }, []); - const beginBenchmark = useCallback( - ( - scenarioKey: ScenarioKey, - implementationKey: ImplementationKey, - runToken: number - ) => { - clearMountedList(() => { - scheduleRun(scenarioKey, implementationKey, runToken); - }); - }, - [clearMountedList, scheduleRun] - ); - - const handleListReady = useCallback( - (runToken: number) => { - const benchmark = activeBenchmarkRef.current; - - if (benchmark === null || runStartRef.current === null) { - return; - } - - if (lastCompletedTokenRef.current === runToken) { - return; - } - - lastCompletedTokenRef.current = runToken; - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const runStart = runStartRef.current; - if (runStart === null) { - return; - } - - const duration = now() - runStart; - const nextScenarioResults = { - ...resultsRef.current[benchmark.scenarioKey], - [benchmark.implementationKey]: [ - ...resultsRef.current[benchmark.scenarioKey][ - benchmark.implementationKey - ], - duration, - ], - }; - const nextResultsByScenario = { - ...resultsRef.current, - [benchmark.scenarioKey]: nextScenarioResults, - }; - - resultsRef.current = nextResultsByScenario; - setResultsByScenario(nextResultsByScenario); + const handleMountDuration = useCallback((duration: number) => { + resultsRef.current = [...resultsRef.current, duration]; + const currentRun = resultsRef.current.length; - if (runToken < STRESS_RUN_COUNT) { - beginBenchmark( - benchmark.scenarioKey, - benchmark.implementationKey, - runToken + 1 - ); - return; - } + if (currentRun >= N) { + setState({ phase: 'done', results: resultsRef.current }); + return; + } - if (benchmark.implementationKey === 'reference') { - beginBenchmark(benchmark.scenarioKey, 'clickable', 1); - return; - } - - finalizeScenario(benchmark.scenarioKey); - }); - }); - }, - [beginBenchmark, finalizeScenario] - ); - - const startScenarioBenchmark = useCallback( - (scenarioKey: ScenarioKey) => { - if (activeBenchmarkRef.current !== null) { - return; - } - - runStartRef.current = null; - lastCompletedTokenRef.current = null; - const nextResults = { - ...resultsRef.current, - [scenarioKey]: { - reference: [], - clickable: [], - }, - }; - - resultsRef.current = nextResults; - setResultsByScenario(nextResults); - setStatusByScenario((previousStatus) => ({ - ...previousStatus, - [scenarioKey]: `Preparing benchmark with ${STRESS_ITEM_COUNT} buttons...`, - })); + // Unmount then remount for next run + setState({ phase: 'idle' }); + setTimeout(() => { + setState({ phase: 'running', run: currentRun + 1 }); + }, 50); + }, []); - requestAnimationFrame(() => { - scheduleRun(scenarioKey, 'reference', 1); - }); - }, - [scheduleRun] - ); + const isRunning = state.phase === 'running'; + const currentRun = state.phase === 'running' ? state.run : 0; + const results = state.phase === 'done' ? state.results : null; + const trimmedAverage = results ? getTrimmedAverage(results, DROPOUT) : null; return ( - - - - - Buttons vs Clickable stress tests + + + + {isRunning ? `Running ${currentRun}/${N}...` : 'Start test'} + + + + {results && ( + + + Runs: {results.length} (trimmed ±{DROPOUT}) - - Each comparison mounts {STRESS_ITEM_COUNT} items for both the - original button and the matching Clickable configuration, runs - {` ${STRESS_RUN_COUNT} `} - samples per side, and drops the {STRESS_TRIM_COUNT} fastest and - slowest runs before averaging. + + Trimmed avg: {trimmedAverage?.toFixed(2)} ms + + + Min: {Math.min(...results).toFixed(2)} ms + + + Max: {Math.max(...results).toFixed(2)} ms + + + All: {results.map((r) => r.toFixed(1)).join(', ')} ms + )} - {STRESS_SCENARIOS.map((scenario) => { - const scenarioResults = resultsByScenario[scenario.key]; - const referenceStats = getTrimmedStats( - scenarioResults.reference, - STRESS_TRIM_COUNT - ); - const clickableStats = getTrimmedStats( - scenarioResults.clickable, - STRESS_TRIM_COUNT - ); - const trimmedDelta = - referenceStats.trimmedAverage !== null && - clickableStats.trimmedAverage !== null - ? clickableStats.trimmedAverage - referenceStats.trimmedAverage - : null; - const isScenarioRunning = - activeBenchmark?.scenarioKey === scenario.key; - - return ( - - {scenario.title} - - {scenario.description} - - - startScenarioBenchmark(scenario.key)} - enabled={activeBenchmark === null} - underlayActiveOpacity={0.2} - underlayColor={COLORS.NAVY}> - - {isScenarioRunning - ? 'Benchmark running...' - : `Run ${scenario.title}`} - - - - - {statusByScenario[scenario.key]} - - - {(scenarioResults.reference.length > 0 || - scenarioResults.clickable.length > 0) && ( - - Results - - {scenario.referenceName}:{' '} - {scenarioResults.reference - .map((value) => value.toFixed(1)) - .join(', ') || '-'}{' '} - ms - - - {scenario.clickableName}:{' '} - {scenarioResults.clickable - .map((value) => value.toFixed(1)) - .join(', ') || '-'}{' '} - ms - - - {scenario.referenceName} avg:{' '} - {referenceStats.average?.toFixed(2) ?? '-'} ms - - - {scenario.referenceName} trimmed avg:{' '} - {referenceStats.trimmedAverage?.toFixed(2) ?? '-'} ms - - - {scenario.clickableName} avg:{' '} - {clickableStats.average?.toFixed(2) ?? '-'} ms - - - {scenario.clickableName} trimmed avg:{' '} - {clickableStats.trimmedAverage?.toFixed(2) ?? '-'} ms - - - Trimmed delta:{' '} - {trimmedDelta === null - ? '-' - : `${trimmedDelta.toFixed(2)} ms`} - - - )} - - {isScenarioRunning ? ( - - ) : ( - - - Active benchmark list renders here while{' '} - {scenario.title.toLowerCase()} is running. - - - )} - - ); - })} - - + {isRunning && ( + + )} + ); } const styles = StyleSheet.create({ container: { flex: 1, - }, - scrollContent: { - paddingBottom: 40, - }, - section: { padding: 20, alignItems: 'center', }, - screenHeader: { - fontSize: 18, - fontWeight: '700', - marginBottom: 4, - }, - sectionHeader: { - fontSize: 16, - fontWeight: 'bold', - marginBottom: 4, - }, - sectionDescription: { - textAlign: 'center', - color: '#4a5368', - marginTop: 4, - }, - benchmarkButton: { - width: 240, - minHeight: 52, - marginTop: 20, - borderRadius: 14, + startButton: { + width: 200, + height: 50, + backgroundColor: '#167a5f', + borderRadius: 10, alignItems: 'center', justifyContent: 'center', - backgroundColor: COLORS.GREEN, - paddingHorizontal: 16, }, - benchmarkButtonBusy: { - backgroundColor: COLORS.GRAY, + startButtonBusy: { + backgroundColor: '#7f879b', }, - benchmarkButtonText: { + startButtonText: { color: 'white', - fontSize: 14, - fontWeight: '700', - textAlign: 'center', - }, - benchmarkStatus: { - marginTop: 12, - textAlign: 'center', - color: COLORS.NAVY, - fontSize: 13, - }, - metricsCard: { - width: '100%', - marginTop: 16, - borderRadius: 16, - backgroundColor: '#eef3fb', - padding: 16, - gap: 8, - }, - metricsHeadline: { - fontSize: 15, fontWeight: '700', - color: COLORS.NAVY, - }, - metricsText: { - color: '#33415c', - fontSize: 13, }, - stressList: { - width: '100%', - height: 320, - marginTop: 16, - borderRadius: 16, - backgroundColor: '#f3f6fb', - }, - stressGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - padding: 12, - }, - stressButton: { - width: 96, - height: 42, + button: { + width: 200, + height: 50, + backgroundColor: 'lightblue', borderRadius: 10, alignItems: 'center', justifyContent: 'center', - backgroundColor: COLORS.PURPLE, - }, - rectButton: { - backgroundColor: COLORS.BLUE, - }, - borderlessButton: { - backgroundColor: COLORS.RED, - }, - stressButtonText: { - color: 'white', - fontSize: 12, - fontWeight: '600', }, - stressPlaceholder: { + results: { + marginTop: 20, + padding: 16, + borderRadius: 12, + backgroundColor: '#eef3fb', width: '100%', - height: 120, - marginTop: 16, - borderRadius: 16, - borderWidth: 1, - borderColor: '#d8dfec', - borderStyle: 'dashed', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 20, + gap: 6, }, - stressPlaceholderText: { - textAlign: 'center', - color: '#5b6478', + resultText: { + color: '#33415c', + fontSize: 13, }, }); From b74ce611d41e4648fdc35dfa5a4beec76415f5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 13:49:50 +0100 Subject: [PATCH 35/51] Deprecate old buttons --- .../src/v3/components/GestureButtons.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx index 13ed2291c7..40731dcd6a 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx @@ -22,6 +22,9 @@ export const RawButton = createNativeWrapper< shouldActivateOnStart: false, }); +/** + * @deprecated `BaseButton` is deprecated, use `Clickable` instead + */ export const BaseButton = (props: BaseButtonProps) => { const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -101,6 +104,9 @@ const btnStyles = StyleSheet.create({ }, }); +/** + * @deprecated `RectButton` is deprecated, use `Clickable` with `underlayInitialOpacity={0.7}` instead + */ export const RectButton = (props: RectButtonProps) => { const activeOpacity = props.activeOpacity ?? 0.105; const underlayColor = props.underlayColor ?? 'black'; @@ -143,6 +149,9 @@ export const RectButton = (props: RectButtonProps) => { ); }; +/** + * @deprecated `BorderlessButton` is deprecated, use `Clickable` with `activeOpacity={0.3}` instead + */ export const BorderlessButton = (props: BorderlessButtonProps) => { const activeOpacity = props.activeOpacity ?? 0.3; const opacity = useRef(new Animated.Value(1)).current; From d422f332905029cc91aa8307540b228d50efd5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 14:00:28 +0100 Subject: [PATCH 36/51] Update SKILL --- skills/gesture-handler-3-migration/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/gesture-handler-3-migration/SKILL.md b/skills/gesture-handler-3-migration/SKILL.md index 888238fe2e..45e3bc5c4c 100644 --- a/skills/gesture-handler-3-migration/SKILL.md +++ b/skills/gesture-handler-3-migration/SKILL.md @@ -169,6 +169,8 @@ The implementation of buttons has been updated, resolving most button-related is `PureNativeButton` has been removed. If encountered, inform the user that it has been removed and let them decide how to handle that case. They can achieve similar functionality with other buttons. +When migrating buttons, you should use `Clickable` component instead. To replace `BaseButton` use `Clickable` with default props, to replace `RectButton` use `Clickable` with `underlayInitialOpacity={0.7}` and to replace `BorderlessButton` use `Clickable` with `activeOpacity={0.3}`. + Other components have also been internally rewritten using the new hook API but are exported under their original names, so no changes are necessary on your part. However, if you need to use the previous implementation for any reason, the legacy components are also available and are prefixed with `Legacy`, e.g., `ScrollView` is now available as `LegacyScrollView`. Rename all instances of createNativeWrapper to legacy_createNativeWrapper. This includes both the import statements and the function calls. From 822ba83b6dfd09227d0c5bb37dc38609cdd734fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 14:34:51 +0100 Subject: [PATCH 37/51] Add onPressIn and onPressOut --- .../new_api/components/clickable/index.tsx | 2 ++ .../src/v3/components/Clickable/Clickable.tsx | 33 ++++++++++++++----- .../v3/components/Clickable/ClickableProps.ts | 10 ++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index e47c2e09be..546ba8e590 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -30,8 +30,10 @@ function ClickableWrapper({ name, color, ...rest }: ButtonWrapperProps) { return ( console.log(`[${name}] onPressIn`)} onPress={() => console.log(`[${name}] onPress`)} onLongPress={() => console.log(`[${name}] onLongPress`)} + onPressOut={() => console.log(`[${name}] onPressOut`)} {...rest}> {name} diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index c7c1029654..e8c5915230 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -18,6 +18,8 @@ export const Clickable = (props: ClickableProps) => { delayLongPress = 600, onLongPress, onPress, + onPressIn, + onPressOut, onActiveStateChange, style, children, @@ -56,7 +58,13 @@ export const Clickable = (props: ClickableProps) => { const onBegin = useCallback( (e: CallbackEventType) => { - if (isAndroid && e.pointerInside) { + if (!isAndroid) { + return; + } + + onPressIn?.(e); + + if (e.pointerInside) { startLongPressTimer(); if (shouldUseJSAnimation) { @@ -64,19 +72,23 @@ export const Clickable = (props: ClickableProps) => { } } }, - [startLongPressTimer, shouldUseJSAnimation, animatedValue] + [startLongPressTimer, shouldUseJSAnimation, animatedValue, onPressIn] ); const onActivate = useCallback( (e: CallbackEventType) => { onActiveStateChange?.(true); - if (shouldUseJSAnimation && !isAndroid) { - animatedValue.setValue(1); - } + if (!isAndroid) { + onPressIn?.(e); - if (!isAndroid && e.pointerInside) { - startLongPressTimer(); + if (e.pointerInside) { + startLongPressTimer(); + + if (shouldUseJSAnimation) { + animatedValue.setValue(1); + } + } } if (!e.pointerInside && longPressTimeout.current !== undefined) { @@ -89,6 +101,7 @@ export const Clickable = (props: ClickableProps) => { shouldUseJSAnimation, animatedValue, startLongPressTimer, + onPressIn, ] ); @@ -104,17 +117,19 @@ export const Clickable = (props: ClickableProps) => { ); const onFinalize = useCallback( - (_e: CallbackEventType) => { + (e: CallbackEventType) => { if (shouldUseJSAnimation) { animatedValue.setValue(0); } + onPressOut?.(e); + if (longPressTimeout.current !== undefined) { clearTimeout(longPressTimeout.current); longPressTimeout.current = undefined; } }, - [shouldUseJSAnimation, animatedValue] + [shouldUseJSAnimation, animatedValue, onPressOut] ); const underlayAnimatedStyle = useMemo(() => { diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 4527a00252..71500e52d8 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -46,4 +46,14 @@ export interface ClickableProps extends Omit { * Configuration for the ripple effect on Android. */ androidRipple?: PressableAndroidRippleConfig | undefined; + + /** + * Called when pointer touches the component. + */ + onPressIn?: ((event: CallbackEventType) => void) | undefined; + + /** + * Called when pointer is released from the component. + */ + onPressOut?: ((event: CallbackEventType) => void) | undefined; } From ff74f2b806055b626d8faf0dddcfe770d26189f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 12 Mar 2026 16:39:31 +0100 Subject: [PATCH 38/51] Docs --- .../docs/components/buttons.mdx | 4 + .../docs/components/clickable.mdx | 239 ++++++++++++++++++ .../docs/guides/upgrading-to-3.mdx | 10 +- 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/docs-gesture-handler/docs/components/clickable.mdx diff --git a/packages/docs-gesture-handler/docs/components/buttons.mdx b/packages/docs-gesture-handler/docs/components/buttons.mdx index 041d387fbe..7b895a26f5 100644 --- a/packages/docs-gesture-handler/docs/components/buttons.mdx +++ b/packages/docs-gesture-handler/docs/components/buttons.mdx @@ -9,6 +9,10 @@ import GifGallery from '@site/components/GifGallery'; import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; +:::danger +Button components described in this section are deprecated and will be removed in the future. Please use [`Clickable`](/docs/components/clickable) instead. +::: + diff --git a/packages/docs-gesture-handler/docs/components/clickable.mdx b/packages/docs-gesture-handler/docs/components/clickable.mdx new file mode 100644 index 0000000000..82c577b57b --- /dev/null +++ b/packages/docs-gesture-handler/docs/components/clickable.mdx @@ -0,0 +1,239 @@ +--- +id: clickable +title: Clickable +sidebar_label: Clickable +--- + +import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; + +`Clickable` is a new component introduced in Gesture Handler 3, designed to replace the previous button components. It provides a more flexible and customizable way to create buttons with native touch handling. + +With `Clickable`, you can decide whether to animate whole component, or just the underlay. This allows to easily recreate both `RectButton` and `BorderlessButton` effects with the same component. + +It also provides consistent behavior across platforms, and resolves most of the button-related issues that were present in previous versions. On android, you can decide whether to use native ripple effect, or JS based animation with Animated API + +## Replacing old buttons + +If you were using `RectButton`, or `BorderlessButton` in your app, you can easily replace them with `Clickable`. Check out full code in [example](#example) section below. + +### RectButton + +To replace `RectButton` with `Clickable`, simply add `underlayActiveOpacity={0.105}` to your `Clickable` component. This will animate the underlay when the button is pressed. + +```tsx + +``` + +### BorderlessButton + +Replacing `BorderlessButton` with `Clickable` is as easy as replacing `RectButton`. Just add `activeOpacity={0.3}` to your `Clickable` component. This will animate the whole component when the button is pressed. + +```tsx + +``` + +## Example + +In this example we will demonstrate how to recreate `RectButton` and `BorderlessButton` effects using `Clickable` component. + + + { + console.log('BaseButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#7d63d9' }]}> + BaseButton + + + { + console.log('RectButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#4f9a84' }]} + underlayActiveOpacity={0.105}> + RectButton + + + { + console.log('BorderlessButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#5f97c8' }]} + activeOpacity={0.3}> + BorderlessButton + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + + gap: 20, + }, + button: { + width: 200, + height: 70, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, +}); +`}/> + + +## Properties + +### exclusive + +```ts +exclusive?: boolean; +``` + +Defines if more than one button could be pressed simultaneously. By default set to `true`. + + +### touchSoundDisabled + + +```ts +touchSoundDisabled?: boolean; +``` + +If set to `true`, the system will not play a sound when the button is pressed. + +### onPressIn + +```ts +onPressIn?: (pointerInside: boolean) => void; +``` + +Triggered when the button gets pressed (analogous to `onPressIn` in `TouchableHighlight` from RN core). + +### onPressOut + +```ts +onPressOut?: (pointerInside: boolean) => void; +``` + +Triggered when the button gets released (analogous to `onPressOut` in `TouchableHighlight` from RN core). + +### onPress + +```ts +onPress?: (pointerInside: boolean) => void; +``` + +Triggered when the button gets pressed (analogous to `onPress` in `TouchableHighlight` from RN core). + +### onLongPress + +```ts +onLongPress?: () => void; +``` + +Triggered when the button gets pressed for at least [`delayLongPress`](#delaylongpress) milliseconds. + + +### onActiveStateChange + +```ts +onActiveStateChange?: (active: boolean) => void; +``` + +Triggered when the button transitions between active and inactive states. It passes the current active state as a boolean variable to the method as the first parameter. + +### delayLongPress + +```ts +delayLongPress?: number; +``` + +Defines the delay, in milliseconds, after which the [`onLongPress`](#onlongpress) callback gets called. By default set to `600`. + +### underlayColor + +```ts +underlayColor?: string; +``` + +Background color of underlay. + +### underlayActiveOpacity + +```ts +underlayActiveOpacity?: number; +``` + +Defines the opacity of underlay when the button is active. + +### activeOpacity + +```ts +activeOpacity?: number; +``` + +Defines the opacity of the whole component when the button is active. + +### underlayInitialOpacity + +```ts +underlayInitialOpacity?: number; +``` + +Defines the initial opacity of underlay when the button is inactive. By default set to `0`. + +### initialOpacity + +```ts +initialOpacity?: number; +``` + +Defines the initial opacity of the whole component when the button is inactive. By default set to `1` + + +### androidRipple + + + + +Configuration for the ripple effect on Android. If not provided, the ripple effect will be disabled. If `{}` is provided, the ripple effect will be enabled with default configuration. diff --git a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx index e5b0812531..45c77c9527 100644 --- a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx +++ b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx @@ -246,7 +246,15 @@ code2={ ### Buttons -The implementation of buttons has been updated, resolving most button-related issues. They have also been internally rewritten to utilize the new hook API. The original button components are still accessible but have been renamed with the prefix `Legacy`, e.g., `RectButton` is now available as `LegacyRectButton`. +RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. + +To help you migrate, here is the current state of our button components: + +- [`Clickable`](/docs/components/clickable) - The recommended component for all new development. + +- Standard Buttons (Deprecated) - `BaseButton`, `RectButton` and `BorderlessButton` are still available but are now deprecated. They have been internally rewritten using the new Hooks API to resolve long-standing issues. + +- Legacy Buttons (Deprecated): The original, pre-rewrite versions are still accessible for backward compatibility but have been renamed with a `Legacy` prefix (e.g., `LegacyRectButton`). Although the legacy JS implementation of the buttons is still available, they also use the new host component internally. Because of that, `PureNativeButton` is no longer available in Gesture Handler 3. From c9aba4d007192a3534e37384f3c491a60d4009fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 13 Mar 2026 12:12:04 +0100 Subject: [PATCH 39/51] Revert changes in button --- .../src/components/GestureHandlerButton.tsx | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 3c34d651ae..178293b68a 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -105,10 +105,7 @@ export const ButtonComponent = RNGestureHandlerButtonNativeComponent as HostComponent; export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { - const flattenedStyle = useMemo( - () => StyleSheet.flatten(style) ?? {}, - [style] - ); + const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); const { // Layout properties @@ -160,18 +157,6 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { end, overflow, - // Native button visual properties - backgroundColor, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderWidth, - borderColor, - borderStyle, - opacity, - // Visual properties ...restStyle } = flattenedStyle; @@ -225,23 +210,7 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { left, start, end, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [flattenedStyle] - ); - - const buttonStyle = useMemo( - () => ({ - backgroundColor, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderWidth, - borderColor, - borderStyle, - opacity, + overflow, }), // eslint-disable-next-line react-hooks/exhaustive-deps [flattenedStyle] @@ -256,7 +225,7 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { (!overflow || overflow === 'hidden') && styles.overflowHidden, restStyle, ]}> - + ); From 78d427369e737af759dd1ba498bebf037852efd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 13 Mar 2026 13:39:31 +0100 Subject: [PATCH 40/51] update SKILL --- skills/gesture-handler-3-migration/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gesture-handler-3-migration/SKILL.md b/skills/gesture-handler-3-migration/SKILL.md index 45e3bc5c4c..63f1bf94c9 100644 --- a/skills/gesture-handler-3-migration/SKILL.md +++ b/skills/gesture-handler-3-migration/SKILL.md @@ -169,7 +169,7 @@ The implementation of buttons has been updated, resolving most button-related is `PureNativeButton` has been removed. If encountered, inform the user that it has been removed and let them decide how to handle that case. They can achieve similar functionality with other buttons. -When migrating buttons, you should use `Clickable` component instead. To replace `BaseButton` use `Clickable` with default props, to replace `RectButton` use `Clickable` with `underlayInitialOpacity={0.7}` and to replace `BorderlessButton` use `Clickable` with `activeOpacity={0.3}`. +When migrating buttons, you should use `Clickable` component instead. To replace `BaseButton` use `Clickable` with default props, to replace `RectButton` use `Clickable` with `underlayActiveOpacity={0.105}` and to replace `BorderlessButton` use `Clickable` with `activeOpacity={0.3}`. Other components have also been internally rewritten using the new hook API but are exported under their original names, so no changes are necessary on your part. However, if you need to use the previous implementation for any reason, the legacy components are also available and are prefixed with `Legacy`, e.g., `ScrollView` is now available as `LegacyScrollView`. From 050f122203e43d3b926a2c33536adec34b0cbac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 11:15:10 +0100 Subject: [PATCH 41/51] Fix deprecation message for `RectButton` to reference `underlayActiveOpacity` --- .../src/v3/components/GestureButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx index 40731dcd6a..c8cbd0f710 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx @@ -105,7 +105,7 @@ const btnStyles = StyleSheet.create({ }); /** - * @deprecated `RectButton` is deprecated, use `Clickable` with `underlayInitialOpacity={0.7}` instead + * @deprecated `RectButton` is deprecated, use `Clickable` with `underlayActiveOpacity={0.7}` instead */ export const RectButton = (props: RectButtonProps) => { const activeOpacity = props.activeOpacity ?? 0.105; From 270b1116d9044f466ba5e61d81676fb56934dd36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 11:43:07 +0100 Subject: [PATCH 42/51] Call onPressIn when e.pointerInsideis true --- .../src/v3/components/Clickable/Clickable.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index e8c5915230..2651f7c550 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -58,18 +58,15 @@ export const Clickable = (props: ClickableProps) => { const onBegin = useCallback( (e: CallbackEventType) => { - if (!isAndroid) { + if (!isAndroid || !e.pointerInside) { return; } onPressIn?.(e); + startLongPressTimer(); - if (e.pointerInside) { - startLongPressTimer(); - - if (shouldUseJSAnimation) { - animatedValue.setValue(1); - } + if (shouldUseJSAnimation) { + animatedValue.setValue(1); } }, [startLongPressTimer, shouldUseJSAnimation, animatedValue, onPressIn] @@ -80,9 +77,8 @@ export const Clickable = (props: ClickableProps) => { onActiveStateChange?.(true); if (!isAndroid) { - onPressIn?.(e); - if (e.pointerInside) { + onPressIn?.(e); startLongPressTimer(); if (shouldUseJSAnimation) { From 77d411df650e0e547e8fa4bcc062f16b8f86c710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:49:46 +0100 Subject: [PATCH 43/51] Stress test example timeout cleanup Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/clickable_stress/index.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/common-app/src/new_api/components/clickable_stress/index.tsx b/apps/common-app/src/new_api/components/clickable_stress/index.tsx index 9cf0c835a9..47d876ade1 100644 --- a/apps/common-app/src/new_api/components/clickable_stress/index.tsx +++ b/apps/common-app/src/new_api/components/clickable_stress/index.tsx @@ -1,4 +1,4 @@ -import { Profiler, useCallback, useRef, useState } from 'react'; +import { Profiler, useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Clickable, ScrollView } from 'react-native-gesture-handler'; @@ -70,6 +70,7 @@ function ClickableList({ run, onMountDuration }: ClickableListProps) { export default function ClickableStress() { const [state, setState] = useState({ phase: 'idle' }); const resultsRef = useRef([]); + const timeoutRef = useRef | null>(null); const start = useCallback(() => { resultsRef.current = []; @@ -87,11 +88,23 @@ export default function ClickableStress() { // Unmount then remount for next run setState({ phase: 'idle' }); - setTimeout(() => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { setState({ phase: 'running', run: currentRun + 1 }); }, 50); }, []); + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + const isRunning = state.phase === 'running'; const currentRun = state.phase === 'running' ? state.run : 0; const results = state.phase === 'done' ? state.results : null; From d80e5cf0eec0c2100bd9710cd40fc3c7112e48e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 12:08:46 +0100 Subject: [PATCH 44/51] Colooooooors --- apps/common-app/src/common.tsx | 6 +++++ .../new_api/components/clickable/index.tsx | 26 +++++-------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/common-app/src/common.tsx b/apps/common-app/src/common.tsx index b8c35a81c4..0196f52109 100644 --- a/apps/common-app/src/common.tsx +++ b/apps/common-app/src/common.tsx @@ -36,15 +36,21 @@ export const COLORS = { offWhite: '#f8f9ff', headerSeparator: '#eef0ff', PURPLE: '#b58df1', + DARK_PURPLE: '#7d63d9', NAVY: '#001A72', RED: '#A41623', YELLOW: '#F2AF29', GREEN: '#0F956F', + DARK_GREEN: '#217838', GRAY: '#ADB1C2', KINDA_RED: '#FFB2AD', + DARK_SALMON: '#d97973', KINDA_YELLOW: '#FFF096', KINDA_GREEN: '#C4E7DB', KINDA_BLUE: '#A0D5EF', + LIGHT_BLUE: '#5f97c8', + WEB_BLUE: '#1067c4', + ANDROID: '#34a853', }; /* eslint-disable react-native/no-unused-styles */ diff --git a/apps/common-app/src/new_api/components/clickable/index.tsx b/apps/common-app/src/new_api/components/clickable/index.tsx index 546ba8e590..4b52cde8f6 100644 --- a/apps/common-app/src/new_api/components/clickable/index.tsx +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -5,27 +5,13 @@ import { Clickable, ClickableProps, } from 'react-native-gesture-handler'; +import { COLORS } from '../../../common'; type ButtonWrapperProps = ClickableProps & { name: string; color: string; }; -export const COLORS = { - PURPLE: '#7d63d9', - NAVY: '#17327a', - RED: '#b53645', - YELLOW: '#c98d1f', - GREEN: '#167a5f', - GRAY: '#7f879b', - KINDA_RED: '#d97973', - KINDA_YELLOW: '#d6b24a', - KINDA_GREEN: '#4f9a84', - KINDA_BLUE: '#5f97c8', - ANDROID: '#34a853', - WEB: '#1067c4', -}; - function ClickableWrapper({ name, color, ...rest }: ButtonWrapperProps) { return ( New component that replaces all buttons and pressables. - + @@ -81,7 +67,7 @@ export default function ClickableExample() { color={COLORS.NAVY} underlayInitialOpacity={0.7} underlayActiveOpacity={0.5} - underlayColor="#217838" + underlayColor={COLORS.DARK_GREEN} /> @@ -90,14 +76,14 @@ export default function ClickableExample() { From 95a887f992ed33563f5886c5954506a60f8c1735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 12:14:24 +0100 Subject: [PATCH 45/51] Update underlayColor description to require underlayActiveOpacity --- .../src/v3/components/Clickable/ClickableProps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts index 71500e52d8..f837742acb 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -16,7 +16,7 @@ type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; export interface ClickableProps extends Omit { /** - * Background color of underlay. Works only when `animationTarget` is set to `UNDERLAY`. + * Background color of underlay. Requires `underlayActiveOpacity` to be set. */ underlayColor?: string | undefined; From 25126cf89e5b115190e0e79200fad002d6cc9228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 12:16:21 +0100 Subject: [PATCH 46/51] Add default values for underlayInitialOpacity and initialOpacity in Clickable component --- .../src/v3/components/Clickable/Clickable.tsx | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 2651f7c550..1da6682bd3 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -10,9 +10,9 @@ const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; export const Clickable = (props: ClickableProps) => { const { underlayColor, - underlayInitialOpacity, + underlayInitialOpacity = 0, underlayActiveOpacity, - initialOpacity, + initialOpacity = 1, activeOpacity, androidRipple, delayLongPress = 600, @@ -29,9 +29,6 @@ export const Clickable = (props: ClickableProps) => { const animatedValue = useRef(new Animated.Value(0)).current; - const underlayStartOpacity = underlayInitialOpacity ?? 0; - const componentStartOpacity = initialOpacity ?? 1; - const shouldAnimateUnderlay = underlayActiveOpacity !== undefined; const shouldAnimateComponent = activeOpacity !== undefined; @@ -136,7 +133,7 @@ export const Clickable = (props: ClickableProps) => { return { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [underlayStartOpacity, underlayActiveOpacity], + outputRange: [underlayInitialOpacity, underlayActiveOpacity], }), backgroundColor: underlayColor ?? 'black', borderRadius: resolvedStyle.borderRadius, @@ -148,7 +145,7 @@ export const Clickable = (props: ClickableProps) => { }, [ shouldAnimateUnderlay, style, - underlayStartOpacity, + underlayInitialOpacity, underlayActiveOpacity, underlayColor, animatedValue, @@ -160,16 +157,11 @@ export const Clickable = (props: ClickableProps) => { ? { opacity: animatedValue.interpolate({ inputRange: [0, 1], - outputRange: [componentStartOpacity, activeOpacity], + outputRange: [initialOpacity, activeOpacity], }), } : undefined, - [ - shouldAnimateComponent, - activeOpacity, - animatedValue, - componentStartOpacity, - ] + [shouldAnimateComponent, activeOpacity, animatedValue, initialOpacity] ); const rippleProps = shouldUseNativeRipple From 45ffb2f23a0907efc207d4034cef1ddfc8beacef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 12:28:33 +0100 Subject: [PATCH 47/51] Remove unnecessary border styles --- .../src/v3/components/Clickable/Clickable.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx index 1da6682bd3..e8fbfdea8f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -126,22 +126,15 @@ export const Clickable = (props: ClickableProps) => { ); const underlayAnimatedStyle = useMemo(() => { - if (!shouldAnimateUnderlay) { - return undefined; - } - const resolvedStyle = StyleSheet.flatten(style ?? {}); - return { - opacity: animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: [underlayInitialOpacity, underlayActiveOpacity], - }), - backgroundColor: underlayColor ?? 'black', - borderRadius: resolvedStyle.borderRadius, - borderTopLeftRadius: resolvedStyle.borderTopLeftRadius, - borderTopRightRadius: resolvedStyle.borderTopRightRadius, - borderBottomLeftRadius: resolvedStyle.borderBottomLeftRadius, - borderBottomRightRadius: resolvedStyle.borderBottomRightRadius, - }; + return shouldAnimateUnderlay + ? { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [underlayInitialOpacity, underlayActiveOpacity], + }), + backgroundColor: underlayColor ?? 'black', + } + : undefined; }, [ shouldAnimateUnderlay, style, From a3b2c87d9593667abb41bb9714010665dfcdba0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 14:25:26 +0100 Subject: [PATCH 48/51] Minor adjustments --- .../docs-gesture-handler/docs/guides/upgrading-to-3.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx index 45c77c9527..16518df37d 100644 --- a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx +++ b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx @@ -246,15 +246,15 @@ code2={ ### Buttons -RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. +RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. To help you migrate, here is the current state of our button components: -- [`Clickable`](/docs/components/clickable) - The recommended component for all new development. +- [`Clickable`](/docs/components/clickable) - The recommended component. - Standard Buttons (Deprecated) - `BaseButton`, `RectButton` and `BorderlessButton` are still available but are now deprecated. They have been internally rewritten using the new Hooks API to resolve long-standing issues. -- Legacy Buttons (Deprecated): The original, pre-rewrite versions are still accessible for backward compatibility but have been renamed with a `Legacy` prefix (e.g., `LegacyRectButton`). +- Legacy Buttons (Deprecated): The original, pre-rewrite versions are still accessible, but have been renamed with a `Legacy` prefix (e.g., `LegacyRectButton`). Although the legacy JS implementation of the buttons is still available, they also use the new host component internally. Because of that, `PureNativeButton` is no longer available in Gesture Handler 3. From 90197cfb5f4192e8d709abfa9ef15478dd26e752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 14:35:33 +0100 Subject: [PATCH 49/51] Update onPressIn and onPressOut type definitions to include GestureEvent details --- .../docs/components/clickable.mdx | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/clickable.mdx b/packages/docs-gesture-handler/docs/components/clickable.mdx index 82c577b57b..a58e2f5ac9 100644 --- a/packages/docs-gesture-handler/docs/components/clickable.mdx +++ b/packages/docs-gesture-handler/docs/components/clickable.mdx @@ -130,17 +130,55 @@ If set to `true`, the system will not play a sound when the button is pressed. ### onPressIn -```ts -onPressIn?: (pointerInside: boolean) => void; -``` +) => void; + +type GestureEvent = { + handlerTag: number; + numberOfPointers: number; + pointerType: PointerType; + pointerInside: boolean; +} + +enum PointerType { + TOUCH, + STYLUS, + MOUSE, + KEY, + OTHER, +} +`}/> Triggered when the button gets pressed (analogous to `onPressIn` in `TouchableHighlight` from RN core). ### onPressOut -```ts -onPressOut?: (pointerInside: boolean) => void; -``` +) => void; + +type GestureEvent = { + handlerTag: number; + numberOfPointers: number; + pointerType: PointerType; + pointerInside: boolean; +} + +enum PointerType { + TOUCH, + STYLUS, + MOUSE, + KEY, + OTHER, +} +`}/> Triggered when the button gets released (analogous to `onPressOut` in `TouchableHighlight` from RN core). From bce2aa58797c32cba3cfd4ca3d80f6883ebf9c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 14:45:39 +0100 Subject: [PATCH 50/51] rephrase --- packages/docs-gesture-handler/docs/components/clickable.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/clickable.mdx b/packages/docs-gesture-handler/docs/components/clickable.mdx index a58e2f5ac9..f19fa52d37 100644 --- a/packages/docs-gesture-handler/docs/components/clickable.mdx +++ b/packages/docs-gesture-handler/docs/components/clickable.mdx @@ -6,11 +6,9 @@ sidebar_label: Clickable import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; -`Clickable` is a new component introduced in Gesture Handler 3, designed to replace the previous button components. It provides a more flexible and customizable way to create buttons with native touch handling. +`Clickable` is a versatile new component introduced in Gesture Handler 3 to succeed previous button implementations. Designed for maximum flexibility, it provides a highly customizable interface for native touch handling while ensuring consistent behavior across platforms. -With `Clickable`, you can decide whether to animate whole component, or just the underlay. This allows to easily recreate both `RectButton` and `BorderlessButton` effects with the same component. - -It also provides consistent behavior across platforms, and resolves most of the button-related issues that were present in previous versions. On android, you can decide whether to use native ripple effect, or JS based animation with Animated API +With `Clickable`, you have more control over animations. It is possible to choose between animating the entire component or just the underlay, along with specifying desired `opacity` values. This allows you to effortlessly replicate both `RectButton` and `BorderlessButton` effects using a single unified component. Furthermore, it resolves many legacy issues found in earlier versions. On Android, you can opt for the native ripple effect or leverage JS-based animations via the [Animated API](https://reactnative.dev/docs/animated). ## Replacing old buttons From cad279b7fedef0cd8e675879f2f39e6b878680e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 16 Mar 2026 15:03:29 +0100 Subject: [PATCH 51/51] Copilot review --- packages/docs-gesture-handler/docs/components/clickable.mdx | 4 ++-- packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/clickable.mdx b/packages/docs-gesture-handler/docs/components/clickable.mdx index f19fa52d37..3983be883d 100644 --- a/packages/docs-gesture-handler/docs/components/clickable.mdx +++ b/packages/docs-gesture-handler/docs/components/clickable.mdx @@ -219,7 +219,7 @@ Defines the delay, in milliseconds, after which the [`onLongPress`](#onlongpress underlayColor?: string; ``` -Background color of underlay. +Background color of underlay. This only takes effect when `underlayActiveOpacity` is set. ### underlayActiveOpacity @@ -227,7 +227,7 @@ Background color of underlay. underlayActiveOpacity?: number; ``` -Defines the opacity of underlay when the button is active. +Defines the opacity of underlay when the button is active. If not set, underlay won't be rendered. ### activeOpacity diff --git a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx index 16518df37d..fe0fa85d3a 100644 --- a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx +++ b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx @@ -246,7 +246,7 @@ code2={ ### Buttons -RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. +RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. To help you migrate, here is the current state of our button components: