My goal is to implement something like non-destructive image cropping, where the aspect ratio is maintained, in my React Native app. As a proxy for this, I thought I could allow the user to zoom and pan the image via gestures, then persist the scale and translate values.
That much is working correctly. Where I've hit a snag is when I try to limit the panning so that the image cannot be panned beyond its borders. My initial idea was to do this:
const maximumXOffset = ((scale.value - 1) / 2) * originalWidth
const maximumYOffset = ((scale.value - 1) / 2) * originalHeight
But that didn't work as intended. I then tried storing the original image size and the scaled image size and limiting the offsets to 1/2 the difference, but that isn't working either — the image can still be panned past its boundaries.
Mostly full code for the component is below. What am I missing here?
// imports elided
function SingleCard({
uri,
dateString,
id,
canZoom,
}) {
const dispatch = useDispatch()
const selectTransformsForId = useMemo(makeSelectTransformsForId, [])
const transformsForId = useSelector((state) =>
selectTransformsForId(state, id)
)
const updateRedux = useCallback(
(transforms) => {
dispatch(updateTransforms({ id, transforms }))
},
[dispatch, id]
)
const zoom = useSharedValue(transformsForId.scale)
const zoomStart = useSharedValue(zoom.value)
const offset = useSharedValue({
x: transformsForId.translateX,
y: transformsForId.translateY,
})
const offsetStart = useSharedValue({ x: offset.value.x, y: offset.value.y })
const originalSize = useSharedValue({ height: 0, width: 0 })
const currentSize = useSharedValue({ height: 0, width: 0 })
const zoomStyle = useAnimatedStyle(() => ({
transform: [
{ scale: zoom.value },
{ translateX: offset.value.x },
{ translateY: offset.value.y },
],
}))
const containerRef = useAnimatedRef()
const imageRef = useAnimatedRef()
const zoomGesture = Gesture.Pinch()
.onStart(() => {
zoomStart.value = zoom.value
const measurement = measure(containerRef)
originalSize.value = {
height: measurement.height,
width: measurement.width,
}
})
.onUpdate((e) => {
zoom.value = Math.max(zoomStart.value * e.scale, 1)
const imageMeasurement = measure(imageRef)
currentSize.value = {
height: imageMeasurement.height,
width: imageMeasurement.width,
}
})
.onEnd(() => {
runOnJS(updateRedux)({ scale: zoom.value })
})
.enabled(canZoom)
.shouldCancelWhenOutside(false)
const dragGesture = Gesture.Pan()
.averageTouches(true)
.onStart(() => {
offsetStart.value = offset.value
})
.onUpdate((e) => {
const maximumXOffset =
(currentSize.value.width - originalSize.value.width) / 2
const maximumYOffset =
(currentSize.value.height - originalSize.value.height) / 2
const xOffset = Math.min(
Math.max(e.translationX + offsetStart.value.x, -maximumXOffset),
maximumXOffset
)
const yOffset = Math.min(
Math.max(e.translationY + offsetStart.value.y, -maximumYOffset),
maximumYOffset
)
offset.value = {
x: xOffset,
y: yOffset,
}
})
.onEnd(() => {
runOnJS(updateRedux)({
translateX: offset.value.x,
translateY: offset.value.y,
})
})
.minPointers(2)
.shouldCancelWhenOutside(false)
.enabled(canZoom)
const composed = Gesture.Simultaneous(dragGesture, zoomGesture)
return (
<View ref={containerRef}>
<GestureDetector gesture={composed}>
<AnimatedImage
style={[styles.image, zoomStyle]}
source={{ uri }}
ref={imageRef}
>
</GestureDetector>
</View>
)
}
const styles = StyleSheet.create({
image: {
width: '100%',
aspectRatio: 3 / 4,
position: 'relative',
}
})
export default memo(SingleCard)
So the approach here was fine, the bounds were just being calculated incorrectly. The working gesture code looks like this:
const zoomGesture = Gesture.Pinch()
.onStart(() => {
zoomStart.value = zoom.value
const measurement = measure(containerRef)
originalSize.value = {
height: measurement.height,
width: measurement.width,
}
})
.onUpdate((e) => {
zoom.value = Math.max(zoomStart.value * e.scale, 1)
})
.onEnd(() => {
runOnJS(updateState)({ scale: zoom.value })
})
.enabled(canZoom)
.shouldCancelWhenOutside(false)
const dragGesture = Gesture.Pan()
.averageTouches(true)
.onStart(() => {
offsetStart.value = { x: offsetX.value, y: offsetY.value }
})
.onUpdate((e) => {
const maximumXOffset =
((zoom.value - 1) * originalSize.value.width) / (zoom.value * 2)
const maximumYOffset =
((zoom.value - 1) * originalSize.value.height) /
(zoom.value * 2)
const xOffset = Math.min(
Math.max(e.translationX + offsetStart.value.x, -maximumXOffset),
maximumXOffset
)
const yOffset = Math.min(
Math.max(e.translationY + offsetStart.value.y, -maximumYOffset),
maximumYOffset
)
offsetX.value = xOffset
offsetY.value = yOffset
offsetPercent.value = {
x: offsetX.value / originalSize.value.width,
y: offsetY.value / originalSize.value.height,
}
})
.onEnd(() => {
runOnJS(updateState)({
translateX: offsetX.value,
translateY: offsetY.value,
offsetPercentX: offsetPercent.value.x,
offsetPercentY: offsetPercent.value.y,
})
})
.minPointers(2)
.shouldCancelWhenOutside(false)
.enabled(canZoom)