3

I'm trying to make a quiz-like game where you drag one of four answer tiles into a box. if it's the correct answer, it snaps into place over the box, and if it's wrong then it goes back to it's original position. I'm using panGestureHandler from react-native-gesture-handler and react-native-reanimated and react-native-redash to handle animations / snapping. Basically, if the dragged tile is let go within a certain bounds, it drops exactly onto the "place here" box. I've tried using the onLayout prop and setting offset shared values to get context to where the answer tiles are as well as the location of the box where they should be dropped. However, onLayout doesn't seem to work consistently, as it seems to be randomly skipping over setting some of the offsets (in QuestionOptions, when layoutReady is false). So instead i'm setting positioning based on the index of the four questions to make a 2x2 grid, then using onLayout on the target box to get the x and y location of the box.

I have been attempting different things for about a week now. This should be a simple problem and I can't figure out why I can't get this to work. I'm not so much looking for a fix to my code more than a better way to implement this because it seems like the wrong approach.

Here's a screenshot

Drag & Drop Quiz in React Native

My code: This currently works to drag & drop the tiles onto the box, however the positioning only works for iPhone 13. Need to make this work on every scale of device.

Constants

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

export const isSmallDevice = SCREEN_HEIGHT < theme.breakpoints.smallDevice;
const bottomTabHeight = isSmallDevice
    ? theme.constants.bottomTabHeightSmall
    : theme.constants.bottomTabHeightLarge;

export const MARGIN = 16;

export const ANSWER_BOX_WIDTH = (SCREEN_WIDTH - MARGIN * 2) / 4;
export const ANSWER_BOX_HEIGHT = ANSWER_BOX_WIDTH;

export const CONTENT_WIDTH = SCREEN_WIDTH - MARGIN * 2;

export const ANSWER_HEIGHT = SCREEN_HEIGHT * 0.4;
export const OPTIONS_HEIGHT = SCREEN_HEIGHT * 0.6 - bottomTabHeight;

