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