diff --git a/apps/ExpoExample/.gitignore b/apps/ExpoExample/.gitignore new file mode 100644 index 0000000000..41fcb5c328 --- /dev/null +++ b/apps/ExpoExample/.gitignore @@ -0,0 +1,40 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +android +ios diff --git a/apps/ExpoExample/App.tsx b/apps/ExpoExample/App.tsx new file mode 100644 index 0000000000..8eec79603b --- /dev/null +++ b/apps/ExpoExample/App.tsx @@ -0,0 +1,462 @@ +import React, { useEffect } from 'react'; +import { + Text, + View, + StyleSheet, + Platform, + Dimensions, + StatusBar, + SafeAreaView, +} from 'react-native'; +import { + createStackNavigator, + StackNavigationProp, + StackScreenProps, +} from '@react-navigation/stack'; +import { NavigationContainer, ParamListBase } from '@react-navigation/native'; +import { + GestureHandlerRootView, + RectButton, + Switch, +} from 'react-native-gesture-handler'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import OverflowParent from './src/release_tests/overflowParent'; +import DoublePinchRotate from './src/release_tests/doubleScalePinchAndRotate'; +import DoubleDraggable from './src/release_tests/doubleDraggable'; +import GesturizedPressable from './src/release_tests/gesturizedPressable'; +import { ComboWithGHScroll } from './src/release_tests/combo'; +import { + TouchablesIndex, + TouchableExample, +} from './src/release_tests/touchables'; +import Rows from './src/release_tests/rows'; +import NestedFling from './src/release_tests/nestedFling'; +import MouseButtons from './src/release_tests/mouseButtons'; +import ContextMenu from './src/release_tests/contextMenu'; +import NestedTouchables from './src/release_tests/nestedTouchables'; +import NestedPressables from './src/release_tests/nestedPressables'; +import NestedButtons from './src/release_tests/nestedButtons'; +import PointerType from './src/release_tests/pointerType'; +import SwipeableReanimation from './src/release_tests/swipeableReanimation'; +import NestedGestureHandlerRootViewWithModal from './src/release_tests/nestedGHRootViewWithModal'; +import TwoFingerPan from './src/release_tests/twoFingerPan'; +import SvgCompatibility from './src/release_tests/svg'; +import NestedText from './src/release_tests/nestedText'; + +import { PinchableBox } from './src/recipes/scaleAndRotate'; +import PanAndScroll from './src/recipes/panAndScroll'; + +import { BottomSheet } from './src/showcase/bottomSheet'; +import Swipeables from './src/showcase/swipeable'; +import ChatHeads from './src/showcase/chatHeads'; + +import Draggable from './src/basic/draggable'; +import MultiTap from './src/basic/multitap'; +import BouncingBox from './src/basic/bouncing'; +import PanResponder from './src/basic/panResponder'; +import HorizontalDrawer from './src/basic/horizontalDrawer'; +import PagerAndDrawer from './src/basic/pagerAndDrawer'; +import ForceTouch from './src/basic/forcetouch'; +import Fling from './src/basic/fling'; +import WebStylesResetExample from './src/release_tests/webStylesReset'; +import StylusData from './src/release_tests/StylusData'; +import ReanimatedDrawerLayout from './src/release_tests/reanimatedDrawerLayout'; + +import Camera from './src/new_api/camera'; +import Transformations from './src/new_api/transformations'; +import Overlap from './src/new_api/overlap'; +import Calculator from './src/new_api/calculator'; +import BottomSheetNewApi from './src/new_api/bottom_sheet'; +import ChatHeadsNewApi from './src/new_api/chat_heads'; +import DragNDrop from './src/new_api/drag_n_drop'; +import BetterHorizontalDrawer from './src/new_api/betterHorizontalDrawer'; +import ManualGestures from './src/new_api/manualGestures/index'; +import Hover from './src/new_api/hover'; +import HoverableIcons from './src/new_api/hoverable_icons'; +import VelocityTest from './src/new_api/velocityTest'; +import Swipeable from './src/new_api/swipeable'; +import Pressable from './src/new_api/pressable'; + +import EmptyExample from './src/empty/EmptyExample'; +import RectButtonBorders from './src/release_tests/rectButton'; +import { ListWithHeader } from './src/ListWithHeader'; +import { COLORS } from './src/common'; + +import MacosDraggable from './src/simple/draggable'; +import Tap from './src/simple/tap'; +import LongPressExample from './src/simple/longPress'; +import ManualExample from './src/simple/manual'; +import SimpleFling from './src/simple/fling'; + +import { Icon } from '@swmansion/icons'; + +interface Example { + name: string; + component: React.ComponentType; + unsupportedPlatforms?: Set; +} +interface ExamplesSection { + sectionTitle: string; + data: Example[]; +} + +const EXAMPLES: ExamplesSection[] = [ + { + sectionTitle: 'Empty', + data: [{ name: 'Empty Example', component: EmptyExample }], + }, + { + sectionTitle: 'New api', + data: [ + { name: 'Ball with velocity', component: VelocityTest }, + { name: 'Camera', component: Camera }, + { name: 'Transformations', component: Transformations }, + { name: 'Overlap', component: Overlap }, + { name: 'Bottom Sheet', component: BottomSheetNewApi }, + { name: 'Calculator', component: Calculator }, + { name: 'Chat Heads', component: ChatHeadsNewApi }, + { name: 'Drag and drop', component: DragNDrop }, + { name: 'New Swipeable', component: Swipeable }, + { name: 'Pressable', component: Pressable }, + { name: 'Hover', component: Hover }, + { name: 'Hoverable icons', component: HoverableIcons }, + { + name: 'Horizontal Drawer (Reanimated 2 & RNGH 2)', + component: BetterHorizontalDrawer, + }, + { + name: 'Manual gestures', + component: ManualGestures, + }, + ], + }, + { + sectionTitle: 'Basic examples', + data: [ + { name: 'Draggable', component: Draggable }, + { name: 'Multitap', component: MultiTap }, + { name: 'Bouncing box', component: BouncingBox }, + { name: 'Pan responder', component: PanResponder }, + { name: 'Horizontal drawer', component: HorizontalDrawer }, + { + name: 'Pager & drawer', + component: PagerAndDrawer, + unsupportedPlatforms: new Set(['web', 'ios', 'macos']), + }, + { + name: 'Force touch', + component: ForceTouch, + unsupportedPlatforms: new Set(['web', 'android', 'macos']), + }, + { name: 'Fling', component: Fling }, + ], + }, + { + sectionTitle: 'Recipes', + data: [ + { name: 'Pinch & rotate', component: PinchableBox }, + { name: 'Pan & scroll', component: PanAndScroll }, + ], + }, + { + sectionTitle: 'Showcase', + data: [ + { name: 'Bottom sheet', component: BottomSheet }, + { name: 'Swipeables', component: Swipeables }, + { name: 'Chat heads', component: ChatHeads }, + ], + }, + { + sectionTitle: 'Release tests', + data: [ + { + name: 'Views overflowing parents - issue #1532', + component: OverflowParent, + }, + { + name: 'Modals with nested GHRootViews - issue #139', + component: NestedGestureHandlerRootViewWithModal, + }, + { + name: 'Nested Touchables - issue #784', + component: NestedTouchables as React.ComponentType, + }, + { + name: 'Nested Pressables - issue #2980', + component: NestedPressables as React.ComponentType, + }, + { + name: 'Nested buttons (sound & ripple)', + component: NestedButtons, + unsupportedPlatforms: new Set(['web', 'ios', 'macos']), + }, + { + name: 'Svg integration with Gesture Handler', + component: SvgCompatibility, + }, + { name: 'Double pinch & rotate', component: DoublePinchRotate }, + { name: 'Double draggable', component: DoubleDraggable }, + { name: 'Rows', component: Rows }, + { name: 'Nested Fling', component: NestedFling }, + { + name: 'Combo', + component: ComboWithGHScroll, + unsupportedPlatforms: new Set(['web']), + }, + { name: 'Touchables', component: TouchablesIndex as React.ComponentType }, + { name: 'MouseButtons', component: MouseButtons }, + { + name: 'ContextMenu', + component: ContextMenu, + unsupportedPlatforms: new Set(['android', 'ios', 'macos']), + }, + { name: 'PointerType', component: PointerType }, + { name: 'Reanimated Drawer Layout', component: ReanimatedDrawerLayout }, + { name: 'Swipeable Reanimation', component: SwipeableReanimation }, + { name: 'RectButton (borders)', component: RectButtonBorders }, + { name: 'Gesturized pressable', component: GesturizedPressable }, + { + name: 'Web styles reset', + component: WebStylesResetExample, + unsupportedPlatforms: new Set(['android', 'ios', 'macos']), + }, + { name: 'Stylus data', component: StylusData }, + { + name: 'Two finger Pan', + component: TwoFingerPan, + unsupportedPlatforms: new Set(['android', 'macos']), + }, + { + name: 'Nested Text', + component: NestedText, + unsupportedPlatforms: new Set(['macos']), + }, + ], + }, + { + sectionTitle: 'Simple', + data: [ + { name: 'Simple Draggable', component: MacosDraggable }, + { name: 'Tap', component: Tap }, + { name: 'LongPress', component: LongPressExample }, + { name: 'Manual', component: ManualExample }, + { name: 'Simple Fling', component: SimpleFling }, + ], + }, +]; + +const OPEN_LAST_EXAMPLE_KEY = 'openLastExample'; +const LAST_EXAMPLE_KEY = 'lastExample'; + +type RootStackParamList = { + Home: undefined; + TouchableExample: { item: string }; +} & { + [Screen: string]: undefined; +}; + +const Stack = createStackNavigator(); + +export default function App() { + return ( + + + + + + {EXAMPLES.flatMap(({ data }) => data).flatMap( + ({ name, component }) => ( + component} + options={{ title: name }} + /> + ) + )} + + + + + ); +} + +function navigate( + navigation: StackNavigationProp, + dest: string +) { + AsyncStorage.setItem(LAST_EXAMPLE_KEY, dest); + navigation.navigate(dest); +} + +function MainScreen({ navigation }: StackScreenProps) { + useEffect(() => { + AsyncStorage.multiGet([OPEN_LAST_EXAMPLE_KEY, LAST_EXAMPLE_KEY]).then( + ([openLastExample, lastExample]) => { + if (openLastExample[1] === 'true' && lastExample[1]) { + navigate(navigation, lastExample[1]); + } + } + ); + // we only want to run this effect once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + example.name} + ListHeaderComponent={OpenLastExampleSetting} + renderItem={({ item }) => ( + navigate(navigation, name)} + enabled={!item.unsupportedPlatforms?.has(Platform.OS)} + /> + )} + renderSectionHeader={({ section: { sectionTitle } }) => ( + {sectionTitle} + )} + ItemSeparatorComponent={() => } + /> + + ); +} + +function OpenLastExampleSetting() { + const [openLastExample, setOpenLastExample] = React.useState(false); + + useEffect(() => { + AsyncStorage.getItem(OPEN_LAST_EXAMPLE_KEY).then((value) => { + setOpenLastExample(value === 'true'); + }); + }, []); + + function updateSetting(value: boolean) { + AsyncStorage.setItem(OPEN_LAST_EXAMPLE_KEY, value.toString()); + setOpenLastExample(value); + } + + return ( + { + updateSetting(!openLastExample); + }}> + + Open last example on launch + { + updateSetting(!openLastExample); + }} + /> + + + ); +} + +interface MainScreenItemProps { + name: string; + onPressItem: (name: string) => void; + enabled: boolean; +} + +function MainScreenItem({ name, onPressItem, enabled }: MainScreenItemProps) { + return ( + onPressItem(name)}> + {name} + {Platform.OS !== 'macos' && enabled && ( + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.offWhite, + }, + sectionTitle: { + ...(Platform.OS !== 'macos' ? { backgroundColor: '#f8f9ff' } : {}), + ...Platform.select({ + ios: { + fontSize: 17, + fontWeight: '500', + }, + android: { + fontSize: 19, + fontFamily: 'sans-serif-medium', + }, + }), + padding: 16, + color: 'black', + }, + text: { + color: 'black', + }, + list: {}, + separator: { + height: 2, + }, + button: { + flex: 1, + height: 50, + paddingVertical: 10, + paddingHorizontal: 20, + flexDirection: 'row', + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'space-between', + }, + buttonContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + autoOpenSetting: { + margin: 16, + borderRadius: 16, + backgroundColor: '#eef0ff', + paddingHorizontal: 16, + justifyContent: 'space-between', + elevation: 8, + ...(Platform.OS !== 'macos' + ? { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + } + : {}), + }, + unavailableExample: { + backgroundColor: 'rgb(220, 220, 220)', + opacity: 0.3, + }, +}); diff --git a/apps/ExpoExample/app.json b/apps/ExpoExample/app.json new file mode 100644 index 0000000000..1e2f6632b1 --- /dev/null +++ b/apps/ExpoExample/app.json @@ -0,0 +1,51 @@ +{ + "expo": { + "name": "ExpoExample", + "slug": "ExpoExample", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash.png", + "resizeMode": "cover", + "backgroundColor": "#F8F9FF" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.example.ExpoExample", + "buildNumber": "1" + }, + "android": { + "versionCode": 1, + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#F8F9FF" + }, + "edgeToEdgeEnabled": true, + "package": "com.example.ExpoExample" + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-camera", + { + "cameraPermission": "Allow RNGH example to access your camera" + } + ], + [ + "expo-font", + { + "fonts": [ + "./node_modules/@swmansion/icons/fonts/broken/swm-icons-broken.ttf", + "./node_modules/@swmansion/icons/fonts/outline/swm-icons-outline.ttf", + "./node_modules/@swmansion/icons/fonts/curved/swm-icons-curved.ttf" + ] + } + ] + ] + } +} diff --git a/apps/ExpoExample/assets/adaptive-icon.png b/apps/ExpoExample/assets/adaptive-icon.png new file mode 100644 index 0000000000..731536835e Binary files /dev/null and b/apps/ExpoExample/assets/adaptive-icon.png differ diff --git a/apps/ExpoExample/assets/favicon.png b/apps/ExpoExample/assets/favicon.png new file mode 100644 index 0000000000..a173d87e9a Binary files /dev/null and b/apps/ExpoExample/assets/favicon.png differ diff --git a/apps/ExpoExample/assets/icon.png b/apps/ExpoExample/assets/icon.png new file mode 100644 index 0000000000..b7e69b8dc8 Binary files /dev/null and b/apps/ExpoExample/assets/icon.png differ diff --git a/apps/ExpoExample/assets/splash-icon.png b/apps/ExpoExample/assets/splash-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/ExpoExample/assets/splash-icon.png differ diff --git a/apps/ExpoExample/assets/splash.png b/apps/ExpoExample/assets/splash.png new file mode 100644 index 0000000000..8fcf181bd8 Binary files /dev/null and b/apps/ExpoExample/assets/splash.png differ diff --git a/apps/ExpoExample/index.ts b/apps/ExpoExample/index.ts new file mode 100644 index 0000000000..1d6e981ef6 --- /dev/null +++ b/apps/ExpoExample/index.ts @@ -0,0 +1,8 @@ +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/ExpoExample/metro.config.js b/apps/ExpoExample/metro.config.js new file mode 100644 index 0000000000..664eeb3857 --- /dev/null +++ b/apps/ExpoExample/metro.config.js @@ -0,0 +1,37 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const exclusionList = require('metro-config/src/defaults/exclusionList'); +const escape = require('escape-string-regexp'); +const pack = require('./package.json'); + +const modulesBlacklist = Object.keys(pack.dependencies); +modulesBlacklist.push(...Object.keys(pack.devDependencies)); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [monorepoRoot]; + +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), +]; + +config.resolver.blacklistRE = exclusionList( + modulesBlacklist.map( + (m) => + new RegExp(`^${escape(path.join(monorepoRoot, 'node_modules', m))}\\/.*$`) + ) +); + +config.transformer.getTransformOptions = async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, + }, +}); + +module.exports = config; diff --git a/apps/ExpoExample/package.json b/apps/ExpoExample/package.json new file mode 100644 index 0000000000..04d8947f07 --- /dev/null +++ b/apps/ExpoExample/package.json @@ -0,0 +1,43 @@ +{ + "name": "expoexample", + "version": "1.0.0", + "main": "index.ts", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web" + }, + "dependencies": { + "@expo/metro-runtime": "~5.0.2", + "@react-native-async-storage/async-storage": "2.1.2", + "@react-native-community/slider": "4.5.6", + "@react-native-community/viewpager": "5.0.11", + "@react-navigation/elements": "^2.3.8", + "@react-navigation/native": "^7.1.6", + "@react-navigation/stack": "^7.2.10", + "@swmansion/icons": "^0.0.1", + "expo": "~53.0.0-preview.7", + "expo-camera": "~16.1.1", + "expo-status-bar": "~2.2.1", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-native": "0.79.0", + "react-native-gesture-handler": "workspace:*", + "react-native-reanimated": "3.17.5", + "react-native-safe-area-context": "5.4.0", + "react-native-svg": "15.11.2", + "react-native-web": "^0.20.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~19.0.10", + "@types/react-dom": "^19", + "@types/react-native-web": "^0", + "typescript": "~5.8.3" + }, + "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/apps/ExpoExample/src/ListWithHeader/Header.tsx b/apps/ExpoExample/src/ListWithHeader/Header.tsx new file mode 100644 index 0000000000..8d4c05d065 --- /dev/null +++ b/apps/ExpoExample/src/ListWithHeader/Header.tsx @@ -0,0 +1,230 @@ +import React, { useEffect } from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { + Easing, + SharedValue, + interpolate, + measure, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { COLORS } from '../common'; + +const SIGNET = require('./signet.png'); +const TEXT = require('./text.png'); + +export const HEADER_HEIGHT = + Platform.OS === 'web' || Platform.OS === 'macos' ? 64 : 192; +export const COLLAPSED_HEADER_HEIGHT = 64; + +export interface HeaderProps { + scrollOffset: SharedValue; +} + +export default function Header(props: HeaderProps) { + if (Platform.OS === 'macos') { + return ; + } + if (Platform.OS === 'web') { + return ; + } + return ; +} + +function HeaderNative(props: HeaderProps) { + const containerRef = useAnimatedRef(); + const headerHeight = useDerivedValue(() => { + return Math.max( + HEADER_HEIGHT - props.scrollOffset.value, + COLLAPSED_HEADER_HEIGHT + ); + }); + + const expandFactor = useDerivedValue(() => { + return Math.min( + 1, + (headerHeight.value - COLLAPSED_HEADER_HEIGHT) / + (HEADER_HEIGHT - COLLAPSED_HEADER_HEIGHT) + ); + }); + + const isMounted = useSharedValue(false); + const opacity = useSharedValue(0); + + const headerStyle = useAnimatedStyle(() => { + return { + height: headerHeight.value, + }; + }); + + const collapsedCoefficient = 0.7; + const openCoefficient = 0.5; + + const signetStyle = useAnimatedStyle(() => { + const size = isMounted.value ? measure(containerRef) : undefined; + const imageSize = interpolate( + expandFactor.value, + [0, 1], + [ + headerHeight.value * collapsedCoefficient, + headerHeight.value * openCoefficient, + ] + ); + const clampedHeight = Math.min(headerHeight.value, HEADER_HEIGHT); + + const signetOpenOffsetCoefficient = 0.5; + + const signetCollapsedOffset = COLLAPSED_HEADER_HEIGHT * 0.25; + const signetOpenOffset = + ((size?.width ?? 0) - imageSize) * signetOpenOffsetCoefficient; + + return { + position: 'absolute', + width: imageSize, + height: imageSize, + top: interpolate( + Math.sqrt(expandFactor.value), + [0, 1], + [clampedHeight * 0.1, 0] + ), + left: interpolate( + expandFactor.value, + [0, 1], + [signetCollapsedOffset, signetOpenOffset] + ), + opacity: opacity.value, + transform: [{ translateY: (1 - opacity.value) * 20 }], + }; + }); + + const textStyle = useAnimatedStyle(() => { + const size = isMounted.value ? measure(containerRef) : undefined; + const height = interpolate( + expandFactor.value, + [0, 1], + [ + headerHeight.value * collapsedCoefficient, + headerHeight.value * openCoefficient, + ] + ); + + const widthCoefficient = 0.2; + const widthBias = 0.4; + + const textWidth = + (size?.width ?? 0) * (expandFactor.value * widthCoefficient + widthBias); + + const textCollapsedOffset = ((size?.width ?? 0) - textWidth) * 0.5; + const textOpenOffset = ((size?.width ?? 0) - textWidth) * 0.5; + + return { + position: 'absolute', + width: textWidth, + height: height, + bottom: interpolate( + expandFactor.value, + [0, 1], + [COLLAPSED_HEADER_HEIGHT * 0.2, 0] + ), + left: interpolate( + expandFactor.value, + [0, 1], + [textCollapsedOffset, textOpenOffset] + ), + opacity: opacity.value, + transform: [{ translateY: (1 - opacity.value) * 20 }], + }; + }); + + useEffect(() => { + setTimeout(() => { + isMounted.value = true; + opacity.value = withTiming(1, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + }, 100); + }, [opacity, isMounted]); + + return ( + + + + + ); +} + +function HeaderWeb(_props: HeaderProps) { + return ( + + + + + ); +} + +function HeaderMacOS(_props: HeaderProps) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + nativeHeader: { + width: '100%', + position: 'absolute', + backgroundColor: COLORS.offWhite, + zIndex: 100, + flexDirection: 'row', + }, + webHeader: { + width: '100%', + position: 'absolute', + backgroundColor: COLORS.offWhite, + zIndex: 100, + flexDirection: 'row', + alignItems: 'center', + paddingStart: 16, + height: HEADER_HEIGHT, + }, + webSignet: { + width: 48, + height: 48, + }, + webText: { + width: 170, + height: 32, + }, + macosSignet: { + // macos stretches the images to fill the available space + width: 31, // 65:100 ratio applied to 48px + height: 48, + marginHorizontal: 8.5, + }, + macosText: { + width: 142, // 1439:323 ratio applied to 32px + height: 32, + marginHorizontal: 14, + }, +}); diff --git a/apps/ExpoExample/src/ListWithHeader/ListWithHeader.tsx b/apps/ExpoExample/src/ListWithHeader/ListWithHeader.tsx new file mode 100644 index 0000000000..51f5516822 --- /dev/null +++ b/apps/ExpoExample/src/ListWithHeader/ListWithHeader.tsx @@ -0,0 +1,142 @@ +import React, { useEffect } from 'react'; +import { + Platform, + ScrollViewProps, + SectionList, + SectionListProps, +} from 'react-native'; +import Animated, { + SharedValue, + useAnimatedRef, + useScrollViewOffset, + useAnimatedReaction, + useSharedValue, + useAnimatedProps, + useAnimatedStyle, + runOnJS, + withSpring, +} from 'react-native-reanimated'; +import Header, { HEADER_HEIGHT } from './Header'; +import { + Gesture, + GestureDetector, + GestureType, +} from 'react-native-gesture-handler'; + +const IS_ANDROID = Platform.OS === 'android'; + +export function ListWithHeader( + props: SectionListProps +) { + const scrollOffset = useSharedValue(0); + const scrollEnabled = useSharedValue(true); + const androidDragDist = useSharedValue(0); + + function enableScroll() { + scrollEnabled.value = true; + } + + const dragGesture = Gesture.Pan() + .onChange((e) => { + if (!IS_ANDROID) { + return; + } + + if (scrollOffset.value <= 0) { + androidDragDist.value -= e.changeY; + scrollOffset.value = -Math.pow( + Math.max(-androidDragDist.value, 0), + 0.85 + ); + + if (scrollOffset.value < 0) { + scrollEnabled.value = false; + } else { + runOnJS(enableScroll)(); + } + } else { + runOnJS(enableScroll)(); + } + }) + .onFinalize(() => { + if (IS_ANDROID && scrollOffset.value <= 0) { + scrollOffset.value = withSpring(0, { damping: 50, stiffness: 500 }); + runOnJS(enableScroll)(); + } + + androidDragDist.value = 0; + }); + + const containerProps = useAnimatedStyle(() => { + return { + paddingTop: IS_ANDROID ? Math.max(-scrollOffset.value, 0) : 0, + }; + }); + + return ( + + +
+ ( + + )} + /> + + + ); +} + +interface ScrollComponentWithOffsetProps extends ScrollViewProps { + scrollOffset: SharedValue; + animatedScrollEnabled: SharedValue; + dragGesture: GestureType; +} + +const ScrollComponentWithOffset = React.forwardRef( + (props: ScrollComponentWithOffsetProps, ref: any) => { + const scrollRef = useAnimatedRef(); + const scrollViewOffset = useScrollViewOffset(scrollRef); + + useAnimatedReaction( + () => { + return scrollViewOffset.value; + }, + (offset) => { + props.scrollOffset.value = offset; + } + ); + + useEffect(() => { + ref.current = scrollRef.current; + }, [ref, scrollRef]); + + const scrollProps = useAnimatedProps(() => { + return { + scrollEnabled: props.animatedScrollEnabled.value, + }; + }); + + const scrollGesture = Gesture.Native() + .disallowInterruption(true) + .simultaneousWithExternalGesture(props.dragGesture); + + return ( + + + + ); + } +); diff --git a/apps/ExpoExample/src/ListWithHeader/index.ts b/apps/ExpoExample/src/ListWithHeader/index.ts new file mode 100644 index 0000000000..ec20a9a229 --- /dev/null +++ b/apps/ExpoExample/src/ListWithHeader/index.ts @@ -0,0 +1,2 @@ +export { ListWithHeader } from './ListWithHeader'; +export { HEADER_HEIGHT, COLLAPSED_HEADER_HEIGHT } from './Header'; diff --git a/apps/ExpoExample/src/ListWithHeader/signet.png b/apps/ExpoExample/src/ListWithHeader/signet.png new file mode 100644 index 0000000000..3f3feec543 Binary files /dev/null and b/apps/ExpoExample/src/ListWithHeader/signet.png differ diff --git a/apps/ExpoExample/src/ListWithHeader/text.png b/apps/ExpoExample/src/ListWithHeader/text.png new file mode 100644 index 0000000000..5b928319f9 Binary files /dev/null and b/apps/ExpoExample/src/ListWithHeader/text.png differ diff --git a/apps/ExpoExample/src/basic/bouncing/index.tsx b/apps/ExpoExample/src/basic/bouncing/index.tsx new file mode 100644 index 0000000000..99a8668518 --- /dev/null +++ b/apps/ExpoExample/src/basic/bouncing/index.tsx @@ -0,0 +1,144 @@ +import React, { Component, PropsWithChildren } from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; + +import { + PanGestureHandler, + RotationGestureHandler, + State, + PanGestureHandlerGestureEvent, + PanGestureHandlerStateChangeEvent, + RotationGestureHandlerGestureEvent, + RotationGestureHandlerStateChangeEvent, +} from 'react-native-gesture-handler'; + +import { USE_NATIVE_DRIVER } from '../../config'; + +class Snappable extends Component>> { + private onGestureEvent?: (event: PanGestureHandlerGestureEvent) => void; + private transX: Animated.AnimatedInterpolation; + private dragX: Animated.Value; + + constructor(props: Record) { + super(props); + this.dragX = new Animated.Value(0); + this.transX = this.dragX.interpolate({ + inputRange: [-100, -50, 0, 50, 100], + outputRange: [-30, -10, 0, 10, 30], + }); + this.onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: this.dragX } }], + { useNativeDriver: USE_NATIVE_DRIVER } + ); + } + + private onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => { + if (event.nativeEvent.oldState === State.ACTIVE) { + Animated.spring(this.dragX, { + velocity: event.nativeEvent.velocityX, + tension: 10, + friction: 2, + toValue: 0, + useNativeDriver: USE_NATIVE_DRIVER, + }).start(); + } + }; + + render() { + const { children } = this.props; + return ( + + + {children} + + + ); + } +} + +class Twistable extends Component> { + private gesture: Animated.Value; + private onGestureEvent?: (event: RotationGestureHandlerGestureEvent) => void; + private rot: Animated.AnimatedInterpolation; + + constructor(props: Record) { + super(props); + this.gesture = new Animated.Value(0); + + this.rot = this.gesture + .interpolate({ + inputRange: [-1.2, -1, -0.5, 0, 0.5, 1, 1.2], + outputRange: [-0.52, -0.5, -0.3, 0, 0.3, 0.5, 0.52], + }) + .interpolate({ + inputRange: [-100, 100], + outputRange: ['-5700deg', '5700deg'], + }); + + this.onGestureEvent = Animated.event( + [{ nativeEvent: { rotation: this.gesture } }], + { useNativeDriver: USE_NATIVE_DRIVER } + ); + } + private onHandlerStateChange = ( + event: RotationGestureHandlerStateChangeEvent + ) => { + if (event.nativeEvent.oldState === State.ACTIVE) { + Animated.spring(this.gesture, { + velocity: event.nativeEvent.velocity, + tension: 10, + friction: 0.2, + toValue: 0, + useNativeDriver: USE_NATIVE_DRIVER, + }).start(); + } + }; + render() { + const { children } = this.props; + return ( + + + {children} + + + ); + } +} + +export default class Example extends Component { + render() { + return ( + + + + + + + + ); + } +} + +const BOX_SIZE = 200; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + box: { + width: BOX_SIZE, + height: BOX_SIZE, + borderColor: '#F5FCFF', + alignSelf: 'center', + backgroundColor: 'plum', + margin: BOX_SIZE / 2, + }, +}); diff --git a/apps/ExpoExample/src/basic/draggable/index.tsx b/apps/ExpoExample/src/basic/draggable/index.tsx new file mode 100644 index 0000000000..6a2b45f96a --- /dev/null +++ b/apps/ExpoExample/src/basic/draggable/index.tsx @@ -0,0 +1,100 @@ +import React, { Component } from 'react'; +import { Animated, StyleProp, StyleSheet, ViewStyle } from 'react-native'; + +import { + PanGestureHandler, + State, + PanGestureHandlerStateChangeEvent, + PanGestureHandlerGestureEvent, + ScrollView, +} from 'react-native-gesture-handler'; + +import { USE_NATIVE_DRIVER } from '../../config'; +import { LoremIpsum } from '../../common'; + +type DraggableBoxProps = { + minDist?: number; + boxStyle?: StyleProp; +}; + +export class DraggableBox extends Component { + private translateX: Animated.Value; + private translateY: Animated.Value; + private lastOffset: { x: number; y: number }; + private onGestureEvent: (event: PanGestureHandlerGestureEvent) => void; + constructor(props: DraggableBoxProps) { + super(props); + this.translateX = new Animated.Value(0); + this.translateY = new Animated.Value(0); + this.lastOffset = { x: 0, y: 0 }; + this.onGestureEvent = Animated.event( + [ + { + nativeEvent: { + translationX: this.translateX, + translationY: this.translateY, + }, + }, + ], + { useNativeDriver: USE_NATIVE_DRIVER } + ); + } + private onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => { + if (event.nativeEvent.oldState === State.ACTIVE) { + this.lastOffset.x += event.nativeEvent.translationX; + this.lastOffset.y += event.nativeEvent.translationY; + this.translateX.setOffset(this.lastOffset.x); + this.translateX.setValue(0); + this.translateY.setOffset(this.lastOffset.y); + this.translateY.setValue(0); + } + }; + render() { + return ( + + + + ); + } +} + +export default class Example extends Component { + render() { + return ( + + + + + + ); + } +} + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + box: { + width: 150, + height: 150, + alignSelf: 'center', + backgroundColor: 'plum', + margin: 10, + zIndex: 200, + }, +}); diff --git a/apps/ExpoExample/src/basic/fling/index.tsx b/apps/ExpoExample/src/basic/fling/index.tsx new file mode 100644 index 0000000000..cc72438ac8 --- /dev/null +++ b/apps/ExpoExample/src/basic/fling/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { + Directions, + Gesture, + GestureDetector, +} from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + Easing, +} from 'react-native-reanimated'; + +export default function Example() { + const position = useSharedValue(0); + const beginPosition = useSharedValue(0); + + const flingGesture = Gesture.Fling() + .direction(Directions.LEFT | Directions.RIGHT) + .onBegin((e) => { + beginPosition.value = e.x; + }) + .onStart((e) => { + const direction = Math.sign(e.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/ExpoExample/src/basic/forcetouch/index.tsx b/apps/ExpoExample/src/basic/forcetouch/index.tsx new file mode 100644 index 0000000000..7a86ea4898 --- /dev/null +++ b/apps/ExpoExample/src/basic/forcetouch/index.tsx @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import { Animated, StyleSheet, View, Text } from 'react-native'; + +import { + State, + ForceTouchGestureHandler, + ForceTouchGestureHandlerStateChangeEvent, +} from 'react-native-gesture-handler'; + +import { USE_NATIVE_DRIVER } from '../../config'; + +export default class Example extends Component { + private force = new Animated.Value(0); + private onGestureEvent = Animated.event( + [ + { + nativeEvent: { + force: this.force, + }, + }, + ], + { useNativeDriver: USE_NATIVE_DRIVER } + ); + private onHandlerStateChange = ( + event: ForceTouchGestureHandlerStateChangeEvent + ) => { + if (event.nativeEvent.oldState === State.ACTIVE) { + this.force.setValue(0); + } + }; + render() { + return ( + + + {' '} + Force touch works only on some Apple devices (iPhones 6s+ excluding + XR) and should be used only as a supportive one{' '} + + + + + + ); + } +} + +const styles = StyleSheet.create({ + view: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + box: { + width: 150, + height: 150, + + backgroundColor: 'mediumspringgreen', + margin: 10, + zIndex: 200, + }, +}); diff --git a/apps/ExpoExample/src/basic/horizontalDrawer/index.tsx b/apps/ExpoExample/src/basic/horizontalDrawer/index.tsx new file mode 100644 index 0000000000..a99ba85ba2 --- /dev/null +++ b/apps/ExpoExample/src/basic/horizontalDrawer/index.tsx @@ -0,0 +1,194 @@ +import React, { Component } from 'react'; +import { + Platform, + StyleSheet, + Text, + Animated, + View, + TextInput, +} from 'react-native'; + +import { RectButton } from 'react-native-gesture-handler'; + +import { DrawerLayout, DrawerType } from 'react-native-gesture-handler'; + +const TYPES: DrawerType[] = ['front', 'back', 'back', 'slide']; +const PARALLAX = [false, false, true, false]; + +type PageProps = { + fromLeft: boolean; + type: DrawerType; + parallaxOn: boolean; + flipSide: () => void; + nextType: () => void; + openDrawer: () => void; +}; + +const Page = ({ + fromLeft, + type, + parallaxOn, + flipSide, + nextType, + openDrawer, +}: PageProps) => ( + + Hi 👋 + + + Drawer to the {fromLeft ? 'left' : 'right'}! {'->'} Flip + + + + + Type {type} {parallaxOn && 'with parallax!'} -> Next + + + + Open drawer + + + +); + +export default class Example extends Component< + Record, + { fromLeft: boolean; type: number } +> { + state = { fromLeft: true, type: 0 }; + + private renderParallaxDrawer = (progressValue: Animated.Value) => { + const parallax = progressValue.interpolate({ + inputRange: [0, 1], + outputRange: [this.state.fromLeft ? -50 : 50, 0], + }); + const animatedStyles = { + transform: [{ translateX: parallax }], + }; + return ( + + I am in the drawer! + + Watch parallax animation while you pull the drawer! + + + ); + }; + + private renderDrawer = () => { + return ( + + I am in the drawer! + + ); + }; + private drawer?: DrawerLayout | null; + + render() { + const drawerType: DrawerType = TYPES[this.state.type]; + const parallax = PARALLAX[this.state.type]; + return ( + + { + this.drawer = drawer; + }} + enableTrackpadTwoFingerGesture + drawerWidth={200} + keyboardDismissMode="on-drag" + drawerPosition={this.state.fromLeft ? 'left' : 'right'} + drawerType={drawerType} + drawerBackgroundColor="#ddd" + overlayColor={drawerType === 'front' ? 'black' : '#00000000'} + renderNavigationView={ + parallax ? this.renderParallaxDrawer : this.renderDrawer + } + contentContainerStyle={ + // careful; don't elevate the child container + // over top of the drawer when the drawer is supposed + // to be in front - you won't be able to see/open it. + drawerType === 'front' + ? {} + : Platform.select({ + ios: { + shadowColor: '#000', + shadowOpacity: 0.5, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 60, + }, + android: { + elevation: 100, + backgroundColor: '#000', + }, + }) + }> + + this.setState((prevState) => ({ + fromLeft: !prevState.fromLeft, + })) + } + nextType={() => + this.setState((prevState) => ({ + type: (prevState.type + 1) % TYPES.length, + })) + } + openDrawer={() => this.drawer?.openDrawer({ speed: 14 })} + /> + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + page: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + paddingTop: 40, + backgroundColor: 'gray', + }, + pageText: { + fontSize: 21, + color: 'white', + }, + rectButton: { + height: 60, + padding: 10, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + backgroundColor: 'white', + }, + rectButtonText: { + backgroundColor: 'transparent', + }, + drawerContainer: { + flex: 1, + paddingTop: 10, + }, + pageInput: { + height: 60, + padding: 10, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + backgroundColor: '#eee', + }, + drawerText: { + margin: 10, + fontSize: 15, + textAlign: 'left', + }, +}); diff --git a/apps/ExpoExample/src/basic/multitap/index.tsx b/apps/ExpoExample/src/basic/multitap/index.tsx new file mode 100644 index 0000000000..0409385e98 --- /dev/null +++ b/apps/ExpoExample/src/basic/multitap/index.tsx @@ -0,0 +1,104 @@ +import React, { Component } from 'react'; +import { StyleSheet, View, Text } from 'react-native'; + +import { + LongPressGestureHandler, + ScrollView, + State, + TapGestureHandler, + LongPressGestureHandlerStateChangeEvent, + TapGestureHandlerStateChangeEvent, +} from 'react-native-gesture-handler'; + +import { LoremIpsum } from '../../common'; + +interface PressBoxProps { + setDuration?: (duration: number) => void; +} + +interface ExampleState { + longPressDuration: number; +} +export class PressBox extends Component { + private doubleTapRef = React.createRef(); + private onHandlerStateChange = ( + event: LongPressGestureHandlerStateChangeEvent + ) => { + this.props.setDuration?.(event.nativeEvent.duration); + }; + private onSingleTap = (event: TapGestureHandlerStateChangeEvent) => { + if (event.nativeEvent.state === State.ACTIVE) { + // eslint-disable-next-line no-alert + window.alert("I'm touched"); + } + }; + private onDoubleTap = (event: TapGestureHandlerStateChangeEvent) => { + if (event.nativeEvent.state === State.ACTIVE) { + // eslint-disable-next-line no-alert + window.alert('Double tap, good job!'); + } + }; + render() { + return ( + + + + + + + + ); + } +} + +export default class Example extends Component< + Record, + ExampleState +> { + constructor(props: Record) { + super(props); + + this.state = { longPressDuration: 0 }; + } + + render() { + return ( + + + + Duration of the last long press: {this.state.longPressDuration}ms + + + this.setState({ longPressDuration: duration }) + } + /> + + + ); + } +} + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + box: { + width: 150, + height: 150, + alignSelf: 'center', + backgroundColor: 'plum', + margin: 10, + zIndex: 200, + }, + text: { + marginLeft: 20, + }, +}); diff --git a/apps/ExpoExample/src/basic/pagerAndDrawer/index.android.tsx b/apps/ExpoExample/src/basic/pagerAndDrawer/index.android.tsx new file mode 100644 index 0000000000..d7a467f834 --- /dev/null +++ b/apps/ExpoExample/src/basic/pagerAndDrawer/index.android.tsx @@ -0,0 +1,77 @@ +import ViewPagerAndroid from '@react-native-community/viewpager'; +import React, { Component } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + createNativeWrapper, + DrawerLayoutAndroid, +} from 'react-native-gesture-handler'; + +const WrappedViewPagerAndroid = createNativeWrapper(ViewPagerAndroid, { + disallowInterruption: true, +}); + +const Page = ({ + backgroundColor, + text, +}: { + backgroundColor: string; + text: string; +}) => ( + + {text} + +); + +export default class Example extends Component { + static platforms = ['android']; + render() { + const navigationView = ( + + + I'm in the Drawer! + + + ); + return ( + + + navigationView}> + + + + + + + navigationView}> + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F5FCFF', + }, + page: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + pageText: { + fontSize: 21, + color: 'white', + }, +}); diff --git a/apps/ExpoExample/src/basic/pagerAndDrawer/index.tsx b/apps/ExpoExample/src/basic/pagerAndDrawer/index.tsx new file mode 100644 index 0000000000..54e98ba9ea --- /dev/null +++ b/apps/ExpoExample/src/basic/pagerAndDrawer/index.tsx @@ -0,0 +1,8 @@ +import React, { Component } from 'react'; +import { Text } from 'react-native'; + +export default class Example extends Component { + render() { + return Sorry, this is a demo of android-only native components; + } +} diff --git a/apps/ExpoExample/src/basic/panResponder/index.tsx b/apps/ExpoExample/src/basic/panResponder/index.tsx new file mode 100644 index 0000000000..e17928f784 --- /dev/null +++ b/apps/ExpoExample/src/basic/panResponder/index.tsx @@ -0,0 +1,163 @@ +import React, { Component } from 'react'; +import { + StyleSheet, + View, + PanResponder, + I18nManager, + GestureResponderEvent, + PanResponderGestureState, + GestureResponderHandlers, +} from 'react-native'; + +import { ScrollView } from 'react-native-gesture-handler'; + +import { DraggableBox } from '../draggable'; +import { LoremIpsum } from '../../common'; + +const CIRCLE_SIZE = 80; + +type CircleStyles = { + backgroundColor?: string; + left?: number; + top?: number; +}; + +// A clone of: https://github.com/facebook/react-native/blob/master/packages/rn-tester/js/examples/PanResponder/PanResponderExample.js +class PanResponderExample extends Component<{}, { style: CircleStyles }> { + private panResponder: { panHandlers?: GestureResponderHandlers } = {}; + private previousLeft = 0; + private previousTop = 0; + + constructor(props: Record) { + super(props); + this.panResponder = PanResponder.create({ + onStartShouldSetPanResponder: this.handleStartShouldSetPanResponder, + onMoveShouldSetPanResponder: this.handleMoveShouldSetPanResponder, + onPanResponderGrant: this.handlePanResponderGrant, + onPanResponderMove: this.handlePanResponderMove, + onPanResponderRelease: this.handlePanResponderEnd, + onPanResponderTerminate: this.handlePanResponderEnd, + }); + + this.previousLeft = 20; + this.previousTop = 84; + + this.state = { + style: { + backgroundColor: 'green', + left: this.previousLeft, + top: this.previousTop, + }, + }; + } + + render() { + return ( + + ); + } + + private highlight = () => { + this.setState({ + style: { + backgroundColor: 'blue', + left: this.previousLeft, + top: this.previousTop, + }, + }); + }; + + private unHighlight = () => { + this.setState({ + style: { + backgroundColor: 'green', + left: this.previousLeft, + top: this.previousTop, + }, + }); + }; + + private setPosition = (x: number, y: number) => { + this.setState({ + style: { + backgroundColor: 'blue', + left: x, + top: y, + }, + }); + }; + + private handleStartShouldSetPanResponder = ( + _e: GestureResponderEvent, + _gestureState: PanResponderGestureState + ) => { + // Should we become active when the user presses down on the circle? + return true; + }; + + private handleMoveShouldSetPanResponder = ( + _e: GestureResponderEvent, + _gestureState: PanResponderGestureState + ) => { + // Should we become active when the user moves a touch over the circle? + return true; + }; + + private handlePanResponderGrant = ( + _e: GestureResponderEvent, + _gestureState: PanResponderGestureState + ) => { + this.highlight(); + }; + + private handlePanResponderMove = ( + _e: GestureResponderEvent, + gestureState: PanResponderGestureState + ) => { + this.setPosition( + this.previousLeft + gestureState.dx * (I18nManager.isRTL ? -1 : 1), + this.previousTop + gestureState.dy + ); + }; + + private handlePanResponderEnd = ( + _e: GestureResponderEvent, + gestureState: PanResponderGestureState + ) => { + this.previousLeft += gestureState.dx * (I18nManager.isRTL ? -1 : 1); + this.previousTop += gestureState.dy; + this.unHighlight(); + }; +} + +export default class Example extends Component { + onClick = () => { + // eslint-disable-next-line no-alert + window.alert("I'm so touched"); + }; + render() { + return ( + + + + + + + ); + } +} + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + circle: { + width: CIRCLE_SIZE, + height: CIRCLE_SIZE, + borderRadius: CIRCLE_SIZE / 2, + zIndex: 100, + }, +}); diff --git a/apps/ExpoExample/src/common.tsx b/apps/ExpoExample/src/common.tsx new file mode 100644 index 0000000000..0a7436e840 --- /dev/null +++ b/apps/ExpoExample/src/common.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Text, StyleSheet, ViewStyle, StyleProp } from 'react-native'; + +const styles = StyleSheet.create({ + lipsum: { + padding: 10, + }, +}); + +type Props = { + words: number; + style: StyleProp; +}; + +export class LoremIpsum extends React.Component { + static defaultProps = { + words: 1000, + style: styles.lipsum, + }; + loremIpsum() { + return LOREM_IPSUM.split(' ').slice(0, this.props.words).join(' '); + } + render() { + return {this.loremIpsum()}; + } +} + +export const COLORS = { + offWhite: '#f8f9ff', + headerSeparator: '#eef0ff', +}; + +const LOREM_IPSUM = ` +Curabitur accumsan sit amet massa quis cursus. Fusce sollicitudin nunc nisl, quis efficitur quam tristique eget. Ut non erat molestie, ullamcorper turpis nec, euismod neque. Praesent aliquam risus ultricies, cursus mi consectetur, bibendum lorem. Nunc eleifend consectetur metus quis pulvinar. In vitae lacus eu nibh tincidunt sagittis ut id lorem. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque sagittis mauris rhoncus, maximus justo in, consequat dolor. Pellentesque ornare laoreet est vulputate vestibulum. Aliquam sit amet metus lorem. + +Morbi tempus elit lorem, ut pulvinar nunc sagittis pharetra. Nulla mi sem, elementum non bibendum eget, viverra in purus. Vestibulum efficitur ex id nisi luctus egestas. Quisque in urna vitae leo consectetur ultricies sit amet at nunc. Cras porttitor neque at nisi ornare, mollis ornare dolor pharetra. Donec iaculis lacus orci, et pharetra eros imperdiet nec. Morbi leo nunc, placerat eget varius nec, volutpat ac velit. Phasellus pulvinar vulputate tincidunt. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce elementum dui at ipsum hendrerit, vitae consectetur erat pulvinar. Sed vehicula sapien felis, id tristique dolor tempor feugiat. Aenean sit amet erat libero. + +Nam posuere at mi ut porttitor. Vivamus dapibus vehicula mauris, commodo pretium nibh. Mauris turpis metus, vulputate iaculis nibh eu, maximus tincidunt nisl. Vivamus in mauris nunc. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse convallis ornare finibus. Quisque leo ex, vulputate quis molestie auctor, congue nec arcu. + +Praesent ac risus nec augue commodo semper eu eget quam. Donec aliquam sodales convallis. Etiam interdum eu nulla at tempor. Duis nec porttitor odio, consectetur tempor turpis. Sed consequat varius lorem vel fermentum. Maecenas dictum sapien vitae lobortis tempus. Aliquam iaculis vehicula velit, non tempus est varius nec. Nunc congue dolor nec sem gravida, nec tincidunt mi luctus. Nam ut porttitor diam. + +Fusce interdum nisi a risus aliquet, non dictum metus cursus. Praesent imperdiet sapien orci, quis sodales metus aliquet id. Aliquam convallis pharetra erat. Fusce gravida diam ut tellus elementum sodales. Fusce varius congue neque, quis laoreet sapien blandit vestibulum. Donec congue libero sapien, nec varius risus viverra ut. Quisque eu maximus magna. Phasellus tortor nisi, tincidunt vitae dignissim nec, interdum vel mi. Ut accumsan urna finibus posuere mattis. +`; diff --git a/apps/ExpoExample/src/config.tsx b/apps/ExpoExample/src/config.tsx new file mode 100644 index 0000000000..ae3503c1f1 --- /dev/null +++ b/apps/ExpoExample/src/config.tsx @@ -0,0 +1 @@ +export const USE_NATIVE_DRIVER = true; diff --git a/apps/ExpoExample/src/empty/EmptyExample.tsx b/apps/ExpoExample/src/empty/EmptyExample.tsx new file mode 100644 index 0000000000..900460d6a6 --- /dev/null +++ b/apps/ExpoExample/src/empty/EmptyExample.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function EmptyExample() { + return ( + + 😞 + It's so empty here + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/apps/ExpoExample/src/new_api/betterHorizontalDrawer/BetterHorizonatalDrawer.tsx b/apps/ExpoExample/src/new_api/betterHorizontalDrawer/BetterHorizonatalDrawer.tsx new file mode 100644 index 0000000000..950505b7d8 --- /dev/null +++ b/apps/ExpoExample/src/new_api/betterHorizontalDrawer/BetterHorizonatalDrawer.tsx @@ -0,0 +1,627 @@ +import React, { useEffect, useState } from 'react'; +import { + I18nManager, + LayoutChangeEvent, + StatusBarAnimation, + StyleProp, + StyleSheet, + ViewStyle, + Keyboard, + StatusBar, +} from 'react-native'; +import { + DrawerKeyboardDismissMode, + DrawerLockMode, + DrawerPosition, + DrawerType, + GestureDetector, + Gesture, +} from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +export enum BetterDrawerState { + IDLE = 'Idle', + DRAGGING = 'Dragging', + SETTLING = 'Settling', +} + +export interface BetterDrawerLayoutProps { + /** + * This attribute is present in the standard implementation already and is one + * of the required params. Gesture handler version of DrawerLayout make it + * possible for the function passed as `renderNavigationView` to take an + * Animated value as a parameter that indicates the progress of drawer + * opening/closing animation (progress value is 0 when closed and 1 when + * opened). This can be used by the drawer component to animated its children + * while the drawer is opening or closing. + */ + renderNavigationView: ( + progressAnimatedValue: Animated.SharedValue + ) => React.ReactNode; + + drawerPosition?: DrawerPosition; + + drawerWidth?: number; + + drawerBackgroundColor?: string; + + drawerLockMode?: DrawerLockMode; + + keyboardDismissMode?: DrawerKeyboardDismissMode; + + /** + * Called when the drawer is closed. + */ + onDrawerClose?: () => void; + + /** + * Called when the drawer is opened. + */ + onDrawerOpen?: () => void; + + /** + * Called when the status of the drawer changes. + */ + onDrawerStateChanged?: ( + newState: BetterDrawerState, + drawerWillShow: boolean + ) => void; + + drawerType?: DrawerType; + + /** + * Defines how far from the edge of the content view the gesture should + * activate. + */ + edgeWidth?: number; + + minSwipeDistance?: number; + + /** + * When set to true Drawer component will use + * {@link https://reactnative.dev/docs/statusbar StatusBar} API to hide the OS + * status bar whenever the drawer is pulled or when its in an "open" state. + */ + hideStatusBar?: boolean; + + /** + * @default 'slide' + * + * Can be used when hideStatusBar is set to true and will select the animation + * used for hiding/showing the status bar. See + * {@link https://reactnative.dev/docs/statusbar StatusBar} documentation for + * more details + */ + statusBarAnimation?: StatusBarAnimation; + + /** + * @default black + * + * Color of a semi-transparent overlay to be displayed on top of the content + * view when drawer gets open. A solid color should be used as the opacity is + * added by the Drawer itself and the opacity of the overlay is animated (from + * 0% to 70%). + */ + overlayColor?: string; + + contentContainerStyle?: StyleProp; + + drawerContainerStyle?: StyleProp; + + /** + * Enables two-finger gestures on supported devices, for example iPads with + * trackpads. If not enabled the gesture will require click + drag, with + * `enableTrackpadTwoFingerGesture` swiping with two fingers will also trigger + * the gesture. + */ + enableTrackpadTwoFingerGesture?: boolean; + + /** + * Called when the pan gesture gets updated, position represents a fraction of + * the drawer that is visible + */ + onDrawerSlide?: (position: number) => void; + + children?: React.ReactNode; +} + +interface OverlayProps { + drawerType: DrawerType; + color: string; + progress: Animated.SharedValue; + lockMode: DrawerLockMode; + close: () => void; +} + +function Overlay(props: OverlayProps) { + const overlayStyle = useAnimatedStyle(() => ({ + backgroundColor: props.color, + opacity: props.progress.value, + transform: [ + { + translateX: + // when the overlay should not be visible move it off the screen + // to prevent it from intercepting touch events on Android + props.drawerType !== 'front' || props.progress.value === 0 + ? 10000 + : 0, + }, + ], + })); + + const tap = Gesture.Tap(); + tap.onEnd((_event, success) => { + 'worklet'; + if (success && props.lockMode !== 'locked-open') { + // close the drawer when tapped on the overlay only if the gesture + // was not cancelled and it's not locked in opened state + props.close(); + } + }); + + return ( + + + + ); +} + +export interface DrawerLayoutController { + open: () => void; + close: () => void; +} + +export const DrawerLayout = React.forwardRef< + DrawerLayoutController, + BetterDrawerLayoutProps +>( + ( + { + drawerWidth = 200, + drawerPosition = 'left', + drawerType = 'front', + edgeWidth = 20, + minSwipeDistance = 3, + overlayColor = 'rgba(0, 0, 0, 0.7)', + drawerLockMode = 'unlocked', + enableTrackpadTwoFingerGesture = false, + keyboardDismissMode, + statusBarAnimation, + hideStatusBar, + drawerBackgroundColor, + drawerContainerStyle, + contentContainerStyle, + children, + renderNavigationView, + onDrawerClose, + onDrawerOpen, + onDrawerSlide, + onDrawerStateChanged, + }: BetterDrawerLayoutProps, + ref + ) => { + const animationConfig = { damping: 30, stiffness: 250 }; + + const fromLeft = drawerPosition === 'left'; + const drawerSlide = drawerType !== 'back'; + const containerSlide = drawerType !== 'front'; + + // setting NaN as a starting value allows to tell when the value gets changes + // for the first time + const [containerWidth, setContainerWidth] = useState(Number.NaN); + const [drawerVisible, setDrawerVisible] = useState(false); + + const drawerState = useSharedValue(BetterDrawerState.IDLE); + // between 0 and drawerWidth (drawer on the left) or -drawerWidth and 0 (drawer on the right) + const drawerOffset = useSharedValue(0); + // stores value of the offset at the start of the gesture + const drawerSavedOffset = useSharedValue(0); + // stores the translation that is supposed to be ignored (user tried to + // drag while animation was running) + const ignoredOffset = useSharedValue(0); + // stores the x coordinate of the drag starting point (to ignore dragging on the overlay) + const dragStartPosition = useSharedValue(0); + // between 0 and 1, 0 - closed, 1 - opened + const openingProgress = useDerivedValue(() => { + if (fromLeft) { + return drawerOffset.value / drawerWidth; + } else { + return -drawerOffset.value / drawerWidth; + } + }, [drawerOffset, containerWidth, drawerWidth, fromLeft]); + + // we rely on row and row-reverse flex directions to position the drawer + // properly. Apparently for RTL these are flipped which requires us to use + // the opposite setting for the drawer to appear from left or right + // according to the drawerPosition prop + const reverseContentDirection = I18nManager.isRTL ? fromLeft : !fromLeft; + + // set the drawer to closed position when the props change to prevent it from + // opening or moving on the screen + useEffect(() => { + drawerOffset.value = 0; + drawerSavedOffset.value = 0; + + setDrawerVisible(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drawerWidth, drawerPosition, drawerType]); + + // measure the container + function handleContainerLayout({ nativeEvent }: LayoutChangeEvent) { + setContainerWidth(nativeEvent.layout.width); + } + + function onDragStart() { + if (keyboardDismissMode === 'on-drag') { + Keyboard.dismiss(); + } + + // this is required in addition to the similar call below, because the gesture + // doesn't change `drawerVisible` state to prevent re-render during gesture + // so when dragging from closed it wouldn't hide the status bar + if (hideStatusBar) { + StatusBar.setHidden(true, statusBarAnimation ?? 'slide'); + } + } + + function setState(newState: BetterDrawerState, willShow: boolean) { + if (hideStatusBar) { + StatusBar.setHidden(willShow, statusBarAnimation ?? 'slide'); + } + + // dispach events + if (drawerState.value !== newState || drawerVisible !== willShow) { + // send state change event only when the state changed or the visibility of the + // drawer (for example when drawer is in SETTLING state after opening and the user + // taps on the overlay the state is still settling, but willShow is now false) + onDrawerStateChanged?.(newState, willShow); + } + + if (drawerVisible !== willShow) { + setDrawerVisible(willShow); + } + + if (newState === BetterDrawerState.IDLE) { + if (willShow) { + onDrawerOpen?.(); + } else { + onDrawerClose?.(); + } + } + + drawerState.value = newState; + } + + function open() { + 'worklet'; + if (fromLeft && drawerOffset.value < drawerWidth) { + // drawer is on the left and is not fully opened + runOnJS(setState)(BetterDrawerState.SETTLING, true); + + drawerOffset.value = withSpring( + drawerWidth, + animationConfig, + (finished) => { + drawerSavedOffset.value = drawerOffset.value; + if (finished) { + // animation cannot be interrupted by a drag, but can be by + // calling close or open (through tap or a controller) + runOnJS(setState)(BetterDrawerState.IDLE, true); + } + } + ); + } else if (!fromLeft && drawerOffset.value > -drawerWidth) { + // drawer is on the right and is not fully opened + runOnJS(setState)(BetterDrawerState.SETTLING, true); + + drawerOffset.value = withSpring( + -drawerWidth, + animationConfig, + (finished) => { + drawerSavedOffset.value = drawerOffset.value; + if (finished) { + // animation cannot be interrupted by a drag, but can be by + // calling close or open (through tap or a controller) + runOnJS(setState)(BetterDrawerState.IDLE, true); + } + } + ); + } else { + // drawer is fully opened + runOnJS(setState)(BetterDrawerState.IDLE, true); + } + } + + function close() { + 'worklet'; + if (fromLeft && drawerOffset.value > 0) { + // drawer is on the left and is not fully closed + runOnJS(setState)(BetterDrawerState.SETTLING, false); + + drawerOffset.value = withSpring(0, animationConfig, (finished) => { + drawerSavedOffset.value = drawerOffset.value; + if (finished) { + // animation cannot be interrupted by a drag, but can be by + // calling close or open (through tap or a controller) + runOnJS(setState)(BetterDrawerState.IDLE, false); + } + }); + } else if (!fromLeft && drawerOffset.value < 0) { + // drawer is on the right and is not fully closed + runOnJS(setState)(BetterDrawerState.SETTLING, false); + + drawerOffset.value = withSpring(0, animationConfig, (finished) => { + drawerSavedOffset.value = drawerOffset.value; + if (finished) { + // animation cannot be interrupted by a drag, but can be by + // calling close or open (through tap or a controller) + runOnJS(setState)(BetterDrawerState.IDLE, false); + } + }); + } else { + // drawer is fully closed + runOnJS(setState)(BetterDrawerState.IDLE, false); + } + } + + // gestureOrientation is 1 if the expected gesture is from left to right and + // -1 otherwise e.g. when drawer is on the left and is closed we expect left + // to right gesture, thus orientation will be 1. + const gestureOrientation = (fromLeft ? 1 : -1) * (drawerVisible ? -1 : 1); + + // When drawer is closed we want the hitSlop to be horizontally shorter than + // the container size by the value of SLOP. This will make it only activate + // when gesture happens not further than SLOP away from the edge + const hitSlop = fromLeft + ? { left: 0, width: drawerVisible ? undefined : edgeWidth } + : { right: 0, width: drawerVisible ? undefined : edgeWidth }; + + // *** THIS IS THE LARGE COMMENT ABOVE *** + // + // While closing the drawer when user starts gesture outside of its area (in greyed + // out part of the window), we want the drawer to follow only once finger reaches the + // edge of the drawer. + // E.g. on the diagram below drawer is illustrate by X signs and the greyed out area by + // dots. The touch gesture starts at '*' and moves left, touch path is indicated by + // an arrow pointing left + // 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+ + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|.<-*..| |XXXXXXXX|<--*..| |XXXXX|<-----*..| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // +---------------+ +---------------+ +---------------+ +---------------+ + // + // For the above to work properly we define animated value that will keep + // start position of the gesture. Then we use that value to calculate how + // much we need to subtract from the dragX. If the gesture started on the + // greyed out area we take the distance from the edge of the drawer to the + // start position. Otherwise we don't subtract at all and the drawer be + // pulled back as soon as you start the pan. + // + // This is used only when drawerType is "front" + // + + const pan = Gesture.Pan(); + pan.failOffsetY([-15, 15]); + pan.hitSlop(hitSlop); + pan.activeOffsetX(gestureOrientation * minSwipeDistance); + pan.enableTrackpadTwoFingerGesture(enableTrackpadTwoFingerGesture); + pan.enabled( + drawerLockMode !== 'locked-closed' && drawerLockMode !== 'locked-open' + ); + pan.onStart((event) => { + 'worklet'; + ignoredOffset.value = 0; + dragStartPosition.value = event.x; + }); + pan.onUpdate((event) => { + 'worklet'; + if (drawerState.value === BetterDrawerState.IDLE) { + runOnJS(setState)(BetterDrawerState.DRAGGING, drawerVisible); + runOnJS(onDragStart)(); + } + + if (drawerState.value === BetterDrawerState.DRAGGING) { + let newOffset = + drawerSavedOffset.value + event.translationX - ignoredOffset.value; + + if (fromLeft) { + // refer to the large comment above + if ( + drawerType === 'front' && + event.translationX < 0 && + drawerOffset.value > 0 + ) { + newOffset += dragStartPosition.value - drawerWidth; + } + + // clamp the offset so the drawer does not move away from the edge + newOffset = Math.max(0, Math.min(drawerWidth, newOffset)); + } else { + // refer to the large comment above + if ( + drawerType === 'front' && + event.translationX > 0 && + drawerOffset.value < 0 + ) { + newOffset += + dragStartPosition.value - (containerWidth - drawerWidth); + } + + // clamp the offset so the drawer does not move away from the edge + newOffset = Math.max(-drawerWidth, Math.min(0, newOffset)); + } + + drawerOffset.value = newOffset; + + // send event if there is a listener + if (onDrawerSlide !== undefined) { + runOnJS(onDrawerSlide)(openingProgress.value); + } + } else { + // drawerState is SETTLING, save the translation to ignore it later + ignoredOffset.value = event.translationX; + } + }); + pan.onEnd((_event) => { + 'worklet'; + if (drawerState.value === BetterDrawerState.DRAGGING) { + // update offsets and animations only when the drag was not ignored + drawerSavedOffset.value = drawerOffset.value; + + // if the drawer was dragged more than half of its width open it, + // otherwise close it + if (fromLeft) { + if (drawerOffset.value > drawerWidth / 2) { + open(); + } else { + close(); + } + } else { + if (drawerOffset.value < -drawerWidth / 2) { + open(); + } else { + close(); + } + } + } + }); + + const dynamicDrawerStyles = { + backgroundColor: drawerBackgroundColor, + width: drawerWidth, + }; + + const drawerStyle = useAnimatedStyle(() => { + let translateX = 0; + + if (drawerSlide) { + // drawer is supposed to be moved with the gesture (in this case + // drawer is anchored to be off the screen when not opened) + if (fromLeft) { + translateX = -drawerWidth; + } else { + translateX = containerWidth; + } + translateX += drawerOffset.value; + } else { + // drawer is stationary (in this case drawer is below the content + // so it's anchored left edge to left edge or right to right) + if (fromLeft) { + translateX = 0; + } else { + translateX = containerWidth - drawerWidth; + } + } + + // if the drawer is not visible move it off the screen to prevent it + // from intercepting touch events on Android + if (drawerOffset.value === 0) { + translateX = 10000; + } + + return { + flexDirection: reverseContentDirection ? 'row-reverse' : 'row', + transform: [{ translateX }], + }; + }); + + const containerStyle = useAnimatedStyle(() => { + let translateX = 0; + + if (containerSlide) { + // the container should be moved with the gesture + translateX = drawerOffset.value; + } + + return { + transform: [{ translateX }], + }; + }); + + if (ref !== null) { + // ref is set, create a controller and pass it + const controller: DrawerLayoutController = { + open: () => { + open(); + }, + close: () => { + close(); + }, + }; + + if (typeof ref === 'function') { + ref(controller); + } else { + ref.current = controller; + } + } + + return ( + + + + {children} + + + + + {renderNavigationView(openingProgress)} + + + + ); + } +); + +const styles = StyleSheet.create({ + drawerContainer: { + ...StyleSheet.absoluteFillObject, + zIndex: 1001, + flexDirection: 'row', + }, + containerInFront: { + ...StyleSheet.absoluteFillObject, + zIndex: 1002, + }, + containerOnBack: { + ...StyleSheet.absoluteFillObject, + }, + main: { + flex: 1, + zIndex: 0, + overflow: 'hidden', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 1000, + }, +}); diff --git a/apps/ExpoExample/src/new_api/betterHorizontalDrawer/index.tsx b/apps/ExpoExample/src/new_api/betterHorizontalDrawer/index.tsx new file mode 100644 index 0000000000..c2f7008a8e --- /dev/null +++ b/apps/ExpoExample/src/new_api/betterHorizontalDrawer/index.tsx @@ -0,0 +1,162 @@ +import React, { useRef, useState } from 'react'; + +import { StyleSheet, Text, View, TextInput } from 'react-native'; + +import { DrawerType, RectButton } from 'react-native-gesture-handler'; +import { + DrawerLayoutController, + DrawerLayout, +} from './BetterHorizonatalDrawer'; +import Animated, { + useAnimatedStyle, + interpolate, +} from 'react-native-reanimated'; + +const TYPES: DrawerType[] = ['front', 'back', 'back', 'slide']; +const PARALLAX = [false, false, true, false]; + +interface PageProps { + fromLeft: boolean; + type: DrawerType; + parallaxOn: boolean; + flipSide: () => void; + nextType: () => void; + openDrawer: () => void; +} + +function Page({ + fromLeft, + type, + parallaxOn, + flipSide, + nextType, + openDrawer, +}: PageProps) { + return ( + + Hi 👋 + + + Drawer to the {fromLeft ? 'left' : 'right'}! {'->'} Flip + + + + + Type {type} {parallaxOn && 'with parallax!'} -> Next + + + + Open drawer + + + + ); +} + +function DrawerContent( + offset: Animated.SharedValue, + parallax: boolean, + fromLeft: boolean +) { + const animatedStyles = useAnimatedStyle(() => ({ + transform: [ + { + translateX: parallax + ? interpolate(offset.value, [0, 1], [fromLeft ? -50 : 50, 0]) + : 0, + }, + ], + })); + + return ( + + + {parallax ? 'Drawer with parallax' : 'Drawer'} + + + ); +} + +export default function Example() { + const [onLeft, setOnLeft] = useState(true); + const [type, setType] = useState(0); + const controller = useRef(null); + + return ( + + { + return DrawerContent(offset, PARALLAX[type], onLeft); + }} + keyboardDismissMode="on-drag" + drawerBackgroundColor="white" + ref={controller}> + { + setOnLeft(!onLeft); + }} + type={TYPES[type]} + nextType={() => { + setType((type + 1) % TYPES.length); + }} + parallaxOn={PARALLAX[type]} + openDrawer={() => { + controller.current?.open(); + }} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + page: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + paddingTop: 40, + backgroundColor: 'gray', + }, + pageText: { + fontSize: 21, + color: 'white', + }, + rectButton: { + height: 60, + padding: 10, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + backgroundColor: 'white', + }, + rectButtonText: { + backgroundColor: 'transparent', + }, + drawerContainer: { + flex: 1, + paddingTop: 10, + }, + pageInput: { + height: 60, + padding: 10, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + backgroundColor: '#eee', + }, + drawerText: { + margin: 10, + fontSize: 15, + textAlign: 'left', + }, +}); diff --git a/apps/ExpoExample/src/new_api/bottom_sheet/index.tsx b/apps/ExpoExample/src/new_api/bottom_sheet/index.tsx new file mode 100644 index 0000000000..1218963588 --- /dev/null +++ b/apps/ExpoExample/src/new_api/bottom_sheet/index.tsx @@ -0,0 +1,156 @@ +import React, { useRef, useState } from 'react'; +import { + Dimensions, + NativeScrollEvent, + NativeSyntheticEvent, + StyleSheet, + View, +} from 'react-native'; +import { + Gesture, + GestureDetector, + PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { LoremIpsum } from '../../../src/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 panGestureRef = useRef(Gesture.Pan()); + const blockScrollUntilAtTheTopRef = useRef(Gesture.Tap()); + 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 = ({ velocityY }: PanGestureHandlerEventPayload) => { + 'worklet'; + const dragToss = 0.05; + const endOffsetY = + bottomSheetTranslateY.value + translationY.value + 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 = Gesture.Pan() + .onUpdate((e) => { + // 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; + } + }) + .onEnd(onHandlerEnd) + .withRef(panGestureRef); + + const blockScrollUntilAtTheTop = Gesture.Tap() + .maxDeltaY(snapPoint - FULLY_OPEN_SNAP_POINT) + .maxDuration(100000) + .simultaneousWithExternalGesture(panGesture) + .withRef(blockScrollUntilAtTheTopRef); + + const headerGesture = Gesture.Pan() + .onUpdate((e) => { + translationY.value = e.translationY; + }) + .onEnd(onHandlerEnd); + + const scrollViewGesture = Gesture.Native().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 }], + }; + }); + + 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/ExpoExample/src/new_api/calculator/index.tsx b/apps/ExpoExample/src/new_api/calculator/index.tsx new file mode 100644 index 0000000000..84b48fcb4f --- /dev/null +++ b/apps/ExpoExample/src/new_api/calculator/index.tsx @@ -0,0 +1,422 @@ +import React, { Dispatch, SetStateAction, useRef, useState } from 'react'; +import { + StyleSheet, + View, + Text, + Dimensions, + LayoutChangeEvent, + LayoutRectangle, +} from 'react-native'; +import { + GestureDetector, + Gesture, + ScrollView, +} 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 = Gesture.Pan() + .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; + } + }) + .onEnd((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