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 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 new file mode 100644 index 0000000000..4b52cde8f6 --- /dev/null +++ b/apps/common-app/src/new_api/components/clickable/index.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { StyleSheet, Text, View, ScrollView } from 'react-native'; +import { + GestureHandlerRootView, + Clickable, + ClickableProps, +} from 'react-native-gesture-handler'; +import { COLORS } from '../../../common'; + +type ButtonWrapperProps = ClickableProps & { + name: string; + color: string; +}; + +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} + + ); +} + +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. + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + section: { + padding: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + 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: 110, + height: 50, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, +}); 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..47d876ade1 --- /dev/null +++ b/apps/common-app/src/new_api/components/clickable_stress/index.tsx @@ -0,0 +1,193 @@ +import { Profiler, useCallback, useEffect, 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 trimmed = + trimCount > 0 ? sorted.slice(trimCount, sorted.length - trimCount) : sorted; + return trimmed.reduce((sum, v) => sum + v, 0) / trimmed.length; +} + +type ClickableListProps = { + run: number; + onMountDuration: (duration: number) => void; +}; + +function ClickableList({ run, onMountDuration }: ClickableListProps) { + const reportedRef = useRef(-1); + + 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((id) => ( + // + + + // + // + + // + // + ))} + + + ); +} + +export default function ClickableStress() { + const [state, setState] = useState({ phase: 'idle' }); + const resultsRef = useRef([]); + const timeoutRef = useRef | null>(null); + + const start = useCallback(() => { + resultsRef.current = []; + setState({ phase: 'running', run: 1 }); + }, []); + + const handleMountDuration = useCallback((duration: number) => { + resultsRef.current = [...resultsRef.current, duration]; + const currentRun = resultsRef.current.length; + + if (currentRun >= N) { + setState({ phase: 'done', results: resultsRef.current }); + return; + } + + // Unmount then remount for next run + setState({ phase: 'idle' }); + 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; + const trimmedAverage = results ? getTrimmedAverage(results, DROPOUT) : null; + + return ( + + + + {isRunning ? `Running ${currentRun}/${N}...` : 'Start test'} + + + + {results && ( + + + Runs: {results.length} (trimmed ±{DROPOUT}) + + + 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 + + + )} + + {isRunning && ( + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + alignItems: 'center', + }, + startButton: { + width: 200, + height: 50, + backgroundColor: '#167a5f', + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + startButtonBusy: { + backgroundColor: '#7f879b', + }, + startButtonText: { + color: 'white', + fontWeight: '700', + }, + button: { + width: 200, + height: 50, + backgroundColor: 'lightblue', + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + results: { + marginTop: 20, + padding: 16, + borderRadius: 12, + backgroundColor: '#eef3fb', + width: '100%', + gap: 6, + }, + resultText: { + color: '#33415c', + fontSize: 13, + }, +}); diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 3c706cf91b..602cb6ceab 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -27,6 +27,8 @@ import RotationExample from './simple/rotation'; 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'; @@ -105,6 +107,8 @@ 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: 'Clickable stress test', component: ClickableStressExample }, { name: 'Switch & TextInput', component: SwitchTextInputExample }, { name: 'Reanimated Swipeable', component: Swipeable }, { name: 'Reanimated Drawer Layout', component: ReanimatedDrawerLayout }, diff --git a/packages/docs-gesture-handler/docs/components/buttons.mdx b/packages/docs-gesture-handler/docs/components/buttons.mdx index 041d387fbe..7b895a26f5 100644 --- a/packages/docs-gesture-handler/docs/components/buttons.mdx +++ b/packages/docs-gesture-handler/docs/components/buttons.mdx @@ -9,6 +9,10 @@ import GifGallery from '@site/components/GifGallery'; import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; +:::danger +Button components described in this section are deprecated and will be removed in the future. Please use [`Clickable`](/docs/components/clickable) instead. +::: + diff --git a/packages/docs-gesture-handler/docs/components/clickable.mdx b/packages/docs-gesture-handler/docs/components/clickable.mdx new file mode 100644 index 0000000000..3983be883d --- /dev/null +++ b/packages/docs-gesture-handler/docs/components/clickable.mdx @@ -0,0 +1,275 @@ +--- +id: clickable +title: Clickable +sidebar_label: Clickable +--- + +import HeaderWithBadges from '@site/src/components/HeaderWithBadges'; + +`Clickable` is a versatile new component introduced in Gesture Handler 3 to succeed previous button implementations. Designed for maximum flexibility, it provides a highly customizable interface for native touch handling while ensuring consistent behavior across platforms. + +With `Clickable`, you have more control over animations. It is possible to choose between animating the entire component or just the underlay, along with specifying desired `opacity` values. This allows you to effortlessly replicate both `RectButton` and `BorderlessButton` effects using a single unified component. Furthermore, it resolves many legacy issues found in earlier versions. On Android, you can opt for the native ripple effect or leverage JS-based animations via the [Animated API](https://reactnative.dev/docs/animated). + +## Replacing old buttons + +If you were using `RectButton`, or `BorderlessButton` in your app, you can easily replace them with `Clickable`. Check out full code in [example](#example) section below. + +### RectButton + +To replace `RectButton` with `Clickable`, simply add `underlayActiveOpacity={0.105}` to your `Clickable` component. This will animate the underlay when the button is pressed. + +```tsx + +``` + +### BorderlessButton + +Replacing `BorderlessButton` with `Clickable` is as easy as replacing `RectButton`. Just add `activeOpacity={0.3}` to your `Clickable` component. This will animate the whole component when the button is pressed. + +```tsx + +``` + +## Example + +In this example we will demonstrate how to recreate `RectButton` and `BorderlessButton` effects using `Clickable` component. + + + { + console.log('BaseButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#7d63d9' }]}> + BaseButton + + + { + console.log('RectButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#4f9a84' }]} + underlayActiveOpacity={0.105}> + RectButton + + + { + console.log('BorderlessButton built with Clickable'); + }} + style={[styles.button, { backgroundColor: '#5f97c8' }]} + activeOpacity={0.3}> + BorderlessButton + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + + gap: 20, + }, + button: { + width: 200, + height: 70, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, +}); +`}/> + + +## Properties + +### exclusive + +```ts +exclusive?: boolean; +``` + +Defines if more than one button could be pressed simultaneously. By default set to `true`. + + +### touchSoundDisabled + + +```ts +touchSoundDisabled?: boolean; +``` + +If set to `true`, the system will not play a sound when the button is pressed. + +### onPressIn + +) => void; + +type GestureEvent = { + handlerTag: number; + numberOfPointers: number; + pointerType: PointerType; + pointerInside: boolean; +} + +enum PointerType { + TOUCH, + STYLUS, + MOUSE, + KEY, + OTHER, +} +`}/> + +Triggered when the button gets pressed (analogous to `onPressIn` in `TouchableHighlight` from RN core). + +### onPressOut + +) => void; + +type GestureEvent = { + handlerTag: number; + numberOfPointers: number; + pointerType: PointerType; + pointerInside: boolean; +} + +enum PointerType { + TOUCH, + STYLUS, + MOUSE, + KEY, + OTHER, +} +`}/> + +Triggered when the button gets released (analogous to `onPressOut` in `TouchableHighlight` from RN core). + +### onPress + +```ts +onPress?: (pointerInside: boolean) => void; +``` + +Triggered when the button gets pressed (analogous to `onPress` in `TouchableHighlight` from RN core). + +### onLongPress + +```ts +onLongPress?: () => void; +``` + +Triggered when the button gets pressed for at least [`delayLongPress`](#delaylongpress) milliseconds. + + +### onActiveStateChange + +```ts +onActiveStateChange?: (active: boolean) => void; +``` + +Triggered when the button transitions between active and inactive states. It passes the current active state as a boolean variable to the method as the first parameter. + +### delayLongPress + +```ts +delayLongPress?: number; +``` + +Defines the delay, in milliseconds, after which the [`onLongPress`](#onlongpress) callback gets called. By default set to `600`. + +### underlayColor + +```ts +underlayColor?: string; +``` + +Background color of underlay. This only takes effect when `underlayActiveOpacity` is set. + +### underlayActiveOpacity + +```ts +underlayActiveOpacity?: number; +``` + +Defines the opacity of underlay when the button is active. If not set, underlay won't be rendered. + +### activeOpacity + +```ts +activeOpacity?: number; +``` + +Defines the opacity of the whole component when the button is active. + +### underlayInitialOpacity + +```ts +underlayInitialOpacity?: number; +``` + +Defines the initial opacity of underlay when the button is inactive. By default set to `0`. + +### initialOpacity + +```ts +initialOpacity?: number; +``` + +Defines the initial opacity of the whole component when the button is inactive. By default set to `1` + + +### androidRipple + + + + +Configuration for the ripple effect on Android. If not provided, the ripple effect will be disabled. If `{}` is provided, the ripple effect will be enabled with default configuration. diff --git a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx index e5b0812531..fe0fa85d3a 100644 --- a/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx +++ b/packages/docs-gesture-handler/docs/guides/upgrading-to-3.mdx @@ -246,7 +246,15 @@ code2={ ### Buttons -The implementation of buttons has been updated, resolving most button-related issues. They have also been internally rewritten to utilize the new hook API. The original button components are still accessible but have been renamed with the prefix `Legacy`, e.g., `RectButton` is now available as `LegacyRectButton`. +RNGH3 introduces the [`Clickable`](/docs/components/clickable) component — a flexible, unified replacement for all previous button types. While `Clickable` shares the same logic as our standard buttons, it offers a more customizable API. + +To help you migrate, here is the current state of our button components: + +- [`Clickable`](/docs/components/clickable) - The recommended component. + +- Standard Buttons (Deprecated) - `BaseButton`, `RectButton` and `BorderlessButton` are still available but are now deprecated. They have been internally rewritten using the new Hooks API to resolve long-standing issues. + +- Legacy Buttons (Deprecated): The original, pre-rewrite versions are still accessible, but have been renamed with a `Legacy` prefix (e.g., `LegacyRectButton`). Although the legacy JS implementation of the buttons is still available, they also use the new host component internally. Because of that, `PureNativeButton` is no longer available in Gesture Handler 3. 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(); + }); + }); }); 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 new file mode 100644 index 0000000000..e8fbfdea8f --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/Clickable.tsx @@ -0,0 +1,197 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { Animated, Platform, StyleSheet } from 'react-native'; +import { RawButton } from '../GestureButtons'; +import { CallbackEventType, ClickableProps } from './ClickableProps'; + +const AnimatedRawButton = Animated.createAnimatedComponent(RawButton); +const isAndroid = Platform.OS === 'android'; +const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const }; + +export const Clickable = (props: ClickableProps) => { + const { + underlayColor, + underlayInitialOpacity = 0, + underlayActiveOpacity, + initialOpacity = 1, + activeOpacity, + androidRipple, + delayLongPress = 600, + onLongPress, + onPress, + onPressIn, + onPressOut, + onActiveStateChange, + style, + children, + ref, + ...rest + } = props; + + const animatedValue = useRef(new Animated.Value(0)).current; + + const shouldAnimateUnderlay = underlayActiveOpacity !== undefined; + const shouldAnimateComponent = activeOpacity !== undefined; + + const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; + const shouldUseJSAnimation = shouldAnimateComponent || shouldAnimateUnderlay; + + const longPressDetected = useRef(false); + const longPressTimeout = useRef | undefined>( + undefined + ); + + const wrappedLongPress = useCallback(() => { + longPressDetected.current = true; + onLongPress?.(); + }, [onLongPress]); + + const startLongPressTimer = useCallback(() => { + longPressDetected.current = false; + + if (onLongPress && !longPressTimeout.current) { + longPressTimeout.current = setTimeout(wrappedLongPress, delayLongPress); + } + }, [onLongPress, delayLongPress, wrappedLongPress]); + + const onBegin = useCallback( + (e: CallbackEventType) => { + if (!isAndroid || !e.pointerInside) { + return; + } + + onPressIn?.(e); + startLongPressTimer(); + + if (shouldUseJSAnimation) { + animatedValue.setValue(1); + } + }, + [startLongPressTimer, shouldUseJSAnimation, animatedValue, onPressIn] + ); + + const onActivate = useCallback( + (e: CallbackEventType) => { + onActiveStateChange?.(true); + + if (!isAndroid) { + if (e.pointerInside) { + onPressIn?.(e); + startLongPressTimer(); + + if (shouldUseJSAnimation) { + animatedValue.setValue(1); + } + } + } + + if (!e.pointerInside && longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [ + onActiveStateChange, + shouldUseJSAnimation, + animatedValue, + startLongPressTimer, + onPressIn, + ] + ); + + const onDeactivate = useCallback( + (e: CallbackEventType, success: boolean) => { + onActiveStateChange?.(false); + + if (success && !longPressDetected.current) { + onPress?.(e.pointerInside); + } + }, + [onActiveStateChange, onPress] + ); + + const onFinalize = useCallback( + (e: CallbackEventType) => { + if (shouldUseJSAnimation) { + animatedValue.setValue(0); + } + + onPressOut?.(e); + + if (longPressTimeout.current !== undefined) { + clearTimeout(longPressTimeout.current); + longPressTimeout.current = undefined; + } + }, + [shouldUseJSAnimation, animatedValue, onPressOut] + ); + + const underlayAnimatedStyle = useMemo(() => { + return shouldAnimateUnderlay + ? { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [underlayInitialOpacity, underlayActiveOpacity], + }), + backgroundColor: underlayColor ?? 'black', + } + : undefined; + }, [ + shouldAnimateUnderlay, + style, + underlayInitialOpacity, + underlayActiveOpacity, + underlayColor, + animatedValue, + ]); + + const componentAnimatedStyle = useMemo( + () => + shouldAnimateComponent + ? { + opacity: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [initialOpacity, activeOpacity], + }), + } + : undefined, + [shouldAnimateComponent, activeOpacity, animatedValue, initialOpacity] + ); + + const rippleProps = shouldUseNativeRipple + ? { + rippleColor: androidRipple?.color, + rippleRadius: androidRipple?.radius, + borderless: androidRipple?.borderless, + foreground: androidRipple?.foreground, + } + : TRANSPARENT_RIPPLE; + + const ButtonComponent = shouldUseJSAnimation ? AnimatedRawButton : RawButton; + + return ( + + {underlayAnimatedStyle && ( + + )} + {children} + + ); +}; + +const styles = StyleSheet.create({ + underlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 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..f837742acb --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Clickable/ClickableProps.ts @@ -0,0 +1,59 @@ +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'; + +export type CallbackEventType = GestureEvent; + +type PressableAndroidRippleConfig = { + [K in keyof RNPressableAndroidRippleConfig]?: Exclude< + RNPressableAndroidRippleConfig[K], + null + >; +}; + +type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground'; + +export interface ClickableProps extends Omit { + /** + * Background color of underlay. Requires `underlayActiveOpacity` to be set. + */ + underlayColor?: string | undefined; + + /** + * Opacity applied to the underlay when it is in an active state. + * If not provided, no visual feedback will be applied. + */ + underlayActiveOpacity?: number | undefined; + + /** + * Opacity applied to the component when it is in an active state. + * If not provided, no visual feedback will be applied. + */ + activeOpacity?: number | undefined; + + /** + * Initial opacity of the underlay. + */ + underlayInitialOpacity?: number | undefined; + + /** + * Initial opacity of the component. + */ + initialOpacity?: number | undefined; + + /** + * 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; +} 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..c8cbd0f710 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,11 +14,17 @@ 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, }); +/** + * @deprecated `BaseButton` is deprecated, use `Clickable` instead + */ export const BaseButton = (props: BaseButtonProps) => { const longPressDetected = useRef(false); const longPressTimeout = useRef | undefined>( @@ -97,6 +104,9 @@ const btnStyles = StyleSheet.create({ }, }); +/** + * @deprecated `RectButton` is deprecated, use `Clickable` with `underlayActiveOpacity={0.7}` instead + */ export const RectButton = (props: RectButtonProps) => { const activeOpacity = props.activeOpacity ?? 0.105; const underlayColor = props.underlayColor ?? 'black'; @@ -139,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; @@ -151,11 +164,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/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index bf4bbc5526..30adc491c4 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 } from './Clickable/Clickable'; +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 3c720e10dd..50655f9cbe 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -66,7 +66,9 @@ export type { BaseButtonProps, RectButtonProps, BorderlessButtonProps, + ClickableProps, } from './components'; + export { RawButton, BaseButton, @@ -78,6 +80,7 @@ export { TextInput, FlatList, RefreshControl, + Clickable, } from './components'; export type { ComposedGesture } from './types'; 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; diff --git a/skills/gesture-handler-3-migration/SKILL.md b/skills/gesture-handler-3-migration/SKILL.md index 888238fe2e..63f1bf94c9 100644 --- a/skills/gesture-handler-3-migration/SKILL.md +++ b/skills/gesture-handler-3-migration/SKILL.md @@ -169,6 +169,8 @@ The implementation of buttons has been updated, resolving most button-related is `PureNativeButton` has been removed. If encountered, inform the user that it has been removed and let them decide how to handle that case. They can achieve similar functionality with other buttons. +When migrating buttons, you should use `Clickable` component instead. To replace `BaseButton` use `Clickable` with default props, to replace `RectButton` use `Clickable` with `underlayActiveOpacity={0.105}` and to replace `BorderlessButton` use `Clickable` with `activeOpacity={0.3}`. + Other components have also been internally rewritten using the new hook API but are exported under their original names, so no changes are necessary on your part. However, if you need to use the previous implementation for any reason, the legacy components are also available and are prefixed with `Legacy`, e.g., `ScrollView` is now available as `LegacyScrollView`. Rename all instances of createNativeWrapper to legacy_createNativeWrapper. This includes both the import statements and the function calls.