2023-09-12

Creating an animated, swipable image stack in React Native

I'm trying to create an interaction in React Native where the user can swipe through a stack of images. The images should animate so that on each swipe, the top card goes to the bottom of the stack, and every other card shifts forward. It should look similar to this:

Card deck animation

In my usecase, the length of the image array is arbitrary, but only 3 images should be visible at once. The user should be able to loop through all of the images by continuously swiping.

I'm using react-native-reanimated 3 and react-native-gesture-handler. I've tried several different approaches, all of which come close but have various issues. Either the animation runs but the images flicker when I update the stack, or the images cycle correctly but don't animate at the end of the gesture (which is what happens with my current approach).

Can anyone put me on the right track here?

EDIT: I think I solved it. I was passing a key prop to a component higher up in the tree which was causing unnecessary re-renders, and my code had some needless complexity that was messing up the card state.

Video here of it working as intended.

Here is the code, with some irrelevant parts elided:


// day-card.js

const CARD_MARGIN = 20
const CARD_OFFSET = 50

function DayCard({
    selectedId,
    dateString,
    creationTime,
    isShowingStack,
    toggleStack,
    allImages,
}) {
    const dispatch = useDispatch()
    const insets = useSafeAreaInsets()
    const numImages = allImages.length

    const [imageStack, setImageStack] = useState(allImages)

    const windowWidth = Dimensions.get('window').width

    const topCardTranslateX = useSharedValue(0)
    const topCardTranslateY = useSharedValue(0)
    const bottomCardTranslateX = useSharedValue(0)
    const bottomCardTranslateY = useSharedValue(0)
    const middleCardTranslateX = useSharedValue(0)
    const middleCardTranslateY = useSharedValue(0)

    const topCardMargin = useSharedValue(CARD_MARGIN)
    const middleCardMargin = useSharedValue(CARD_MARGIN)
    const bottomCardMargin = useSharedValue(CARD_MARGIN)

    const topCardZIndex = useSharedValue(4)
    const middleCardZIndex = useSharedValue(3)
    const bottomCardZIndex = useSharedValue(2)

    const topCardOpacity = useSharedValue(1)
    const middleCardOpacity = useSharedValue(1)
    const bottomCardOpacity = useSharedValue(1)

    const middleCardRotation = useSharedValue(0)
    const bottomCardRotation = useSharedValue(0)
    const topCardRotation = useSharedValue(0)

    const topCardBrightness = useSharedValue(1)
    const middleCardBrightness = useSharedValue(0.75)
    const bottomCardBrightness = useSharedValue(0.5)

    const animationIdx = useSharedValue(0)

    const cardStyles = [
        useAnimatedStyle(() => ({
            transform: [
                {
                    translateX: topCardTranslateX.value,
                },
                {
                    translateY: topCardTranslateY.value,
                },
                {
                    rotate: `${topCardRotation.value}deg`,
                },
            ],
            width: windowWidth - topCardMargin.value * 2,
            marginHorizontal: topCardMargin.value,
            zIndex: topCardZIndex.value,
            opacity: topCardOpacity.value,
        })),

        useAnimatedStyle(() => ({
            transform: [
                {
                    translateX: middleCardTranslateX.value,
                },
                {
                    translateY: middleCardTranslateY.value,
                },
                {
                    rotate: `${middleCardRotation.value}deg`,
                },
            ],
            width: windowWidth - middleCardMargin.value * 2,
            marginHorizontal: middleCardMargin.value,
            zIndex: middleCardZIndex.value,
            opacity: middleCardOpacity.value,
        })),

        useAnimatedStyle(() => ({
            transform: [
                {
                    translateX: bottomCardTranslateX.value,
                },
                {
                    translateY: bottomCardTranslateY.value,
                },
                {
                    rotate: `${bottomCardRotation.value}deg`,
                },
            ],
            width: windowWidth - bottomCardMargin.value * 2,
            marginHorizontal: bottomCardMargin.value,
            zIndex: bottomCardZIndex.value,
            opacity: bottomCardOpacity.value,
        })),
    ]

    const imageIndices = [
        [0, 1, 2],
        [2, 0, 1],
        [1, 2, 0],
    ]

    const Cards = [
        <Animated.View style={[styles.card, cardStyles[0]]}>
            <SingleCard
                dateString={dateString}
                uri={imageStack[imageIndices[animationIdx.value][0]].uri}
                id={imageStack[imageIndices[animationIdx.value][0]].id}
                brightness={topCardBrightness}
            />
        </Animated.View>,
        numImages > 1 ? (
            <Animated.View style={[styles.card, cardStyles[1]]}>
                <SingleCard
                    uri={imageStack[imageIndices[animationIdx.value][1]].uri}
                    id={imageStack[imageIndices[animationIdx.value][1]].id}
                    dateString={dateString}
                    brightness={middleCardBrightness}
                />
            </Animated.View>
        ) : null,
        numImages > 2 ? (
            <Animated.View style={[styles.card, cardStyles[2]]}>
                <SingleCard
                    uri={imageStack[imageIndices[animationIdx.value][2]].uri}
                    id={imageStack[imageIndices[animationIdx.value][2]].id}
                    dateString={dateString}
                    brightness={bottomCardBrightness}
                />
            </Animated.View>
        ) : null,
    ]

    const dispatchNewState = () => {
        dispatch(selectImageForDate(imageStack[0]))
    }

    const handleToggle = () => {
        if (isShowingStack) {
            topCardMargin.value = withTiming(CARD_MARGIN)
            middleCardMargin.value = withTiming(CARD_MARGIN)
            bottomCardMargin.value = withTiming(CARD_MARGIN)

            middleCardTranslateY.value = withTiming(0)
            bottomCardTranslateY.value = withTiming(0)
            topCardTranslateY.value = withTiming(0)
            dispatchNewState()
        } else {
            setCardState()
        }
        toggleStack()
    }

    const DEFAULT_WIDTH = windowWidth - CARD_MARGIN * 2
    const MIDDLE_WIDTH = windowWidth - CARD_MARGIN

    const styleMap = {
        translateY: [CARD_OFFSET, CARD_OFFSET / 2, 0],
        brightness: [1, 0.75, 0.5],
        margin: [0, CARD_MARGIN / 2, CARD_MARGIN],
        width: [windowWidth, MIDDLE_WIDTH, DEFAULT_WIDTH],
        zIndex: [4, 3, 2],
    }

    const endGesture = () => {
        'worklet'
        topCardTranslateX.value = withSpring(0)
        topCardRotation.value = withTiming(0)
        topCardOpacity.value = withTiming(1)
        middleCardTranslateX.value = withSpring(0)
        middleCardRotation.value = withTiming(0)
        middleCardOpacity.value = withTiming(1)
        bottomCardTranslateX.value = withSpring(0)
        bottomCardRotation.value = withTiming(0)
        bottomCardOpacity.value = withTiming(1)
    }

    const setCardState = () => {
        'worklet'
        const topCardIdx = imageIndices[animationIdx.value][0]
        const middleCardIdx = imageIndices[animationIdx.value][1]
        const bottomCardIdx = imageIndices[animationIdx.value][2]

        topCardTranslateY.value = withSpring(styleMap.translateY[topCardIdx])
        middleCardTranslateY.value = withSpring(
            styleMap.translateY[middleCardIdx]
        )
        bottomCardTranslateY.value = withSpring(
            styleMap.translateY[bottomCardIdx]
        )

        topCardMargin.value = withSpring(styleMap.margin[topCardIdx])
        middleCardMargin.value = withSpring(styleMap.margin[middleCardIdx])
        bottomCardMargin.value = withSpring(styleMap.margin[bottomCardIdx])

        topCardZIndex.value = styleMap.zIndex[topCardIdx]
        middleCardZIndex.value = styleMap.zIndex[middleCardIdx]
        bottomCardZIndex.value = styleMap.zIndex[bottomCardIdx]

        middleCardBrightness.value = withTiming(
            styleMap.brightness[middleCardIdx]
        )
        topCardBrightness.value = withTiming(styleMap.brightness[topCardIdx])
        bottomCardBrightness.value = withTiming(
            styleMap.brightness[bottomCardIdx]
        )
    }

    const stackGesture = Gesture.Pan()
        .onChange(({ translationX }) => {
            const rotation = interpolate(
                translationX,
                [-windowWidth, windowWidth],
                [-45, 45]
            )
            const opacity = interpolate(
                Math.abs(translationX),
                [0, windowWidth],
                [1, 0.5]
            )
            if (animationIdx.value === 0) {
                topCardRotation.value = `${rotation}deg`
                topCardTranslateX.value = translationX
                topCardOpacity.value = opacity
            } else if (animationIdx.value === 1) {
                middleCardRotation.value = `${rotation}deg`
                middleCardTranslateX.value = translationX
                middleCardOpacity.value = opacity
            } else if (animationIdx.value === 2) {
                bottomCardRotation.value = `${rotation}deg`
                bottomCardTranslateX.value = translationX
                bottomCardOpacity.value = opacity
            }
        })
        .onEnd(() => {
            endGesture()
            if (
                (animationIdx.value === 0 &&
                    Math.abs(topCardTranslateX.value) > 150) ||
                (animationIdx.value === 1 &&
                    Math.abs(middleCardTranslateX.value) > 150) ||
                (animationIdx.value === 2 &&
                    Math.abs(bottomCardTranslateX.value) > 150)
            ) {
                animationIdx.value = (animationIdx.value + 1) % 3
                runOnJS(setImageStack)([...imageStack.slice(1), imageStack[0]])
                setCardState()
            }
        })

    return (
        <BlurView
            intensity={10}
            alignContent="center"
            justifyContent="center"
            flex={1}
            tint="dark"
        >
            <GestureDetector
                gesture={isShowingStack ? stackGesture : null}
            >
                <View>
                    {Cards[0]}
                    {Cards[1]}
                    {Cards[2]}
                </View>
            </GestureDetector>
        </BlurView>
    )
}

const styles = StyleSheet.create({
    card: {
        position: 'absolute',
        top: 0,
        width: '100%',
    },
})

export default memo(DayCard)

// single-card.js

function SingleCard({ uri, dateString, id, brightness }) {
    const animatedStyle = useAnimatedStyle(() =>
        brightness
            ? {
                  opacity: 1 - brightness.value,
              }
            : {}
    )
    return (
        <SharedElement id={id}>
            <ImageBackground
                style={styles.image}
                source=
                alt={`A photo taken on ${dateString}`}
            >
                {brightness && (
                    <Animated.View
                        style={[
                            {
                                backgroundColor: 'black',
                                flex: 1,
                            },
                            animatedStyle,
                        ]}
                    />
                )}
            </ImageBackground>
        </SharedElement>
    )
}

const styles = StyleSheet.create({
    image: {
        width: '100%',
        aspectRatio: 3 / 4,
        position: 'relative',
    },
})

export default memo(SingleCard)



No comments:

Post a Comment