You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在React Native中实现可滑动动画图片堆叠交互

React Native 可循环滑动图片堆叠交互实现(已解决)

问题背景

需要实现以下交互:用户滑动图片堆叠,每次滑动时顶部卡片移至堆叠底部,其余卡片向前移动,同一时间仅显示3张图片,支持任意长度的图片数组循环浏览。使用react-native-reanimated 3和react-native-gesture-handler开发时遇到两个问题:

  • 动画运行时更新堆叠导致图片闪烁
  • 卡片循环正常但手势结束时无动画

解决说明

问题已解决,根源在于:

  1. 组件树上层传递了不必要的key属性,触发了不必要的重渲染
  2. 代码中存在冗余逻辑,干扰了卡片状态的正确维护

最终代码

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={{ uri }}
                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)

内容的提问来源于stack exchange,提问作者resistor

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 04:45:57