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:
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)
Comments
Post a Comment