Rewrite Android lightbox (#1624)
parent
8366fe2c4a
commit
64153067e3
|
@ -1,159 +1,398 @@
|
|||
/**
|
||||
* 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, {MutableRefObject, useState} from 'react'
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
|
||||
import {
|
||||
Animated,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
NativeMethodsMixin,
|
||||
} from 'react-native'
|
||||
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
withDecay,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
GestureDetector,
|
||||
Gesture,
|
||||
GestureType,
|
||||
} from 'react-native-gesture-handler'
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
import usePanResponder from '../../hooks/usePanResponder'
|
||||
import {
|
||||
createTransform,
|
||||
readTransform,
|
||||
applyRounding,
|
||||
prependPan,
|
||||
prependPinch,
|
||||
prependTransform,
|
||||
TransformMatrix,
|
||||
} from '../../transforms'
|
||||
import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||
|
||||
import {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
|
||||
const MIN_DOUBLE_TAP_SCALE = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
const initialTransform = createTransform()
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
||||
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
isScrollViewBeingDragged,
|
||||
pinchGestureRef,
|
||||
}: Props) => {
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
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 panTranslation = useSharedValue({x: 0, y: 0})
|
||||
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
||||
const pinchScale = useSharedValue(1)
|
||||
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
||||
const dismissSwipeTranslateY = useSharedValue(0)
|
||||
const containerRef = useAnimatedRef()
|
||||
|
||||
const onLoaded = useCallback(() => setLoadEnd(true), [])
|
||||
const onZoomPerformed = useCallback(
|
||||
(isZoomed: boolean) => {
|
||||
onZoom(isZoomed)
|
||||
if (imageContainer?.current) {
|
||||
imageContainer.current.setNativeProps({
|
||||
scrollEnabled: !isZoomed,
|
||||
})
|
||||
function getCommittedScale(): number {
|
||||
'worklet'
|
||||
const [, , committedScale] = readTransform(committedTransform.value)
|
||||
return committedScale
|
||||
}
|
||||
|
||||
// Keep track of when we're entering or leaving scaled rendering.
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return pinchScale.value !== 1 || getCommittedScale() !== 1
|
||||
},
|
||||
(nextIsScaled, prevIsScaled) => {
|
||||
if (nextIsScaled !== prevIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
},
|
||||
[onZoom],
|
||||
)
|
||||
|
||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
||||
initialScale: scale || 1,
|
||||
initialTranslate: translate || {x: 0, y: 0},
|
||||
onZoom: onZoomPerformed,
|
||||
})
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
setIsScaled(nextIsScaled)
|
||||
onZoom(nextIsScaled)
|
||||
}
|
||||
|
||||
const imagesStyles = getImageStyles(
|
||||
imageDimensions,
|
||||
translateValue,
|
||||
scaleValue,
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
// Apply the active adjustments on top of the committed transform before the gestures.
|
||||
// This is matrix multiplication, so operations are applied in the reverse order.
|
||||
let t = createTransform()
|
||||
prependPan(t, panTranslation.value)
|
||||
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||
prependTransform(t, committedTransform.value)
|
||||
const [translateX, translateY, scale] = readTransform(t)
|
||||
|
||||
const dismissDistance = dismissSwipeTranslateY.value
|
||||
const dismissProgress = Math.min(
|
||||
Math.abs(dismissDistance) / (SCREEN.height / 2),
|
||||
1,
|
||||
)
|
||||
const imageOpacity = scrollValueY.interpolate({
|
||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||
outputRange: [0.7, 1, 0.7],
|
||||
return {
|
||||
opacity: 1 - dismissProgress,
|
||||
transform: [
|
||||
{translateX},
|
||||
{translateY: translateY + dismissDistance},
|
||||
{scale},
|
||||
],
|
||||
}
|
||||
})
|
||||
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
|
||||
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
|
||||
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||
function getExtraTranslationToStayInBounds(
|
||||
candidateTransform: TransformMatrix,
|
||||
) {
|
||||
onRequestClose()
|
||||
'worklet'
|
||||
if (!imageDimensions) {
|
||||
return [0, 0]
|
||||
}
|
||||
const [nextTranslateX, nextTranslateY, nextScale] =
|
||||
readTransform(candidateTransform)
|
||||
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
|
||||
// This is a hack.
|
||||
// We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
|
||||
// However, there is no great reliable way to coordinate this yet in RGNH.
|
||||
// This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
|
||||
const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
|
||||
if (isScrollViewBeingDragged) {
|
||||
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||
manager.activate()
|
||||
return
|
||||
}
|
||||
const measurement = measure(containerRef)
|
||||
if (!measurement || measurement.pageX !== 0) {
|
||||
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||
manager.activate()
|
||||
return
|
||||
}
|
||||
// Fail this "fake" gesture so that the gestures after it can proceed.
|
||||
manager.fail()
|
||||
})
|
||||
|
||||
scrollValueY.setValue(offsetY)
|
||||
const pinch = Gesture.Pinch()
|
||||
.withRef(pinchGestureRef)
|
||||
.onStart(e => {
|
||||
pinchOrigin.value = {
|
||||
x: e.focalX - SCREEN.width / 2,
|
||||
y: e.focalY - SCREEN.height / 2,
|
||||
}
|
||||
})
|
||||
.onChange(e => {
|
||||
if (!imageDimensions) {
|
||||
return
|
||||
}
|
||||
// Don't let the picture zoom in so close that it gets blurry.
|
||||
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
|
||||
const committedScale = getCommittedScale()
|
||||
const maxCommittedScale =
|
||||
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||
const minPinchScale = 1 / committedScale
|
||||
const maxPinchScale = maxCommittedScale / committedScale
|
||||
const nextPinchScale = Math.min(
|
||||
Math.max(minPinchScale, e.scale),
|
||||
maxPinchScale,
|
||||
)
|
||||
pinchScale.value = nextPinchScale
|
||||
|
||||
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
|
||||
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
|
||||
const t = createTransform()
|
||||
prependPan(t, panTranslation.value)
|
||||
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
|
||||
prependTransform(t, committedTransform.value)
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
pinchTranslation.value = {
|
||||
x: pinchTranslation.value.x + dx,
|
||||
y: pinchTranslation.value.y + dy,
|
||||
}
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
// Commit just the pinch.
|
||||
let t = createTransform()
|
||||
prependPinch(
|
||||
t,
|
||||
pinchScale.value,
|
||||
pinchOrigin.value,
|
||||
pinchTranslation.value,
|
||||
)
|
||||
prependTransform(t, committedTransform.value)
|
||||
applyRounding(t)
|
||||
committedTransform.value = t
|
||||
|
||||
// Reset just the pinch.
|
||||
pinchScale.value = 1
|
||||
pinchOrigin.value = {x: 0, y: 0}
|
||||
pinchTranslation.value = {x: 0, y: 0}
|
||||
})
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.averageTouches(true)
|
||||
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
|
||||
.minPointers(isScaled ? 1 : 2)
|
||||
.onChange(e => {
|
||||
if (!imageDimensions) {
|
||||
return
|
||||
}
|
||||
const nextPanTranslation = {x: e.translationX, y: e.translationY}
|
||||
let t = createTransform()
|
||||
prependPan(t, nextPanTranslation)
|
||||
prependPinch(
|
||||
t,
|
||||
pinchScale.value,
|
||||
pinchOrigin.value,
|
||||
pinchTranslation.value,
|
||||
)
|
||||
prependTransform(t, committedTransform.value)
|
||||
|
||||
// Prevent panning from going out of bounds.
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||
nextPanTranslation.x += dx
|
||||
nextPanTranslation.y += dy
|
||||
panTranslation.value = nextPanTranslation
|
||||
})
|
||||
.onEnd(() => {
|
||||
// Commit just the pan.
|
||||
let t = createTransform()
|
||||
prependPan(t, panTranslation.value)
|
||||
prependTransform(t, committedTransform.value)
|
||||
applyRounding(t)
|
||||
committedTransform.value = t
|
||||
|
||||
// Reset just the pan.
|
||||
panTranslation.value = {x: 0, y: 0}
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
if (!imageDimensions) {
|
||||
return
|
||||
}
|
||||
const committedScale = getCommittedScale()
|
||||
if (committedScale !== 1) {
|
||||
// Go back to 1:1 using the identity vector.
|
||||
let t = createTransform()
|
||||
committedTransform.value = withClampedSpring(t)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
|
||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||
const screenAspect = SCREEN.width / SCREEN.height
|
||||
const candidateScale = Math.max(
|
||||
imageAspect / screenAspect,
|
||||
screenAspect / imageAspect,
|
||||
MIN_DOUBLE_TAP_SCALE,
|
||||
)
|
||||
// But don't zoom in so close that the picture gets blurry.
|
||||
const maxScale =
|
||||
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||
const scale = Math.min(candidateScale, maxScale)
|
||||
|
||||
// Calculate where we would be if the user pinched into the double tapped point.
|
||||
// We won't use this transform directly because it may go out of bounds.
|
||||
const candidateTransform = createTransform()
|
||||
const origin = {
|
||||
x: e.absoluteX - SCREEN.width / 2,
|
||||
y: e.absoluteY - SCREEN.height / 2,
|
||||
}
|
||||
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
|
||||
|
||||
// Now we know how much we went out of bounds, so we can shoot correctly.
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
|
||||
const finalTransform = createTransform()
|
||||
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
|
||||
committedTransform.value = withClampedSpring(finalTransform)
|
||||
})
|
||||
|
||||
const dismissSwipePan = Gesture.Pan()
|
||||
.enabled(!isScaled)
|
||||
.activeOffsetY([-10, 10])
|
||||
.failOffsetX([-10, 10])
|
||||
.maxPointers(1)
|
||||
.onUpdate(e => {
|
||||
dismissSwipeTranslateY.value = e.translationY
|
||||
})
|
||||
.onEnd(e => {
|
||||
if (Math.abs(e.velocityY) > 1000) {
|
||||
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
|
||||
runOnJS(onRequestClose)()
|
||||
} else {
|
||||
dismissSwipeTranslateY.value = withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = !isLoaded || !imageDimensions
|
||||
return (
|
||||
<ScrollView
|
||||
ref={imageContainer}
|
||||
style={styles.listItem}
|
||||
pagingEnabled
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.imageScrollContainer}
|
||||
scrollEnabled={true}
|
||||
onScroll={onScroll}
|
||||
onScrollEndDrag={onScrollEndDrag}>
|
||||
<Animated.View ref={containerRef} style={styles.container}>
|
||||
{isLoading && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<GestureDetector
|
||||
gesture={Gesture.Exclusive(
|
||||
consumeHScroll,
|
||||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
)}>
|
||||
<AnimatedImage
|
||||
{...panHandlers}
|
||||
source={imageSrc}
|
||||
style={imageStylesWithOpacity}
|
||||
onLoad={onLoaded}
|
||||
contentFit="contain"
|
||||
style={[styles.image, animatedStyle]}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
||||
</ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
container: {
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT * 2,
|
||||
image: {
|
||||
flex: 1,
|
||||
},
|
||||
loading: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const getImageStyles = (
|
||||
image: {width: number; height: number} | null,
|
||||
translate: Animated.ValueXY,
|
||||
scale?: Animated.Value,
|
||||
) => {
|
||||
if (!image?.width || !image?.height) {
|
||||
return {width: 0, height: 0}
|
||||
}
|
||||
|
||||
const transform = translate.getTranslateTransform()
|
||||
|
||||
if (scale) {
|
||||
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
|
||||
transform.push({scale}, {perspective: new Animated.Value(1000)})
|
||||
}
|
||||
|
||||
function getScaledDimensions(
|
||||
imageDimensions: ImageDimensions,
|
||||
scale: number,
|
||||
): ImageDimensions {
|
||||
'worklet'
|
||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||
const screenAspect = SCREEN.width / SCREEN.height
|
||||
const isLandscape = imageAspect > screenAspect
|
||||
if (isLandscape) {
|
||||
return {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
transform,
|
||||
width: scale * SCREEN.width,
|
||||
height: (scale * SCREEN.width) / imageAspect,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: scale * SCREEN.height * imageAspect,
|
||||
height: scale * SCREEN.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clampTranslation(
|
||||
value: number,
|
||||
scaledSize: number,
|
||||
screenSize: number,
|
||||
): number {
|
||||
'worklet'
|
||||
// Figure out how much the user should be allowed to pan, and constrain the translation.
|
||||
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
|
||||
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
|
||||
return clampedValue
|
||||
}
|
||||
|
||||
function withClampedSpring(value: any) {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
}
|
||||
|
||||
export default React.memo(ImageItem)
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import React, {useCallback, useRef, useState} from 'react'
|
||||
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
||||
|
||||
import {
|
||||
Animated,
|
||||
|
@ -20,11 +20,11 @@ import {
|
|||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {GestureType} from 'react-native-gesture-handler'
|
||||
|
||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||
|
||||
import {getImageTransform} from '../../utils'
|
||||
import {ImageSource} from '../../@types'
|
||||
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||
import {ImageLoading} from './ImageLoading'
|
||||
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
|
@ -40,6 +40,8 @@ type Props = {
|
|||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||
|
@ -164,7 +166,7 @@ const styles = StyleSheet.create({
|
|||
})
|
||||
|
||||
const getZoomRectAfterDoubleTap = (
|
||||
imageDimensions: {width: number; height: number} | null,
|
||||
imageDimensions: ImageDimensions | null,
|
||||
touchX: number,
|
||||
touchY: number,
|
||||
): {
|
||||
|
@ -252,7 +254,7 @@ const getZoomRectAfterDoubleTap = (
|
|||
}
|
||||
|
||||
const getImageStyles = (
|
||||
image: {width: number; height: number} | null,
|
||||
image: ImageDimensions | null,
|
||||
translate: {readonly x: number; readonly y: number} | undefined,
|
||||
scale?: number,
|
||||
) => {
|
||||
|
@ -275,4 +277,37 @@ const getImageStyles = (
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
// default implementation fallback for web
|
||||
|
||||
import React from 'react'
|
||||
import React, {MutableRefObject} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {GestureType} from 'react-native-gesture-handler'
|
||||
import {ImageSource} from '../../@types'
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||
isScrollViewBeingDragged: boolean
|
||||
}
|
||||
|
||||
const ImageItem = (_props: Props) => {
|
||||
|
|
|
@ -1,423 +0,0 @@
|
|||
/**
|
||||
* 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 {useEffect} from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
GestureResponderEvent,
|
||||
GestureResponderHandlers,
|
||||
NativeTouchEvent,
|
||||
PanResponder,
|
||||
PanResponderGestureState,
|
||||
} from 'react-native'
|
||||
|
||||
import {Position} from '../@types'
|
||||
import {getImageTranslate} from '../utils'
|
||||
|
||||
const SCREEN = Dimensions.get('window')
|
||||
const SCREEN_WIDTH = SCREEN.width
|
||||
const SCREEN_HEIGHT = SCREEN.height
|
||||
const ANDROID_BAR_HEIGHT = 24
|
||||
|
||||
const MIN_ZOOM = 2
|
||||
const MAX_SCALE = 2
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
const OUT_BOUND_MULTIPLIER = 0.75
|
||||
|
||||
type Props = {
|
||||
initialScale: number
|
||||
initialTranslate: Position
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
}
|
||||
|
||||
const usePanResponder = ({
|
||||
initialScale,
|
||||
initialTranslate,
|
||||
onZoom,
|
||||
}: Props): Readonly<
|
||||
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
|
||||
> => {
|
||||
let numberInitialTouches = 1
|
||||
let initialTouches: NativeTouchEvent[] = []
|
||||
let currentScale = initialScale
|
||||
let currentTranslate = initialTranslate
|
||||
let tmpScale = 0
|
||||
let tmpTranslate: Position | null = null
|
||||
let isDoubleTapPerformed = false
|
||||
let lastTapTS: number | null = null
|
||||
|
||||
// TODO: It's not valid to reinitialize Animated values during render.
|
||||
// This is a bug.
|
||||
const scaleValue = new Animated.Value(initialScale)
|
||||
const translateValue = new Animated.ValueXY(initialTranslate)
|
||||
|
||||
const imageDimensions = getImageDimensionsByTranslate(
|
||||
initialTranslate,
|
||||
SCREEN,
|
||||
)
|
||||
|
||||
const getBounds = (scale: number) => {
|
||||
const scaledImageDimensions = {
|
||||
width: imageDimensions.width * scale,
|
||||
height: imageDimensions.height * scale,
|
||||
}
|
||||
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
|
||||
|
||||
const left = initialTranslate.x - translateDelta.x
|
||||
const right = left - (scaledImageDimensions.width - SCREEN.width)
|
||||
const top = initialTranslate.y - translateDelta.y
|
||||
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
|
||||
|
||||
return [top, left, bottom, right]
|
||||
}
|
||||
|
||||
const getTransformAfterDoubleTap = (
|
||||
touchX: number,
|
||||
touchY: number,
|
||||
): [number, Position] => {
|
||||
let nextScale = initialScale
|
||||
let nextTranslateX = initialTranslate.x
|
||||
let nextTranslateY = initialTranslate.y
|
||||
|
||||
// First, let's figure out how much we want to zoom in.
|
||||
// We want to try to zoom in at least close enough to get rid of black bars.
|
||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||
const screenAspect = SCREEN.width / SCREEN.height
|
||||
let zoom = Math.max(
|
||||
imageAspect / screenAspect,
|
||||
screenAspect / imageAspect,
|
||||
MIN_ZOOM,
|
||||
)
|
||||
// Don't zoom so hard that the original image's pixels become blurry.
|
||||
zoom = Math.min(zoom, MAX_SCALE / initialScale)
|
||||
nextScale = initialScale * zoom
|
||||
|
||||
// Next, let's see if we need to adjust the scaled image translation.
|
||||
// Ideally, we want the tapped point to stay under the finger after the scaling.
|
||||
const dx = SCREEN.width / 2 - touchX
|
||||
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
|
||||
// Before we try to adjust the translation, check how much wiggle room we have.
|
||||
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
||||
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
|
||||
if (leftBound > rightBound) {
|
||||
// Content fills the screen horizontally so we have horizontal wiggle room.
|
||||
// Try to keep the tapped point under the finger after zoom.
|
||||
nextTranslateX += dx * zoom - dx
|
||||
nextTranslateX = Math.min(nextTranslateX, leftBound)
|
||||
nextTranslateX = Math.max(nextTranslateX, rightBound)
|
||||
}
|
||||
if (topBound > bottomBound) {
|
||||
// Content fills the screen vertically so we have vertical wiggle room.
|
||||
// Try to keep the tapped point under the finger after zoom.
|
||||
nextTranslateY += dy * zoom - dy
|
||||
nextTranslateY = Math.min(nextTranslateY, topBound)
|
||||
nextTranslateY = Math.max(nextTranslateY, bottomBound)
|
||||
}
|
||||
|
||||
return [
|
||||
nextScale,
|
||||
{
|
||||
x: nextTranslateX,
|
||||
y: nextTranslateY,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const fitsScreenByWidth = () =>
|
||||
imageDimensions.width * currentScale < SCREEN_WIDTH
|
||||
const fitsScreenByHeight = () =>
|
||||
imageDimensions.height * currentScale < SCREEN_HEIGHT
|
||||
|
||||
useEffect(() => {
|
||||
scaleValue.addListener(({value}) => {
|
||||
if (typeof onZoom === 'function') {
|
||||
onZoom(value !== initialScale)
|
||||
}
|
||||
})
|
||||
|
||||
return () => scaleValue.removeAllListeners()
|
||||
})
|
||||
|
||||
const panResponder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponderCapture: () => true,
|
||||
onPanResponderGrant: (
|
||||
_: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState,
|
||||
) => {
|
||||
numberInitialTouches = gestureState.numberActiveTouches
|
||||
|
||||
if (gestureState.numberActiveTouches > 1) {
|
||||
return
|
||||
}
|
||||
},
|
||||
onPanResponderStart: (
|
||||
event: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState,
|
||||
) => {
|
||||
initialTouches = event.nativeEvent.touches
|
||||
numberInitialTouches = gestureState.numberActiveTouches
|
||||
|
||||
if (gestureState.numberActiveTouches > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const tapTS = Date.now()
|
||||
// Handle double tap event by calculating diff between first and second taps timestamps
|
||||
|
||||
isDoubleTapPerformed = Boolean(
|
||||
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
|
||||
)
|
||||
|
||||
if (isDoubleTapPerformed) {
|
||||
let nextScale = initialScale
|
||||
let nextTranslate = initialTranslate
|
||||
|
||||
const willZoom = currentScale === initialScale
|
||||
if (willZoom) {
|
||||
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
|
||||
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
|
||||
touchX,
|
||||
touchY,
|
||||
)
|
||||
}
|
||||
onZoom(willZoom)
|
||||
|
||||
Animated.parallel(
|
||||
[
|
||||
Animated.timing(translateValue.x, {
|
||||
toValue: nextTranslate.x,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateValue.y, {
|
||||
toValue: nextTranslate.y,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleValue, {
|
||||
toValue: nextScale,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
],
|
||||
{stopTogether: false},
|
||||
).start(() => {
|
||||
currentScale = nextScale
|
||||
currentTranslate = nextTranslate
|
||||
})
|
||||
|
||||
lastTapTS = null
|
||||
} else {
|
||||
lastTapTS = Date.now()
|
||||
}
|
||||
},
|
||||
onPanResponderMove: (
|
||||
event: GestureResponderEvent,
|
||||
gestureState: PanResponderGestureState,
|
||||
) => {
|
||||
// Don't need to handle move because double tap in progress (was handled in onStart)
|
||||
if (isDoubleTapPerformed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
numberInitialTouches === 1 &&
|
||||
gestureState.numberActiveTouches === 2
|
||||
) {
|
||||
numberInitialTouches = 2
|
||||
initialTouches = event.nativeEvent.touches
|
||||
}
|
||||
|
||||
const isTapGesture =
|
||||
numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
|
||||
const isPinchGesture =
|
||||
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
|
||||
|
||||
if (isPinchGesture) {
|
||||
const initialDistance = getDistanceBetweenTouches(initialTouches)
|
||||
const currentDistance = getDistanceBetweenTouches(
|
||||
event.nativeEvent.touches,
|
||||
)
|
||||
|
||||
let nextScale = (currentDistance / initialDistance) * currentScale
|
||||
|
||||
/**
|
||||
* In case image is scaling smaller than initial size ->
|
||||
* slow down this transition by applying OUT_BOUND_MULTIPLIER
|
||||
*/
|
||||
if (nextScale < initialScale) {
|
||||
nextScale =
|
||||
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
/**
|
||||
* In case image is scaling down -> move it in direction of initial position
|
||||
*/
|
||||
if (currentScale > initialScale && currentScale > nextScale) {
|
||||
const k = (currentScale - initialScale) / (currentScale - nextScale)
|
||||
|
||||
const nextTranslateX =
|
||||
nextScale < initialScale
|
||||
? initialTranslate.x
|
||||
: currentTranslate.x -
|
||||
(currentTranslate.x - initialTranslate.x) / k
|
||||
|
||||
const nextTranslateY =
|
||||
nextScale < initialScale
|
||||
? initialTranslate.y
|
||||
: currentTranslate.y -
|
||||
(currentTranslate.y - initialTranslate.y) / k
|
||||
|
||||
translateValue.x.setValue(nextTranslateX)
|
||||
translateValue.y.setValue(nextTranslateY)
|
||||
|
||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
||||
}
|
||||
|
||||
scaleValue.setValue(nextScale)
|
||||
tmpScale = nextScale
|
||||
}
|
||||
|
||||
if (isTapGesture && currentScale > initialScale) {
|
||||
const {x, y} = currentTranslate
|
||||
|
||||
const {dx, dy} = gestureState
|
||||
const [topBound, leftBound, bottomBound, rightBound] =
|
||||
getBounds(currentScale)
|
||||
|
||||
let nextTranslateX = x + dx
|
||||
let nextTranslateY = y + dy
|
||||
|
||||
if (nextTranslateX > leftBound) {
|
||||
nextTranslateX =
|
||||
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateX < rightBound) {
|
||||
nextTranslateX =
|
||||
nextTranslateX -
|
||||
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateY > topBound) {
|
||||
nextTranslateY =
|
||||
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (nextTranslateY < bottomBound) {
|
||||
nextTranslateY =
|
||||
nextTranslateY -
|
||||
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
|
||||
}
|
||||
|
||||
if (fitsScreenByWidth()) {
|
||||
nextTranslateX = x
|
||||
}
|
||||
|
||||
if (fitsScreenByHeight()) {
|
||||
nextTranslateY = y
|
||||
}
|
||||
|
||||
translateValue.x.setValue(nextTranslateX)
|
||||
translateValue.y.setValue(nextTranslateY)
|
||||
|
||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: () => {
|
||||
if (isDoubleTapPerformed) {
|
||||
isDoubleTapPerformed = false
|
||||
}
|
||||
|
||||
if (tmpScale > 0) {
|
||||
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
|
||||
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
|
||||
Animated.timing(scaleValue, {
|
||||
toValue: tmpScale,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
|
||||
currentScale = tmpScale
|
||||
tmpScale = 0
|
||||
}
|
||||
|
||||
if (tmpTranslate) {
|
||||
const {x, y} = tmpTranslate
|
||||
const [topBound, leftBound, bottomBound, rightBound] =
|
||||
getBounds(currentScale)
|
||||
|
||||
let nextTranslateX = x
|
||||
let nextTranslateY = y
|
||||
|
||||
if (!fitsScreenByWidth()) {
|
||||
if (nextTranslateX > leftBound) {
|
||||
nextTranslateX = leftBound
|
||||
} else if (nextTranslateX < rightBound) {
|
||||
nextTranslateX = rightBound
|
||||
}
|
||||
}
|
||||
|
||||
if (!fitsScreenByHeight()) {
|
||||
if (nextTranslateY > topBound) {
|
||||
nextTranslateY = topBound
|
||||
} else if (nextTranslateY < bottomBound) {
|
||||
nextTranslateY = bottomBound
|
||||
}
|
||||
}
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(translateValue.x, {
|
||||
toValue: nextTranslateX,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateValue.y, {
|
||||
toValue: nextTranslateY,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
|
||||
currentTranslate = {x: nextTranslateX, y: nextTranslateY}
|
||||
tmpTranslate = null
|
||||
}
|
||||
},
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onShouldBlockNativeResponder: () => false,
|
||||
})
|
||||
|
||||
return [panResponder.panHandlers, scaleValue, translateValue]
|
||||
}
|
||||
|
||||
const getImageDimensionsByTranslate = (
|
||||
translate: Position,
|
||||
screen: {width: number; height: number},
|
||||
): {width: number; height: number} => ({
|
||||
width: screen.width - translate.x * 2,
|
||||
height: screen.height - translate.y * 2,
|
||||
})
|
||||
|
||||
const getDistanceBetweenTouches = (touches: NativeTouchEvent[]): number => {
|
||||
const [a, b] = touches
|
||||
|
||||
if (a == null || b == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.sqrt(
|
||||
Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
|
||||
)
|
||||
}
|
||||
|
||||
export default usePanResponder
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import React, {
|
||||
ComponentType,
|
||||
createRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
|
@ -32,6 +33,7 @@ import ImageItem from './components/ImageItem/ImageItem'
|
|||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||
|
||||
import {ImageSource} from './@types'
|
||||
import {ScrollView, GestureType} from 'react-native-gesture-handler'
|
||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||
|
||||
type Props = {
|
||||
|
@ -67,6 +69,8 @@ function ImageViewing({
|
|||
FooterComponent,
|
||||
}: Props) {
|
||||
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
||||
const [headerTranslate] = useState(
|
||||
|
@ -115,10 +119,9 @@ function ImageViewing({
|
|||
}
|
||||
}
|
||||
|
||||
const onZoom = (isScaled: boolean) => {
|
||||
// @ts-ignore
|
||||
imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
|
||||
toggleBarsVisible(!isScaled)
|
||||
const onZoom = (nextIsScaled: boolean) => {
|
||||
toggleBarsVisible(!nextIsScaled)
|
||||
setIsScaled(false)
|
||||
}
|
||||
|
||||
const edges = useMemo(() => {
|
||||
|
@ -134,6 +137,17 @@ function ImageViewing({
|
|||
}
|
||||
}, [imageList, imageIndex])
|
||||
|
||||
// This is a hack.
|
||||
// RNGH doesn't have an easy way to express that pinch of individual items
|
||||
// should "steal" all pinches from the scroll view. So we're keeping a ref
|
||||
// to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
|
||||
const [pinchGestureRefs] = useState(new Map())
|
||||
for (let imageSrc of images) {
|
||||
if (!pinchGestureRefs.get(imageSrc)) {
|
||||
pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
@ -163,6 +177,7 @@ function ImageViewing({
|
|||
data={images}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
scrollEnabled={!isScaled || isDragging}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
getItem={(_, index) => images[index]}
|
||||
|
@ -177,9 +192,26 @@ function ImageViewing({
|
|||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestCloseEnhanced}
|
||||
pinchGestureRef={pinchGestureRefs.get(imageSrc)}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
/>
|
||||
)}
|
||||
onMomentumScrollEnd={onScroll}
|
||||
renderScrollComponent={props => (
|
||||
<ScrollView
|
||||
{...props}
|
||||
waitFor={Array.from(pinchGestureRefs.values())}
|
||||
/>
|
||||
)}
|
||||
onScrollBeginDrag={() => {
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onScrollEndDrag={() => {
|
||||
setIsDragging(false)
|
||||
}}
|
||||
onMomentumScrollEnd={e => {
|
||||
setIsScaled(false)
|
||||
onScroll(e)
|
||||
}}
|
||||
//@ts-ignore
|
||||
keyExtractor={(imageSrc, index) =>
|
||||
keyExtractor
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
import type {Position} from './@types'
|
||||
|
||||
export type TransformMatrix = [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
|
||||
// These are affine transforms. See explanation of every cell here:
|
||||
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
|
||||
|
||||
export function createTransform(): TransformMatrix {
|
||||
'worklet'
|
||||
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||
}
|
||||
|
||||
export function applyRounding(t: TransformMatrix) {
|
||||
'worklet'
|
||||
t[2] = Math.round(t[2])
|
||||
t[5] = Math.round(t[5])
|
||||
// For example: 0.985, 0.99, 0.995, then 1:
|
||||
t[0] = Math.round(t[0] * 200) / 200
|
||||
t[4] = Math.round(t[0] * 200) / 200
|
||||
}
|
||||
|
||||
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
|
||||
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
|
||||
|
||||
// All write operations are applied in-place to avoid unnecessary allocations.
|
||||
|
||||
export function readTransform(t: TransformMatrix): [number, number, number] {
|
||||
'worklet'
|
||||
const scale = t[0]
|
||||
const translateX = t[2]
|
||||
const translateY = t[5]
|
||||
return [translateX, translateY, scale]
|
||||
}
|
||||
|
||||
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
|
||||
'worklet'
|
||||
t[2] += t[0] * x + t[1] * y
|
||||
t[5] += t[3] * x + t[4] * y
|
||||
}
|
||||
|
||||
export function prependScale(t: TransformMatrix, value: number) {
|
||||
'worklet'
|
||||
t[0] *= value
|
||||
t[1] *= value
|
||||
t[3] *= value
|
||||
t[4] *= value
|
||||
}
|
||||
|
||||
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
|
||||
'worklet'
|
||||
// In-place matrix multiplication.
|
||||
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]
|
||||
}
|
||||
|
||||
export function prependPan(t: TransformMatrix, translation: Position) {
|
||||
'worklet'
|
||||
prependTranslate(t, translation.x, translation.y)
|
||||
}
|
||||
|
||||
export function prependPinch(
|
||||
t: TransformMatrix,
|
||||
scale: number,
|
||||
origin: Position,
|
||||
translation: Position,
|
||||
) {
|
||||
'worklet'
|
||||
prependTranslate(t, translation.x, translation.y)
|
||||
prependTranslate(t, origin.x, origin.y)
|
||||
prependScale(t, scale)
|
||||
prependTranslate(t, -origin.x, -origin.y)
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* 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 {Dimensions, Position} from './@types'
|
||||
|
||||
export const getImageTransform = (
|
||||
image: Dimensions | null,
|
||||
screen: Dimensions,
|
||||
) => {
|
||||
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
|
||||
}
|
||||
|
||||
export const getImageTranslate = (
|
||||
image: Dimensions,
|
||||
screen: Dimensions,
|
||||
): Position => {
|
||||
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'),
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue