UNPKG

react-native-gesture-image-viewer

Version:

πŸ–ΌοΈ A highly customizable and easy-to-use React Native image viewer with gesture support and external controls

359 lines (356 loc) β€’ 13.4 kB
"use strict"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { InteractionManager, useWindowDimensions } from 'react-native'; import { Gesture } from 'react-native-gesture-handler'; import { Easing, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; import { registry } from "./GestureViewerRegistry.js"; import { createBoundsConstraint, createScrollAction, getLoopAdjustedIndex } from "./utils.js"; export const useGestureViewer = ({ data, initialIndex = 0, onIndexChange, onDismiss, width: customWidth, dismissThreshold = 80, resistance = 2, // swipeThreshold = 0.5, // velocityThreshold = 200, animateBackdrop = true, enableDismissGesture = true, enableSwipeGesture = true, enableZoomGesture = true, enableDoubleTapGesture = true, enableZoomPanGesture = true, enableLoop = false, maxZoomScale = 2, itemSpacing = 0, useSnap = false, id = 'default' }) => { const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const width = useSnap ? customWidth || screenWidth : screenWidth; const [isZoomed, setIsZoomed] = useState(false); const [isRotated, setIsRotated] = useState(false); const [currentIndex, setCurrentIndex] = useState(initialIndex); const [manager, setManager] = useState(null); const unsubscribeRef = useRef(null); const initialTranslateY = useSharedValue(0); const initialTranslateX = useSharedValue(0); const startScale = useSharedValue(1); const translateY = useSharedValue(0); const translateX = useSharedValue(0); const scale = useSharedValue(1); const backdropOpacity = useSharedValue(1); const rotation = useSharedValue(0); const listRef = useRef(null); const dataLength = data?.length || 0; const adjustedInitialIndex = useMemo(() => { if (enableLoop && data.length > 1) { return initialIndex + 1; } return initialIndex; }, [enableLoop, data.length, initialIndex]); const constrainTranslation = useMemo(() => createBoundsConstraint({ width, height: screenHeight }), [width, screenHeight]); const scrollTo = useCallback((index, animated) => { const scrollAction = createScrollAction(listRef.current, width + itemSpacing); return scrollAction.scrollTo(index, animated); }, [width, itemSpacing]); const emitZoomChange = useCallback((currentScale, prevScale) => { manager?.emitZoomChange(currentScale, prevScale); }, [manager]); const emitRotationChange = useCallback((currentRotation, prevRotation) => { manager?.emitRotationChange(currentRotation, prevRotation); }, [manager]); useAnimatedReaction(() => scale.value, (currentScale, previousScale) => { if (manager && currentScale !== previousScale) { runOnJS(emitZoomChange)(currentScale, previousScale); } runOnJS(setIsZoomed)(currentScale > 1); }); useAnimatedReaction(() => rotation.value, (currentRotation, previousRotation) => { if (manager && currentRotation !== previousRotation) { runOnJS(emitRotationChange)(currentRotation, previousRotation); } runOnJS(setIsRotated)(currentRotation % 360 !== 0); }); useEffect(() => { const handleManagerChange = manager => { unsubscribeRef.current?.(); unsubscribeRef.current = null; setManager(manager); if (manager) { setCurrentIndex(manager.getState().currentIndex); unsubscribeRef.current = manager.subscribe(state => { setCurrentIndex(state.currentIndex); }); return; } setCurrentIndex(0); }; const unsubscribeFromRegistry = registry.subscribeToManager(id, handleManagerChange); return () => { unsubscribeFromRegistry(); unsubscribeRef.current?.(); }; }, [id]); useEffect(() => { if (!manager) { return; } manager.setDataLength(dataLength); manager.setEnableSwipeGesture(enableSwipeGesture); manager.setCurrentIndex(initialIndex); manager.setWidth(width + itemSpacing); manager.setHeight(screenHeight); manager.setZoomSharedValues(scale, translateX, translateY, maxZoomScale); manager.setRotation(rotation); manager.setEnableLoop(enableLoop); manager.notifyStateChange(); }, [dataLength, enableSwipeGesture, initialIndex, manager, width, itemSpacing, maxZoomScale, enableLoop, scale, screenHeight, translateX, translateY, rotation]); useEffect(() => { if (!manager || !listRef.current) { return; } manager.setListRef(listRef.current); }, [manager]); useEffect(() => { onIndexChange?.(currentIndex); }, [currentIndex, onIndexChange]); useEffect(() => { translateY.value = 0; translateX.value = 0; scale.value = 1; backdropOpacity.value = 1; startScale.value = 1; rotation.value = 0; if (adjustedInitialIndex <= 0 || !listRef.current) { return; } const runAfterInteractions = InteractionManager.runAfterInteractions(() => { scrollTo(adjustedInitialIndex, false); }); return () => { runAfterInteractions?.cancel(); }; }, [adjustedInitialIndex, translateY, backdropOpacity, translateX, scale, startScale, rotation, scrollTo]); const onMomentumScrollEnd = useCallback(event => { if (!enableSwipeGesture) { return; } const contentOffset = event.nativeEvent.contentOffset; const scrollIndex = Math.round(contentOffset.x / (width + itemSpacing)); const isLoopHandled = manager?.handleMomentumScrollEnd(scrollIndex); if (isLoopHandled) { return; } const { realIndex, needsJump, jumpToIndex } = getLoopAdjustedIndex(scrollIndex, dataLength, enableLoop); if (needsJump && jumpToIndex !== undefined) { scrollTo(jumpToIndex, false); } if (realIndex !== currentIndex && realIndex >= 0 && realIndex < dataLength) { if (manager) { manager.setCurrentIndex(realIndex); setCurrentIndex(realIndex); manager.notifyStateChange(); } translateX.value = withTiming(0); translateY.value = withTiming(0); initialTranslateX.value = withTiming(0); initialTranslateY.value = withTiming(0); startScale.value = withTiming(1); scale.value = withTiming(1); rotation.value = 0; } }, [scrollTo, manager, currentIndex, dataLength, width, itemSpacing, enableSwipeGesture, enableLoop, translateX, translateY, scale, initialTranslateX, initialTranslateY, startScale, rotation]); const dismissGesture = useMemo(() => { return Gesture.Pan().minDistance(10).averageTouches(true).activeCursor('grabbing').activeOffsetY([-10, 10]).failOffsetX([-10, 10]).enabled(!isZoomed).onUpdate(event => { translateY.value = event.translationY / resistance; }).onEnd(event => { if (event.translationY > dismissThreshold && enableDismissGesture && onDismiss) { runOnJS(onDismiss)(); return; } translateY.value = withSpring(0, { damping: 15, stiffness: 150 }); }); }, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance, isZoomed]); const zoomPinchGesture = useMemo(() => { return Gesture.Pinch().enabled(enableZoomGesture).onBegin(() => { startScale.value = scale.value; initialTranslateX.value = translateX.value; initialTranslateY.value = translateY.value; }).onUpdate(event => { const newScale = startScale.value * event.scale; scale.value = newScale; if (newScale <= 1) { translateX.value = withTiming(0); translateY.value = withTiming(0); return; } const deltaScale = newScale - startScale.value; const centerX = event.focalX - width / 2; const centerY = event.focalY - screenHeight / 2; // NOTE μƒˆλ‘œμš΄ 이동값 = κΈ°μ‘΄ 이동값 - (쀑심점 거리 Γ— μŠ€μΌ€μΌ λ³€ν™”λŸ‰) / μ›λž˜ μŠ€μΌ€μΌ (쀑심점이 ν™”λ©΄ μ€‘μ‹¬μ—μ„œ λ©€μˆ˜λ‘, ν™•λŒ€ 배율이 클수둝 더 많이 이동) const newTranslateX = initialTranslateX.value - centerX * deltaScale / startScale.value; const newTranslateY = initialTranslateY.value - centerY * deltaScale / startScale.value; const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({ translateX: newTranslateX, translateY: newTranslateY, scale: newScale }); translateX.value = constrainedTranslateX; translateY.value = constrainedTranslateY; }).onEnd(() => { if (scale.value > maxZoomScale) { scale.value = withTiming(maxZoomScale, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({ translateX: translateX.value, translateY: translateY.value, scale: maxZoomScale }); translateX.value = withTiming(constrainedTranslateX); translateY.value = withTiming(constrainedTranslateY); return; } if (scale.value < 1) { scale.value = withTiming(1, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); translateX.value = withTiming(0); translateY.value = withTiming(0); initialTranslateX.value = withTiming(0); initialTranslateY.value = withTiming(0); return; } const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({ translateX: translateX.value, translateY: translateY.value, scale: scale.value }); translateX.value = withTiming(constrainedTranslateX); translateY.value = withTiming(constrainedTranslateY); }); }, [scale, enableZoomGesture, maxZoomScale, translateX, translateY, startScale, initialTranslateX, initialTranslateY, width, screenHeight, constrainTranslation]); const zoomPanGesture = useMemo(() => { return Gesture.Pan().enabled(enableZoomPanGesture && isZoomed).activeCursor('grabbing').averageTouches(true).onBegin(() => { initialTranslateX.value = translateX.value; initialTranslateY.value = translateY.value; }).onUpdate(event => { if (scale.value > 1) { const newTranslateX = initialTranslateX.value + event.translationX; const newTranslateY = initialTranslateY.value + event.translationY; const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({ translateX: newTranslateX, translateY: newTranslateY, scale: scale.value }); translateX.value = constrainedTranslateX; translateY.value = constrainedTranslateY; } }); }, [translateX, translateY, enableZoomPanGesture, isZoomed, scale, initialTranslateX, initialTranslateY, constrainTranslation]); const doubleTapGesture = useMemo(() => { return Gesture.Tap().enabled(enableDoubleTapGesture).numberOfTaps(2).onEnd(event => { const nextScale = scale.value > 1 ? 1 : maxZoomScale; if (nextScale > 1) { const centerX = event.x - width / 2; const centerY = event.y - screenHeight / 2; // NOTE ν™•λŒ€λ‘œ λ°€λ €λ‚œ 거리만큼 λ°˜λŒ€λ‘œ μ΄λ™ν•΄μ„œ νƒ­ 지점을 μ œμžλ¦¬μ— μœ μ§€ translateX.value = withTiming(-centerX * (nextScale - 1), { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); translateY.value = withTiming(-centerY * (nextScale - 1), { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); } else { translateX.value = withTiming(0, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); translateY.value = withTiming(0, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); } scale.value = withTiming(nextScale, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1.0) }); }); }, [scale, enableDoubleTapGesture, maxZoomScale, translateX, translateY, width, screenHeight]); const zoomGesture = useMemo(() => { return Gesture.Race(zoomPinchGesture, Gesture.Exclusive(zoomPanGesture, doubleTapGesture)); }, [zoomPinchGesture, zoomPanGesture, doubleTapGesture]); const animatedStyle = useAnimatedStyle(() => { return { transform: [{ translateY: translateY.value }, { translateX: translateX.value }, { scale: scale.value }, { rotate: `${rotation.value}deg` }] }; }); const backdropStyle = useAnimatedStyle(() => { if (!animateBackdrop || scale.value !== 1) { return { opacity: 1 }; } const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp'); return { opacity }; }, [animateBackdrop]); const onScrollBeginDrag = useCallback(() => { manager?.handleScrollBeginDrag(); }, [manager]); return { currentIndex, dataLength, translateY, listRef, isZoomed, isRotated, dismissGesture, zoomGesture, onMomentumScrollEnd, onScrollBeginDrag, animatedStyle, backdropStyle }; }; //# sourceMappingURL=useGestureViewer.js.map