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/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;