diff --git a/docs/docs/components/gallery.md b/docs/docs/components/gallery.md index 2537c7f..2bd48bb 100644 --- a/docs/docs/components/gallery.md +++ b/docs/docs/components/gallery.md @@ -125,14 +125,14 @@ export default GalleryImage; ## Properties ### data -| Type | Required | +| Type | Required | |------|----------| | `T[]`| `Yes` | An array of items to render. ### renderItem -| Type | Required | +| Type | Required | |------|----------| | `(item: T, index: number) => JSX.Element` | `Yes` | @@ -146,28 +146,28 @@ Takes an item from data and renders it into the list, provides additional metada Used to extract a unique key for a given item at the specified index. ### windowSize -| Type | Default | +| Type | Default | |------|----------| | `number` | `5` | Maximum number of items to be rendered at once. ### initialIndex -| Type | Default | +| Type | Default | |------|----------| | `number` | `0` | Sets the initial position of the list. ### vertical -| Type | Default | +| Type | Default | |------|----------| | `boolean` | `false` | Modifies the orientation of the component to vertical mode. ### maxScale -| Type | Default | +| Type | Default | |------|----------| | `SizeVector[] \| number` | `6` | @@ -176,7 +176,7 @@ Maximum scale value allowed by the pinch gesture for all elements, expects value Alternatively you can pass an array with the resolution of your images/videos, for instance `[{ width: 1920, height: 1080 }]`; this will instruct the component to calculate `maxScale` in such a way it's a value just before images and videos start getting pixelated for each element, the resolutions array must be as big as the `data` property array. ### tapOnEdgeToItem -| Type | Default | +| Type | Default | |------|----------| | `boolean` | `true` | @@ -189,7 +189,7 @@ Allow the user to go to the next or previous item by tapping the horizontal edge ### allowPinchPanning | Type | Default | |------|---------| -| `boolean` | `true` | +| `boolean` | `true` | ::: warning Beware iOS users This feature is disabled by default for iOS users when a version of React Native Gesture Handler prior to `2.16.0` is installed, installing a version greater than equals `2.16.0` will set the value of this property to `true` by default. @@ -205,13 +205,13 @@ Lets the user drag the current item around as they pinch, it also provides a mor | `PinchCenteringMode` | `PinchCenteringMode.CLAMP` | see [PinchCenteringMode](#pinchcenteringmode-enum) | ::: tip Tip -To get the best out of this feature keep `allowPinchPanning` set to `true`. +To get the best out of this feature keep `allowPinchPanning` property set to `true`. ::: Modify the way the pinch gesture reacts to the user interaction. ### onIndexChange -| Type | Default | +| Type | Default | |------|----------| | `(index: number) => void` | `undefined` | @@ -371,7 +371,7 @@ Jump to the item at the given index. ### PinchCenteringMode Enum Determine the behavior used by the pinch gesture relative to the boundaries of its enclosing component. -| Property | Description | +| Property | Description | |--------------|--------------| | `CLAMP` | Keeps the pinch gesture clamped to the borders or its enclosing container during the entirity of the gesture, just like seen on Android galleries. | | `INTERACTION` | Keeps the pinch gesture in sync with user interaction, if the pinch gesture was released in an out bonds position it will animate back to a position within the bondaries of its enclosing container. | diff --git a/docs/docs/components/resumablezoom.md b/docs/docs/components/resumablezoom.md index 13ba707..e7da36d 100644 --- a/docs/docs/components/resumablezoom.md +++ b/docs/docs/components/resumablezoom.md @@ -65,7 +65,7 @@ export default App; All properties for this component are optional. ### extendGestures -| Type | Default | +| Type | Default | |------|---------| | `boolean` | `false` | @@ -74,7 +74,7 @@ By default the gesture detection area is the same size as the width and height o To summarize this property: It improves the gesture detection for small components. ### minScale -| Type | Default | +| Type | Default | |------|---------| | `number` | `1` | @@ -103,6 +103,17 @@ Select which one of the three available pan modes to use. Select which one of the two available scale modes to use. +### pinchCenteringMode +| Type | Default | Additional Info | +|------|----------|-----------------| +| `PinchCenteringMode` | `PinchCenteringMode.CLAMP` | see [PinchCenteringMode](#pinchcenteringmode-enum) | + +::: tip Tip +To get the best out of this feature keep `allowPinchPanning` property set to `true`. +::: + +Modify the way the pinch gesture reacts to the user interaction. + ### decay | Type | Default | Additional Info | |------|---------|-----------------| @@ -113,7 +124,7 @@ Whether to apply a decay animation when the pan gesture ends or not. ### allowPinchPanning | Type | Default | |------|---------| -| `boolean` | `true` | +| `boolean` | `true` | ::: warning Beware iOS users This feature is disabled by default for iOS users when a version of React Native Gesture Handler prior to `2.16.0` is installed, installing a version greater than equals `2.16.0` will set the value of this property to `true` by default. @@ -210,7 +221,7 @@ Callback triggered as soon as the user lifts their fingers off the screen after Worklet callback triggered when the internal state of the component changes, the internal state is updated as the user makes use of the gestures or execute its [methods](#methods), ideal if you need to mirror its current transformation values to some other component as it updates, see [ResumableZoomState](#resumablezoomstate). ### onGestureEnd -| Type | Default | +| Type | Default | |------|---------| | `() => void` | `undefined` | @@ -293,4 +304,12 @@ Determine how your component must behave when the pinch gesture's scale value ex | Property |Description | |----------|------------| | `CLAMP` | Prevents the user from exceeding the scale boundaries. | -| `BOUNCE` | Lets the user scale above and below the scale boundary values, when the pinch gesture ends the scale value returns to `minScale` or `maxScale` respectively. | \ No newline at end of file +| `BOUNCE` | Lets the user scale above and below the scale boundary values, when the pinch gesture ends the scale value returns to `minScale` or `maxScale` respectively. | + +### PinchCenteringMode Enum +Determine the behavior used by the pinch gesture relative to the boundaries of its enclosing component. + +| Property | Description | +|--------------|--------------| +| `CLAMP` | Keeps the pinch gesture clamped to the borders or its enclosing container during the entirity of the gesture, just like seen on Android galleries. | +| `INTERACTION` | Keeps the pinch gesture in sync with user interaction, if the pinch gesture was released in an out bonds position it will animate back to a position within the bondaries of its enclosing container. | diff --git a/example/app.json b/example/app.json index 8a08db6..ea1e869 100644 --- a/example/app.json +++ b/example/app.json @@ -12,9 +12,7 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "com.glazzes.example" @@ -30,8 +28,6 @@ "favicon": "./assets/favicon.png", "bundler": "metro" }, - "plugins": [ - "expo-router" - ] + "plugins": ["expo-router"] } } diff --git a/example/src/gallery/GalleryExample.tsx b/example/src/gallery/GalleryExample.tsx index 551da7d..868aaa1 100644 --- a/example/src/gallery/GalleryExample.tsx +++ b/example/src/gallery/GalleryExample.tsx @@ -1,20 +1,25 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { View } from 'react-native'; -import { useSharedValue, withTiming } from 'react-native-reanimated'; -import { - stackTransition, - Gallery, - type GalleryType, -} from 'react-native-zoom-toolkit'; +import { StyleSheet } from 'react-native'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { getAssetsAsync, - MediaType, requestPermissionsAsync, + MediaType, type Asset, } from 'expo-media-library'; +import { + stackTransition, + Gallery, + PinchCenteringMode, + type GalleryType, +} from 'react-native-zoom-toolkit'; import GalleryImage from './GalleryImage'; -import { StyleSheet } from 'react-native'; import VideoControls from './controls/VideoControls'; import GalleryVideo from './GalleryVideo'; @@ -67,6 +72,25 @@ const GalleryExample = () => { opacityControls.value = withTiming(toValue); }, [assets, activeIndex, opacityControls]); + // used to derived the color animation when pulling vertically + const translateY = useSharedValue(0); + const onVerticalPulling = (ty: number) => { + 'worklet'; + translateY.value = ty; + }; + + const animatedStyle = useAnimatedStyle(() => { + const color = interpolateColor( + translateY.value, + [-150, 0, 150], + ['#fff', '#000', '#fff'], + 'RGB', + { gamma: 2.2 } + ); + + return { backgroundColor: color }; + }); + useEffect(() => { const requestAssets = async () => { const { granted } = await requestPermissionsAsync(); @@ -95,7 +119,7 @@ const GalleryExample = () => { } return ( - + { activeIndex.value = idx; }} onTap={onTap} + pinchCenteringMode={PinchCenteringMode.INTERACTION} + onVerticalPull={onVerticalPulling} customTransition={customTransition} /> @@ -116,14 +142,13 @@ const GalleryExample = () => { isSeeking={isSeeking} opacity={opacityControls} /> - + ); }; const styles = StyleSheet.create({ root: { flex: 1, - backgroundColor: '#000', }, }); diff --git a/example/src/gallery/GalleryImage.tsx b/example/src/gallery/GalleryImage.tsx index 724907b..6a5d83b 100644 --- a/example/src/gallery/GalleryImage.tsx +++ b/example/src/gallery/GalleryImage.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { useWindowDimensions } from 'react-native'; -import { Image } from 'expo-image'; import { runOnJS, useAnimatedReaction, type SharedValue, } from 'react-native-reanimated'; +import { Image } from 'expo-image'; import { type Asset } from 'expo-media-library'; import { calculateItemSize } from './utils/utils'; diff --git a/example/src/gallery/GalleryVideo.tsx b/example/src/gallery/GalleryVideo.tsx index 9fe40a6..640f574 100644 --- a/example/src/gallery/GalleryVideo.tsx +++ b/example/src/gallery/GalleryVideo.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { useWindowDimensions } from 'react-native'; +import { type SharedValue } from 'react-native-reanimated'; import { ResizeMode, Video, type AVPlaybackStatus } from 'expo-av'; import type { Asset } from 'expo-media-library'; @@ -10,7 +11,6 @@ import { listenToSeekVideoEvent, listenToStopVideoEvent, } from './utils/emitter'; -import { type SharedValue } from 'react-native-reanimated'; type GalleryVideoProps = { asset: Asset; diff --git a/lefthook.yml b/lefthook.yml index ea1a93b..0eba2de 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,10 +2,10 @@ pre-commit: parallel: true commands: lint: - glob: "*.{js,ts,jsx,tsx}" + glob: '*.{js,ts,jsx,tsx}' run: yarn eslint {staged_files} types: - glob: "*.{js,ts, jsx, tsx}" + glob: '*.{js,ts, jsx, tsx}' run: npx tsc --noEmit commit-msg: parallel: true diff --git a/src/commons/hooks/usePanCommons.ts b/src/commons/hooks/usePanCommons.ts index 30f7010..bf97f77 100644 --- a/src/commons/hooks/usePanCommons.ts +++ b/src/commons/hooks/usePanCommons.ts @@ -27,8 +27,8 @@ import { useVector } from './useVector'; import getSwipeDirection from '../utils/getSwipeDirection'; type PanCommmonOptions = { + container: SizeVector>; translate: Vector>; - detector: SizeVector>; detectorTranslate: Vector>; offset: Vector>; panMode: PanMode; @@ -54,7 +54,7 @@ const DECELERATION = 0.9955; export const usePanCommons = (options: PanCommmonOptions) => { const { - detector, + container, detectorTranslate, translate, offset, @@ -69,13 +69,14 @@ export const usePanCommons = (options: PanCommmonOptions) => { const time = useSharedValue(0); const position = useVector(0, 0); - + const gestureEnd = useSharedValue(0); // Gimmick value to trigger onGestureEnd callback const isWithinBoundX = useSharedValue(true); const isWithinBoundY = useSharedValue(true); const onPanStart = (e: PanGestureEvent) => { 'worklet'; + userCallbacks.onPanStart && runOnJS(userCallbacks.onPanStart)(e); cancelAnimation(translate.x); cancelAnimation(translate.y); cancelAnimation(detectorTranslate.x); @@ -87,10 +88,6 @@ export const usePanCommons = (options: PanCommmonOptions) => { time.value = performance.now(); position.x.value = e.absoluteX; position.y.value = e.absoluteY; - - if (userCallbacks.onPanStart) { - runOnJS(userCallbacks.onPanStart)(e); - } }; const onPanChange = (e: PanGestureUpdadeEvent) => { @@ -104,35 +101,27 @@ export const usePanCommons = (options: PanCommmonOptions) => { const { x: boundX, y: boundY } = boundFn(toScale); const exceedX = Math.max(0, Math.abs(toX) - boundX); const exceedY = Math.max(0, Math.abs(toY) - boundY); + isWithinBoundX.value = exceedX === 0; + isWithinBoundY.value = exceedY === 0; - if ( - (exceedX > 0 || exceedY > 0) && - userCallbacks.onOverPanning !== undefined - ) { + if ((exceedX > 0 || exceedY > 0) && userCallbacks.onOverPanning) { const ex = Math.sign(toX) * exceedX; const ey = Math.sign(toY) * exceedY; userCallbacks.onOverPanning(ex, ey); } - if (panMode === PanMode.FREE) { - translate.x.value = toX; - translate.y.value = toY; - detectorTranslate.x.value = toX; - detectorTranslate.y.value = toY; - return; - } - - if (panMode === PanMode.CLAMP) { - translate.x.value = clamp(toX, -1 * boundX, boundX); - translate.y.value = clamp(toY, -1 * boundY, boundY); - detectorTranslate.x.value = clamp(toX, -1 * boundX, boundX); - detectorTranslate.y.value = clamp(toX, -1 * boundY, boundY); - return; + // Simplify both pan modes in one condition due to their similarity + if (panMode !== PanMode.FRICTION) { + const isFree = panMode === PanMode.FREE; + translate.x.value = isFree ? toX : clamp(toX, -1 * boundX, boundX); + translate.y.value = isFree ? toY : clamp(toY, -1 * boundY, boundY); + detectorTranslate.x.value = translate.x.value; + detectorTranslate.y.value = translate.y.value; } if (panMode === PanMode.FRICTION) { const overScrollFraction = - Math.max(detector.width.value, detector.height.value) * 1.5; + Math.max(container.width.value, container.height.value) * 1.5; if (isWithinBoundX.value) { translate.x.value = toX; @@ -155,7 +144,7 @@ export const usePanCommons = (options: PanCommmonOptions) => { const onPanEnd = (e: PanGestureEvent) => { 'worklet'; - if (panMode === PanMode.CLAMP && userCallbacks.onSwipe !== undefined) { + if (panMode === PanMode.CLAMP && userCallbacks.onSwipe) { const boundaries = boundFn(scale.value); const direction = getSwipeDirection(e, { boundaries, @@ -170,9 +159,7 @@ export const usePanCommons = (options: PanCommmonOptions) => { } } - if (userCallbacks.onPanEnd) { - runOnJS(userCallbacks.onPanEnd)(e); - } + userCallbacks.onPanEnd && runOnJS(userCallbacks.onPanEnd)(e); const toScale = clamp(scale.value, minScale, maxScale.value); const { x: boundX, y: boundY } = boundFn(toScale); @@ -181,61 +168,57 @@ export const usePanCommons = (options: PanCommmonOptions) => { const toX = clamp(translate.x.value, -1 * boundX, boundX); const toY = clamp(translate.y.value, -1 * boundY, boundY); - const hasEndCB = userCallbacks.onGestureEnd !== undefined; - - if (decay && isWithinBoundX.value) { - detectorTranslate.x.value = translate.x.value; - translate.x.value = withDecay( - { - velocity: e.velocityX, - clamp: clampX, - deceleration: DECELERATION, - }, - (finished) => { - if (finished && hasEndCB) { - runOnJS(userCallbacks.onGestureEnd!)(); - } + const shouldDecayX = decay && isWithinBoundX.value; + const shouldDecayY = decay && isWithinBoundY.value; + const decayConfigX = { + velocity: e.velocityX, + clamp: clampX, + deceleration: DECELERATION, + }; + + const decayConfigY = { + velocity: e.velocityY, + clamp: clampY, + deceleration: DECELERATION, + }; + + detectorTranslate.x.value = translate.x.value; + detectorTranslate.x.value = shouldDecayX + ? withDecay(decayConfigX) + : withTiming(toX); + + translate.x.value = shouldDecayX + ? withDecay(decayConfigX) + : withTiming(toX); + + detectorTranslate.y.value = translate.y.value; + detectorTranslate.x.value = shouldDecayY + ? withDecay(decayConfigY) + : withTiming(toY); + + translate.y.value = shouldDecayY + ? withDecay(decayConfigY) + : withTiming(toY); + + const restX = Math.max(0, Math.abs(translate.x.value) - boundX); + const restY = Math.max(0, Math.abs(translate.y.value) - boundY); + gestureEnd.value = restX > restY ? translate.x.value : translate.y.value; + + if (shouldDecayX && shouldDecayY) { + const config = restX > restY ? decayConfigX : decayConfigY; + gestureEnd.value = withDecay(config, (finished) => { + if (finished && userCallbacks.onGestureEnd) { + runOnJS(userCallbacks.onGestureEnd)(); } - ); - - detectorTranslate.x.value = withDecay({ - velocity: e.velocityX, - clamp: clampX, - deceleration: DECELERATION, }); } else { - translate.x.value = withTiming(toX, undefined, () => { - if (!isWithinBoundX.value && hasEndCB) { - runOnJS(userCallbacks.onGestureEnd!)(); + const toValue = restX > restY ? toX : toY; + gestureEnd.value = withTiming(toValue, undefined, (finished) => { + if (finished && userCallbacks.onGestureEnd) { + runOnJS(userCallbacks.onGestureEnd)(); } }); - - detectorTranslate.x.value = withTiming(toX); - } - - if (decay && isWithinBoundY.value) { - detectorTranslate.y.value = translate.y.value; - - translate.y.value = withDecay({ - velocity: e.velocityY, - clamp: clampY, - deceleration: DECELERATION, - }); - - detectorTranslate.y.value = withDecay({ - velocity: e.velocityY, - clamp: clampY, - deceleration: DECELERATION, - }); - } else { - translate.y.value = withTiming(toY, undefined, () => { - if (isWithinBoundX.value && !isWithinBoundY.value && hasEndCB) { - runOnJS(userCallbacks.onGestureEnd!)(); - } - }); - - detectorTranslate.y.value = withTiming(toY); } }; diff --git a/src/commons/hooks/usePinchCommons.ts b/src/commons/hooks/usePinchCommons.ts index 7517513..aaecffb 100644 --- a/src/commons/hooks/usePinchCommons.ts +++ b/src/commons/hooks/usePinchCommons.ts @@ -3,6 +3,7 @@ import { withTiming, cancelAnimation, runOnJS, + useSharedValue, type SharedValue, } from 'react-native-reanimated'; import { @@ -13,8 +14,8 @@ import { import { clamp } from '../utils/clamp'; import { pinchTransform } from '../utils/pinchTransform'; import { - PanMode, ScaleMode, + PinchCenteringMode, type BoundsFuction, type SizeVector, type Vector, @@ -36,7 +37,7 @@ type PinchOptions = { minScale: number; maxScale: SharedValue; boundFn: BoundsFuction; - panMode: PanMode; + pinchCenteringMode: PinchCenteringMode; allowPinchPanning: boolean; userCallbacks: Partial<{ onGestureEnd: () => void; @@ -61,26 +62,29 @@ export const usePinchCommons = (options: PinchOptions) => { minScale, maxScale, scaleMode, - panMode, + pinchCenteringMode, allowPinchPanning, origin, boundFn, userCallbacks, } = options; - const panClamp = panMode === PanMode.CLAMP; + const pinchClamp = pinchCenteringMode === PinchCenteringMode.CLAMP; const scaleClamp = scaleMode === ScaleMode.CLAMP; + // This value is used to trigger the onGestureEnd callback as a gimmick to avoid unneccesary calculations. + const gestureEnd = useSharedValue(0); + const [gesturesEnabled, setGesturesEnabled] = useState(true); const switchGesturesState = (value: boolean) => { - if (scaleMode === ScaleMode.BOUNCE) { - setGesturesEnabled(value); - } + if (scaleMode !== ScaleMode.BOUNCE) return; + setGesturesEnabled(value); }; const onPinchStart = (e: PinchGestureEvent) => { 'worklet'; runOnJS(switchGesturesState)(false); + userCallbacks.onPinchStart && runOnJS(userCallbacks.onPinchStart)(e); cancelAnimation(translate.x); cancelAnimation(translate.y); @@ -95,10 +99,6 @@ export const usePinchCommons = (options: PinchOptions) => { offset.x.value = translate.x.value; offset.y.value = translate.y.value; scaleOffset.value = scale.value; - - if (userCallbacks.onPinchStart) { - runOnJS(userCallbacks.onPinchStart)(e); - } }; const onPinchUpdate = (e: PinchGestueUpdateEvent) => { @@ -125,8 +125,8 @@ export const usePinchCommons = (options: PinchOptions) => { const clampedX = clamp(toX, -1 * boundX, boundX); const clampedY = clamp(toY, -1 * boundY, boundY); - translate.x.value = panClamp ? clampedX : toX; - translate.y.value = panClamp ? clampedY : toY; + translate.x.value = pinchClamp ? clampedX : toX; + translate.y.value = pinchClamp ? clampedY : toY; scale.value = toScale; }; @@ -137,10 +137,6 @@ export const usePinchCommons = (options: PinchOptions) => { cancelAnimation(translate.y); cancelAnimation(scale); - const { x: bx, y: by } = boundFn(scale.value); - const inBoundX = translate.x.value >= -1 * bx && translate.x.value <= bx; - const inBoundY = translate.y.value >= -1 * by && translate.y.value <= by; - detectorTranslate.x.value = translate.x.value; detectorTranslate.y.value = translate.y.value; detectorScale.value = scale.value; @@ -148,21 +144,20 @@ export const usePinchCommons = (options: PinchOptions) => { detectorTranslate.y.value = withTiming(toY); detectorScale.value = withTiming(toScale); - translate.x.value = withTiming(toX, undefined, (finished) => { - if (finished && !inBoundX && userCallbacks.onGestureEnd) { - runOnJS(userCallbacks.onGestureEnd)(); - } - }); + const areTXNotEqual = translate.x.value !== toX; + const areTYNotEqual = translate.y.value !== toY; + const areScalesNotEqual = scale.value !== toScale; + const toValue = areTXNotEqual || areTYNotEqual || areScalesNotEqual ? 1 : 0; - translate.y.value = withTiming(toY, undefined, (finished) => { - if (finished && !inBoundY && inBoundX && userCallbacks.onGestureEnd) { - runOnJS(userCallbacks.onGestureEnd)(); - } + translate.x.value = withTiming(toX); + translate.y.value = withTiming(toY); + scale.value = withTiming(toScale, undefined, () => { + runOnJS(switchGesturesState)(true); }); - scale.value = withTiming(toScale, undefined, (finished) => { - runOnJS(switchGesturesState)(true); - if (finished && inBoundX && inBoundY && userCallbacks.onGestureEnd) { + gestureEnd.value = withTiming(toValue, undefined, (finished) => { + gestureEnd.value = 0; + if (finished && userCallbacks.onGestureEnd !== undefined) { runOnJS(userCallbacks.onGestureEnd)(); } }); @@ -171,46 +166,27 @@ export const usePinchCommons = (options: PinchOptions) => { const onPinchEnd = (e: PinchGestureEvent) => { 'worklet'; - if (userCallbacks.onPinchEnd) { - runOnJS(userCallbacks.onPinchEnd)(e); - } - - if (scale.value < minScale && scaleMode === ScaleMode.BOUNCE) { - const { x: boundX, y: boundY } = boundFn(minScale); - const toX = clamp(translate.x.value, -1 * boundX, boundX); - const toY = clamp(translate.y.value, -1 * boundY, boundY); - - reset(toX, toY, minScale); - return; - } - - if (scale.value > maxScale.value && scaleMode === ScaleMode.BOUNCE) { - const scaleDiff = Math.max( - 0, - scaleOffset.value - (scale.value - scaleOffset.value) / 2 - ); - - const { x, y } = pinchTransform({ - toScale: maxScale.value, - fromScale: scale.value, - origin: { x: origin.x.value, y: origin.y.value }, - offset: { x: translate.x.value, y: translate.y.value }, - delta: { x: 0, y: allowPinchPanning ? -delta.y.value * scaleDiff : 0 }, - }); - - const { x: boundX, y: boundY } = boundFn(maxScale.value); - const toX = clamp(x, -1 * boundX, boundX); - const toY = clamp(y, -1 * boundY, boundY); - reset(toX, toY, maxScale.value); - - return; - } - - const { x: boundX, y: boundY } = boundFn(scale.value); - const toX = clamp(translate.x.value, -1 * boundX, boundX); - const toY = clamp(translate.y.value, -1 * boundY, boundY); - - reset(toX, toY, scale.value); + userCallbacks.onPinchEnd && runOnJS(userCallbacks.onPinchEnd)(e); + + const toScale = clamp(scale.value, minScale, maxScale.value); + const scaleDiff = + scaleMode === ScaleMode.BOUNCE && scale.value > maxScale.value + ? Math.max(0, scaleOffset.value - (scale.value - scaleOffset.value) / 2) + : 0; + + const { x, y } = pinchTransform({ + toScale: toScale, + fromScale: scale.value, + origin: { x: origin.x.value, y: origin.y.value }, + offset: { x: translate.x.value, y: translate.y.value }, + delta: { x: 0, y: allowPinchPanning ? -delta.y.value * scaleDiff : 0 }, + }); + + const { x: boundX, y: boundY } = boundFn(toScale); + const toX = clamp(x, -1 * boundX, boundX); + const toY = clamp(y, -1 * boundY, boundY); + + reset(toX, toY, toScale); }; return { gesturesEnabled, onPinchStart, onPinchUpdate, onPinchEnd }; diff --git a/src/commons/types.ts b/src/commons/types.ts index 41ee8e5..fa6dbe5 100644 --- a/src/commons/types.ts +++ b/src/commons/types.ts @@ -4,12 +4,12 @@ import type { TapGestureHandlerEventPayload, PanGestureHandlerEventPayload, } from 'react-native-gesture-handler'; -import type { HitSlop } from 'react-native-gesture-handler/lib/typescript/handlers/gestureHandlerCommon'; import type { EasingFunction, EasingFunctionFactory, ReduceMotion, } from 'react-native-reanimated'; +import type { HitSlop } from 'react-native-gesture-handler/lib/typescript/handlers/gestureHandlerCommon'; export type TimingConfig = Partial<{ duration: number; @@ -75,6 +75,11 @@ export enum ScaleMode { BOUNCE, } +export enum PinchCenteringMode { + CLAMP, + INTERACTION, +} + export type CommonZoomProps = Partial<{ hitSlop: HitSlop; timingConfig: TimingConfig; diff --git a/src/components/crop/CropZoom.tsx b/src/components/crop/CropZoom.tsx index 3bfed25..86d7332 100644 --- a/src/components/crop/CropZoom.tsx +++ b/src/components/crop/CropZoom.tsx @@ -12,13 +12,19 @@ import { GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; + import { useSizeVector } from '../../commons/hooks/useSizeVector'; import { getCropRotatedSize } from '../../commons/utils/getCropRotatedSize'; import { usePanCommons } from '../../commons/hooks/usePanCommons'; import { usePinchCommons } from '../../commons/hooks/usePinchCommons'; import { getMaxScale } from '../../commons/utils/getMaxScale'; import { useVector } from '../../commons/hooks/useVector'; -import { PanMode, type BoundsFuction, ScaleMode } from '../../commons/types'; +import { + PanMode, + type BoundsFuction, + ScaleMode, + PinchCenteringMode, +} from '../../commons/types'; import { crop } from '../../commons/utils/crop'; import { CropMode, @@ -152,7 +158,7 @@ const CropZoom: React.FC = (props) => { delta, allowPinchPanning, scaleMode, - panMode, + pinchCenteringMode: PinchCenteringMode.INTERACTION, boundFn: boundsFn, userCallbacks: { onGestureEnd: onGestureEnd, @@ -162,12 +168,12 @@ const CropZoom: React.FC = (props) => { }); const { onPanStart, onPanChange, onPanEnd } = usePanCommons({ + container: detector, translate, offset, scale, minScale, maxScale, - detector, detectorTranslate, panMode, boundFn: boundsFn, @@ -234,80 +240,47 @@ const CropZoom: React.FC = (props) => { cb ) => { if (!canRotate.value) return; + if (animate) canRotate.value = false; // Determine the direction multiplier based on clockwise or counterclockwise rotation const direction = clockwise ? 1 : -1; const toAngle = rotation.value + (Math.PI / 2) * direction; sizeAngle.value = toAngle; - if (cb !== undefined) cb(toAngle % (Math.PI * 2)); - - if (animate) { - canRotate.value = false; - - translate.x.value = withTiming(0); - translate.y.value = withTiming(0); - detectorTranslate.x.value = withTiming(0); - detectorTranslate.y.value = withTiming(0); - scale.value = withTiming(1); - detectorScale.value = withTiming(1); - - rotation.value = withTiming(toAngle, undefined, (_) => { - canRotate.value = true; - if (Math.abs(rotation.value) === Math.PI * 2) rotation.value = 0; - }); - - return; - } - - translate.x.value = 0; - translate.y.value = 0; - detectorTranslate.x.value = 0; - detectorTranslate.y.value = 0; - scale.value = 1; - detectorScale.value = 1; + cb?.(toAngle % (Math.PI * 2)); + + translate.x.value = animate ? withTiming(0) : 0; + translate.y.value = animate ? withTiming(0) : 0; + detectorTranslate.x.value = animate ? withTiming(0) : 0; + detectorTranslate.y.value = animate ? withTiming(0) : 0; + scale.value = animate ? withTiming(1) : 1; + detectorScale.value = animate ? withTiming(1) : 1; + rotation.value = animate + ? withTiming(toAngle, undefined, (_) => { + canRotate.value = true; + if (rotation.value === Math.PI * 2) rotation.value = 0; + }) + : toAngle; }; const flipHorizontal: RotateTransitionCallback = (animate = true, cb) => { const toAngle = rotate.y.value !== Math.PI ? Math.PI : 0; - if (cb !== undefined) cb(toAngle * RAD2DEG); - - if (animate) { - rotate.y.value = withTiming(toAngle); - return; - } - - rotate.y.value = toAngle; + cb?.(toAngle * RAD2DEG); + rotate.y.value = animate ? withTiming(toAngle) : toAngle; }; const flipVertical: RotateTransitionCallback = (animate = true, cb) => { const toAngle = rotate.x.value !== Math.PI ? Math.PI : 0; - if (cb !== undefined) cb(toAngle * RAD2DEG); - - if (animate) { - rotate.x.value = withTiming(toAngle); - return; - } - - rotate.x.value = toAngle; + cb?.(toAngle * RAD2DEG); + rotate.x.value = animate ? withTiming(toAngle) : toAngle; }; const handleReset = (animate: boolean = true) => { - if (animate) { - translate.x.value = withTiming(0); - translate.y.value = withTiming(0); - rotation.value = withTiming(0); - rotate.x.value = withTiming(0); - rotate.y.value = withTiming(0); - scale.value = withTiming(minScale); - return; - } - - translate.x.value = 0; - translate.y.value = 0; - rotation.value = 0; - rotate.x.value = 0; - rotate.y.value = 0; - scale.value = minScale; + translate.x.value = animate ? withTiming(0) : 0; + translate.y.value = animate ? withTiming(0) : 0; + rotation.value = animate ? withTiming(0) : 0; + rotate.x.value = animate ? withTiming(0) : 0; + rotate.y.value = animate ? withTiming(0) : 0; + scale.value = animate ? withTiming(minScale) : minScale; }; const handleCrop = (fixedWidth?: number): CropContextResult => { @@ -352,22 +325,12 @@ const CropZoom: React.FC = (props) => { const toRotateX = Math.sign(state.rotateX - DEG90) === 1 ? Math.PI : 0; const toRotateY = Math.sign(state.rotateY - DEG90) === 1 ? Math.PI : 0; - if (animate) { - translate.x.value = withTiming(toX); - translate.y.value = withTiming(toY); - scale.value = withTiming(toScale); - rotation.value = withTiming(toRotate); - rotate.x.value = withTiming(toRotateX); - rotate.y.value = withTiming(toRotateY); - return; - } - - translate.x.value = toX; - translate.y.value = toY; - scale.value = toScale; - rotation.value = toRotate; - rotate.x.value = toRotateX; - rotate.y.value = toRotateY; + translate.x.value = animate ? withTiming(toX) : toX; + translate.y.value = animate ? withTiming(toY) : toY; + scale.value = animate ? withTiming(toScale) : toScale; + rotation.value = animate ? withTiming(toRotate) : toRotate; + rotate.x.value = animate ? withTiming(toRotateX) : toRotateX; + rotate.y.value = animate ? withTiming(toRotateY) : toRotateY; }; useImperativeHandle(ref, () => ({ @@ -397,7 +360,7 @@ const CropZoom: React.FC = (props) => { if (mode === CropMode.MANAGED) { return ( - + {children} @@ -428,8 +391,6 @@ const CropZoom: React.FC = (props) => { const styles = StyleSheet.create({ root: { flex: 1, - justifyContent: 'center', - alignItems: 'center', }, absolute: { flex: 1, diff --git a/src/components/gallery/Gallery.tsx b/src/components/gallery/Gallery.tsx index dcde35e..01eeacb 100644 --- a/src/components/gallery/Gallery.tsx +++ b/src/components/gallery/Gallery.tsx @@ -8,6 +8,7 @@ import { } from 'react-native-reanimated'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { PinchCenteringMode } from '../../commons/types'; import { clamp } from '../../commons/utils/clamp'; import { getMaxScale } from '../../commons/utils/getMaxScale'; import { getPanWithPinchStatus } from '../../commons/utils/getPanWithPinchStatus'; @@ -15,11 +16,7 @@ import { getPanWithPinchStatus } from '../../commons/utils/getPanWithPinchStatus import Reflection from './Reflection'; import GalleryItem from './GalleryItem'; import { GalleryContext } from './context'; -import { - PinchCenteringMode, - type GalleryProps, - type GalleryType, -} from './types'; +import { type GalleryProps, type GalleryType } from './types'; type GalleryPropsWithRef = GalleryProps & { reference?: React.ForwardedRef; @@ -67,7 +64,7 @@ const Gallery = (props: GalleryPropsWithRef) => { hasZoomed, } = useContext(GalleryContext); - const scrollDirection = useDerivedValue(() => { + const itemSize = useDerivedValue(() => { return vertical ? rootSize.height.value : rootSize.width.value; }, [vertical, rootSize]); @@ -93,12 +90,11 @@ const Gallery = (props: GalleryPropsWithRef) => { scroll.value = activeIndex.value * e.nativeEvent.layout.width; }; - useDerivedValue(() => { - onScroll?.( - scroll.value, - data.length * scrollDirection.value - scrollDirection.value - ); - }, [scroll.value, data.length, scrollDirection]); + useAnimatedReaction( + () => ({ scroll: scroll.value, itemSize: itemSize.value }), + (value) => onScroll?.(value.scroll, (data.length - 1) * value.itemSize), + [scroll, itemSize] + ); useAnimatedReaction( () => activeIndex.value, @@ -118,7 +114,7 @@ const Gallery = (props: GalleryPropsWithRef) => { const direction = value ? rootSize.height.value : rootSize.width.value; scroll.value = activeIndex.value * direction; }, - [vertical, activeIndex, rootSize] + [vertical] ); useAnimatedReaction( @@ -140,7 +136,7 @@ const Gallery = (props: GalleryPropsWithRef) => { const clamped = clamp(index, 0, data.length); activeIndex.value = clamped; fetchIndex.value = clamped; - scroll.value = clamped * scrollDirection.value; + scroll.value = clamped * itemSize.value; }; const requestState = () => ({ @@ -190,7 +186,7 @@ const Gallery = (props: GalleryPropsWithRef) => { ; - scrollDirection: Readonly>; + itemSize: Readonly>; vertical: boolean; tapOnEdgeToItem: boolean; allowPinchPanning: boolean; @@ -59,11 +60,11 @@ type ReflectionProps = { const Reflection = ({ length, maxScale, - scrollDirection, + itemSize, vertical, tapOnEdgeToItem, allowPinchPanning, - pinchCenteringMode: pinchMode, + pinchCenteringMode, onTap, onPanStart, onPanEnd, @@ -132,16 +133,16 @@ const Reflection = ({ const clampScroll = (value: number) => { 'worklet'; - return clamp(value, 0, (length - 1) * scrollDirection.value); + return clamp(value, 0, (length - 1) * itemSize.value); }; const onScrollEnd = (e: PanGestureEvent) => { 'worklet'; const index = activeIndex.value; - const prev = scrollDirection.value * (index - 1); - const current = scrollDirection.value * index; - const next = scrollDirection.value * (index + 1); + const prev = itemSize.value * (index - 1); + const current = itemSize.value * index; + const next = itemSize.value * (index + 1); const velocity = vertical ? e.velocityY : e.velocityX; const points = scroll.value >= current ? [current, next] : [prev, current]; @@ -174,7 +175,7 @@ const Reflection = ({ fetchIndex.value = toIndex; - const to = clampScroll(toIndex * scrollDirection.value); + const to = clampScroll(toIndex * itemSize.value); scroll.value = withTiming(to, config, (finished) => { activeIndex.value = toIndex; if (finished) isScrolling.value = false; @@ -221,8 +222,7 @@ const Reflection = ({ delta, allowPinchPanning, scaleMode: ScaleMode.BOUNCE, - panMode: - pinchMode === PinchCenteringMode.CLAMP ? PanMode.CLAMP : PanMode.FREE, + pinchCenteringMode, boundFn: boundsFn, userCallbacks: { onPinchStart: onUserPinchStart, @@ -239,27 +239,21 @@ const Reflection = ({ .maxPointers(1) .enabled(gesturesEnabled) .onStart((e) => { - if (onPanStart !== undefined) { - runOnJS(onPanStart)(e); - } + onPanStart && runOnJS(onPanStart)(e); + cancelAnimation(translate.x); + cancelAnimation(translate.y); + cancelAnimation(detectorTranslate.x); + cancelAnimation(detectorTranslate.y); + const isVerticalPan = Math.abs(e.velocityY) > Math.abs(e.velocityX); + isPullingVertical.value = isVerticalPan && scale.value === 1 && !vertical; isScrolling.value = true; - scrollOffset.value = scroll.value; time.value = performance.now(); position.x.value = e.absoluteX; position.y.value = e.absoluteY; - const isVerticalPan = Math.abs(e.velocityY) > Math.abs(e.velocityX); - if (isVerticalPan && scale.value === 1 && !vertical) { - isPullingVertical.value = true; - } - - cancelAnimation(translate.x); - cancelAnimation(translate.y); - cancelAnimation(detectorTranslate.x); - cancelAnimation(detectorTranslate.y); - + scrollOffset.value = scroll.value; offset.x.value = translate.x.value; offset.y.value = translate.y.value; }) @@ -276,23 +270,13 @@ const Reflection = ({ const exceedX = Math.max(0, Math.abs(toX) - boundX); const exceedY = Math.max(0, Math.abs(toY) - boundY); - if (exceedX > 0 && !vertical) { - const ex = -1 * Math.sign(toX) * exceedX; - scroll.value = clamp( - scrollOffset.value + ex, - 0, - (length - 1) * rootSize.width.value - ); - } - - if (exceedY > 0 && vertical) { - const ey = -1 * Math.sign(toY) * exceedY; - scroll.value = clamp( - scrollOffset.value + ey, - 0, - (length - 1) * rootSize.height.value - ); - } + const scrollX = -1 * Math.sign(toX) * exceedX; + const scrollY = -1 * Math.sign(toY) * exceedY; + scroll.value = clamp( + scrollOffset.value + (vertical ? scrollY : scrollX), + 0, + (length - 1) * itemSize.value + ); translate.x.value = clamp(toX, -1 * boundX, boundX); translate.y.value = clamp(toY, -1 * boundY, boundY); @@ -330,18 +314,13 @@ const Reflection = ({ const clampX: [number, number] = [-1 * boundaries.x, boundaries.x]; const clampY: [number, number] = [-1 * boundaries.y, boundaries.y]; + const configX: WithDecayConfig = { velocity: e.velocityX, clamp: clampX }; + const configY: WithDecayConfig = { velocity: e.velocityY, clamp: clampY }; - translate.x.value = withDecay({ velocity: e.velocityX, clamp: clampX }); - detectorTranslate.x.value = withDecay({ - velocity: e.velocityX, - clamp: clampX, - }); - - translate.y.value = withDecay({ velocity: e.velocityY, clamp: clampY }); - detectorTranslate.y.value = withDecay({ - velocity: e.velocityY, - clamp: clampY, - }); + translate.x.value = withDecay(configX); + translate.y.value = withDecay(configY); + detectorTranslate.x.value = withDecay(configX); + detectorTranslate.y.value = withDecay(configY); }); const tap = Gesture.Tap() @@ -371,15 +350,13 @@ const Reflection = ({ }); const tapEdge = 44 / scale.value; - const left = originX + tapEdge; + const leftEdge = originX + tapEdge; const rightEdge = originX + width - tapEdge; let toIndex = activeIndex.value; - if (e.x <= left && tapOnEdgeToItem && !vertical) - toIndex = activeIndex.value - 1; - - if (e.x >= rightEdge && tapOnEdgeToItem && !vertical) - toIndex = activeIndex.value + 1; + const canGoToItem = tapOnEdgeToItem && !vertical; + if (e.x <= leftEdge && canGoToItem) toIndex = activeIndex.value - 1; + if (e.x >= rightEdge && canGoToItem) toIndex = activeIndex.value + 1; if (toIndex === activeIndex.value) { onTap?.(e, activeIndex.value); @@ -387,7 +364,7 @@ const Reflection = ({ } toIndex = clamp(toIndex, 0, length - 1); - scroll.value = toIndex * scrollDirection.value; + scroll.value = toIndex * itemSize.value; activeIndex.value = toIndex; fetchIndex.value = toIndex; }); diff --git a/src/components/gallery/types.ts b/src/components/gallery/types.ts index abffafc..2dd8936 100644 --- a/src/components/gallery/types.ts +++ b/src/components/gallery/types.ts @@ -1,4 +1,5 @@ import type { ViewStyle } from 'react-native'; +import { PinchCenteringMode } from '../../commons/types'; import type { PanGestureCallbacks, PinchGestureCallbacks, @@ -9,11 +10,6 @@ import type { } from '../../commons/types'; import type { ResumableZoomState } from '../resumable/types'; -export enum PinchCenteringMode { - CLAMP, - INTERACTION, -} - export type GalleryTransitionState = { index: number; activeIndex: number; diff --git a/src/components/resumable/ResumableZoom.tsx b/src/components/resumable/ResumableZoom.tsx index fc96c91..1049382 100644 --- a/src/components/resumable/ResumableZoom.tsx +++ b/src/components/resumable/ResumableZoom.tsx @@ -19,7 +19,12 @@ import { useSizeVector } from '../../commons/hooks/useSizeVector'; import { usePanCommons } from '../../commons/hooks/usePanCommons'; import { pinchTransform } from '../../commons/utils/pinchTransform'; import { usePinchCommons } from '../../commons/hooks/usePinchCommons'; -import { PanMode, ScaleMode, type BoundsFuction } from '../../commons/types'; +import { + PanMode, + PinchCenteringMode, + ScaleMode, + type BoundsFuction, +} from '../../commons/types'; import withResumableValidation from '../../commons/hoc/withResumableValidation'; import type { @@ -46,6 +51,7 @@ const ResumableZoom: React.FC = (props) => { maxScale: userMaxScale = 6, panMode = PanMode.CLAMP, scaleMode = ScaleMode.BOUNCE, + pinchCenteringMode = PinchCenteringMode.CLAMP, allowPinchPanning: pinchPanning, onTap, onGestureActive, @@ -98,11 +104,11 @@ const ResumableZoom: React.FC = (props) => { const boundsFn: BoundsFuction = (scaleValue) => { 'worklet'; - const { width: dWidth, height: dHeight } = childSize; + const { width: cWidth, height: cHeight } = childSize; const { width: rWidth, height: rHeight } = rootSize; - const boundX = Math.max(0, dWidth.value * scaleValue - rWidth.value) / 2; - const boundY = Math.max(0, dHeight.value * scaleValue - rHeight.value) / 2; + 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 }; }; @@ -150,7 +156,7 @@ const ResumableZoom: React.FC = (props) => { delta, allowPinchPanning, scaleMode, - panMode, + pinchCenteringMode, boundFn: boundsFn, userCallbacks: { onGestureEnd, @@ -160,7 +166,7 @@ const ResumableZoom: React.FC = (props) => { }); const { onPanStart, onPanChange, onPanEnd } = usePanCommons({ - detector: childSize, + container: extendGestures ? extendedSize : childSize, detectorTranslate, translate, offset, diff --git a/src/components/resumable/types.ts b/src/components/resumable/types.ts index d385629..d426daa 100644 --- a/src/components/resumable/types.ts +++ b/src/components/resumable/types.ts @@ -2,6 +2,7 @@ import type React from 'react'; import type { CommonResumableProps, PanGestureCallbacks, + PinchCenteringMode, PinchGestureCallbacks, SizeVector, SwipeDirection, @@ -28,6 +29,7 @@ export type ResumableZoomProps = React.PropsWithChildren<{ panEnabled?: boolean; pinchEnabled?: boolean; maxScale?: SizeVector | number; + pinchCenteringMode?: PinchCenteringMode; onSwipe?: (direction: SwipeDirection) => void; onGestureActive?: (e: ResumableZoomState) => void; onGestureEnd?: (() => void) | undefined; diff --git a/src/index.ts b/src/index.ts index 5d51ec3..521b38d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,13 @@ export * from './components/crop/types'; export { default as Gallery } from './components/gallery/GalleryProvider'; export * from './components/gallery/types'; -export { PanMode, ScaleMode, SwipeDirection } from './commons/types'; +export { + PanMode, + ScaleMode, + PinchCenteringMode, + SwipeDirection, +} from './commons/types'; + export type { Vector, SizeVector,