Glazzes / react-native-zoom-toolkit

Zoom anything you want! Most complete pinch to zoom utilities for React Native
https://glazzes.github.io/react-native-zoom-toolkit/
MIT License
192 stars 11 forks source link

[Question]: Is it possible to make SVG interactable? #53

Closed DenPostika closed 2 months ago

DenPostika commented 3 months ago

Summary

Hey, I am developing interactive map for a subway:

import React, { useImperativeHandle } from 'react';
import {StyleSheet, type LayoutChangeEvent, Alert} from 'react-native';
import Animated, {
    useAnimatedStyle,
    useDerivedValue,
    useSharedValue,
    withTiming,
} from 'react-native-reanimated';
import {
    Gesture,
    GestureDetector,
    GestureHandlerRootView,
} from 'react-native-gesture-handler';

import {usePinchCommons} from "@/hooks/usePinchCommons";
import {usePanCommons} from "@/hooks/usePanCommons";
import {useSizeVector} from "@/hooks/useSizeVector";
import {useVector} from "@/hooks/useVector";
import {clamp} from "@/utils/clamp";
import {pinchTransform} from "@/utils/pinchTransform";
import {
    type BoundsFuction, PanMode, PinchCenteringMode, ScaleMode,
} from '@/types/types';

import type {
    ResumableZoomState,
    ResumableZoomType,
    ResumableZoomAssignableState,
} from './types';
import Svg, {Circle, Defs, G, Image, Path, Rect, Text, Use} from "react-native-svg";
import {MapSvg} from "@/components/navigation/MapSvg";

type ResumableReference = React.ForwardedRef<ResumableZoomType> | undefined;

const minScale = 0.2;
const extendGestures = false;
const decay  = true;
const panMode = PanMode.CLAMP;
const scaleMode = ScaleMode.CLAMP;
const pinchCenteringMode = PinchCenteringMode.CLAMP;

