|
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> |
|
) |
|
} |
Maybe I messed something up or reanimated library changed since you posted, but I think onClose (line 130) should go through runOnJS