diff --git a/apps/common-app/App.tsx b/apps/common-app/App.tsx index 2e4a95d279..0b0c530413 100644 --- a/apps/common-app/App.tsx +++ b/apps/common-app/App.tsx @@ -76,6 +76,18 @@ 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 V3NestedText from './src/v3_api/nested_text/nested_text'; import { Icon } from '@swmansion/icons'; interface Example { @@ -93,6 +105,23 @@ 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 }, + { name: 'V3 Nested Text', component: V3NestedText }, + ], + }, { sectionTitle: 'New api', data: [ diff --git a/apps/common-app/src/common.tsx b/apps/common-app/src/common.tsx index 0a7436e840..f5692d8da1 100644 --- a/apps/common-app/src/common.tsx +++ b/apps/common-app/src/common.tsx @@ -1,10 +1,21 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Text, StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; const styles = StyleSheet.create({ lipsum: { padding: 10, }, + feedback: { + marginTop: 20, + fontSize: 16, + fontWeight: '600', + }, }); type Props = { @@ -25,9 +36,84 @@ export class LoremIpsum extends React.Component { } } +type FeedbackProps = { + text: string; + highlight: string; + color?: string; + resetState?: () => void; + duration?: number; +}; + +// this piece of code is certainly not beutiful, but functional. It enables simple testing without the console +export function Feedback({ + text, + highlight, + color = 'black', + resetState, + duration = 1000, +}: FeedbackProps) { + const opacity = useSharedValue(0); + const [activeHighlight, setActiveHighlight] = useState(highlight); + const [activeText, setActiveText] = useState(text); + const [activeColor, setActiveColor] = useState(color); + const timerRef = useRef(null); + useEffect(() => { + if (highlight === '') { + return; + } + if (timerRef.current) { + clearTimeout(timerRef.current); + } + setActiveHighlight(highlight); + setActiveText(text); + setActiveColor(color); + opacity.value = 1; + if (resetState) { + resetState(); + } + timerRef.current = setTimeout(() => { + opacity.value = withTiming(0, { + duration: 500, + easing: Easing.out(Easing.ease), + }); + }, duration); + }, [text, highlight, opacity, duration]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + if (!activeHighlight || !activeText.includes(activeHighlight)) { + return ( + + {text} + + ); + } + + const parts = activeText.split(activeHighlight); + + return ( + + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + {activeHighlight} + )} + + ))} + + ); +} + export const COLORS = { offWhite: '#f8f9ff', headerSeparator: '#eef0ff', + NAVY: '#001A72', + KINDA_RED: '#FFB2AD', + YELLOW: '#FFF096', + KINDA_GREEN: '#C4E7DB', + KINDA_BLUE: '#A0D5EF', }; const LOREM_IPSUM = ` 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..6626c29d04 --- /dev/null +++ b/apps/common-app/src/v3_api/bottom_sheet/index.tsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { + Dimensions, + NativeScrollEvent, + NativeSyntheticEvent, + StyleSheet, + View, +} from 'react-native'; +import { + GestureDetector, + PanGestureEvent, + useSimultaneousGestures, + usePanGesture, + useTapGesture, + useNativeGesture, +} 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 onHandlerDeactivate = (e: PanGestureEvent) => { + 'worklet'; + const dragToss = 0.01; + const endOffsetY = + bottomSheetTranslateY.value + translationY.value + e.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 = usePanGesture({ + 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.translationY - scrollOffset.value; + } else { + translationY.value = e.translationY; + } + }, + onDeactivate: onHandlerDeactivate, + }); + + const blockScrollUntilAtTheTop = useTapGesture({ + maxDeltaY: snapPoint - FULLY_OPEN_SNAP_POINT, + maxDuration: 100000, + simultaneousWith: panGesture, + }); + + const headerGesture = usePanGesture({ + onUpdate: (e) => { + 'worklet'; + translationY.value = e.translationY; + }, + onDeactivate: onHandlerDeactivate, + }); + + const scrollViewGesture = useNativeGesture({ + requireToFail: 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 = useSimultaneousGestures( + 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..701ef11959 --- /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, + usePanGesture, + useTapGesture, + GestureDetector, +} 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 = usePanGesture({ + onUpdate: (e) => { + 'worklet'; + const translatedOffset = dragOffset.value + e.translationY; + + if (translatedOffset > -offset.value) { + drag.value = -offset.value; + } else if (translatedOffset < 0) { + drag.value = 0; + } else { + drag.value = translatedOffset; + } + }, + onDeactivate: (e) => { + 'worklet'; + const translatedOffset = dragOffset.value + e.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