const ZoomableSvg = () => {
    const ref = ({} as any).reference as ResumableReference;

    const allowPinchPanning = true;

    const translate = useVector(0, 0);
    const offset = useVector(0, 0);
    const scale = useSharedValue<number>(minScale);
    const scaleOffset = useSharedValue<number>(minScale);

    const origin = useVector(0, 0);
    const delta = useVector(0, 0);

    const rootSize = useSizeVector(0, 0);
    const childSize = useSizeVector(0, 0);
    const extendedSize = useSizeVector(0, 0);

    const detectorTranslate = useVector(0, 0);
    const detectorScale = useSharedValue(minScale);
    const maxScale = useSharedValue(1);

    useDerivedValue(() => {
        extendedSize.width.value = extendGestures
            ? Math.max(rootSize.width.value, childSize.width.value)
            : childSize.width.value;

        extendedSize.height.value = extendGestures
            ? Math.max(rootSize.height.value, childSize.height.value)
            : childSize.height.value;
    }, [extendGestures, rootSize, childSize]);

    const boundsFn: BoundsFuction = (scaleValue) => {
        'worklet';
        const { width: cWidth, height: cHeight } = childSize;
        const { width: rWidth, height: rHeight } = rootSize;

        const boundX = Math.max(0, cWidth.value * scaleValue - rWidth.value) / 2;
        const boundY = Math.max(0, cHeight.value * scaleValue - rHeight.value) / 2;
        return { x: boundX, y: boundY };
    };

    const reset = (
        toX: number,
        toY: number,
        toScale: number,
        animate: boolean = true
    ) => {
        'worklet';
        detectorTranslate.x.value = translate.x.value;
        detectorTranslate.y.value = translate.y.value;
        detectorScale.value = scale.value;

        translate.x.value = animate ? withTiming(toX) : toX;
        translate.y.value = animate ? withTiming(toY) : toY;
        scale.value = animate ? withTiming(toScale) : toScale;
        detectorTranslate.x.value = animate ? withTiming(toX) : toX;
        detectorTranslate.y.value = animate ? withTiming(toY) : toY;
        detectorScale.value = animate ? withTiming(toScale) : toScale;
    };

    const { gesturesEnabled, onPinchStart, onPinchUpdate, onPinchEnd } =
        usePinchCommons({
            container: extendGestures ? extendedSize : childSize,
            detectorTranslate,
            detectorScale,
            translate,
            offset,
            origin,
            scale,
            scaleOffset,
            minScale,
            maxScale,
            delta,
            allowPinchPanning,
            scaleMode,
            pinchCenteringMode,
            boundFn: boundsFn,
            userCallbacks: {
                onGestureEnd: () => {},
                onPinchStart: () => {},
                onPinchEnd: () => {},
            },
        });

    const { onPanStart, onPanChange, onPanEnd } = usePanCommons({
        container: extendGestures ? extendedSize : childSize,
        detectorTranslate,
        translate,
        offset,
        scale,
        panMode,
        boundFn: boundsFn,
        decay,
        userCallbacks: {
            onSwipe: () => {},
            onGestureEnd: () => {},
            onPanStart: () => {},
            onPanEnd: () => {},
            onOverPanning: () => {},
        },
    });

    const pinch = Gesture.Pinch()
        .onStart(onPinchStart)
        .onUpdate(onPinchUpdate)
        .onEnd(onPinchEnd);

    const pan = Gesture.Pan()
        .enabled(gesturesEnabled)
        .maxPointers(1)
        .onStart(onPanStart)
        .onChange(onPanChange)
        .onEnd(onPanEnd);

    const tap = Gesture.Tap()
        .enabled(gesturesEnabled)
        .maxDuration(250)
        .numberOfTaps(1)
        .runOnJS(true)
        .onEnd((e) => { console.log(e) });

    const doubleTap = Gesture.Tap()
        .enabled(gesturesEnabled)
        .maxDuration(250)
        .numberOfTaps(2)
        .onEnd((e) => {
            const originX = e.x - extendedSize.width.value / 2;
            const originY = e.y - extendedSize.height.value / 2;
            const toScale =
                scale.value >= maxScale.value * 0.8 ? minScale : maxScale.value;

            const { x, y } = pinchTransform({
                toScale: toScale,
                fromScale: scale.value,
                origin: { x: originX, y: originY },
                delta: { x: 0, y: 0 },
                offset: { x: translate.x.value, y: translate.y.value },
            });

            const { x: boundX, y: boundY } = boundsFn(toScale);
            const toX = clamp(x, -1 * boundX, boundX);
            const toY = clamp(y, -1 * boundY, boundY);

            reset(toX, toY, toScale, true);
        });

    const measureRoot = (e: LayoutChangeEvent) => {
        rootSize.width.value = e.nativeEvent.layout.width;
        rootSize.height.value = e.nativeEvent.layout.height;
    };

    const measureChild = (e: LayoutChangeEvent) => {
        childSize.width.value = e.nativeEvent.layout.width;
        childSize.height.value = e.nativeEvent.layout.height;
    };

    const childStyle = useAnimatedStyle(
        () => ({
            width: 1890,
            height: 2422,
            transform: [
                { translateX: translate.x.value },
                { translateY: translate.y.value },
                { scale: scale.value },
            ],
        }),
        [translate, scale]
    );

    const detectorStyle = useAnimatedStyle(() => {
        return {
            width: extendedSize.width.value,
            height: extendedSize.height.value,
            position: 'absolute',
            transform: [
                { translateX: detectorTranslate.x.value },
                { translateY: detectorTranslate.y.value },
                { scale: detectorScale.value },
            ],
        };
    }, [childSize, rootSize, detectorTranslate, detectorScale]);

    const requestState = (): ResumableZoomState => {
        return {
            width: childSize.width.value,
            height: childSize.height.value,
            translateX: translate.x.value,
            translateY: translate.y.value,
            scale: scale.value,
        };
    };

    const assignState = (state: ResumableZoomAssignableState, animate = true) => {
        const toScale = clamp(state.scale, minScale, maxScale.value);
        const { x: boundX, y: boundY } = boundsFn(toScale);
        const toX = clamp(state.translateX, -1 * boundX, boundX);
        const toY = clamp(state.translateY, -1 * boundY, boundY);

        reset(toX, toY, toScale, animate);
    };

    useImperativeHandle(ref, () => ({
        reset: (animate) => reset(0, 0, minScale, animate),
        requestState: requestState,
        assignState: assignState,
    }));

    const composedTap = Gesture.Exclusive(doubleTap, tap);
    const composedGesture = Gesture.Race(pinch, pan, composedTap);

    return (
        <GestureHandlerRootView style={styles.root} onLayout={measureRoot}>
            <Animated.View style={childStyle} onLayout={measureChild}>
                <MapSvg/>
            </Animated.View>

            <GestureDetector gesture={composedGesture} >
                <Animated.View style={detectorStyle} />
            </GestureDetector>
        </GestureHandlerRootView>
    );
};

