react-native-zoom-toolkit
Version:
Most complete set of pinch to zoom utilites for React Native
359 lines (355 loc) • 12.8 kB
JavaScript
import React, { useContext } from 'react';
import Animated, { Easing, cancelAnimation, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDecay, withTiming } from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { clamp } from '../../commons/utils/clamp';
import { useVector } from '../../commons/hooks/useVector';
import { snapPoint } from '../../commons/utils/snapPoint';
import { getVisibleRect } from '../../commons/utils/getVisibleRect';
import { usePinchCommons } from '../../commons/hooks/usePinchCommons';
import { useDoubleTapCommons } from '../../commons/hooks/useDoubleTapCommons';
import { getSwipeDirection } from '../../commons/utils/getSwipeDirection';
import { GalleryContext } from './context';
import { getScrollPosition } from '../../commons/utils/getScrollPosition';
const minScale = 1;
const config = {
duration: 300,
easing: Easing.linear
};
/*
* Pinchable views are really heavy components, therefore in order to maximize performance
* only a single pinchable view is shared among all the list items, items listen to this
* component updates and only update themselves if they are the current item.
*/
const GalleryGestureHandler = ({
length,
gap,
maxScale,
itemSize,
vertical,
tapOnEdgeToItem,
zoomEnabled,
scaleMode,
allowOverflow,
allowPinchPanning,
pinchMode,
longPressDuration,
onTap,
onPanStart,
onPanEnd,
onPinchStart: onUserPinchStart,
onPinchEnd: onUserPinchEnd,
onSwipe: onUserSwipe,
onLongPress,
onVerticalPull,
onGestureEnd
}) => {
const {
activeIndex,
scroll,
scrollOffset,
isScrolling,
rootSize,
rootChildSize,
translate,
scale,
overflow,
hideAdjacentItems
} = useContext(GalleryContext);
const offset = useVector(0, 0);
const scaleOffset = useSharedValue(1);
const time = useSharedValue(0);
const position = useVector(0, 0);
const gestureEnd = useSharedValue(0);
const isPullingVertical = useSharedValue(false);
const pullReleased = useSharedValue(false);
const boundsFn = optionalScale => {
'worklet';
const scaleValue = optionalScale ?? scale.value;
const {
width: cWidth,
height: cHeight
} = rootChildSize;
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, toY, toScale, animate = true) => {
'worklet';
cancelAnimation(translate.x);
cancelAnimation(translate.y);
cancelAnimation(scale);
translate.x.value = animate ? withTiming(toX) : toX;
translate.y.value = animate ? withTiming(toY) : toY;
scale.value = animate ? withTiming(toScale) : toScale;
scaleOffset.value = toScale;
};
const snapToScrollPosition = e => {
'worklet';
cancelAnimation(scroll);
const prev = getScrollPosition({
index: clamp(activeIndex.value - 1, 0, length - 1),
itemSize: itemSize.value,
gap
});
const current = getScrollPosition({
index: activeIndex.value,
itemSize: itemSize.value,
gap
});
const next = getScrollPosition({
index: clamp(activeIndex.value + 1, 0, length - 1),
itemSize: itemSize.value,
gap
});
const velocity = vertical ? e.velocityY : e.velocityX;
const toScroll = snapPoint(scroll.value, velocity, [prev, current, next]);
scroll.value = withTiming(toScroll, config, finished => {
if (!finished) return;
if (toScroll !== current) {
activeIndex.value += toScroll === next ? 1 : -1;
}
isScrolling.value = false;
toScroll !== current && reset(0, 0, minScale, false);
});
};
const onSwipe = direction => {
'worklet';
cancelAnimation(scroll);
let toIndex = activeIndex.value;
if (direction === 'up' && vertical) toIndex += 1;
if (direction === 'down' && vertical) toIndex -= 1;
if (direction === 'left' && !vertical) toIndex += 1;
if (direction === 'right' && !vertical) toIndex -= 1;
toIndex = clamp(toIndex, 0, length - 1);
const newScrollPosition = getScrollPosition({
index: toIndex,
itemSize: itemSize.value,
gap
});
scroll.value = withTiming(newScrollPosition, config, finished => {
if (!finished) return;
activeIndex.value = toIndex;
isScrolling.value = false;
reset(0, 0, minScale, false);
});
};
useAnimatedReaction(() => ({
translate: translate.y.value,
isPulling: isPullingVertical.value,
released: pullReleased.value
}), val => {
val.isPulling && (onVerticalPull === null || onVerticalPull === void 0 ? void 0 : onVerticalPull(val.translate, val.released));
}, [translate, isPullingVertical, pullReleased]);
useAnimatedReaction(() => ({
width: rootSize.width.value,
height: rootSize.height.value
}), () => reset(0, 0, minScale, false), [rootSize]);
const onGestureEndWrapper = () => {
overflow.value = 'hidden';
hideAdjacentItems.value = false;
onGestureEnd === null || onGestureEnd === void 0 || onGestureEnd();
};
const {
gesturesEnabled,
onTouchesDown,
onTouchesMove,
onTouchesUp,
onPinchStart,
onPinchUpdate,
onPinchEnd
} = usePinchCommons({
container: rootSize,
translate,
offset,
scale,
scaleOffset,
minScale,
maxScale,
scaleMode,
allowPinchPanning,
pinchMode,
boundFn: boundsFn,
userCallbacks: {
onPinchStart: onUserPinchStart,
onPinchEnd: onUserPinchEnd,
onGestureEnd: onGestureEndWrapper
}
});
const {
onDoubleTapEnd
} = useDoubleTapCommons({
container: rootSize,
translate,
scale,
minScale,
maxScale,
scaleOffset,
boundsFn,
onGestureEnd
});
const pinch = Gesture.Pinch().withTestId('pinch').enabled(zoomEnabled).manualActivation(true).onTouchesDown(onTouchesDown).onTouchesMove(onTouchesMove).onTouchesUp(onTouchesUp).onStart(e => {
if (allowOverflow) {
overflow.value = 'visible';
hideAdjacentItems.value = true;
}
onPinchStart(e);
}).onUpdate(onPinchUpdate).onEnd(onPinchEnd);
const pan = Gesture.Pan().withTestId('pan').maxPointers(1).minVelocity(100).enabled(gesturesEnabled).onStart(e => {
cancelAnimation(translate.x);
cancelAnimation(translate.y);
cancelAnimation(scroll);
onPanStart && runOnJS(onPanStart)(e);
const isVerticalPan = Math.abs(e.velocityY) > Math.abs(e.velocityX);
isPullingVertical.value = isVerticalPan && scale.value === 1 && !vertical;
isScrolling.value = true;
time.value = performance.now();
position.x.value = e.absoluteX;
position.y.value = e.absoluteY;
scrollOffset.value = scroll.value;
offset.x.value = translate.x.value;
offset.y.value = translate.y.value;
}).onUpdate(e => {
if (isPullingVertical.value) {
translate.y.value = e.translationY;
return;
}
const toX = offset.x.value + e.translationX;
const toY = offset.y.value + e.translationY;
const {
x: boundX,
y: boundY
} = boundsFn(scale.value);
const exceedX = Math.max(0, Math.abs(toX) - boundX);
const exceedY = Math.max(0, Math.abs(toY) - boundY);
const scrollX = -1 * Math.sign(toX) * exceedX;
const scrollY = -1 * Math.sign(toY) * exceedY;
const to = scrollOffset.value + (vertical ? scrollY : scrollX);
const items = length - 1;
scroll.value = clamp(to, 0, items * itemSize.value + items * gap);
translate.x.value = clamp(toX, -1 * boundX, boundX);
translate.y.value = clamp(toY, -1 * boundY, boundY);
}).onEnd(e => {
const bounds = boundsFn(scale.value);
const direction = getSwipeDirection(e, {
boundaries: bounds,
time: time.value,
position: {
x: position.x.value,
y: position.y.value
},
translate: {
x: isPullingVertical.value ? 100 : translate.x.value,
y: isPullingVertical.value ? 0 : translate.y.value
}
});
direction !== undefined && onSwipe(direction);
direction !== undefined && onUserSwipe && runOnJS(onUserSwipe)(direction);
if (isPullingVertical.value) {
pullReleased.value = true;
translate.y.value = withTiming(0, undefined, finished => {
isPullingVertical.value = !finished;
pullReleased.value = !finished;
});
return;
}
const isSwipingH = direction === 'left' || direction === 'right';
const isSwipingV = direction === 'up' || direction === 'down';
const snapV = vertical && (direction === undefined || isSwipingH);
const snapH = !vertical && (direction === undefined || isSwipingV);
if (snapV || snapH) {
snapToScrollPosition(e);
}
if (direction === undefined && onPanEnd !== undefined) {
runOnJS(onPanEnd)(e);
}
const configX = {
velocity: e.velocityX,
clamp: [-bounds.x, bounds.x]
};
const configY = {
velocity: e.velocityY,
clamp: [-bounds.y, bounds.y]
};
const restX = Math.abs(Math.abs(translate.x.value) - bounds.x);
const restY = Math.abs(Math.abs(translate.y.value) - bounds.y);
const finalConfig = restX > restY ? configX : configY;
gestureEnd.value = restX > restY ? translate.x.value : translate.y.value;
gestureEnd.value = withDecay(finalConfig, () => {
onGestureEnd && runOnJS(onGestureEnd)();
});
translate.x.value = withDecay(configX);
translate.y.value = withDecay(configY);
});
const tap = Gesture.Tap().withTestId('tap').enabled(gesturesEnabled).numberOfTaps(1).maxDuration(250).onEnd(event => {
const gallerySize = {
width: rootSize.width.value,
height: rootSize.height.value
};
const rect = getVisibleRect({
scale: scale.value,
containerSize: gallerySize,
itemSize: gallerySize,
translation: {
x: translate.x.value,
y: translate.y.value
}
});
const tapEdge = 44 / scale.value;
const leftEdge = rect.x + tapEdge;
const rightEdge = rect.x + rect.width - tapEdge;
let toIndex = activeIndex.value;
const canGoToItem = tapOnEdgeToItem && !vertical;
if (event.x <= leftEdge && canGoToItem) toIndex -= 1;
if (event.x >= rightEdge && canGoToItem) toIndex += 1;
toIndex = clamp(toIndex, 0, length - 1);
if (toIndex === activeIndex.value) {
onTap && runOnJS(onTap)(event, activeIndex.value);
return;
}
const toScroll = getScrollPosition({
index: toIndex,
itemSize: itemSize.value,
gap
});
scroll.value = toScroll;
activeIndex.value = toIndex;
reset(0, 0, minScale, false);
});
const doubleTap = Gesture.Tap().withTestId('doubleTap').enabled(gesturesEnabled && zoomEnabled).numberOfTaps(2).maxDuration(250).onEnd(onDoubleTapEnd);
const longPress = Gesture.LongPress().withTestId('longPress').enabled(gesturesEnabled).minDuration(longPressDuration).runOnJS(true).onStart(e => onLongPress === null || onLongPress === void 0 ? void 0 : onLongPress(e, activeIndex.value));
const detectorStyle = useAnimatedStyle(() => {
const width = Math.max(rootSize.width.value, rootChildSize.width.value);
const height = Math.max(rootSize.height.value, rootChildSize.height.value);
return {
width: width,
height: height,
position: 'absolute',
zIndex: 2_147_483_647,
transform: [{
translateX: translate.x.value
}, {
translateY: translate.y.value
}, {
scale: scale.value
}]
};
}, [rootSize, rootChildSize, translate, scale]);
const composedTaps = Gesture.Exclusive(doubleTap, tap, longPress);
const composed = Gesture.Race(pan, pinch, composedTaps);
return /*#__PURE__*/React.createElement(GestureDetector, {
gesture: composed
}, /*#__PURE__*/React.createElement(Animated.View, {
style: detectorStyle
}));
};
export default /*#__PURE__*/React.memo(GalleryGestureHandler, (prev, next) => {
return prev.onTap === next.onTap && prev.onPanStart === next.onPanStart && prev.onPanEnd === next.onPanEnd && prev.onPinchStart === next.onPinchStart && prev.onPinchEnd === next.onPinchEnd && prev.onSwipe === next.onSwipe && prev.onLongPress === next.onLongPress && prev.onVerticalPull === next.onVerticalPull && prev.length === next.length && prev.vertical === next.vertical && prev.tapOnEdgeToItem === next.tapOnEdgeToItem && prev.zoomEnabled === next.zoomEnabled && prev.scaleMode === next.scaleMode && prev.allowPinchPanning === next.allowPinchPanning && prev.allowOverflow === next.allowOverflow && prev.pinchMode === next.pinchMode;
});
//# sourceMappingURL=GalleryGestureHandler.js.map