javascriptreact-nativeimagecropgesture

Non-destructive image cropping in react native; zoom and pan with bounds


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)

Solution

  • 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)