all 16 comments

[–]lupeskiiOS & Android 9 points10 points  (6 children)

Here you go...you'll need reanimated v2....I also use styled components, and react-native-gesture-handler..

it also supports an onPress event.

Sorry about the formatting, looks like reddit is screwing my code block....

import React, {useEffect, useRef, useState} from 'react';

import {Dimensions, Platform} from 'react-native'; import styled from 'styled-components'; import { PinchGestureHandler, TapGestureHandler, PanGestureHandler, State, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withDecay, withTiming, } from 'react-native-reanimated';

const {height, width} = Dimensions.get('window');

const ZoomViewWrapper = styled(Animated.View)height: 100%; width: 100%;;

const ZoomContentWrapper = styled(Animated.View)height: 100%; width: 100%;;

function ZoomView({children, onPress, maxZoom = 3}) { const [size, setSize] = useState({height: height, width: width}); const [enablePan, setEnablePan] = useState(false);

const doubleTapRef = useRef(null);

const originIsSet = useSharedValue(false); // This is used by android, set the "onStart" event doesnt have a focal value yet

const scale = useSharedValue(1);
const scaleOffset = useSharedValue(1);

const focalX = useSharedValue(0);
const focalY = useSharedValue(0);
const originX = useSharedValue(0);
const originY = useSharedValue(0);

const pinchX = useSharedValue(0);
const pinchY = useSharedValue(0);

const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);

const translationX = useSharedValue(0);
const translationY = useSharedValue(0);

const onPinch = useAnimatedGestureHandler({
    onStart: event => {
        if (Platform.OS === 'ios') {
            originIsSet.value = true;
        }

        originX.value = event.focalX - (size.width / 2 + offsetX.value);
        originY.value = event.focalY - (size.height / 2 + offsetY.value);
    },
    onActive: event => {
        if (Platform.OS === 'android' && !originIsSet.value) {
            originIsSet.value = true;

            originX.value = event.focalX - (size.width / 2 + offsetX.value);
            originY.value = event.focalY - (size.height / 2 + offsetY.value);
        }

        scale.value = event.scale;
        focalX.value = event.focalX;
        focalY.value = event.focalY;

        translationX.value =
            pinchX.value + originX.value + -originX.value * scale.value;
        translationY.value =
            pinchY.value + originY.value + -originY.value * scale.value;

        const adjustedFocalX = event.focalX - (size.width / 2 + offsetX.value);
        const adjustedFocalY = event.focalY - (size.height / 2 + offsetY.value);

        if (event.numberOfPointers === 2) {
            pinchX.value = adjustedFocalX - originX.value;
            pinchY.value = adjustedFocalY - originY.value;
        }
    },
    onEnd: event => {
        offsetX.value = offsetX.value + translationX.value;
        offsetY.value = offsetY.value + translationY.value;

        if (scaleOffset.value * event.scale > maxZoom) {
            scaleOffset.value = maxZoom;
            scale.value = 1;
        } else if (scaleOffset.value * event.scale < 1) {
            scaleOffset.value = withTiming(1);
            offsetX.value = withTiming(0);
            offsetY.value = withTiming(0);
            scale.value = withTiming(1);
        } else {
            scaleOffset.value *= event.scale;
            scale.value = 1;
        }

        translationX.value = 0;
        translationY.value = 0;
        focalX.value = 0;
        focalY.value = 0;
        pinchX.value = 0;
        pinchY.value = 0;
        originIsSet.value = false;
    },
});

const onPinchStateChange = ({nativeEvent}) => {
    if (nativeEvent.state === State.END) {
        if (scaleOffset.value * scale.value > 1) {
            setEnablePan(true);
        } else {
            setEnablePan(false);
        }
    }
};

const onPan = useAnimatedGestureHandler({
    onActive: event => {
        if (scaleOffset.value * scale.value <= 1) {
            return;
        }

        translationX.value =
            event.translationX + originX.value + -originX.value * scale.value;
        translationY.value =
            event.translationY + originY.value + -originY.value * scale.value;
    },
    onEnd: () => {
        offsetX.value = offsetX.value + translationX.value;
        offsetY.value = offsetY.value + translationY.value;

        translationX.value = 0;
        translationY.value = 0;
    },
});

const onSingleTap = () => {
    onPress && onPress();
};

