Skip to content

Instantly share code, notes, and snippets.

@treksis
Forked from Glazzes/App.tsx
Created July 30, 2024 03:20
Show Gist options
  • Save treksis/e151de749caf5ba5b48421c7625cf248 to your computer and use it in GitHub Desktop.
Save treksis/e151de749caf5ba5b48421c7625cf248 to your computer and use it in GitHub Desktop.
React Native pinch to zoom advanced
/**
* After some thoughts on this topic, I've decided to turn this gist along with other features into a library so you can zoom
* whatever you want, library you can find here https://github.com/Glazzes/react-native-zoom-toolkit.
*
* @author Santiago Zapata, Glazzes at Github <3
* @description This gist makes part of an article I'm writing about this topic (in spanish). This solution takes into account
* the linear algebra concepts and geometrical interpretation of the transform-origin property specification, this solution
* takes heavy inspiration from William's Candillon +3 year old video in this topic, however this solution brings it to the
* modern day along with a huge fix that prevents the origin from being displaced by an incorrect offset calculation after
* the first zoom interaction.
*
* This solution goes beyond a proof of concept as it does not have any drawbacks compared to the "only"
* solution seen in many youtube videos, therefore making this solution viable for real world
* applications
*
* With the previous statement I do not intend to offend anyone who have had the intention of
* supporting and teaching the React Native community.
*
* What features does this implementations has?
* - Remember previous interactions
* - Keep the image within a set of boundaries
* - Friction when going out of bounds
* - Double tap to scale to specific point
*
* With all that said, enjoy!
*
* Tested in Expo 50 (React Native 0.73), Reanimated 3.6.1 and Gesture handler 2.14
* @see https://drafts.csswg.org/css-transforms/#transform-rendering
* @see https://www.youtube.com/watch?v=MukiK57qwVY
* @see https://www.youtube.com/watch?v=VZ73JdhjFC8 friction implementation
*/
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';
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';
const fromPinchX = -1 * (origin.x * fromScale - origin.x);
const fromPinchY = -1 * (origin.y * fromScale - origin.y);
const toPinchX = -1 * (origin.x * toScale - origin.x);
const toPinchY = -1 * (origin.y * toScale - origin.y);
const x = offset.x + toPinchX - fromPinchX + delta.x;
const y = offset.y + toPinchY - fromPinchY + delta.y;
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
export default function App() {
const {width, height} = useWindowDimensions();
const imageWidth = useSharedValue<number>(0);
const imageHeight = useSharedValue<number>(0);
const scale = useSharedValue<number>(1);
const scaleOffset = useSharedValue<number>(1);
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 => {
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 => {
const toScale = e.scale * scaleOffset.value;
const deltaX = e.focalX - imageWidth.value / 2 - originX.value;
const deltaY = e.focalY - imageHeight.value / 2 - originY.value;
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},
});
const boundX = Math.max(0, imageWidth.value * toScale - width) / 2;
const boundY = Math.max(0, imageHeight.value * toScale - height) / 2;
translateX.value = clamp(-1 * boundX, boundX, toX);
translateY.value = clamp(-1 * boundY, boundY, toY);
scale.value = toScale;
})
.onEnd(() => {
isPinchActive.value = false;
if (scale.value < 1) {
scale.value = withTiming(1);
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(() => ({
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.Image
style={animatedStyle}
source={{uri: IMAGE}}
resizeMethod={'scale'}
/>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#121212',
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment