[WIP] Rewrite Android lightbox

This commit is contained in:
Dan Abramov
2023-09-28 18:04:56 +01:00
parent fd5bbb2769
commit 44733bb390
3 changed files with 219 additions and 567 deletions
@@ -1,16 +1,7 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback, useRef, useState} from 'react'
import React, {useCallback, useRef, useEffect, useState} from 'react'
import {
Animated,
ScrollView,
View,
Dimensions,
StyleSheet,
NativeScrollEvent,
@@ -18,19 +9,24 @@ import {
NativeMethodsMixin,
} from 'react-native'
import {Image} from 'expo-image'
import Animated, {
useAnimatedStyle,
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 usePanResponder from '../../hooks/usePanResponder'
import {getImageStyles, getImageTransform} from '../../utils'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1.75
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: ImageSource
@@ -42,7 +38,112 @@ type Props = {
doubleTapToZoomEnabled?: boolean
}
const AnimatedImage = Animated.createAnimatedComponent(Image)
type Matrix3 = readonly [
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 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,
@@ -53,104 +154,111 @@ const ImageItem = ({
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
const imageDimensions = useImageDimensions(imageSrc)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0)
const [isLoaded, setLoadEnd] = useState(false)
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 })
const onLoaded = useCallback(() => setLoadEnd(true), [])
const onZoomPerformed = useCallback(
(isZoomed: boolean) => {
onZoom(isZoomed)
if (imageContainer?.current) {
imageContainer.current.setNativeProps({
scrollEnabled: !isZoomed,
})
}
},
[onZoom],
)
const onLongPressHandler = useCallback(() => {
onLongPress(imageSrc)
}, [imageSrc, onLongPress])
const [panHandlers, scaleValue, translateValue] = usePanResponder({
initialScale: scale || 1,
initialTranslate: translate || {x: 0, y: 0},
onZoom: onZoomPerformed,
doubleTapToZoomEnabled,
onLongPress: onLongPressHandler,
delayLongPress,
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)
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue,
)
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.7, 1, 0.7],
})
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
const onScrollEndDrag = ({
nativeEvent,
}: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
offsetY > SWIPE_CLOSE_OFFSET) ||
offsetY > SCREEN_HEIGHT / 2
) {
onRequestClose()
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 onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = nativeEvent?.contentOffset?.y ?? 0
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);
committedTransform.value = t
pinchScale.value = 1;
pinchOrigin.value = { x: 0, y: 0 };
pinchTranslation.value = { x: 0, y: 0 };
});
scrollValueY.setValue(offsetY)
}
const pan = Gesture.Pan()
.averageTouches(true)
.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);
committedTransform.value = t
panTranslation.value = { x: 0, y: 0 };
});
return (
<ScrollView
ref={imageContainer}
style={styles.listItem}
pagingEnabled
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled}
{...(swipeToCloseEnabled && {
onScroll,
onScrollEndDrag,
})}>
<AnimatedImage
{...panHandlers}
source={imageSrc}
style={imageStylesWithOpacity}
onLoad={onLoaded}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
/>
{(!isLoaded || !imageDimensions) && <ImageLoading />}
</ScrollView>
)
<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({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
container: {
width: SCREEN.width,
height: SCREEN.height,
},
imageScrollContainer: {
height: SCREEN_HEIGHT * 2,
image: {
flex: 1,
},
})