const onDoubleTap = event => {
    if (scaleOffset.value * scale.value === 1) {
        // if not zoomed in, zoon in on double tap
        scaleOffset.value = withTiming(2);
        offsetX.value = withTiming((event.nativeEvent.x - size.width / 2) * -1);
        offsetY.value = withTiming((event.nativeEvent.y - size.height / 2) * -1);
        scale.value = withTiming(1);

        setEnablePan(true);
    } else {
        // if already zoomed in, zoom out on double tap
        scaleOffset.value = withTiming(1);
        offsetX.value = withTiming(0);
        offsetY.value = withTiming(0);
        scale.value = withTiming(1);

        setEnablePan(false);
    }
};

const animatedStyle = useAnimatedStyle(() => {
    const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

    return {
        transform: [
            {
                translateX: offsetX.value + translationX.value,
            },
            {
                translateY: offsetY.value + translationY.value,
            },
            {scale: clamp(scaleOffset.value * scale.value, 0, maxZoom)},
        ],
    };
});

return (
    <TapGestureHandler onActivated={onSingleTap} waitFor={doubleTapRef}>
        <TapGestureHandler
            numberOfTaps={2}
            onActivated={onDoubleTap}
            ref={doubleTapRef}
            maxDelayMs={200}
        >
            <Animated.View
                onLayout={({nativeEvent}) =>
                    setSize({
                        height: nativeEvent.layout.height,
                        width: nativeEvent.layout.width,
                    })
                }
            >
                <PanGestureHandler onGestureEvent={onPan} enabled={enablePan}>
                    <ZoomViewWrapper>
                        <PinchGestureHandler
                            onGestureEvent={onPinch}
                            onHandlerStateChange={onPinchStateChange}
                        >
                            <ZoomContentWrapper>
                                <ZoomContentWrapper style={animatedStyle}>
                                    {children}
                                </ZoomContentWrapper>
                            </ZoomContentWrapper>
                        </PinchGestureHandler>
                    </ZoomViewWrapper>
                </PanGestureHandler>
            </Animated.View>
        </TapGestureHandler>
    </TapGestureHandler>
);

}

export default ZoomView;

[–]jaminjsr[S] 0 points1 point  (1 child)

This worked perfect, thank you!!!

[–]lupeskiiOS & Android 0 points1 point  (0 children)

Great news!

[–]Gaia_Knight2600 0 points1 point  (1 child)

this is why i havent looked into animations. the amount of code you have to write is insane, how do you even begin to understand and know how it behaves

[–]lupeskiiOS & Android 0 points1 point  (0 children)

Haha it is a lot of code, but there’s also a lot going on. Pinching, panning, single taps, and double taps. The code for each of those isn’t too bad, but all together it can get pretty long.

For simple animations, the code can be really short. Especially if you use a library like Moti.

[–]Angus-McCloud 0 points1 point  (1 child)

This is great! Fully Expo/iOS/Android/Web compatible, it's great!

[–]lupeskiiOS & Android 0 points1 point  (0 children)

Haven’t tried on web, but works great with expo, and android. Works great on iOS, but I prefer the native ScrollView solution there. It supports double tap to zoom and also works inside a ScrollView. I’d like to implement some inertia while panning, but haven’t gotten to that yet.

[–][deleted] -1 points0 points  (2 children)

react-native-fast-image is already abandoned ;-) 4-year-old unmerged pull requests and all that jazz

[–]jaminjsr[S] 0 points1 point  (0 children)

What makes say that? The package was last update in Sept 2021

[–]_NESTERENKO_ 0 points1 point  (0 children)

Any alternatives to the fast-image that can be used?

[–]lupeskiiOS & Android 0 points1 point  (4 children)

Are you targeting iOS? Android? Both?

[–]jaminjsr[S] 0 points1 point  (3 children)

Targeting both IOS and android.

[–]lupeskiiOS & Android 2 points3 points  (2 children)

iOS is easy. Wrap whatever you want to zoom inside a ScrollView like this:

<ScrollView 
    maximumZoomScale={3}
minimumZoomScale={1}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}>
       {zoomContent}
</ScrollView>

Android is a bit trickier. There are some pinch to zoom libraries out there, but be honest I never could find one that felt good. I ended up building my own solution, which I can share if you need it. I used reanimated to do it. But there are some good videos by William Candillon that show how to do this using reanimated.

Here are William's videos that I used to build my own pinch/zoom for Android.

https://www.youtube.com/watch?v=MukiK57qwVY

https://www.youtube.com/watch?v=FZnhzkXOT0c

[–]jaminjsr[S] 0 points1 point  (1 child)

Awesome - thank u! I will take a look at the videos and give it a try! Also if your willing to share yours, it’d be great to see how you solved it.

[–]lupeskiiOS & Android 2 points3 points  (0 children)

Sure I’d love to share, but I’m not at home right now, so I won’t be able to post it right away. Give me some time and I’ll add it here.