Skip to content

Instantly share code, notes, and snippets.

@davidfurlong
Last active October 2, 2024 14:33
Show Gist options
  • Save davidfurlong/cdcc7fc955a43131f02f172fba896467 to your computer and use it in GitHub Desktop.
Save davidfurlong/cdcc7fc955a43131f02f172fba896467 to your computer and use it in GitHub Desktop.
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 =
(
<GestureDetector gesture={pan}>
<Animated.View style={[styles.content, animatedStyle]}>{children}</Animated.View>
</GestureDetector>
);
return (
<>
<View style={styles.container}>{view}</View>
</>
);
};
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);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment