Files
bsky-social-app/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
T
2023-10-03 04:48:46 +01:00

298 lines
7.8 KiB
TypeScript

import React, {useCallback, useRef, useEffect, useState} from 'react'
import {
View,
Dimensions,
StyleSheet,
NativeScrollEvent,
NativeSyntheticEvent,
NativeMethodsMixin,
} from 'react-native'
import {Image} from 'expo-image'
import Animated, {
runOnJS,
useAnimatedStyle,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import {
GestureDetector,
Gesture
} from 'react-native-gesture-handler'
const AnimatedImage = Animated.createAnimatedComponent(Image)
import useImageDimensions from '../../hooks/useImageDimensions'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SCREEN = Dimensions.get('window')
type Props = {
imageSrc: ImageSource
onRequestClose: () => void
onZoom: (isZoomed: boolean) => void
onLongPress: (image: ImageSource) => void
delayLongPress: number
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
}
type Matrix3 = [
number,
number,
number,
number,
number,
number,
number,
number,
number
];
function createTransform(): Matrix3 {
'worklet'
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
function prependTranslate(t: Matrix3, x: number, y: number) {
"worklet";
t[2] += t[0] * x + t[1] * y;
t[5] += t[3] * x + t[4] * y;
}
function prependScale(t: Matrix3, value: number) {
'worklet'
t[0] *= value;
t[1] *= value;
t[3] *= value;
t[4] *= value;
}
function prependTransform(ta: Matrix3, tb: Matrix3) {
"worklet";
const a00 = ta[0], a01 = ta[1], a02 = ta[2];
const a10 = ta[3], a11 = ta[4], a12 = ta[5];
const a20 = ta[6], a21 = ta[7], a22 = ta[8];
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6];
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7];
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8];
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6];
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7];
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8];
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6];
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7];
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8];
}
function readTransform(t): [number, number, number] {
'worklet'
const scale = t[0]
const translateX = t[2]
const translateY = t[5]
return [translateX, translateY, scale]
}
function prependPan(t, translation) {
'worklet'
prependTranslate(t, translation.x, translation.y);
}
function prependPinch(t, scale, origin, translation) {
'worklet'
prependTranslate(t, translation.x, translation.y);
prependTranslate(t, origin.x, origin.y);
prependScale(t, scale);
prependTranslate(t, -origin.x, -origin.y);
}
function applyRounding(t) {
'worklet'
t[2] = Math.round(t[2])
t[5] = Math.round(t[5])
t[0] = Math.round(t[0] * 200) / 200;
t[4] = Math.round(t[0] * 200) / 200;
}
function toStyle(t) {
"worklet";
const [translateX, translateY, scale] = readTransform(t)
return {
transform: [
{ translateX },
{ translateY },
{ scale }
],
};
}
function getScaledDimensions(imageDimensions, scale) {
'worklet'
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * SCREEN.width,
height: scale * SCREEN.width / imageAspect
}
} else {
return {
width: scale * SCREEN.height * imageAspect,
height: scale * SCREEN.height
}
}
}
function clampTranslation(value, scaledSize, screenSize) {
'worklet'
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
return clampedValue
}
const initialTransform = createTransform();
const ImageItem = ({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
delayLongPress,
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc)
const committedTransform = useSharedValue(initialTransform)
const pinchOrigin = useSharedValue({ x: 0, y: 0 })
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({ x: 0, y: 0 })
const panTranslation = useSharedValue({ x: 0, y: 0 })
useAnimatedReaction(
() => {
if (pinchScale.value !== 1) {
return true;
}
const [,,scale] = readTransform(committedTransform.value)
if (scale !== 1) {
return true;
}
return false;
},
(isScaledNow, wasScaledBefore) => {
if (isScaledNow !== wasScaledBefore) {
runOnJS(setIsScaled)(isScaledNow)
}
}
)
const animatedStyle = useAnimatedStyle(() => {
let t = createTransform();
prependPan(t, panTranslation.value);
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
prependTransform(t, committedTransform.value);
return toStyle(t)
})
function getPanLimitAdjustment(nextPanTranslation) {
'worklet';
if (!imageDimensions) {
return [0, 0]
}
let t = createTransform();
prependPan(t, nextPanTranslation)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
prependTransform(t, committedTransform.value)
const [nextTranslateX, nextTranslateY, nextScale] = readTransform(t)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
const clampedTranslateX = clampTranslation(nextTranslateX, scaledDimensions.width, SCREEN.width)
const clampedTranslateY = clampTranslation(nextTranslateY, scaledDimensions.height, SCREEN.height)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
return [dx, dy]
}
const pinch = Gesture.Pinch()
.onStart((e) => {
pinchOrigin.value = {
x: e.focalX - SCREEN.width/2,
y: e.focalY - SCREEN.height/2
};
})
.onChange((e) => {
const [,,committedScale] = readTransform(committedTransform.value)
const nextScale = Math.max(e.scale, 1 / committedScale);
pinchScale.value = nextScale
const [dx, dy] = getPanLimitAdjustment(panTranslation.value)
if (dx !== 0 || dy !== 0) {
pinchTranslation.value = {
x: pinchTranslation.value.x + dx,
y: pinchTranslation.value.y + dy
}
}
})
.onEnd(() => {
let t = createTransform();
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value);
applyRounding(t)
committedTransform.value = t
pinchScale.value = 1;
pinchOrigin.value = { x: 0, y: 0 };
pinchTranslation.value = { x: 0, y: 0 };
});
const pan = Gesture.Pan()
.averageTouches(true)
.enabled(isScaled)
.onChange((e) => {
if (!imageDimensions) {
return;
}
const nextPanTranslation = { x: e.translationX, y: e.translationY }
const [dx, dy] = getPanLimitAdjustment(nextPanTranslation)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.value = nextPanTranslation;
})
.onEnd(() => {
let t = createTransform();
prependPan(t, panTranslation.value)
prependTransform(t, committedTransform.value);
applyRounding(t)
committedTransform.value = t
panTranslation.value = { x: 0, y: 0 };
});
return (
<View
style={styles.container}>
<GestureDetector gesture={Gesture.Simultaneous(pinch, pan)}>
<AnimatedImage
source={imageSrc}
resizeMode="contain"
style={[styles.image, animatedStyle]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
/>
</GestureDetector>
</View>
)
}
const styles = StyleSheet.create({
container: {
width: SCREEN.width,
height: SCREEN.height,
},
image: {
flex: 1,
},
})
export default React.memo(ImageItem)