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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 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 36/44] 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 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 37/44] 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 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 38/44] 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 39/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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,