const styles = StyleSheet.create({
    root: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
    },
});

export default ZoomableSvg;

here the link to the SVG file: https://filebin.net/1sqq6o03fjwdjl0a

I need to make elements inside of svg intractable.

Expected API

No response

Glazzes commented 3 months ago

@DenPostika When I asked you to open an issue I asked you for more details. What's exactly wrong with the approach you're taking? is it not working? is it getting pixelated? What's that you need? I do see spanish words on the svg, do you speak it?

DenPostika commented 3 months ago

@Glazzes Sorry, I don't speak Spanish very well.

Ok, problem:

I want to create interactive elements inside of svg like that:

 <G onPress={() => Alert.alert("MORERES!")}>
    <Rect x="1580" y="1710" width="80" height="30" fill="transparent" />
    <Text x={1582.319} y={1726.638} fontSize={22}>
        {"Moreres"}
    </Text>
</G>

but we have a positioned absolute to detect gesture and it not propagate the events to svg. I tried to fix it with pointer events:

<GestureHandlerRootView style={styles.root} onLayout={measureRoot}>
            <Animated.View style={childStyle} onLayout={measureChild}>
                <MapSvg/>
            </Animated.View>

            <GestureDetector gesture={composedGesture}>
                <Animated.View style={detectorStyle} pointerEvents={"none"}/>
            </GestureDetector>
        </GestureHandlerRootView>

but gesture handler stop working (what is logical). Maybe you know some workaround how to fix it.

Glazzes commented 3 months ago

I've been making tests with Gesture Handler and it's actually doable, Gestures do not collide and everything touch related should work, currently I'm looking for a way to modify my existing solution, so this may take a while.

Glazzes commented 2 months ago

@DenPostika If you want you can try out the gist that evolved into this library, it does not have as much features as the lib but I think it may serve you well https://gist.github.com/Glazzes/357201f74fbfaddb3e933f4c258c4878

DenPostika commented 2 months ago

@Glazzes maybe u know how to fix jumping focal point

https://github.com/user-attachments/assets/dbd0d3c4-4d9c-42ed-b8ae-7919d16cb5db

import React, {useEffect} from 'react';
import {Image, StyleSheet, View, useWindowDimensions} from 'react-native';
import Animated, {
    Easing,
    cancelAnimation,
    useAnimatedStyle,
    useDerivedValue,
    useSharedValue,
    withDecay,
    withTiming,
} from 'react-native-reanimated';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {MapSvg} from "@/components/navigation/MapSvg";

type PinchOptions = {
    toScale: number;
    fromScale: number;
    origin: { x: number; y: number };
    delta: { x: number; y: number };
    offset: { x: number; y: number };
};

const pinchTransform = ({toScale, fromScale, delta, origin, offset}: PinchOptions) => {
    'worklet';

    // console.log('------------------------');

    const fromPinchX = -1 * (origin.x * fromScale - origin.x);
    const fromPinchY = -1 * (origin.y * fromScale - origin.y);
    // console.log(`fromPinch: (${fromPinchX}, ${fromPinchY})`);

    const toPinchX = -1 * (origin.x * toScale - origin.x);
    const toPinchY = -1 * (origin.y * toScale - origin.y);
    // console.log(`toPinch: (${toPinchX}, ${toPinchY})`);

    const x = offset.x + toPinchX - fromPinchX + delta.x;
    const y = offset.y + toPinchY - fromPinchY + delta.y;

    // console.log('------------------------');

    return {x, y};
};

const clamp = (lowerBound: number, upperBound: number, value: number) => {
    'worklet';
    return Math.max(lowerBound, Math.min(value, upperBound));
};

// https://api.flutter.dev/flutter/widgets/BouncingScrollPhysics/frictionFactor.html
const friction = (fraction: number) => {
    'worklet';
    return 0.75 * Math.pow(1 - fraction * fraction, 2);
};

const IMAGE =
    'https://e0.pxfuel.com/wallpapers/322/848/desktop-wallpaper-fennec-fox-algeria-national-animal-desert-fox.jpg';

const config = {duration: 200, easing: Easing.linear};

