Instantly share code, notes, and snippets.
Created
November 10, 2020 22:07
-
Save nandorojo/762a2cc57128de1b6ce7fc40f7106873 to your computer and use it in GitHub Desktop.
React Native Reanimated v2 Shared Element Transition Image Gallery
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState, useRef, useEffect } from 'react' | |
import Animated, { | |
useSharedValue, | |
useAnimatedStyle, | |
runOnUI, | |
useAnimatedGestureHandler, | |
interpolate, | |
Extrapolate, | |
withTiming, | |
Easing, | |
} from 'react-native-reanimated' | |
import { | |
Dimensions, | |
StyleSheet, | |
View, | |
Image, | |
Platform, | |
Pressable, | |
} from 'react-native' | |
import { ScrollView, PanGestureHandler } from 'react-native-gesture-handler' | |
const Header = { | |
HEIGHT: 0, | |
} | |
const AnimatedImage = Animated.createAnimatedComponent(Image) | |
const dimensions = Dimensions.get('window') | |
const GUTTER_WIDTH = 3 | |
const NUMBER_OF_IMAGES = 4 | |
const IMAGE_SIZE = | |
(dimensions.width - GUTTER_WIDTH * (NUMBER_OF_IMAGES - 1)) / NUMBER_OF_IMAGES | |
const styles = StyleSheet.create({ | |
container: { | |
paddingTop: 0, | |
height: | |
Platform.OS === 'web' ? dimensions.height - Header.HEIGHT : undefined, | |
}, | |
scrollContainer: { | |
flexDirection: 'row', | |
flexWrap: 'wrap', | |
}, | |
backdrop: { | |
...StyleSheet.absoluteFillObject, | |
backgroundColor: 'black', | |
}, | |
}) | |
function ImageList({ images, onItemPress }) { | |
return ( | |
<ScrollView contentContainerStyle={styles.scrollContainer}> | |
{images.map((item, i) => ( | |
<ListItem onPress={onItemPress} key={i} index={i} item={item} /> | |
))} | |
</ScrollView> | |
) | |
} | |
function ListItem({ item, index, onPress }) { | |
const ref = useRef() | |
const opacity = useSharedValue(1) | |
const containerStyle = { | |
marginRight: (index + 1) % 4 === 0 ? 0 : GUTTER_WIDTH, | |
marginBottom: GUTTER_WIDTH, | |
} | |
const styles = useAnimatedStyle(() => { | |
return { | |
width: IMAGE_SIZE, | |
height: IMAGE_SIZE, | |
opacity: opacity.value, | |
} | |
}) | |
return ( | |
<Pressable | |
style={containerStyle} | |
onPress={() => onPress(ref, item, opacity)} | |
> | |
<AnimatedImage ref={ref} source={{ uri: item.uri }} style={styles} /> | |
</Pressable> | |
) | |
} | |
const timingConfig = { | |
duration: 350, | |
easing: Easing.bezier(0.33, 0.01, 0, 1), | |
} | |
function ImageTransition({ activeImage, onClose }) { | |
const { | |
x, | |
item, | |
width, | |
height, | |
targetWidth, | |
targetHeight, | |
sv: imageOpacity, | |
} = activeImage | |
const { uri } = item | |
const y = activeImage.y - Header.HEIGHT | |
const animationProgress = useSharedValue(0) | |
const backdropOpacity = useSharedValue(0) | |
const scale = useSharedValue(1) | |
const targetX = useSharedValue(0) | |
const targetY = useSharedValue( | |
(dimensions.height - targetHeight) / 2 - Header.HEIGHT | |
) | |
const translateX = useSharedValue(0) | |
const translateY = useSharedValue(0) | |
const onEnd = (forceClose?: boolean) => { | |
if (Math.abs(translateY.value) > 40 || forceClose) { | |
targetX.value = translateX.value - targetX.value * -1 | |
targetY.value = translateY.value - targetY.value * -1 | |
translateX.value = 0 | |
translateY.value = 0 | |
animationProgress.value = withTiming(0, timingConfig, () => { | |
imageOpacity.value = 1 | |
onClose() | |
}) | |
backdropOpacity.value = withTiming(0, timingConfig) | |
} else { | |
backdropOpacity.value = withTiming(1, timingConfig) | |
translateX.value = withTiming(0, timingConfig) | |
translateY.value = withTiming(0, timingConfig) | |
} | |
scale.value = withTiming(1, timingConfig) | |
} | |
const onPan = useAnimatedGestureHandler({ | |
onActive: (event) => { | |
translateX.value = event.translationX | |
translateY.value = event.translationY | |
scale.value = interpolate( | |
translateY.value, | |
[-250, 0, 250], | |
[0.65, 1, 0.65] | |
// Extrapolate.CLAMP | |
) | |
backdropOpacity.value = interpolate( | |
translateY.value, | |
[-100, 0, 100], | |
[0, 1, 0], | |
Extrapolate.CLAMP | |
) | |
}, | |
onEnd: () => onEnd(), | |
}) | |
const imageStyles = useAnimatedStyle(() => { | |
const interpolateProgress = (range) => | |
interpolate(animationProgress.value, [0, 1], range, Extrapolate.CLAMP) | |
const top = translateY.value + interpolateProgress([y, targetY.value]) | |
const left = translateX.value + interpolateProgress([x, targetX.value]) | |
return { | |
position: 'absolute', | |
top, | |
left, | |
width: interpolateProgress([width, targetWidth]), | |
height: interpolateProgress([height, targetHeight]), | |
transform: [ | |
{ | |
scale: scale.value, | |
}, | |
], | |
} | |
}) | |
const backdropStyles = useAnimatedStyle(() => { | |
return { | |
opacity: backdropOpacity.value, | |
} | |
}) | |
useEffect(() => { | |
runOnUI(() => { | |
'worklet' | |
animationProgress.value = withTiming(1, timingConfig, () => { | |
imageOpacity.value = 0 | |
}) | |
backdropOpacity.value = withTiming(1, timingConfig) | |
})() | |
}, []) | |
return ( | |
<View style={StyleSheet.absoluteFillObject}> | |
<Animated.View style={[styles.backdrop, backdropStyles]} /> | |
<Pressable | |
style={StyleSheet.absoluteFillObject} | |
onPress={() => onEnd(true)} | |
> | |
<PanGestureHandler onGestureEvent={onPan}> | |
<Animated.View style={StyleSheet.absoluteFillObject}> | |
<AnimatedImage source={{ uri }} style={imageStyles} /> | |
</Animated.View> | |
</PanGestureHandler> | |
</Pressable> | |
</View> | |
) | |
} | |
const images = Array.from({ length: 30 }, (_, index) => { | |
return { | |
uri: `https://picsum.photos/id/${index + 10}/400/400`, | |
width: dimensions.width, | |
height: 400, | |
} | |
}) | |
export default function LightboxExample() { | |
const [activeImage, setActiveImage] = useState(null) | |
function onItemPress(imageRef, item, sv) { | |
imageRef.current.measure((x, y, width, height, pageX, pageY) => { | |
if (width === 0 && height === 0) { | |
return | |
} | |
const targetWidth = dimensions.width | |
const scaleFactor = item.width / targetWidth | |
const targetHeight = item.height / scaleFactor | |
setActiveImage({ | |
item, | |
width, | |
height, | |
x: pageX, | |
y: pageY, | |
targetHeight, | |
targetWidth, | |
sv, | |
}) | |
}) | |
} | |
function onClose() { | |
setActiveImage(null) | |
} | |
return ( | |
<View style={styles.container}> | |
<ImageList onItemPress={onItemPress} images={images} /> | |
{activeImage && ( | |
<ImageTransition onClose={onClose} activeImage={activeImage} /> | |
)} | |
</View> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I’ve actually been facing the same issue in general with RN as of 3 days ago. Still trying to figure out why. But I don’t think it’s related to this — I’m not using it in my app, it was just a proof of concept.