Skip to content

Instantly share code, notes, and snippets.

@davidfurlong
Last active October 2, 2024 14:33

Revisions

  1. davidfurlong revised this gist Oct 2, 2024. 1 changed file with 1 addition and 53 deletions.
    54 changes: 1 addition & 53 deletions SideSwipe.tsx
    Original file line number Diff line number Diff line change
    @@ -157,9 +157,7 @@ export const SideSwipe: React.FC<SideSwipeProps> = ({
    });

    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>
    @@ -168,56 +166,6 @@ export const SideSwipe: React.FC<SideSwipeProps> = ({
    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}
    </>
    );
    };
  2. davidfurlong created this gist Oct 2, 2024.
    266 changes: 266 additions & 0 deletions SideSwipe.tsx
    Original 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);
    }