Refactor iOS lightbox to Reanimated (#1645)
* Remove unnecessary transform logic * Switch iOS swipe-to-dimiss to Reanimatedzio/stable
parent
832b05b64a
commit
f452ce74f4
|
@ -6,20 +6,25 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
import React, {MutableRefObject, useCallback, useState} from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
NativeTouchEvent,
|
NativeTouchEvent,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {GestureType} from 'react-native-gesture-handler'
|
import {GestureType} from 'react-native-gesture-handler'
|
||||||
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
|
@ -31,10 +36,8 @@ const DOUBLE_TAP_DELAY = 300
|
||||||
const SWIPE_CLOSE_OFFSET = 75
|
const SWIPE_CLOSE_OFFSET = 75
|
||||||
const SWIPE_CLOSE_VELOCITY = 1
|
const SWIPE_CLOSE_VELOCITY = 1
|
||||||
const SCREEN = Dimensions.get('screen')
|
const SCREEN = Dimensions.get('screen')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const MIN_DOUBLE_TAP_SCALE = 2
|
||||||
const MIN_ZOOM = 2
|
|
||||||
const MAX_SCALE = 2
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
|
@ -49,44 +52,42 @@ const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
let lastTapTS: number | null = null
|
let lastTapTS: number | null = null
|
||||||
|
|
||||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||||
const scrollViewRef = useRef<ScrollView>(null)
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||||
|
const translationY = useSharedValue(0)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [scaled, setScaled] = useState(false)
|
const [scaled, setScaled] = useState(false)
|
||||||
const imageDimensions = useImageDimensions(imageSrc)
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const maxZoomScale = imageDimensions
|
||||||
const [scrollValueY] = useState(() => new Animated.Value(0))
|
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
const maxScrollViewZoom = MAX_SCALE / (scale || 1)
|
: 1
|
||||||
|
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
return {
|
||||||
outputRange: [0.5, 1, 0.5],
|
opacity: interpolate(
|
||||||
|
translationY.value,
|
||||||
|
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||||
|
[0.5, 1, 0.5],
|
||||||
|
),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1)
|
|
||||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
|
||||||
|
|
||||||
const onScrollEndDrag = useCallback(
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
onScroll(e) {
|
||||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
translationY.value = e.zoomScale > 1 ? 0 : e.contentOffset.y
|
||||||
const currentScaled = nativeEvent?.zoomScale > 1
|
},
|
||||||
|
onEndDrag(e) {
|
||||||
onZoom(currentScaled)
|
const velocityY = e.velocity?.y ?? 0
|
||||||
setScaled(currentScaled)
|
const nextIsScaled = e.zoomScale > 1
|
||||||
|
runOnJS(handleZoom)(nextIsScaled)
|
||||||
if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
|
||||||
onRequestClose()
|
runOnJS(onRequestClose)()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onRequestClose, onZoom],
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
onZoom(nextIsScaled)
|
||||||
|
setScaled(nextIsScaled)
|
||||||
if (nativeEvent?.zoomScale > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollValueY.setValue(offsetY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDoubleTap = useCallback(
|
const handleDoubleTap = useCallback(
|
||||||
|
@ -121,23 +122,21 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||||
lastTapTS = nowTS
|
lastTapTS = nowTS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imageDimensions, scaled],
|
[imageDimensions, scaled, scrollViewRef],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ScrollView
|
<Animated.ScrollView
|
||||||
|
// @ts-ignore Something's up with the types here
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
style={styles.listItem}
|
style={styles.listItem}
|
||||||
pinchGestureEnabled
|
pinchGestureEnabled
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
maximumZoomScale={maxScrollViewZoom}
|
maximumZoomScale={maxZoomScale}
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
scrollEnabled={true}
|
onScroll={scrollHandler}>
|
||||||
onScroll={onScroll}
|
|
||||||
onScrollEndDrag={onScrollEndDrag}
|
|
||||||
scrollEventThrottle={1}>
|
|
||||||
{(!loaded || !imageDimensions) && <ImageLoading />}
|
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
onPress={handleDoubleTap}
|
onPress={handleDoubleTap}
|
||||||
|
@ -145,23 +144,28 @@ const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||||
accessibilityLabel={imageSrc.alt}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<AnimatedImage
|
<AnimatedImage
|
||||||
|
contentFit="contain"
|
||||||
source={imageSrc}
|
source={imageSrc}
|
||||||
style={imageStylesWithOpacity}
|
style={[styles.image, animatedStyle]}
|
||||||
onLoad={() => setLoaded(true)}
|
onLoad={() => setLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
</ScrollView>
|
</Animated.ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
listItem: {
|
|
||||||
width: SCREEN_WIDTH,
|
|
||||||
height: SCREEN_HEIGHT,
|
|
||||||
},
|
|
||||||
imageScrollContainer: {
|
imageScrollContainer: {
|
||||||
height: SCREEN_HEIGHT,
|
height: SCREEN.height,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
width: SCREEN.width,
|
||||||
|
height: SCREEN.height,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: SCREEN.width,
|
||||||
|
height: SCREEN.height,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -191,7 +195,7 @@ const getZoomRectAfterDoubleTap = (
|
||||||
const zoom = Math.max(
|
const zoom = Math.max(
|
||||||
imageAspect / screenAspect,
|
imageAspect / screenAspect,
|
||||||
screenAspect / imageAspect,
|
screenAspect / imageAspect,
|
||||||
MIN_ZOOM,
|
MIN_DOUBLE_TAP_SCALE,
|
||||||
)
|
)
|
||||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||||
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||||
|
@ -253,61 +257,4 @@ const getZoomRectAfterDoubleTap = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getImageStyles = (
|
|
||||||
image: ImageDimensions | null,
|
|
||||||
translate: {readonly x: number; readonly y: number} | undefined,
|
|
||||||
scale?: number,
|
|
||||||
) => {
|
|
||||||
if (!image?.width || !image?.height) {
|
|
||||||
return {width: 0, height: 0}
|
|
||||||
}
|
|
||||||
const transform = []
|
|
||||||
if (translate) {
|
|
||||||
transform.push({translateX: translate.x})
|
|
||||||
transform.push({translateY: translate.y})
|
|
||||||
}
|
|
||||||
if (scale) {
|
|
||||||
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
|
|
||||||
transform.push({scale}, {perspective: new Animated.Value(1000)})
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
transform,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImageTransform = (
|
|
||||||
image: ImageDimensions | null,
|
|
||||||
screen: ImageDimensions,
|
|
||||||
) => {
|
|
||||||
if (!image?.width || !image?.height) {
|
|
||||||
return [] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const wScale = screen.width / image.width
|
|
||||||
const hScale = screen.height / image.height
|
|
||||||
const scale = Math.min(wScale, hScale)
|
|
||||||
const {x, y} = getImageTranslate(image, screen)
|
|
||||||
|
|
||||||
return [{x, y}, scale] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImageTranslate = (
|
|
||||||
image: ImageDimensions,
|
|
||||||
screen: ImageDimensions,
|
|
||||||
): {x: number; y: number} => {
|
|
||||||
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
|
|
||||||
const imageSize = axis === 'x' ? image.width : image.height
|
|
||||||
const screenSize = axis === 'x' ? screen.width : screen.height
|
|
||||||
|
|
||||||
return (screenSize - imageSize) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: getTranslateForAxis('x'),
|
|
||||||
y: getTranslateForAxis('y'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(ImageItem)
|
export default React.memo(ImageItem)
|
||||||
|
|
Loading…
Reference in New Issue