diff --git a/apps/common-app/App.tsx b/apps/common-app/App.tsx
index b2a49d60f9..dd86bb8109 100644
--- a/apps/common-app/App.tsx
+++ b/apps/common-app/App.tsx
@@ -81,6 +81,17 @@ import LongPressExample from './src/simple/longPress';
import ManualExample from './src/simple/manual';
import SimpleFling from './src/simple/fling';
+import Lock from './src/v3_api/lock/lock';
+import V3Fling from './src/v3_api/fling/fling';
+import LogicDetectorExample from './src/v3_api/svg/svg';
+import V3Hover from './src/v3_api/hover/index';
+import V3Overlap from './src/v3_api/overlap/index';
+import V3Calculator from './src/v3_api/calculator/index';
+import V3Velocity from './src/v3_api/velocity_test/index';
+import V3BottomSheet from './src/v3_api/bottom_sheet/index';
+import V3ChatHeads from './src/v3_api/chat_heads/index';
+import V3HoverableIcons from './src/v3_api/hoverable_icons/index';
+import V3Camera from './src/v3_api/camera/index';
import { Icon } from '@swmansion/icons';
interface Example {
@@ -98,6 +109,22 @@ const EXAMPLES: ExamplesSection[] = [
sectionTitle: 'Empty',
data: [{ name: 'Empty Example', component: EmptyExample }],
},
+ {
+ sectionTitle: 'V3 api',
+ data: [
+ { name: 'V3 Fling', component: V3Fling },
+ { name: 'Svg', component: LogicDetectorExample },
+ { name: 'Lock', component: Lock },
+ { name: 'V3 Hover', component: V3Hover },
+ { name: 'V3 Overlap', component: V3Overlap },
+ { name: 'V3 Calculator', component: V3Calculator },
+ { name: 'V3 Velocity Test', component: V3Velocity },
+ { name: 'V3 Bottom Sheet', component: V3BottomSheet },
+ { name: 'V3 Chat Heads', component: V3ChatHeads },
+ { name: 'V3 Hoverable Icons', component: V3HoverableIcons },
+ { name: 'V3 Camera', component: V3Camera },
+ ],
+ },
{
sectionTitle: 'New api',
data: [
diff --git a/apps/common-app/src/v3_api/bottom_sheet/index.tsx b/apps/common-app/src/v3_api/bottom_sheet/index.tsx
new file mode 100644
index 0000000000..dc2dd0815f
--- /dev/null
+++ b/apps/common-app/src/v3_api/bottom_sheet/index.tsx
@@ -0,0 +1,162 @@
+import React, { useState } from 'react';
+import {
+ Dimensions,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ StyleSheet,
+ View,
+} from 'react-native';
+import {
+ NativeDetector,
+ PanGestureEvent,
+ useNative,
+ usePan,
+ useSimultaneous,
+ useTap,
+} from 'react-native-gesture-handler';
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { LoremIpsum } from '../../common';
+
+const HEADER_HEIGTH = 50;
+const windowHeight = Dimensions.get('window').height;
+const SNAP_POINTS_FROM_TOP = [50, windowHeight * 0.4, windowHeight * 0.8];
+
+const FULLY_OPEN_SNAP_POINT = SNAP_POINTS_FROM_TOP[0];
+const CLOSED_SNAP_POINT = SNAP_POINTS_FROM_TOP[SNAP_POINTS_FROM_TOP.length - 1];
+
+function Example() {
+ const [snapPoint, setSnapPoint] = useState(CLOSED_SNAP_POINT);
+ const translationY = useSharedValue(0);
+ const scrollOffset = useSharedValue(0);
+ const bottomSheetTranslateY = useSharedValue(CLOSED_SNAP_POINT);
+
+ const onHandlerEndOnJS = (point: number) => {
+ setSnapPoint(point);
+ };
+ const onHandlerEnd = (e: PanGestureEvent) => {
+ 'worklet';
+ const dragToss = 0.01;
+ const endOffsetY =
+ bottomSheetTranslateY.value +
+ translationY.value +
+ e.handlerData.velocityY * dragToss;
+
+ // calculate nearest snap point
+ let destSnapPoint = FULLY_OPEN_SNAP_POINT;
+
+ if (
+ snapPoint === FULLY_OPEN_SNAP_POINT &&
+ endOffsetY < FULLY_OPEN_SNAP_POINT
+ ) {
+ return;
+ }
+ for (const snapPoint of SNAP_POINTS_FROM_TOP) {
+ const distFromSnap = Math.abs(snapPoint - endOffsetY);
+ if (distFromSnap < Math.abs(destSnapPoint - endOffsetY)) {
+ destSnapPoint = snapPoint;
+ }
+ }
+
+ // update current translation to be able to animate withSpring to snapPoint
+ bottomSheetTranslateY.value =
+ bottomSheetTranslateY.value + translationY.value;
+ translationY.value = 0;
+
+ bottomSheetTranslateY.value = withSpring(destSnapPoint, {
+ mass: 0.5,
+ });
+ runOnJS(onHandlerEndOnJS)(destSnapPoint);
+ };
+ const panGesture = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ // when bottom sheet is not fully opened scroll offset should not influence
+ // its position (prevents random snapping when opening bottom sheet when
+ // the content is already scrolled)
+ if (snapPoint === FULLY_OPEN_SNAP_POINT) {
+ translationY.value = e.handlerData.translationY - scrollOffset.value;
+ } else {
+ translationY.value = e.handlerData.translationY;
+ }
+ },
+ onEnd: onHandlerEnd,
+ });
+
+ const blockScrollUntilAtTheTop = useTap({
+ maxDeltaY: snapPoint - FULLY_OPEN_SNAP_POINT,
+ maxDuration: 100000,
+ simultaneousWithExternalGesture: panGesture,
+ });
+
+ const headerGesture = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ translationY.value = e.handlerData.translationY;
+ },
+ onEnd: onHandlerEnd,
+ });
+
+ const scrollViewGesture = useNative({
+ requireExternalGestureToFail: blockScrollUntilAtTheTop,
+ });
+
+ const bottomSheetAnimatedStyle = useAnimatedStyle(() => {
+ const translateY = bottomSheetTranslateY.value + translationY.value;
+
+ const minTranslateY = Math.max(FULLY_OPEN_SNAP_POINT, translateY);
+ const clampedTranslateY = Math.min(CLOSED_SNAP_POINT, minTranslateY);
+ return {
+ transform: [{ translateY: clampedTranslateY }],
+ };
+ });
+
+ const simultanousGesture = useSimultaneous(panGesture, scrollViewGesture);
+
+ return (
+
+
+
+
+
+
+
+
+
+ ) => {
+ scrollOffset.value = e.nativeEvent.contentOffset.y;
+ }}>
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ height: HEADER_HEIGTH,
+ backgroundColor: 'coral',
+ },
+ bottomSheet: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#ff9f7A',
+ },
+});
+
+export default Example;
diff --git a/apps/common-app/src/v3_api/calculator/index.tsx b/apps/common-app/src/v3_api/calculator/index.tsx
new file mode 100644
index 0000000000..d27fb3fbe6
--- /dev/null
+++ b/apps/common-app/src/v3_api/calculator/index.tsx
@@ -0,0 +1,426 @@
+import React, { Dispatch, SetStateAction, useRef, useState } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ Dimensions,
+ LayoutChangeEvent,
+ LayoutRectangle,
+} from 'react-native';
+import {
+ ScrollView,
+ usePan,
+ NativeDetector,
+ useTap,
+} from 'react-native-gesture-handler';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ runOnJS,
+} from 'react-native-reanimated';
+
+const DRAG_ANIMATION_DURATION = 300;
+const TAP_ANIMATION_DURATION = 100;
+const OPERATIONS_TOGGLE_OFFSET = 75;
+const OUTPUT_TOGGLE_OFFSET = 100;
+const window = Dimensions.get('window');
+
+export default function CalculatorUI() {
+ const outputOffset = useSharedValue(0);
+ const [history, setHistory] = useState(Array());
+ const [expression, setExpression] = useState('');
+
+ function measure({
+ nativeEvent: {
+ layout: { height },
+ },
+ }: LayoutChangeEvent) {
+ outputOffset.value = -height;
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+interface OutputProps {
+ offset: Animated.SharedValue;
+ expression: string;
+ history: string[];
+}
+
+function Output({ offset, expression, history }: OutputProps) {
+ const layout = useRef({});
+ const scrollView = useRef(null);
+ const drag = useSharedValue(0);
+ const dragOffset = useSharedValue(0);
+ const [opened, setOpened] = useState(false);
+
+ function measure({ nativeEvent: { layout: newLayout } }: LayoutChangeEvent) {
+ layout.current = newLayout;
+ }
+
+ function open() {
+ drag.value = withTiming(-offset.value, {
+ duration: DRAG_ANIMATION_DURATION,
+ });
+ dragOffset.value = -offset.value;
+
+ setOpened(true);
+ }
+
+ function close() {
+ drag.value = withTiming(0, { duration: DRAG_ANIMATION_DURATION });
+ dragOffset.value = 0;
+
+ setOpened(false);
+ }
+
+ const translationStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateY: offset.value + drag.value }],
+ };
+ });
+
+ const dragGesture = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ const translatedOffset = dragOffset.value + e.handlerData.translationY;
+
+ if (translatedOffset > -offset.value) {
+ drag.value = -offset.value;
+ } else if (translatedOffset < 0) {
+ drag.value = 0;
+ } else {
+ drag.value = translatedOffset;
+ }
+ },
+ onEnd: (e) => {
+ 'worklet';
+ const translatedOffset = dragOffset.value + e.handlerData.translationY;
+
+ if (opened) {
+ if (translatedOffset < -offset.value - OUTPUT_TOGGLE_OFFSET) {
+ runOnJS(close)();
+ } else {
+ runOnJS(open)();
+ }
+ } else {
+ if (translatedOffset > OUTPUT_TOGGLE_OFFSET) {
+ runOnJS(open)();
+ } else {
+ runOnJS(close)();
+ }
+ }
+ },
+ });
+
+ scrollView.current?.scrollToEnd({ animated: true });
+
+ return (
+
+
+ {
+ if (!opened) {
+ ref?.scrollToEnd({ animated: false });
+ }
+ scrollView.current = ref;
+ }}
+ enabled={opened}
+ contentContainerStyle={{ flexGrow: 1 }}>
+
+
+ {history.map((exp: string) => {
+ return ;
+ })}
+
+
+
+
+
+
+
+
+ );
+}
+
+interface ExpressionProps {
+ expression: string;
+}
+
+function Expression({ expression }: ExpressionProps) {
+ return (
+
+ {expression}
+ {expression}
+
+ );
+}
+
+interface InputProps {
+ setHistory: Dispatch>;
+ setExpression: Dispatch>;
+ measure: (e: LayoutChangeEvent) => void;
+ offset: Animated.SharedValue;
+ expression: string;
+}
+
+function Input({
+ setHistory,
+ setExpression,
+ measure,
+ offset,
+ expression,
+}: InputProps) {
+ const translationStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateY: offset.value }],
+ };
+ });
+
+ function append(symbol: string) {
+ if (symbol === '<') {
+ setHistory((h) => h.concat(expression));
+ setExpression((_e) => '');
+ } else {
+ setExpression((e) => e + symbol);
+ }
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+interface NumPadProps {
+ append: (text: string) => void;
+}
+
+function NumPad({ append }: NumPadProps) {
+ const buttons = ['7', '8', '9', '4', '5', '6', '1', '2', '3', '<', '0', '.'];
+ return (
+
+ {buttons.map((text) => {
+ return ;
+ })}
+
+ );
+}
+
+function Operations() {
+ const layout = useSharedValue({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
+ const drag = useSharedValue(0);
+ const dragOffset = useSharedValue(0);
+ const [opened, setOpened] = useState(false);
+
+ function open() {
+ const margin = window.width - layout.value.x;
+
+ drag.value = withTiming(-layout.value.width + margin, {
+ duration: DRAG_ANIMATION_DURATION,
+ });
+ dragOffset.value = -layout.value.width + margin;
+
+ setOpened(true);
+ }
+
+ function close() {
+ drag.value = withTiming(0, { duration: DRAG_ANIMATION_DURATION });
+ dragOffset.value = 0;
+
+ setOpened(false);
+ }
+
+ const dragGesture = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ const margin = window.width - layout.value.x;
+ const translatedOffset = dragOffset.value + e.handlerData.translationX;
+
+ if (translatedOffset < -layout.value.width + margin) {
+ drag.value = -layout.value.width + margin;
+ } else if (translatedOffset > 0) {
+ drag.value = 0;
+ } else {
+ drag.value = translatedOffset;
+ }
+ },
+ onEnd: (e) => {
+ 'worklet';
+ const margin = window.width - layout.value.x;
+ const translatedOffset = dragOffset.value + e.handlerData.translationX;
+
+ if (opened) {
+ if (
+ translatedOffset >
+ -layout.value.width + margin + OPERATIONS_TOGGLE_OFFSET
+ ) {
+ runOnJS(close)();
+ } else {
+ runOnJS(open)();
+ }
+ } else {
+ if (translatedOffset < -OPERATIONS_TOGGLE_OFFSET) {
+ runOnJS(open)();
+ } else {
+ runOnJS(close)();
+ }
+ }
+ },
+ });
+
+ const translationStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ translateX: drag.value }],
+ };
+ });
+
+ function measure({ nativeEvent: { layout: newLayout } }: LayoutChangeEvent) {
+ layout.value = newLayout;
+ }
+
+ return (
+
+
+
+ );
+}
+
+interface ButtonProps {
+ text: string;
+ append: (text: string) => void;
+}
+
+function Button({ text, append }: ButtonProps) {
+ const alpha = useSharedValue(0);
+
+ const backgroundStyles = useAnimatedStyle(() => {
+ return {
+ backgroundColor: `rgba(200, 200, 200, ${alpha.value})`,
+ };
+ });
+
+ const tapHandler = useTap({
+ onEnd: (_e, success) => {
+ 'worklet';
+ alpha.value = withTiming(0, { duration: TAP_ANIMATION_DURATION });
+
+ if (success) {
+ runOnJS(append)(text);
+ }
+ },
+ onBegin: (_e) => {
+ 'worklet';
+ alpha.value = withTiming(0.75, { duration: TAP_ANIMATION_DURATION });
+ },
+ });
+
+ return (
+
+
+
+ {text}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ home: {
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ zIndex: 0,
+ },
+ numPad: {
+ width: '92%',
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+ output: {
+ backgroundColor: 'white',
+ width: '100%',
+ height: '100%',
+ zIndex: 1,
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ expression: {
+ width: '100%',
+ flexDirection: 'column',
+ padding: 30,
+ },
+ expressionText: {
+ fontSize: 35,
+ alignSelf: 'flex-end',
+ },
+ expressionResult: {
+ fontSize: 24,
+ alignSelf: 'flex-end',
+ color: 'rgb(64, 64, 64)',
+ },
+ handleView: {
+ width: '100%',
+ height: 50,
+ flexDirection: 'column',
+ justifyContent: 'center',
+ },
+ handle: {
+ width: 50,
+ height: 8,
+ backgroundColor: 'rgb(200, 200, 200)',
+ alignSelf: 'center',
+ borderRadius: 10,
+ },
+ input: {
+ display: 'flex',
+ flexDirection: 'row',
+ },
+ operations: {
+ backgroundColor: 'blue',
+ width: '86%',
+ },
+ button: {
+ width: '33.33%',
+ height: '33.33%',
+ aspectRatio: 1,
+ padding: 15,
+ },
+ buttonText: {
+ alignSelf: 'center',
+ fontSize: 25,
+ },
+ buttonTextContainer: {
+ borderRadius: 150,
+ width: '100%',
+ height: '100%',
+ justifyContent: 'center',
+ },
+});
diff --git a/apps/common-app/src/v3_api/camera/index.tsx b/apps/common-app/src/v3_api/camera/index.tsx
new file mode 100644
index 0000000000..cd8ca73c71
--- /dev/null
+++ b/apps/common-app/src/v3_api/camera/index.tsx
@@ -0,0 +1,272 @@
+import React, { useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+import {
+ NativeDetector,
+ useExclusive,
+ useLongPress,
+ usePan,
+ usePinch,
+ useRace,
+ useSimultaneous,
+ useTap,
+} from 'react-native-gesture-handler';
+import Animated, {
+ SharedValue,
+ runOnJS,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { Circle, Svg } from 'react-native-svg';
+import AnimatedCameraView from '../../new_api/camera/AnimatedCameraView';
+
+const FILTERS = ['red', 'green', 'blue', 'yellow', 'orange', 'cyan'];
+const CAROUSEL_SIZE = 100;
+const FILTER_SIZE = 60;
+const VIDEO_DURATION = 20000;
+const RECORD_INDICATOR_STROKE = 10;
+
+const AnimatedCircle = Animated.createAnimatedComponent(Circle);
+
+export default function Camera() {
+ const [facing, setFacing] = useState<'front' | 'back'>('back');
+ const selectedFilter = useSharedValue(0);
+ const captureProgress = useSharedValue(0);
+ const zoom = useSharedValue(1);
+
+ const filterChangeGesture = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ selectedFilter.value -= e.handlerData.changeX / FILTER_SIZE;
+ },
+ onEnd: () => {
+ 'worklet';
+ const nextFilter = Math.min(
+ FILTERS.length - 1,
+ Math.max(0, Math.round(selectedFilter.value))
+ );
+ selectedFilter.value = withTiming(nextFilter, { duration: 150 });
+ },
+ });
+
+ function takePhoto() {
+ alert("I didn't bother to implement this :)");
+ }
+
+ function startRecording() {
+ // no-op
+ }
+
+ function stopRecording() {
+ alert("I didn't bother to implement this either :)");
+ }
+
+ const takePhotoGesture = useTap({
+ onEnd: () => {
+ 'worklet';
+ runOnJS(takePhoto)();
+ captureProgress.value = withTiming(0, { duration: 1000 });
+ },
+ });
+
+ const takeVideoGesture = useLongPress({
+ shouldCancelWhenOutside: false,
+ maxDistance: 10000,
+ onStart: () => {
+ 'worklet';
+ runOnJS(startRecording)();
+ captureProgress.value = withTiming(1, { duration: VIDEO_DURATION });
+ },
+ onEnd: () => {
+ 'worklet';
+ runOnJS(stopRecording)();
+ captureProgress.value = 0;
+ },
+ });
+
+ const panZoomGesture = usePan({
+ shouldCancelWhenOutside: false,
+ requireExternalGestureToFail: filterChangeGesture,
+ onUpdate: (e) => {
+ zoom.value = Math.max(
+ 1,
+ Math.min(2, zoom.value - e.handlerData.changeY / 500)
+ );
+ },
+ });
+
+ const pinchZoomGesture = usePinch({
+ onUpdate: (e) => {
+ zoom.value = Math.max(
+ 1,
+ Math.min(2, zoom.value * ((e.handlerData.scaleChange - 1) * 0.2 + 1))
+ );
+ console.log(zoom.value);
+ },
+ });
+
+ const changeCameraGesture = useTap({
+ numberOfTaps: 2,
+ onEnd: () => {
+ 'worklet';
+ setFacing((f) => (f === 'back' ? 'front' : 'back'));
+ },
+ disableReanimated: true,
+ });
+
+ const raceGesture = useRace(pinchZoomGesture, changeCameraGesture);
+ const simultanousGesture = useSimultaneous(panZoomGesture, takeVideoGesture);
+ const exclusiveGesture = useExclusive(simultanousGesture, takePhotoGesture);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface FilterCarouselProps {
+ filters: string[];
+ selected: SharedValue;
+}
+
+function FilterCarousel(props: FilterCarouselProps) {
+ const style = useAnimatedStyle(() => {
+ return {
+ flexDirection: 'row',
+ position: 'absolute',
+ left: '50%',
+ gap: FILTER_SIZE * 0.4,
+ transform: [
+ {
+ translateX:
+ -FILTER_SIZE / 2 - FILTER_SIZE * 1.4 * props.selected.value,
+ },
+ ],
+ };
+ });
+
+ return (
+
+ {props.filters.map((filter) => (
+
+ ))}
+
+ );
+}
+
+interface FilterOverlayProps {
+ filters: string[];
+ selected: SharedValue;
+}
+
+export function FilterOverlay(props: FilterOverlayProps) {
+ const style = useAnimatedStyle(() => {
+ const progress = props.selected.value % 1;
+
+ return {
+ opacity: 0.3 * (Math.abs(progress - 0.5) * 2),
+ backgroundColor: props.filters[Math.round(props.selected.value)],
+ };
+ });
+
+ return (
+
+ );
+}
+
+interface CaptureButtonProps {
+ progress: SharedValue;
+}
+
+function CaptureButton(props: CaptureButtonProps) {
+ const radius = CAROUSEL_SIZE / 2;
+ const svgRadius = CAROUSEL_SIZE / 2 - RECORD_INDICATOR_STROKE * 0.5;
+ const circumference = svgRadius * 2 * Math.PI;
+
+ const animatedProps = useAnimatedProps(() => {
+ const svgProgress = 100 - props.progress.value * 100;
+ return {
+ strokeDashoffset: svgRadius * Math.PI * 2 * (svgProgress / 100),
+ };
+ });
+
+ return (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ carouselContainer: {
+ position: 'absolute',
+ left: 0,
+ bottom: 32,
+ height: CAROUSEL_SIZE,
+ width: '100%',
+ justifyContent: 'center',
+ },
+ shutterContainer: {
+ position: 'absolute',
+ top: 0,
+ left: '50%',
+ width: CAROUSEL_SIZE,
+ height: CAROUSEL_SIZE,
+ transform: [{ translateX: -CAROUSEL_SIZE / 2 }],
+ },
+ shutterButtonBackground: {
+ width: CAROUSEL_SIZE,
+ height: CAROUSEL_SIZE,
+ borderRadius: CAROUSEL_SIZE / 2,
+ borderWidth: RECORD_INDICATOR_STROKE,
+ borderColor: 'white',
+ position: 'absolute',
+ },
+ shutterButtonRecordingIndicator: {
+ width: CAROUSEL_SIZE,
+ height: CAROUSEL_SIZE,
+ position: 'absolute',
+ top: 0,
+ left: '50%',
+ transform: [{ translateX: -CAROUSEL_SIZE / 2 }, { rotateZ: '-90deg' }],
+ },
+});
diff --git a/apps/common-app/src/v3_api/chat_heads/index.tsx b/apps/common-app/src/v3_api/chat_heads/index.tsx
new file mode 100644
index 0000000000..622a35c743
--- /dev/null
+++ b/apps/common-app/src/v3_api/chat_heads/index.tsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import { StyleSheet, ImageStyle, LayoutChangeEvent } from 'react-native';
+import { NativeDetector, usePan } from 'react-native-gesture-handler';
+import Animated, {
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useHeaderHeight } from '@react-navigation/elements';
+
+const CHAT_HEADS = [
+ { imageUrl: 'https://avatars0.githubusercontent.com/u/379606?v=4&s=460' },
+ { imageUrl: 'https://avatars3.githubusercontent.com/u/90494?v=4&s=460' },
+ { imageUrl: 'https://avatars3.githubusercontent.com/u/726445?v=4&s=460' },
+ { imageUrl: 'https://avatars.githubusercontent.com/u/15989228?v=4&s=460' },
+];
+
+interface AnimatedOffset {
+ x: Animated.SharedValue;
+ y: Animated.SharedValue;
+}
+
+interface FollowingChatHeadProps {
+ imageUri: string;
+ offset: AnimatedOffset;
+ offsetToFollow: AnimatedOffset;
+ style?: ImageStyle;
+}
+
+function FollowingChatHead({
+ imageUri,
+ style,
+ offset,
+ offsetToFollow,
+}: FollowingChatHeadProps) {
+ useDerivedValue(() => {
+ offset.x.value = withSpring(offsetToFollow.x.value);
+ offset.y.value = withSpring(offsetToFollow.y.value);
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ { translateX: offset.x.value },
+ { translateY: offset.y.value },
+ ],
+ };
+ });
+
+ return (
+
+ );
+}
+
+function useOffsetAnimatedValue() {
+ return {
+ x: useSharedValue(0),
+ y: useSharedValue(0),
+ };
+}
+
+function clampToValues({
+ value,
+ bottom,
+ top,
+}: {
+ value: number;
+ bottom: number;
+ top: number;
+}) {
+ 'worklet';
+ return Math.max(bottom, Math.min(value, top));
+}
+
+const Example = () => {
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
+ const panOffset = useOffsetAnimatedValue();
+ const mainChatHeadPosition = useOffsetAnimatedValue();
+ const chatHeadsOffsets = CHAT_HEADS.map(useOffsetAnimatedValue);
+ const headerHeight = useHeaderHeight();
+
+ const onLayout = ({ nativeEvent }: LayoutChangeEvent) => {
+ const { width, height } = nativeEvent.layout;
+ setDimensions({ width, height });
+ };
+
+ const panHandler = usePan({
+ onUpdate: (e) => {
+ 'worklet';
+ panOffset.x.value =
+ mainChatHeadPosition.x.value + e.handlerData.translationX;
+ panOffset.y.value =
+ mainChatHeadPosition.y.value + e.handlerData.translationY;
+ },
+ onEnd: (e) => {
+ 'worklet';
+ const { height, width } = dimensions;
+
+ const velocityDragX = clampToValues({
+ value: e.handlerData.velocityX * 0.05,
+ bottom: -100,
+ top: 100,
+ });
+ const velocityDragY = clampToValues({
+ value: e.handlerData.velocityY * 0.05,
+ bottom: -100,
+ top: 100,
+ });
+
+ const distFromTop =
+ e.handlerData.absoluteY + velocityDragY - headerHeight;
+ const distFromBottom = height + velocityDragY - e.handlerData.absoluteY;
+ const distFromLeft = e.handlerData.absoluteX + velocityDragX;
+ const distFromRight = width - e.handlerData.absoluteX + velocityDragX;
+
+ const minDist = Math.min(
+ distFromTop,
+ distFromBottom,
+ distFromLeft,
+ distFromRight
+ );
+
+ // drag to the edge
+ switch (minDist) {
+ case distFromTop: {
+ panOffset.y.value = withSpring(-IMAGE_SIZE / 2);
+ panOffset.x.value = withSpring(panOffset.x.value + velocityDragX);
+ mainChatHeadPosition.y.value = -IMAGE_SIZE / 2;
+ mainChatHeadPosition.x.value = panOffset.x.value;
+ break;
+ }
+ case distFromBottom: {
+ panOffset.y.value = withSpring(height - IMAGE_SIZE / 2);
+ panOffset.x.value = withSpring(panOffset.x.value + velocityDragX);
+ mainChatHeadPosition.y.value = height - IMAGE_SIZE / 2;
+ mainChatHeadPosition.x.value = panOffset.x.value;
+ break;
+ }
+ case distFromLeft: {
+ panOffset.x.value = withSpring(-IMAGE_SIZE / 2);
+ panOffset.y.value = withSpring(panOffset.y.value + velocityDragY);
+ mainChatHeadPosition.x.value = -IMAGE_SIZE / 2;
+ mainChatHeadPosition.y.value = panOffset.y.value;
+ break;
+ }
+ case distFromRight: {
+ panOffset.x.value = withSpring(width - IMAGE_SIZE / 2);
+ panOffset.y.value = withSpring(panOffset.y.value + velocityDragY);
+ mainChatHeadPosition.x.value = width - IMAGE_SIZE / 2;
+ mainChatHeadPosition.y.value = panOffset.y.value;
+ break;
+ }
+ }
+ },
+ });
+
+ const headsComponents = CHAT_HEADS.map(({ imageUrl }, idx) => {
+ const headOffset = chatHeadsOffsets[idx];
+ if (idx === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ });
+
+ return (
+
+ {/* we want ChatHead with gesture on top */}
+ {headsComponents.reverse()}
+
+ );
+};
+
+export default Example;
+
+const IMAGE_SIZE = 80;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ box: {
+ position: 'absolute',
+ width: IMAGE_SIZE,
+ height: IMAGE_SIZE,
+ borderColor: '#F5FCFF',
+ backgroundColor: 'plum',
+ borderRadius: IMAGE_SIZE / 2,
+ },
+});
diff --git a/apps/common-app/src/v3_api/fling/fling.tsx b/apps/common-app/src/v3_api/fling/fling.tsx
new file mode 100644
index 0000000000..ba6c0c3e14
--- /dev/null
+++ b/apps/common-app/src/v3_api/fling/fling.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+import {
+ Directions,
+ NativeDetector,
+ useFling,
+} from 'react-native-gesture-handler';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ Easing,
+} from 'react-native-reanimated';
+
+export default function V3Fling() {
+ const position = useSharedValue(0);
+ const beginPosition = useSharedValue(0);
+
+ const flingGesture = useFling({
+ direction: Directions.LEFT | Directions.RIGHT,
+ onBegin: (e) => {
+ 'worklet';
+ beginPosition.value = e.handlerData.x;
+ },
+ onStart: (e) => {
+ 'worklet';
+ const direction = Math.sign(e.handlerData.x - beginPosition.value);
+ position.value = withTiming(position.value + direction * 50, {
+ duration: 300,
+ easing: Easing.bounce,
+ });
+ },
+ });
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: position.value }],
+ }));
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ centerView: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ box: {
+ height: 120,
+ width: 120,
+ backgroundColor: '#b58df1',
+ marginBottom: 30,
+ },
+});
diff --git a/apps/common-app/src/v3_api/hover/index.tsx b/apps/common-app/src/v3_api/hover/index.tsx
new file mode 100644
index 0000000000..c12aed2b86
--- /dev/null
+++ b/apps/common-app/src/v3_api/hover/index.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { View, Text } from 'react-native';
+import {
+ NativeDetector,
+ HoverEffect,
+ useHover,
+} from 'react-native-gesture-handler';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+} from 'react-native-reanimated';
+
+function useColoredHover(color: string) {
+ const hovered = useSharedValue(false);
+
+ const style = useAnimatedStyle(() => ({
+ opacity: hovered.value ? 0.5 : 1,
+ }));
+
+ const gesture = useHover({
+ onBegin: () => {
+ 'worklet';
+ hovered.value = true;
+ console.log('hover begin', color);
+ },
+ onStart: () => {
+ 'worklet';
+ console.log('hover start', color);
+ },
+ onEnd: (_, success) => {
+ 'worklet';
+ console.log('hover end', color, 'failed', !success);
+ },
+ onFinalize: () => {
+ 'worklet';
+ hovered.value = false;
+ console.log('hover finalize', color);
+ },
+ hoverEffect: HoverEffect.LIFT,
+ });
+
+ return [gesture, style] as const;
+}
+
+export default function Example() {
+ const [hover1, style1] = useColoredHover('red');
+
+ const [hover2, style2] = useColoredHover('green');
+
+ const [hover3, style3] = useColoredHover('red');
+
+ const [hover4, style4] = useColoredHover('green');
+
+ const [hover5, style5] = useColoredHover('blue');
+
+ return (
+
+ Parent & child
+
+
+
+
+
+
+
+
+
+
+ Absolute positioning
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/common-app/src/v3_api/hoverable_icons/index.tsx b/apps/common-app/src/v3_api/hoverable_icons/index.tsx
new file mode 100644
index 0000000000..f36f3d1d89
--- /dev/null
+++ b/apps/common-app/src/v3_api/hoverable_icons/index.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import {
+ HoverEffect,
+ NativeDetector,
+ useHover,
+} from 'react-native-gesture-handler';
+import Animated, {
+ useAnimatedStyle,
+ useFrameCallback,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { Platform, StyleSheet } from 'react-native';
+
+// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires
+const SVG = require('../../new_api/hoverable_icons/svg.png');
+// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires
+const FREEZE = require('../../new_api/hoverable_icons/freeze.png');
+// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires
+const REA = require('../../new_api/hoverable_icons/rea.png');
+// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires
+const GH = require('../../new_api/hoverable_icons/gh.png');
+// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires
+const SCREENS = require('../../new_api/hoverable_icons/screens.png');
+
+const images = [GH, REA, SCREENS, SVG, FREEZE];
+const SIZE = 100;
+
+function BoxReanimated(props: { source: any }) {
+ const scale = useSharedValue(1);
+ const offsetX = useSharedValue(0);
+ const targetOffsetX = useSharedValue(0);
+ const offsetY = useSharedValue(0);
+ const targetOffsetY = useSharedValue(0);
+
+ useFrameCallback((frame) => {
+ offsetX.value +=
+ ((targetOffsetX.value - offsetX.value) *
+ 0.15 *
+ (frame.timeSincePreviousFrame ?? 1)) /
+ 16;
+ offsetY.value +=
+ ((targetOffsetY.value - offsetY.value) *
+ 0.15 *
+ (frame.timeSincePreviousFrame ?? 1)) /
+ 16;
+ });
+
+ const style = useAnimatedStyle(() => ({
+ transform: [
+ { scale: scale.value },
+ { translateX: offsetX.value },
+ { translateY: offsetY.value },
+ ],
+ }));
+
+ const hover = useHover({
+ onBegin: () => {
+ 'worklet';
+ scale.value = withTiming(1.15, { duration: 100 });
+ },
+ onUpdate: (e) => {
+ 'worklet';
+ const oX = e.handlerData.x - SIZE / 2;
+ const oY = e.handlerData.y - SIZE / 2;
+
+ targetOffsetX.value = Math.pow(Math.abs(oX), 0.3) * Math.sign(oX);
+ targetOffsetY.value = Math.pow(Math.abs(oY), 0.3) * Math.sign(oY);
+ },
+ onFinalize: () => {
+ 'worklet';
+ scale.value = withTiming(1, { duration: 100 });
+ targetOffsetX.value = 0;
+ targetOffsetY.value = 0;
+ },
+ });
+
+ return (
+
+
+
+
+
+ );
+}
+
+function BoxNative(props: { source: any }) {
+ const hover = useHover({
+ hoverEffect: HoverEffect.LIFT,
+ });
+
+ return (
+
+
+
+ );
+}
+
+export default function Example() {
+ const BoxComponent = Platform.OS === 'ios' ? BoxNative : BoxReanimated;
+
+ return (
+
+
+ {images.map((source, index) => (
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ image: {
+ overflow: 'visible',
+ width: SIZE,
+ height: SIZE,
+ marginHorizontal: 8,
+ },
+});
diff --git a/apps/common-app/src/v3_api/lock/lock.tsx b/apps/common-app/src/v3_api/lock/lock.tsx
new file mode 100644
index 0000000000..9129beb2df
--- /dev/null
+++ b/apps/common-app/src/v3_api/lock/lock.tsx
@@ -0,0 +1,163 @@
+import React, { useState } from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ runOnJS,
+} from 'react-native-reanimated';
+import {
+ NativeDetector,
+ useExclusive,
+ useLongPress,
+ usePinch,
+ useRotation,
+ useSimultaneous,
+ useTap,
+} from 'react-native-gesture-handler';
+
+export default function Lock() {
+ const rotation = useSharedValue(-Math.PI / 2);
+ const savedRotation = useSharedValue(-Math.PI / 2);
+
+ const scale = useSharedValue(0.6);
+ const savedScale = useSharedValue(1);
+
+ const [locked, setLocked] = useState(true);
+ const snapThreshold = 0.4;
+ const scaleThreshold = 0.1;
+ const minScale = 0.5;
+ const maxScale = 1;
+ const TWO_PI = 2 * Math.PI;
+
+ // Tap to lock
+ const tap = useTap({
+ onEnd: () => {
+ 'worklet';
+ if (savedRotation.value === 0 && scale.value === maxScale) {
+ runOnJS(setLocked)(false);
+ }
+ },
+ });
+
+ // Long press to cancel tap
+ const longPress = useLongPress({});
+
+ const confirm = useExclusive(longPress, tap);
+
+ const rotationGesture = useRotation({
+ onUpdate: (e) => {
+ 'worklet';
+ rotation.value = savedRotation.value + e.handlerData.rotation;
+
+ if (!locked) {
+ runOnJS(setLocked)(true);
+ }
+ },
+ onEnd: () => {
+ 'worklet';
+
+ const nearestMultiple = Math.round(rotation.value / TWO_PI) * TWO_PI;
+
+ if (Math.abs(rotation.value - nearestMultiple) < snapThreshold) {
+ rotation.value = withTiming(nearestMultiple, { duration: 300 });
+ savedRotation.value = 0;
+ } else {
+ rotation.value = withTiming(-Math.PI / 2, { duration: 300 });
+ savedRotation.value = -Math.PI / 2;
+ }
+ },
+ simultaneousWithExternalGesture: confirm,
+ });
+
+ const pinchGesture = usePinch({
+ onUpdate: (e) => {
+ 'worklet';
+ const value = savedScale.value * e.handlerData.scale;
+ if (value < minScale || value > maxScale) {
+ return;
+ }
+ scale.value = value;
+
+ if (!locked) {
+ runOnJS(setLocked)(true);
+ }
+ },
+ onEnd: () => {
+ 'worklet';
+
+ if (Math.abs(scale.value - maxScale) < scaleThreshold) {
+ scale.value = withTiming(maxScale, { duration: 300 });
+ } else {
+ scale.value = withTiming(minScale, { duration: 300 });
+ }
+ savedScale.value = scale.value;
+ },
+ simultaneousWithExternalGesture: confirm,
+ });
+
+ const unlockingGesture = useSimultaneous(rotationGesture, pinchGesture);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ { rotateZ: `${(rotation.value / Math.PI) * 180}deg` },
+ { scale: scale.value },
+ ],
+ }));
+
+ return (
+
+
+
+
+
+ {locked ? '🔒' : '🔓'}
+
+
+
+
+ {locked ? 'Locked' : 'Unlocked!'}
+
+ Tou unlock rotate 90 degrees clockwise, and scale to fill the square.
+ Then tap to confirm, longpress to cancel the tap
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ lockIcon: {
+ fontSize: 40,
+ color: '#fff',
+ fontWeight: 'bold',
+ },
+ instructions: {
+ color: '#9CA3AF',
+ marginTop: 8,
+ textAlign: 'center',
+ paddingHorizontal: 16,
+ },
+
+ outerBox: {
+ height: 200,
+ width: 200,
+ backgroundColor: 'gray',
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 50,
+ },
+ box: {
+ height: 200,
+ width: 200,
+ backgroundColor: '#b58df1',
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/apps/common-app/src/v3_api/overlap/index.tsx b/apps/common-app/src/v3_api/overlap/index.tsx
new file mode 100644
index 0000000000..49d3eed671
--- /dev/null
+++ b/apps/common-app/src/v3_api/overlap/index.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import { StyleSheet, View, Text } from 'react-native';
+import {
+ useTap,
+ NativeDetector,
+ LogicDetector,
+} from 'react-native-gesture-handler';
+
+function Box(props: {
+ color: string;
+ overlap?: boolean;
+ children?: React.ReactNode;
+ elevated: boolean;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function OverlapSiblings() {
+ const [elevated, setElevated] = React.useState('');
+
+ const tapRed = useTap({
+ onEnd: (_e, success) => {
+ 'worklet';
+ if (success) {
+ setElevated('red');
+ }
+ },
+ disableReanimated: true,
+ });
+
+ const tapGreen = useTap({
+ onEnd: (_e, success) => {
+ 'worklet';
+ if (success) {
+ setElevated('green');
+ }
+ },
+ disableReanimated: true,
+ });
+
+ return (
+
+ Overlap Siblings
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function OverlapParents() {
+ const [elevated, setElevated] = React.useState('');
+
+ const tapRed = useTap({
+ onEnd: (_e, success) => {
+ 'worklet';
+ console.log('tap red');
+ if (success) {
+ setElevated('red');
+ }
+ },
+ disableReanimated: true,
+ });
+
+ const tapGreen = useTap({
+ onEnd: (_e, success) => {
+ console.log('tap green');
+ if (success) {
+ setElevated('green');
+ }
+ },
+ disableReanimated: true,
+ });
+
+ return (
+
+ Overlap Child
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function Example() {
+ return (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ row: {
+ padding: 30,
+ alignItems: 'center',
+ height: 225,
+ marginTop: 48,
+ },
+ box: {
+ width: 150,
+ height: 150,
+ },
+ overlap: {
+ position: 'absolute',
+ left: 75,
+ top: 75,
+ },
+ text: {
+ fontSize: 24,
+ margin: 4,
+ },
+ elevated: {
+ zIndex: 10,
+ elevation: 16,
+ shadowColor: 'black',
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.5,
+ shadowRadius: 8,
+ },
+});
diff --git a/apps/common-app/src/v3_api/svg/svg.tsx b/apps/common-app/src/v3_api/svg/svg.tsx
new file mode 100644
index 0000000000..c93df1bce6
--- /dev/null
+++ b/apps/common-app/src/v3_api/svg/svg.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { Text, View, StyleSheet } from 'react-native';
+import {
+ NativeDetector,
+ LogicDetector,
+ useTap,
+} from 'react-native-gesture-handler';
+
+import Svg, { Circle, Rect } from 'react-native-svg';
+
+export default function LogicDetectorExample() {
+ const circleElementTap = useTap({
+ onStart: () => {
+ 'worklet';
+ console.log('clicked circle');
+ },
+ });
+ const rectElementTap = useTap({
+ onStart: () => {
+ 'worklet';
+ console.log('clicked parallelogram');
+ },
+ });
+ const containerTap = useTap({
+ onStart: () => {
+ 'worklet';
+ console.log('clicked container');
+ },
+ });
+
+ return (
+
+
+
+ Overlapping SVGs with gesture detectors
+
+
+
+
+
+
+
+ Tapping each color should read to a different console.log output
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 48,
+ },
+ header: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ margin: 10,
+ },
+});
diff --git a/apps/common-app/src/v3_api/velocity_test/index.tsx b/apps/common-app/src/v3_api/velocity_test/index.tsx
new file mode 100644
index 0000000000..8b6e9c7653
--- /dev/null
+++ b/apps/common-app/src/v3_api/velocity_test/index.tsx
@@ -0,0 +1,106 @@
+import { StyleSheet, View } from 'react-native';
+import Animated, {
+ interpolateColor,
+ measure,
+ useAnimatedRef,
+ useAnimatedStyle,
+ useSharedValue,
+ withDecay,
+ withTiming,
+} from 'react-native-reanimated';
+
+import React from 'react';
+import { NativeDetector, usePan } from 'react-native-gesture-handler';
+
+const BOX_SIZE = 120;
+
+export default function App() {
+ const aref = useAnimatedRef();
+ const offsetX = useSharedValue(0);
+ const offsetY = useSharedValue(0);
+ const isPressed = useSharedValue(false);
+ const colorProgress = useSharedValue(0);
+
+ const pan = usePan({
+ onBegin: () => {
+ 'worklet';
+ isPressed.value = true;
+ colorProgress.value = withTiming(1, {
+ duration: 100,
+ });
+ },
+ onUpdate: (event) => {
+ 'worklet';
+ offsetX.value += event.handlerData.changeX;
+ offsetY.value += event.handlerData.changeY;
+ },
+ onFinalize: (event) => {
+ 'worklet';
+ isPressed.value = false;
+ colorProgress.value = withTiming(0, {
+ duration: 100,
+ });
+ // If we can't get view size, just ignore it. Half of the view will be
+ // able to go outside the screen
+ const size = measure(aref) ?? { width: 0, height: 0 };
+
+ offsetX.value = withDecay({
+ velocity: event.handlerData.velocityX,
+ clamp: [-size.width / 2 + BOX_SIZE / 2, size.width / 2 - BOX_SIZE / 2],
+ rubberBandEffect: true,
+ rubberBandFactor: 0.75,
+ });
+
+ offsetY.value = withDecay({
+ velocity: event.handlerData.velocityY,
+ clamp: [
+ -size.height / 2 + BOX_SIZE / 2,
+ size.height / 2 - BOX_SIZE / 2,
+ ],
+ rubberBandEffect: true,
+ rubberBandFactor: 0.75,
+ });
+ },
+ });
+
+ const animatedStyles = useAnimatedStyle(() => {
+ const backgroundColor = interpolateColor(
+ colorProgress.value,
+ [0, 1],
+ ['#0a2688', '#6fcef5']
+ );
+
+ return {
+ transform: [
+ { translateX: offsetX.value },
+ { translateY: offsetY.value },
+ { scale: withTiming(isPressed.value ? 1.2 : 1, { duration: 100 }) },
+ ],
+ backgroundColor,
+ };
+ });
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ },
+ box: {
+ width: BOX_SIZE,
+ height: BOX_SIZE,
+ borderRadius: BOX_SIZE / 2,
+ // @ts-expect-error `grab` is correct value for `cursor` property
+ cursor: 'grab',
+ },
+});