Last active
October 2, 2024 14:33
Revisions
-
davidfurlong revised this gist
Oct 2, 2024 . 1 changed file with 1 addition and 53 deletions.There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -157,9 +157,7 @@ export const SideSwipe: React.FC<SideSwipeProps> = ({ }); const view = ( <GestureDetector gesture={pan}> <Animated.View style={[styles.content, animatedStyle]}>{children}</Animated.View> </GestureDetector> @@ -168,56 +166,6 @@ export const SideSwipe: React.FC<SideSwipeProps> = ({ return ( <> <View style={styles.container}>{view}</View> </> ); }; -
davidfurlong created this gist
Oct 2, 2024 .There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,266 @@ import { ScrollingContext } from "@/app/(auth)/(tabs)/hot"; import React, { ReactNode, useContext, useState } from "react"; import { View, StyleSheet, Platform } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS, } from "react-native-reanimated"; import { Button } from "./ui/button"; import { Text } from "./ui/text"; interface SideSwipeProps { children: ReactNode; COLUMN_WIDTH: number; } const DECELERATION_RATE = 0.999; /** This component is needed because PagerView doesn't support displaying two adjacent items at once (so as to show the start of the next, indicating there's more) */ export const SideSwipe: React.FC<SideSwipeProps> = ({ children, // based on the padding of the parent COLUMN_WIDTH, }) => { const scrollingContext = useContext(ScrollingContext); const clamp = (value: number, min: number, max: number) => { "worklet"; return Math.min(Math.max(value, min), max); }; const snapToColumn = (x: number) => { "worklet"; const columnIndex = Math.round(x / COLUMN_WIDTH); return columnIndex * COLUMN_WIDTH; }; const translateX = useSharedValue(0); const isDecided = useSharedValue(false); const startX = useSharedValue(translateX.value); const startY = useSharedValue(0); const [webExpectedPage, setWebExpectedPage] = useState(0); // gestures also should work on web but this one isn't updating properly let pan = Gesture.Pan() .onStart(() => { startX.value = translateX.value; startY.value = 0; isDecided.value = false; }) .onUpdate((event: { translationX: number; translationY: number; velocityX: number }) => { if (!isDecided.value) { // to prevent some amount of vertical scrolling while horizontally swiping, we need to check the ratio of the translations if (Math.abs(event.translationX) > Math.abs(event.translationY)) { runOnJS(scrollingContext.setIsScrollEnabled)(false); isDecided.value = true; } else { runOnJS(scrollingContext.setIsScrollEnabled)(true); isDecided.value = true; return; } } if (!scrollingContext.isScrollEnabled) { const projectedX = projectGesture(event.velocityX, DECELERATION_RATE); const swipeDirection = event.translationX < 0 ? -1 : 1; const translationX = swipeDirection * // don't allow swiping more than one column at a time Math.min( // translation by user's swipe plus the projection from velocity of the swipe Math.abs( event.translationX + // project velocity projectedX ), COLUMN_WIDTH ); const isOverExtendingBounds = // is on last page and is swiping right (startX.value === Math.round(-COLUMN_WIDTH * ((children as ReactNode[]).length - 1)) && +translationX < 0) || // is on first page and is swiping left (translationX > 0 && startX.value === 0); if (isOverExtendingBounds) { translateX.value = // exponential decay on the translationX to communicate a springy resistance Math.round( startX.value + // reinstate direction swipeDirection * Math.sqrt( 10 * // sqrt only works on positive numbers Math.abs(event.translationX) ) ); return; } // don't snap here, as user is still panning const clampedX = Math.round( clamp( startX.value + translationX, -COLUMN_WIDTH * ((children as ReactNode[]).length - 1), 0 ) ); translateX.value = withSpring(clampedX, getSpringConfig(1, 0.4)); } }) .onEnd((event: { translationX: number; translationY: number; velocityX: number }) => { if (!scrollingContext.isScrollEnabled) { const projectedX = projectGesture(event.velocityX, DECELERATION_RATE); const swipeDirection = event.translationX < 0 ? -1 : 1; const translationX = swipeDirection * // don't allow swiping more than one column at a time Math.min( // translation by user's swipe plus the projection from velocity of the swipe Math.abs(event.translationX + projectedX), COLUMN_WIDTH ); const snappedX = /** we need to round everything as decimal translateX are invalid */ Math.round( snapToColumn( clamp( startX.value + translationX, // the furthest to the right snap is the number of columns minus 1, because we start counting from 0 -COLUMN_WIDTH * ((children as ReactNode[]).length - 1), 0 ) ) ); translateX.value = withSpring(snappedX, getSpringConfig(1, 0.4)); } }) .onFinalize(() => { runOnJS(scrollingContext.setIsScrollEnabled)(true); }); if (scrollingContext.ref) { pan = pan.simultaneousWithExternalGesture(scrollingContext.ref); } const animatedStyle = useAnimatedStyle(() => { return { transform: [{ translateX: translateX.value }], }; }); const view = Platform.OS === "web" ? ( <Animated.View style={[styles.content, animatedStyle]}>{children}</Animated.View> ) : ( <GestureDetector gesture={pan}> <Animated.View style={[styles.content, animatedStyle]}>{children}</Animated.View> </GestureDetector> ); return ( <> <View style={styles.container}>{view}</View> {Platform.OS === "web" ? ( <View className="absolute top-2 right-0 flex flex-row"> <Button variant={"ghost"} disabled={webExpectedPage === 0} size={"lg"} onPress={() => { const snappedX = /** we need to round everything as decimal translateX are invalid */ Math.round( snapToColumn( clamp( translateX.value + COLUMN_WIDTH, // the furthest to the right snap is the number of columns minus 1, because we start counting from 0 -COLUMN_WIDTH * ((children as ReactNode[]).length - 1), 0 ) ) ); translateX.value = withSpring(snappedX, getSpringConfig(1, 0.4)); // need to trigger a rerender setWebExpectedPage(webExpectedPage - 1); }}> <Text className="text-2xl">{"←"}</Text> </Button> <Button variant={"ghost"} disabled={webExpectedPage === (children as ReactNode[]).length - 1} size={"lg"} onPress={() => { const snappedX = /** we need to round everything as decimal translateX are invalid */ Math.round( snapToColumn( clamp( translateX.value - COLUMN_WIDTH, // the furthest to the right snap is the number of columns minus 1, because we start counting from 0 -COLUMN_WIDTH * ((children as ReactNode[]).length - 1), 0 ) ) ); translateX.value = withSpring(snappedX, getSpringConfig(1, 0.4)); // need to trigger a rerender setWebExpectedPage(webExpectedPage + 1); }}> <Text className="text-2xl">{"→"}</Text> </Button> </View> ) : null} </> ); }; const styles = StyleSheet.create({ container: { flex: 1, overflow: "hidden", }, content: { flexDirection: "row", }, }); // https://medium.com/ios-os-x-development/demystifying-uikit-spring-animations-2bb868446773 function getSpringConfig( /** 1 >= dampingRatio >= 0 */ dampingRatio: number, /** frequencyResponse > 0 */ frequencyResponse: number ) { "worklet"; const mass = 1; const stiffness = Math.pow((2 * Math.PI) / frequencyResponse, 2) * mass; const damping = (4 * Math.PI * dampingRatio * mass) / frequencyResponse; return { mass, stiffness, damping, overshootClamping: true, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, }; } // https://medium.com/ios-os-x-development/gestures-in-fluid-interfaces-on-intent-and-projection-36d158db7395 function projectGesture( initialVelocity: number, /** 0.99 means very low friction and is close to what most interfaces use */ decelerationRate: number ) { "worklet"; return ((initialVelocity / 1000.0) * decelerationRate) / (1.0 - decelerationRate); }