// For a better understanding of the pinch gesture you can delete pan and tap gestures entirely if you feel like it
const ZoomableSvg = () => {
    const {width, height} = useWindowDimensions();
    const imageWidth = useSharedValue<number>(1890);
    const imageHeight = useSharedValue<number>(2422);

    const scale = useSharedValue<number>(0.2);
    const scaleOffset = useSharedValue<number>(0.2);

    const translateX = useSharedValue<number>(0);
    const translateY = useSharedValue<number>(0);
    const translateXOffset = useSharedValue<number>(0);
    const translateYOffset = useSharedValue<number>(0);

    const originX = useSharedValue<number>(0);
    const originY = useSharedValue<number>(0);

    const boundaries = useDerivedValue(() => {
        const offsetX = Math.max(0, imageWidth.value * scale.value - width) / 2;
        const offsetY = Math.max(0, imageHeight.value * scale.value - height) / 2;

        return {x: offsetX, y: offsetY};
    }, [scale, imageWidth, imageHeight, width, height]);

    const isPinchActive = useSharedValue<boolean>(false);
    const pinch = Gesture.Pinch()
        .onStart(e => {
            // console.log(`Start: focal: (${e.focalX}, ${e.focalY})`);

            isPinchActive.value = true;
            originX.value = e.focalX - imageWidth.value / 2;
            originY.value = e.focalY - imageHeight.value / 2;

            translateXOffset.value = translateX.value;
            translateYOffset.value = translateY.value;
            scaleOffset.value = scale.value;
        })
        .onUpdate(e => {
            // console.log('--------------------------------------------------------------');
            // console.log(`Update: focal: (${e.focalX}, ${e.focalY}), scale: (${e.scale})`);

            const toScale = e.scale * scaleOffset.value;
            // console.log(`toScale: (${toScale})`);
            //
            // console.log(`image: (${imageWidth.value}, ${imageHeight.value})`);
            // console.log(`origin: (${originX.value}, ${originY.value})`);

            const deltaX = e.focalX - imageWidth.value / 2 - originX.value;
            const deltaY = e.focalY - imageHeight.value / 2 - originY.value;
            // console.log(`delta: (${deltaX}, ${deltaY})`);

            const {x: toX, y: toY} = pinchTransform({
                toScale: toScale,
                fromScale: scaleOffset.value,
                origin: {x: originX.value, y: originY.value},
                offset: {x: translateXOffset.value, y: translateYOffset.value},
                delta: {x: deltaX, y: deltaY},
            });

            // console.log(`to point: (${toX}, ${toY})`);

            const boundX = Math.max(0, imageWidth.value * toScale - width) / 2;
            const boundY = Math.max(0, imageHeight.value * toScale - height) / 2;

            // console.log(`bound: (${boundX}, ${boundY})`);

            translateX.value = clamp(-1 * boundX, boundX, toX);
            translateY.value = clamp(-1 * boundY, boundY, toY);

            scale.value = toScale;
        })
        .onEnd(() => {
            isPinchActive.value = false;

            if (scale.value < 0.2) {
                scale.value = withTiming(0.2);
                translateX.value = withTiming(0);
                translateY.value = withTiming(0);
            }
        });

    const isWithinBoundX = useSharedValue<boolean>(true);
    const isWithinBoundY = useSharedValue<boolean>(true);
    const pan = Gesture.Pan()
        .maxPointers(1)
        .onStart(_ => {
            cancelAnimation(translateX);
            cancelAnimation(translateY);

            translateXOffset.value = translateX.value;
            translateYOffset.value = translateY.value;
        })
        .onChange(({translationX, translationY, changeX, changeY}) => {
            const toX = translateXOffset.value + translationX;
            const toY = translateYOffset.value + translationY;

            const {x: boundX, y: boundY} = boundaries.value;
            isWithinBoundX.value = toX >= -1 * boundX && toX <= boundX;
            isWithinBoundY.value = toY >= -1 * boundY && toY <= boundY;

            if (isWithinBoundX.value) {
                translateX.value = clamp(-1 * boundX, boundX, toX);
            } else {
                if (imageWidth.value * scale.value < width) {
                    translateX.value = clamp(-1 * boundX, boundX, toX);
                } else {
                    const fraction = (Math.abs(toX) - boundX) / width;
                    const frictionX = friction(clamp(0, 1, fraction));
                    translateX.value += changeX * frictionX;
                }
            }

            if (isWithinBoundY.value) {
                translateY.value = clamp(-1 * boundY, boundY, toY);
            } else {
                if (imageHeight.value * scale.value < height) {
                    translateY.value = clamp(-1 * boundY, boundY, toY);
                } else {
                    const fraction = (Math.abs(toY) - boundY) / width;
                    const frictionY = friction(clamp(0, 1, fraction));
                    translateY.value += changeY * frictionY;
                }
            }
        })
        .onEnd(({velocityX, velocityY}) => {
            const {x: boundX, y: boundY} = boundaries.value;
            const toX = clamp(-1 * boundX, boundX, translateX.value);
            const toY = clamp(-1 * boundY, boundY, translateY.value);

            translateX.value = isWithinBoundX.value
                ? withDecay({velocity: velocityX / 2, clamp: [-1 * boundX, boundX]})
                : withTiming(toX, config);

            translateY.value = isWithinBoundY.value
                ? withDecay({velocity: velocityY / 2, clamp: [-1 * boundY, boundY]})
                : withTiming(toY, config);
        });

    const doubleTap = Gesture.Tap()
        .numberOfTaps(2)
        .maxDuration(250)
        .onStart(_ => {
            translateXOffset.value = translateX.value;
            translateYOffset.value = translateY.value;
        })
        .onEnd(e => {
            if (isPinchActive.value) {
                return;
            }

            if (scale.value > 2) {
                translateX.value = withTiming(0);
                translateY.value = withTiming(0);
                scale.value = withTiming(1);
                return;
            }

            const orgnX = e.x - imageWidth.value / 2;
            const orgnY = e.y - imageHeight.value / 2;
            const highestScreenDimension = Math.max(width, height);
            const higheststImageDimension = Math.max(
                imageWidth.value,
                imageHeight.value,
            );

            const tapOrigin = width > height ? orgnX : orgnY;
            const toScale =
                ((highestScreenDimension + Math.abs(tapOrigin)) /
                    higheststImageDimension) *
                2;

            const {x, y} = pinchTransform({
                fromScale: scale.value,
                toScale,
                origin: {x: orgnX, y: orgnY},
                offset: {x: translateXOffset.value, y: translateYOffset.value},
                delta: {x: 0, y: 0},
            });

            const boundX = Math.max(0, (imageWidth.value * toScale - width) / 2);
            const boundY = Math.max(0, (imageHeight.value * toScale - height) / 2);

            translateX.value = withTiming(clamp(-boundX, boundX, x));
            translateY.value = withTiming(clamp(-boundY, boundY, y));
            scale.value = withTiming(toScale);
        });

    const animatedStyle = useAnimatedStyle(() => {

        console.log(`translate: (${translateX.value}, ${translateY.value})`);

        return {
            width: imageWidth.value,
            height: imageHeight.value,
            transform: [
            {translateX: translateX.value},
            {translateY: translateY.value},
            {scale: scale.value},
        ],
        }
    });

    // useEffect(() => {
    //     Image.getSize(
    //         IMAGE,
    //         (w, h) => {
    //             const isPortrait = width < height;
    //             const aspectRatio = w / h;
    //
    //             if (isPortrait) {
    //                 imageWidth.value = width;
    //                 imageHeight.value = width / aspectRatio;
    //             } else {
    //                 imageWidth.value = height * aspectRatio;
    //                 imageHeight.value = height;
    //             }
    //
    //             scale.value = withTiming(1, config);
    //             translateX.value = withTiming(0, config);
    //             translateY.value = withTiming(0, config);
    //         },
    //         e => console.log(e),
    //     );
    // }, [width, height]);

    return (
        <View style={styles.root}>
            <GestureDetector gesture={Gesture.Race(pan, pinch, doubleTap)}>
                <Animated.View style={animatedStyle}>
                    <MapSvg/>
                </Animated.View>
            </GestureDetector>
        </View>
    );
}

const styles = StyleSheet.create({
    root: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#121212',
    },
});

export default ZoomableSvg;
Glazzes commented 2 months ago

@DenPostika I've made severe changes to the gist I've sent you, give a try and let me know if it works https://gist.github.com/Glazzes/357201f74fbfaddb3e933f4c258c4878, if it works please send me a video of it working on iOS (as I don't have an iOS device), this is should be the pinch gesture implementation that makes it to the library in the next version.

Glazzes commented 2 months ago

onPress support has been added in version 3.0.0