diff --git a/packages/rn-tester/js/examples/AnimationBackend/AnimationBackendIndex.js b/packages/rn-tester/js/examples/AnimationBackend/AnimationBackendIndex.js new file mode 100644 index 000000000000..f8eb3d25d773 --- /dev/null +++ b/packages/rn-tester/js/examples/AnimationBackend/AnimationBackendIndex.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RNTesterModule} from '../../types/RNTesterTypes'; + +import PlaygroundExample from './ChessboardExample'; +import SwipeableListExample from './SwipeableListExample'; +import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; + +const canUseBackend = + // eslint-disable-next-line + ReactNativeFeatureFlags.useSharedAnimatedBackend() && + ReactNativeFeatureFlags.cxxNativeAnimatedEnabled(); + +export default ({ + framework: 'React', + title: 'Animation Backend', + category: 'UI', + description: `Examples demonstrating the Animation Backend for layout-updating animations. ${canUseBackend ? '' : 'You need to enable c++ Animated and the Animation Backend to see these examples.'}`, + showIndividualExamples: true, + examples: canUseBackend ? [PlaygroundExample, SwipeableListExample] : [], +}: RNTesterModule); diff --git a/packages/rn-tester/js/examples/AnimationBackend/ChessboardExample.js b/packages/rn-tester/js/examples/AnimationBackend/ChessboardExample.js new file mode 100644 index 000000000000..5bf011860ba2 --- /dev/null +++ b/packages/rn-tester/js/examples/AnimationBackend/ChessboardExample.js @@ -0,0 +1,134 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import * as React from 'react'; +import {useMemo} from 'react'; +import {Animated, StyleSheet, Text, View, useAnimatedValue} from 'react-native'; +import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; + +allowStyleProp('width'); +allowStyleProp('height'); +const colors = ['lime', 'green']; + +function useLoop() { + const animatedValue = useAnimatedValue(0); + + React.useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(animatedValue, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(animatedValue, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + + return () => { + animation.stop(); + }; + }, [animatedValue]); + + return animatedValue; +} + +const N = 12; +const STATE_MAX = 30; + +function ChessboardExample() { + const [state, setState] = React.useState(0); + + const animatedValue = useLoop(); + + React.useEffect(() => { + const id = setInterval(() => { + setState(s => (s + 1) % STATE_MAX); + }, 10); + return () => { + clearInterval(id); + }; + }, []); + + const animatedStyle = useMemo(() => { + return { + width: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [10, 30], + }), + height: animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [10, 30], + }), + }; + }, [animatedValue]); + + return ( + + {state} + + + {[...Array(N).keys()].map(i => ( + + {[...Array(N).keys()].map(j => ( + + ))} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + workaround: { + height: 400, + }, + chessboard: { + alignItems: 'flex-start', + }, + border: { + borderWidth: 10, + borderColor: 'red', + }, + row: { + flexDirection: 'row', + }, + text: { + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + margin: 10, + }, +}); + +export default ({ + title: 'Chessboard', + name: 'chessboard', + description: 'Combine animating layout with state updates', + render: (): React.Node => , +}: RNTesterModuleExample); diff --git a/packages/rn-tester/js/examples/AnimationBackend/SwipeableListExample.js b/packages/rn-tester/js/examples/AnimationBackend/SwipeableListExample.js new file mode 100644 index 000000000000..2ede306fa51d --- /dev/null +++ b/packages/rn-tester/js/examples/AnimationBackend/SwipeableListExample.js @@ -0,0 +1,286 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import * as React from 'react'; +import {useCallback, useMemo, useRef} from 'react'; +import { + Alert, + Animated, + Dimensions, + Easing, + PanResponder, + StyleSheet, + Text, + TouchableOpacity, + View, + useAnimatedValue, +} from 'react-native'; +import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; + +allowStyleProp('height'); + +const windowDimensions = Dimensions.get('window'); +const BUTTON_WIDTH = 80; +const MAX_TRANSLATE = -BUTTON_WIDTH; + +type Data = { + id: string, + title: string, +}; + +const initialData: $ReadOnlyArray = [ + {id: '1', title: 'Kate Bell'}, + {id: '2', title: 'John Appleseed'}, + {id: '3', title: 'Mark Zuckerberg'}, + {id: '4', title: 'Iron Man'}, + {id: '5', title: 'Captain America'}, + {id: '6', title: 'Batman'}, + {id: '7', title: 'Matt Smith'}, +]; + +const springConfig = { + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + useNativeDriver: true, +}; + +const timingConfig = { + duration: 400, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + useNativeDriver: true, +}; + +function SwipeableListExample(): React.Node { + const [data, setData] = React.useState<$ReadOnlyArray>(initialData); + + const handleRemove = useCallback((id: string) => { + setData(currentData => currentData.filter(item => item.id !== id)); + Alert.alert('Removed'); + }, []); + + return ( + + {data.map(item => ( + handleRemove(item.id)} + /> + ))} + + ); +} + +type ListItemProps = { + item: Data, + onRemove: () => void, +}; + +function ListItem({item, onRemove}: ListItemProps): React.Node { + const isRemoving = useRef(false); + const translateX = useAnimatedValue(0); + const removalOffset = useAnimatedValue(0); // Separate value for removal animation + const height = useAnimatedValue(78); + const opacity = useAnimatedValue(1); + + const clampedTranslateX = useMemo( + () => + translateX.interpolate({ + inputRange: [MAX_TRANSLATE, 0], + outputRange: [MAX_TRANSLATE, 0], + extrapolate: 'clamp', + }), + [translateX], + ); + + // Combine clamped pan position with removal offset + const finalTranslateX = useMemo( + () => Animated.add(clampedTranslateX, removalOffset), + [clampedTranslateX, removalOffset], + ); + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => { + return Math.abs(gestureState.dx) > 10; + }, + onPanResponderGrant: () => { + translateX.extractOffset(); + }, + onPanResponderMove: Animated.event([null, {dx: translateX}], { + useNativeDriver: false, + }), + onPanResponderRelease: (_, gestureState) => { + translateX.flattenOffset(); + + const shouldOpen = gestureState.vx < -0.05; + const targetValue = shouldOpen ? MAX_TRANSLATE : 0; + + Animated.spring(translateX, { + toValue: targetValue, + velocity: gestureState.vx, + ...springConfig, + }).start(); + }, + }), + [translateX], + ); + + const handleRemove = useCallback(() => { + if (isRemoving.current) { + return; + } + isRemoving.current = true; + + // Animate removal offset separately - this bypasses the clamp + Animated.parallel([ + Animated.timing(height, { + toValue: 0, + ...timingConfig, + }), + Animated.timing(opacity, { + toValue: 0, + ...timingConfig, + }), + Animated.timing(removalOffset, { + toValue: -windowDimensions.width - MAX_TRANSLATE, + ...timingConfig, + }), + ]).start(() => { + onRemove(); + }); + }, [height, opacity, removalOffset, onRemove]); + + const animatedStyle = useMemo( + () => ({ + height, + opacity, + transform: [{translateX: finalTranslateX}], + }), + [height, opacity, finalTranslateX], + ); + + const removeButton = useMemo( + () => ({ + title: 'Delete', + backgroundColor: 'red', + color: 'white', + onPress: handleRemove, + }), + [handleRemove], + ); + + return ( + + + + +