QuestionOptions (there's one more component higher than this, but is irrelevant because it is only used for fetching question data)

export default function QuestionOptions({ answers, question }: Props) {
    const [layoutReady, setLayoutReady] = useState(false);
    const [boxLocation, setBoxLocation] = useState({ x: 0, y: 0 });

    const offsets = answers.map(() => ({
        width: useSharedValue(0),
        height: useSharedValue(0),
        x: useSharedValue(0),
        y: useSharedValue(0),
        originalX: useSharedValue(0),
        originalY: useSharedValue(0),
    }));

    if (!layoutReady) {
        return (
            <Box
                flexDirection={'row'}
                flexWrap='wrap'
                justifyContent={'center'}>
                {answers.map(({ content }, index) => {
                    return (
                        <Card
                            variant={'answerBox'}
                            key={index}
                            onLayout={({
                                nativeEvent: {
                                    layout: { x, y, width, height },
                                },
                            }) => {
                                const offset = offsets[index];
                                offset.width.value = width;
                                offset.height.value = height;
                                offset.originalX.value = x;
                                offset.originalY.value = y;
                                if (index === answers.length - 1) {
                                    setLayoutReady(true);
                                }
                            }}>
                            <Text>{content}</Text>
                        </Card>
                    );
                })}
            </Box>
        );
    }

    return (
        <>
            <Box
                height={ANSWER_HEIGHT}
                alignItems='center'
                padding='m'
                justifyContent='center'>
                <Card
                    width={ANSWER_BOX_WIDTH}
                    variant='answerBox'
                    borderStyle='dashed'
                    justifyContent='center'
                    alignItems='center'
                    height={ANSWER_BOX_HEIGHT}
                    style={{
                        position: 'absolute',
                        bottom: 10,
                    }}
                    onLayout={({
                        nativeEvent: {
                            layout: { x, y },
                        },
                    }) => {
                        setBoxLocation({ x, y });
                    }}>
                    <Text variant='body' color='border' fontSize={12}>
                        Place here
                    </Text>
                </Card>
                <Text variant='cardHeader'>{question}</Text>
            </Box>
            <Box
                height={OPTIONS_HEIGHT}
                flexDirection='row'
                justifyContent={'center'}
                flexWrap={'wrap'}>
                {answers.map((answer, index) => {
                    const x =
                        index < 2 // either 0 or 1
                            ? index * ANSWER_BOX_WIDTH + MARGIN * index
                            : (index - 2) * ANSWER_BOX_WIDTH +
                              MARGIN * (index - 2);

                    const y =
                        index > 1 // 2 to 3
                            ? ANSWER_BOX_HEIGHT * 2 + MARGIN * 2
                            : ANSWER_BOX_HEIGHT + MARGIN;
                    console.log(x, y);
                    return (
                        <QuestionBox
                            boxLocation={boxLocation}
                            key={index}
                            index={index}
                            isAnswer={answer.isAnswer}
                            content={answer.content}
                            offsets={offsets}
                            position={{
                                x,
                                y,
                            }}
                        />
                    );
                })}
            </Box>
        </>
    );
}

Question Box Component

const springDamping = 12;

export default function QuestionBox({
    content,
    isAnswer,
    position,
    boxLocation,
    index,
    offsets,
}: QuestionBoxProps) {
    const isGestureActive = useSharedValue(false);
    const translateX = useSharedValue(position.x);
    const translateY = useSharedValue(position.y);

    const CORRECT_TRANSLATION_Y_LOWER = -boxLocation.y + ANSWER_BOX_HEIGHT;
    const SNAP_POINT_X = boxLocation.x;
    const SNAP_POINT_Y =
        -boxLocation.y + ANSWER_BOX_HEIGHT + ANSWER_BOX_HEIGHT / 2;

    const panGestureEvent = useAnimatedGestureHandler<
        PanGestureHandlerGestureEvent,
        { x: number; y: number }
    >({
        onStart: (_, ctx) => {
            ctx.x = translateX.value;
            ctx.y = translateY.value;
            runOnJS(mediumHaptic)();
            isGestureActive.value = true;
        },
        onActive: ({ translationX, translationY }, ctx) => {
            translateX.value = ctx.x + translationX;
            translateY.value = ctx.y + translationY;
        },
        onEnd: ({ translationX, translationY, velocityX, velocityY }) => {
            const snapPointsX = [SNAP_POINT_X, SCREEN_WIDTH - ANSWER_BOX_WIDTH];
            const snapPointsY = [
                SNAP_POINT_Y,
                SCREEN_HEIGHT - ANSWER_BOX_HEIGHT,
            ];

            const snapPointX = snapPoint(translationX, velocityX, snapPointsX);
            const snapPointY = snapPoint(translationY, velocityY, snapPointsY);

            if (translationY < CORRECT_TRANSLATION_Y_LOWER && isAnswer) {
                //correct answer, places into slot
                translateX.value = withSpring(snapPointX, {
                    damping: springDamping,
                    velocity: velocityX,
                });
                translateY.value = withSpring(snapPointY, {
                    damping: springDamping,
                    velocity: velocityY,
                });
                runOnJS(successHaptic)();
            } else {
                //returns to bank
                translateX.value = withSpring(position.x, {
                    damping: springDamping,
                });
                translateY.value = withSpring(position.y, {
                    damping: springDamping,
                });
                runOnJS(errorHaptic)();
            }
            isGestureActive.value = false;
        },
    });

    const rStyle = useAnimatedStyle(() => {
        return {
            zIndex: isGestureActive.value ? 100 : 0,
            position: 'absolute',
            top: 0,
            left: 0,
            transform: [
                { translateX: translateX.value },
                { translateY: translateY.value },
            ],
        };
    });

    return (
        <PanGestureHandler onGestureEvent={panGestureEvent}>
            <AnimatedCard
                width={ANSWER_BOX_WIDTH}
                variant='questionBox'
                height={ANSWER_BOX_HEIGHT}
                alignItems='center'
                justifyContent='center'
                style={[rStyle]}>
                <AnimatedText variant='questionText'>{content}</AnimatedText>
            </AnimatedCard>
        </PanGestureHandler>
    );
}

Note: I'm using Shopify Restyle so that's why some styles are placed as props, and why some constants pull from the theme.

0 Answers0