diff --git a/native/components/gesture-touchable-opacity.react.js b/native/components/gesture-touchable-opacity.react.js index 3690e1fca..3ceccc868 100644 --- a/native/components/gesture-touchable-opacity.react.js +++ b/native/components/gesture-touchable-opacity.react.js @@ -1,252 +1,199 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { LongPressGestureHandler, TapGestureHandler, State as GestureState, type LongPressGestureEvent, type TapGestureEvent, } from 'react-native-gesture-handler'; -import Animated, { EasingNode } from 'react-native-reanimated'; +import Animated, { + Easing, + useSharedValue, + useAnimatedGestureHandler, + runOnJS, + withTiming, + useAnimatedStyle, +} from 'react-native-reanimated'; import type { ReactRefSetter } from 'lib/types/react-types.js'; import type { AnimatedViewStyle, ViewStyle } from '../types/styles.js'; -import { - runTiming, - useReanimatedValueForBoolean, -} from '../utils/animation-utils.js'; - -const { - Clock, - block, - event, - set, - call, - cond, - not, - and, - or, - eq, - stopClock, - clockRunning, - useValue, -} = Animated; +import { useSharedValueForBoolean } from '../utils/animation-utils.js'; const pressAnimationSpec = { duration: 150, - easing: EasingNode.inOut(EasingNode.quad), + easing: Easing.inOut(Easing.quad), }; const resetAnimationSpec = { duration: 250, - easing: EasingNode.inOut(EasingNode.quad), + easing: Easing.inOut(Easing.quad), }; type Props = { +activeOpacity?: number, +onPress?: () => mixed, +onLongPress?: () => mixed, +children?: React.Node, +style?: ViewStyle, +animatedStyle?: AnimatedViewStyle, // If stickyActive is a boolean, we assume that we should stay active after a // successful onPress or onLongPress. We will wait for stickyActive to flip // from true to false before animating back to our deactivated mode. +stickyActive?: boolean, +overlay?: React.Node, +disabled?: boolean, }; function ForwardedGestureTouchableOpacity( props: Props, ref: ReactRefSetter, ) { const { onPress: innerOnPress, onLongPress: innerOnLongPress } = props; const onPress = React.useCallback(() => { innerOnPress && innerOnPress(); }, [innerOnPress]); const onLongPress = React.useCallback(() => { innerOnLongPress && innerOnLongPress(); }, [innerOnLongPress]); const activeOpacity = props.activeOpacity ?? 0.2; const { stickyActive, disabled } = props; - const activeValue = useReanimatedValueForBoolean(!!stickyActive); - const disabledValue = useReanimatedValueForBoolean(!!disabled); + const activeValue = useSharedValueForBoolean(!!stickyActive); + const disabledValue = useSharedValueForBoolean(!!disabled); const stickyActiveEnabled = stickyActive !== null && stickyActive !== undefined; - const longPressState = useValue(-1); - const tapState = useValue(-1); - const longPressEvent = React.useMemo( - () => - event([ - { - nativeEvent: { - state: longPressState, - }, - }, - ]), - [longPressState], - ); - const tapEvent = React.useMemo( - () => - event([ - { - nativeEvent: { - state: tapState, - }, - }, - ]), - [tapState], + const longPressState = useSharedValue(-1); + const tapState = useSharedValue(-1); + const longPressEvent = useAnimatedGestureHandler( + { + onStart: () => { + longPressState.value = GestureState.BEGAN; + }, + onActive: () => { + longPressState.value = GestureState.ACTIVE; + if (disabledValue.value) { + return; + } + if (stickyActiveEnabled) { + activeValue.value = true; + } + runOnJS(onLongPress)(); + }, + onEnd: () => { + longPressState.value = GestureState.END; + }, + onFail: () => { + longPressState.value = GestureState.FAILED; + }, + onCancel: () => { + longPressState.value = GestureState.CANCELLED; + }, + onFinish: () => { + longPressState.value = GestureState.END; + }, + }, + [stickyActiveEnabled, onLongPress], ); - const gestureActive = React.useMemo( - () => - or( - eq(longPressState, GestureState.ACTIVE), - eq(tapState, GestureState.BEGAN), - eq(tapState, GestureState.ACTIVE), - activeValue, - ), - [longPressState, tapState, activeValue], + const tapEvent = useAnimatedGestureHandler( + { + onStart: () => { + tapState.value = GestureState.BEGAN; + }, + onActive: () => { + tapState.value = GestureState.ACTIVE; + }, + onEnd: () => { + tapState.value = GestureState.END; + if (disabledValue.value) { + return; + } + if (stickyActiveEnabled) { + activeValue.value = true; + } + runOnJS(onPress)(); + }, + onFail: () => { + tapState.value = GestureState.FAILED; + }, + onCancel: () => { + tapState.value = GestureState.CANCELLED; + }, + onFinish: () => { + tapState.value = GestureState.END; + }, + }, + [stickyActiveEnabled, onPress], ); - const curOpacity = useValue(1); - - const pressClockRef = React.useRef(); - if (!pressClockRef.current) { - pressClockRef.current = new Clock(); - } - const pressClock = pressClockRef.current; - const resetClockRef = React.useRef(); - if (!resetClockRef.current) { - resetClockRef.current = new Clock(); - } - const resetClock = resetClockRef.current; - - const animationCode = React.useMemo( - () => [ - cond(or(gestureActive, clockRunning(pressClock)), [ - set( - curOpacity, - runTiming( - pressClock, - curOpacity, - activeOpacity, - true, - pressAnimationSpec, - ), - ), - stopClock(resetClock), - ]), - // We have to do two separate conds here even though the condition is the - // same because if runTiming stops the pressClock, we need to immediately - // start the resetClock or Reanimated won't keep running the code because - // it will think there is nothing left to do - cond( - not(or(gestureActive, clockRunning(pressClock))), - set( - curOpacity, - runTiming(resetClock, curOpacity, 1, true, resetAnimationSpec), - ), - ), - ], - [gestureActive, curOpacity, pressClock, resetClock, activeOpacity], - ); - - const prevTapSuccess = useValue(0); - const prevLongPressSuccess = useValue(0); - - const transformStyle = React.useMemo(() => { - const tapSuccess = eq(tapState, GestureState.END); - const longPressSuccess = eq(longPressState, GestureState.ACTIVE); - const opacity = block([ - ...animationCode, - [ - cond(and(tapSuccess, not(prevTapSuccess), not(disabledValue)), [ - stickyActiveEnabled ? set(activeValue, 1) : undefined, - call([], onPress), - ]), - set(prevTapSuccess, tapSuccess), - ], - [ - cond( - and(longPressSuccess, not(prevLongPressSuccess), not(disabledValue)), - [ - stickyActiveEnabled ? set(activeValue, 1) : undefined, - call([], onLongPress), - ], - ), - set(prevLongPressSuccess, longPressSuccess), - ], - curOpacity, - ]); - return { opacity }; - }, [ - animationCode, - tapState, - longPressState, - prevTapSuccess, - prevLongPressSuccess, - curOpacity, - onPress, - onLongPress, - activeValue, - disabledValue, - stickyActiveEnabled, - ]); + const curOpacity = useSharedValue(1); + + const transformStyle = useAnimatedStyle(() => { + const gestureActive = + longPressState.value === GestureState.ACTIVE || + tapState.value === GestureState.BEGAN || + tapState.value === GestureState.ACTIVE || + activeValue.value; + if (gestureActive) { + curOpacity.value = withTiming(activeOpacity, pressAnimationSpec); + } else { + curOpacity.value = withTiming(1, resetAnimationSpec); + } + return { opacity: curOpacity.value }; + }); const fillStyle = React.useMemo(() => { const result = StyleSheet.flatten(props.style); if (!result) { return undefined; } const { flex } = result; if (flex === null || flex === undefined) { return undefined; } return { flex }; }, [props.style]); const tapHandler = ( {props.children} {props.overlay} ); if (!innerOnLongPress) { return tapHandler; } return ( {tapHandler} ); } const GestureTouchableOpacity: React.AbstractComponent< Props, TapGestureHandler, > = React.forwardRef( ForwardedGestureTouchableOpacity, ); GestureTouchableOpacity.displayName = 'GestureTouchableOpacity'; export default GestureTouchableOpacity; diff --git a/native/utils/animation-utils.js b/native/utils/animation-utils.js index 705d06027..c00a6bc88 100644 --- a/native/utils/animation-utils.js +++ b/native/utils/animation-utils.js @@ -1,254 +1,252 @@ // @flow import * as React from 'react'; import { State as GestureState } from 'react-native-gesture-handler'; import Animated, { EasingNode, type NodeParam, type SpringConfig, type TimingConfig, + useSharedValue, + type SharedValue, } from 'react-native-reanimated'; const { Clock, Node, Value, block, cond, not, and, or, greaterThan, lessThan, eq, neq, add, sub, divide, set, max, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Animated; function clamp( value: Node, minValue: Node | number, maxValue: Node | number, ): Node { return cond( greaterThan(value, maxValue), maxValue, cond(greaterThan(minValue, value), minValue, value), ); } function delta(value: Node): Node { const prevValue = new Value(0); const deltaValue = new Value(0); return block([ set(deltaValue, cond(eq(prevValue, 0), 0, sub(value, prevValue))), set(prevValue, value), deltaValue, ]); } function gestureJustStarted(state: Node): Node { const prevValue = new Value(-1); return cond(eq(prevValue, state), 0, [ set(prevValue, state), eq(state, GestureState.ACTIVE), ]); } function gestureJustEnded(state: Node): Node { const prevValue = new Value(-1); return cond(eq(prevValue, state), 0, [ set(prevValue, state), eq(state, GestureState.END), ]); } const defaultTimingConfig = { duration: 250, easing: EasingNode.out(EasingNode.ease), }; function runTiming( clock: Clock, initialValue: Node | number, finalValue: Node | number, startStopClock: boolean = true, config?: Partial, ): Node { const state = { finished: new Value(0), position: new Value(0), frameTime: new Value(0), time: new Value(0), }; const timingConfig = { ...defaultTimingConfig, ...config, toValue: new Value(0), }; return block([ cond(not(clockRunning(clock)), [ set(state.finished, 0), set(state.frameTime, 0), set(state.time, 0), set(state.position, initialValue), set(timingConfig.toValue, finalValue), startStopClock ? startClock(clock) : undefined, ]), timing(clock, state, timingConfig), cond(state.finished, startStopClock ? stopClock(clock) : undefined), state.position, ]); } const defaultSpringConfig = SpringUtils.makeDefaultConfig(); type SpringAnimationInitialState = Partial<{ +velocity: Value | number, }>; function runSpring( clock: Clock, initialValue: Node | number, finalValue: Node | number, startStopClock: boolean = true, config?: Partial, initialState?: SpringAnimationInitialState, ): Node { const state = { finished: new Value(0), position: new Value(0), velocity: new Value(0), time: new Value(0), }; const springConfig = { ...defaultSpringConfig, ...config, toValue: new Value(0), }; return block([ cond(not(clockRunning(clock)), [ set(state.finished, 0), set(state.velocity, initialState?.velocity ?? 0), set(state.time, 0), set(state.position, initialValue), set(springConfig.toValue, finalValue), startStopClock ? startClock(clock) : undefined, ]), spring(clock, state, springConfig), cond(state.finished, startStopClock ? stopClock(clock) : undefined), state.position, ]); } // You provide a node that performs a "ratchet", // and this function will call it as keyboard height increases function ratchetAlongWithKeyboardHeight( keyboardHeight: Node, ratchetFunction: NodeParam, ): Node { const prevKeyboardHeightValue = new Value(-1); // In certain situations, iOS will send multiple keyboardShows in rapid // succession with increasing height values. Only the final value has any // semblance of reality. I've encountered this when using the native // password management integration const whenToUpdate = greaterThan( keyboardHeight, max(prevKeyboardHeightValue, 0), ); const whenToReset = and( eq(keyboardHeight, 0), greaterThan(prevKeyboardHeightValue, 0), ); return block([ cond( lessThan(prevKeyboardHeightValue, 0), set(prevKeyboardHeightValue, keyboardHeight), ), cond(or(whenToUpdate, whenToReset), ratchetFunction), set(prevKeyboardHeightValue, keyboardHeight), ]); } -function useReanimatedValueForBoolean(booleanValue: boolean): Value { - const reanimatedValueRef = React.useRef(); - if (!reanimatedValueRef.current) { - reanimatedValueRef.current = new Value(booleanValue ? 1 : 0); - } - const val = reanimatedValueRef.current; +function useSharedValueForBoolean(booleanValue: boolean): SharedValue { + const sharedValue = useSharedValue(booleanValue); React.useEffect(() => { - reanimatedValueRef.current?.setValue(booleanValue ? 1 : 0); - }, [booleanValue]); - return val; + sharedValue.value = booleanValue; + }, [sharedValue, booleanValue]); + return sharedValue; } // Target can be either 0 or 1. Caller handles interpolating function animateTowards( target: Node, fullAnimationLength: number, // in ms ): Node { const curValue = new Value(-1); const prevTarget = new Value(-1); const clock = new Clock(); const prevClockValue = new Value(0); const curDeltaClockValue = new Value(0); const deltaClockValue = [ set( curDeltaClockValue, cond(eq(prevClockValue, 0), 0, sub(clock, prevClockValue)), ), set(prevClockValue, clock), curDeltaClockValue, ]; const progressPerFrame = divide(deltaClockValue, fullAnimationLength); return block([ [ cond(eq(curValue, -1), set(curValue, target)), cond(eq(prevTarget, -1), set(prevTarget, target)), ], cond(neq(target, prevTarget), [stopClock(clock), set(prevTarget, target)]), cond(neq(curValue, target), [ cond(not(clockRunning(clock)), [ set(prevClockValue, 0), startClock(clock), ]), set( curValue, cond( eq(target, 1), add(curValue, progressPerFrame), sub(curValue, progressPerFrame), ), ), ]), [ cond(greaterThan(curValue, 1), set(curValue, 1)), cond(lessThan(curValue, 0), set(curValue, 0)), ], cond(eq(curValue, target), [stopClock(clock)]), curValue, ]); } export { clamp, delta, gestureJustStarted, gestureJustEnded, runTiming, runSpring, ratchetAlongWithKeyboardHeight, - useReanimatedValueForBoolean, + useSharedValueForBoolean, animateTowards, };