diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 126425377a3a..78c6d9fb668d 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -246,15 +246,15 @@ "../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/refs" 2 +"../../src/components/MultiGestureCanvas/useCanvasPanGesture.ts" "no-restricted-syntax" 1 +"../../src/components/MultiGestureCanvas/useCanvasPinchGesture.ts" "react-hooks/set-state-in-effect" 1 +"../../src/components/MultiGestureCanvas/useCanvasTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/MultiGestureCanvas/usePanGesture.ts" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1 -"../../src/components/MultiGestureCanvas/usePanGesture.ts" "no-restricted-syntax" 1 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/Gesture.Pinch" 2 "../../src/components/MultiGestureCanvas/usePinchGesture.ts" "@typescript-eslint/no-deprecated/LegacyPinchGesture" 1 -"../../src/components/MultiGestureCanvas/usePinchGesture.ts" "react-hooks/set-state-in-effect" 1 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 4 "../../src/components/MultiGestureCanvas/useTapGestures.ts" "@typescript-eslint/no-deprecated/LegacyTapGesture" 2 -"../../src/components/MultiGestureCanvas/useTapGestures.ts" "no-restricted-syntax" 1 "../../src/components/Navigation/QuickCreationActionsBar/index.tsx" "no-restricted-imports" 1 "../../src/components/NumberWithSymbolForm.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/NumberWithSymbolForm.tsx" "react-hooks/set-state-in-effect" 2 diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index f135960ba2ae..b269e7c53b2c 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -22,11 +22,11 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {ListRenderItemInfo} from 'react-native'; -import type {LegacyComposedGesture, GestureType} from 'react-native-gesture-handler'; +import type {PanGesture} from 'react-native-gesture-handler'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Keyboard, PixelRatio, View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type AttachmentCarouselViewProps from './types'; @@ -41,7 +41,7 @@ const MIN_FLING_VELOCITY = 500; type DeviceAwareGestureDetectorProps = { canUseTouchScreen: boolean; - gesture: LegacyComposedGesture | GestureType; + gesture: PanGesture; children: React.ReactNode; }; @@ -77,7 +77,6 @@ function AttachmentCarouselView({ const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source); - const pagerRef = useRef(undefined); const scrollRef = useAnimatedRef>(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -157,15 +156,53 @@ function AttachmentCarouselView({ [cellWidth], ); + /** Pan gesture handling swiping through attachments on touch screen devices */ + const pan = usePanGesture({ + enabled: canUseTouchScreen, + onUpdate: ({translationX}) => { + if (!isScrollEnabled.get()) { + return; + } + + if (translationX !== 0) { + isPagerScrolling.set(true); + } + + scrollTo(scrollRef, page * cellWidth - translationX, 0, false); + }, + onDeactivate: ({translationX, velocityX}) => { + if (!isScrollEnabled.get()) { + return; + } + + let newIndex; + if (velocityX > MIN_FLING_VELOCITY) { + // User flung to the right + newIndex = Math.max(0, page - 1); + } else if (velocityX < -MIN_FLING_VELOCITY) { + // User flung to the left + newIndex = Math.min(attachments.length - 1, page + 1); + } else { + // snap scroll position to the nearest cell (making sure it's within the bounds of the list) + const delta = Math.round(-translationX / cellWidth); + newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); + } + + isPagerScrolling.set(false); + scrollTo(scrollRef, newIndex * cellWidth, 0, true); + }, + }); + const stateValue = useMemo( () => ({ pagerItems: [{source, index: 0, isActive: true}], activePage: 0, - pagerRef, + pagerRef: scrollRef, + pagerGesture: pan, isPagerScrolling, isScrollEnabled, }), - [source, isPagerScrolling, isScrollEnabled], + [source, scrollRef, pan, isPagerScrolling, isScrollEnabled], ); const actionsValue = useMemo( @@ -193,46 +230,6 @@ function AttachmentCarouselView({ ), [activeAttachmentID, canUseTouchScreen, cellWidth, handleTap, report?.reportID, shouldShowArrows, styles.h100], ); - /** Pan gesture handing swiping through attachments on touch screen devices */ - const pan = useMemo( - () => - Gesture.Pan() - .enabled(canUseTouchScreen) - .onUpdate(({translationX}) => { - if (!isScrollEnabled.get()) { - return; - } - - if (translationX !== 0) { - isPagerScrolling.set(true); - } - - scrollTo(scrollRef, page * cellWidth - translationX, 0, false); - }) - .onEnd(({translationX, velocityX}) => { - if (!isScrollEnabled.get()) { - return; - } - - let newIndex; - if (velocityX > MIN_FLING_VELOCITY) { - // User flung to the right - newIndex = Math.max(0, page - 1); - } else if (velocityX < -MIN_FLING_VELOCITY) { - // User flung to the left - newIndex = Math.min(attachments.length - 1, page + 1); - } else { - // snap scroll position to the nearest cell (making sure it's within the bounds of the list) - const delta = Math.round(-translationX / cellWidth); - newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta)); - } - - isPagerScrolling.set(false); - scrollTo(scrollRef, newIndex * cellWidth, 0, true); - }) - .withRef(pagerRef), - [attachments.length, canUseTouchScreen, cellWidth, page, isScrollEnabled, scrollRef, isPagerScrolling], - ); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { @@ -303,6 +300,4 @@ function AttachmentCarouselView({ ); } -// OXC's React Compiler bails on this file (refs accessed during render in gesture handlers), so it -// is not memoized on web. Memoize it explicitly to keep parent-driven re-renders cheap there. -export default React.memo(AttachmentCarouselView); +export default AttachmentCarouselView; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index f1f9a8545baa..f7dbe5043d2f 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -9,7 +9,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, useNativeGesture} from 'react-native-gesture-handler'; import PagerView from 'react-native-pager-view'; import Animated, {useAnimatedProps, useSharedValue} from 'react-native-reanimated'; @@ -85,7 +85,7 @@ function AttachmentCarouselPager({items, activeAttachmentID, initialPage, setSho const extractItemKey = useCallback((item: Attachment, index: number) => `attachmentID-${item.attachmentID}-${index}`, []); - const nativeGestureHandler = Gesture.Native(); + const nativeGestureHandler = useNativeGesture(); const stateValue = useMemo( () => ({ diff --git a/src/components/Attachments/AttachmentCarousel/Pager/types.ts b/src/components/Attachments/AttachmentCarousel/Pager/types.ts index a21d16796e06..57ff2e861646 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/types.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/types.ts @@ -1,10 +1,10 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type {ForwardedRef} from 'react'; -import type {GestureType} from 'react-native-gesture-handler'; -import type {BaseGestureConfig} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; +import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type PagerView from 'react-native-pager-view'; -import type {SharedValue} from 'react-native-reanimated'; +import type Animated from 'react-native-reanimated'; +import type {AnimatedRef, SharedValue} from 'react-native-reanimated'; /** The pager items array is used within the pager to render and navigate between the images */ type AttachmentCarouselPagerItems = Pick & { @@ -28,8 +28,11 @@ type AttachmentCarouselPagerStateContextType = { /** Index of the currently active page */ activePage: number; - /** Ref to the active attachment */ - pagerRef?: ForwardedRef | BaseGestureConfig['ref']; + /** Ref to the pager: the PagerView on native and the attachment list on web */ + pagerRef?: ForwardedRef | AnimatedRef>; + + /** The pan gesture that swipes between attachments on web. The pan gesture of the attachment needs to work simultaneously with it */ + pagerGesture?: PanGesture; /** Indicates if the pager is currently scrolling */ isPagerScrolling: SharedValue; @@ -38,7 +41,7 @@ type AttachmentCarouselPagerStateContextType = { isScrollEnabled: SharedValue; /** In case we need a gesture that should work simultaneously with panning in MultiGestureCanvas */ - externalGestureHandler?: GestureType; + externalGestureHandler?: NativeGesture; }; type AttachmentCarouselPagerActionsContextType = { diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx index 1c1273639647..254d58919b76 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React, {memo, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useSharedValue} from 'react-native-reanimated'; import type AttachmentViewPdfProps from './types'; @@ -33,9 +33,9 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { const offsetY = useSharedValue(0); const isPanGestureActive = useSharedValue(false); - const Pan = Gesture.Pan() - .manualActivation(true) - .onTouchesMove((evt) => { + const Pan = usePanGesture({ + manualActivation: true, + onTouchesMove: (evt) => { if (offsetX.get() !== 0 && offsetY.get() !== 0 && isScrollEnabled && scale.get() === 1) { const translateX = Math.abs((evt.allTouches.at(0)?.absoluteX ?? 0) - offsetX.get()); const translateY = Math.abs((evt.allTouches.at(0)?.absoluteY ?? 0) - offsetY.get()); @@ -54,14 +54,15 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { isPanGestureActive.set(true); offsetX.set(evt.allTouches.at(0)?.absoluteX ?? 0); offsetY.set(evt.allTouches.at(0)?.absoluteY ?? 0); - }) - .onTouchesUp(() => { + }, + onTouchesUp: () => { isPanGestureActive.set(false); if (!isScrollEnabled) { return; } isScrollEnabled.set(scale.get() === 1); - }); + }, + }); const Content = useMemo( () => ( diff --git a/src/components/Attachments/MultiGestureIcon/index.tsx b/src/components/Attachments/MultiGestureIcon/index.tsx index a0488dce25ba..6694ea3cfbbe 100644 --- a/src/components/Attachments/MultiGestureIcon/index.tsx +++ b/src/components/Attachments/MultiGestureIcon/index.tsx @@ -29,7 +29,7 @@ type MultiGestureIconProps = { /** Renders an icon inside a multi-gesture canvas for pinch, pan, and swipe interactions. */ function MultiGestureIcon({additionalStyles, src, contentSize, fill}: MultiGestureIconProps) { const {canvasSize, updateCanvasSize, isCanvasLoading} = useCanvasSize(); - const {pagerRef, isScrollEnabled, onTap, onSwipeDown} = useIconCarouselPager(); + const {pagerGesture, isScrollEnabled, onTap, onSwipeDown} = useIconCarouselPager(); return ( ; @@ -26,7 +26,7 @@ function useIconCarouselPager(): IconCarouselPagerProps { if (state === null || actions === null) { return { - pagerRef: undefined, + pagerGesture: undefined, isScrollEnabled: isScrollingEnabledFallback, onTap: () => {}, onSwipeDown: () => {}, @@ -34,7 +34,7 @@ function useIconCarouselPager(): IconCarouselPagerProps { } return { - pagerRef: state.pagerRef, + pagerGesture: state.pagerGesture, isScrollEnabled: state.isScrollEnabled, onTap: actions.onTap ?? (() => {}), onSwipeDown: actions.onSwipeDown ?? (() => {}), diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index 1bd0531e14ed..049f61fbc523 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -94,6 +94,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange onScaleChanged: () => {}, onSwipeDown: () => {}, pagerRef: undefined, + pagerGesture: undefined, externalGestureHandler: undefined, }; } else { @@ -116,7 +117,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange onTap, onScaleChanged: onScaleChangedContext, onSwipeDown, - pagerRef, + pagerGesture, isScrollEnabled, externalGestureHandler, } = carouselContext; @@ -250,7 +251,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange canvasSize={canvasSize} contentSize={contentSize} zoomRange={zoomRange} - pagerRef={pagerRef} + pagerGesture={pagerGesture} isUsedInCarousel={isUsedInCarousel} shouldDisableTransformationGestures={isPagerScrolling} isPagerScrollEnabled={isScrollEnabled} diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 5d7e9368fdf4..fa11a99b3da5 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -1,27 +1,24 @@ -import type {AttachmentCarouselPagerStateContextType} from '@components/Attachments/AttachmentCarousel/Pager/types'; - import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {GestureType} from 'react-native-gesture-handler'; -import type {GestureRef} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture'; +import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {GestureDetector, useCompetingGestures, useSimultaneousGestures} from 'react-native-gesture-handler'; import Animated, {cancelAnimation, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; import type {OnScaleChangedCallback, OnSwipeDownCallback, OnTapCallback, ZoomRange} from './types'; import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG} from './constants'; -import usePanGesture from './usePanGesture'; -import usePinchGesture from './usePinchGesture'; -import useTapGestures from './useTapGestures'; +import useCanvasPanGesture from './useCanvasPanGesture'; +import useCanvasPinchGesture from './useCanvasPinchGesture'; +import useCanvasTapGestures from './useCanvasTapGestures'; import * as MultiGestureCanvasUtils from './utils'; type MultiGestureCanvasProps = ChildrenProps & { @@ -52,8 +49,8 @@ type MultiGestureCanvasProps = ChildrenProps & { isPagerScrollEnabled: SharedValue; - /** If there is a pager wrapping the canvas, we need to disable the pan gesture in case the pager is swiping */ - pagerRef?: AttachmentCarouselPagerStateContextType['pagerRef']; // TODO: For TS migration: Exclude + /** If there is a pager wrapping the canvas that swipes with a pan gesture (web), the canvas pan gesture needs to work simultaneously with it */ + pagerGesture?: PanGesture; isUsedInCarousel: boolean; @@ -73,7 +70,7 @@ type MultiGestureCanvasProps = ChildrenProps & { shouldPreventTouchEndDefault?: boolean; /** We need to ensure that any native gesture handlers in this component tree is working simultaneously with panning and do not get blocked. */ - externalGestureHandler?: GestureType; + externalGestureHandler?: NativeGesture; }; const defaultContentSize = {width: 1, height: 1}; @@ -84,7 +81,7 @@ function MultiGestureCanvas({ zoomRange: zoomRangeProp, isActive = true, children, - pagerRef, + pagerGesture, isUsedInCarousel, shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp, isTransformGestureActive: isTransformGestureActiveProp, @@ -128,7 +125,6 @@ function MultiGestureCanvas({ const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); const isSwipingDownToClose = useSharedValue(false); - const panGestureRef = useRef(Gesture.Pan()); const pinchScale = useSharedValue(1); const pinchTranslateX = useSharedValue(0); @@ -197,31 +193,27 @@ function MultiGestureCanvas({ [offsetX, offsetY, panTranslateX, panTranslateY, pinchScale, pinchTranslateX, pinchTranslateY, stopAnimation, zoomScale], ); - const {singleTapGesture: baseSingleTapGesture, doubleTapGesture} = useTapGestures({ + // Pinch must be created before pan: on web, handlers end in creation order, and pan has to end last + // to re-center the content using the offset that already includes the pinch translation. + const pinchGesture = useCanvasPinchGesture({ canvasSize, - contentSize, + zoomScale, zoomRange, - minContentScale, - maxContentScale, offsetX, offsetY, + pinchTranslateX, + pinchTranslateY, pinchScale, - zoomScale, - reset, stopAnimation, onScaleChanged, isTransformGestureActive, - onTap, shouldDisableTransformationGestures, }); - const singleTapGesture = baseSingleTapGesture.requireExternalGestureToFail(doubleTapGesture, panGestureRef); - const panGestureSimultaneousList = useMemo( - () => (pagerRef === undefined ? [singleTapGesture, doubleTapGesture] : [pagerRef as unknown as Exclude, singleTapGesture, doubleTapGesture]), - [doubleTapGesture, pagerRef, singleTapGesture], - ); + // Gestures from outside of the canvas that the pan gesture has to work simultaneously with + const panGestureExternalGestures = [pagerGesture, externalGestureHandler].filter((gesture) => gesture !== undefined); - const panGesture = usePanGesture({ + const panGesture = useCanvasPanGesture({ canvasSize, contentSize, zoomScale, @@ -235,24 +227,32 @@ function MultiGestureCanvas({ isSwipingDownToClose, shouldDisableSwipeDownToClose, onSwipeDown, - }) - .simultaneousWithExternalGesture(...panGestureSimultaneousList) - .withRef(panGestureRef); + simultaneousWith: panGestureExternalGestures, + }); - const pinchGesture = usePinchGesture({ + // The tap gestures are created after the pan gesture, because the single tap waits for it to fail. + // They also declare that they can run simultaneously with the pan gesture, which is symmetric. + const {singleTapGesture, doubleTapGesture} = useCanvasTapGestures({ canvasSize, - zoomScale, + contentSize, zoomRange, + minContentScale, + maxContentScale, offsetX, offsetY, - pinchTranslateX, - pinchTranslateY, pinchScale, + zoomScale, + reset, stopAnimation, onScaleChanged, isTransformGestureActive, + onTap, shouldDisableTransformationGestures, - }).simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture); + panGesture, + }); + + const tapsAndPanGesture = useCompetingGestures(singleTapGesture, doubleTapGesture, panGesture); + const canvasGesture = useSimultaneousGestures(pinchGesture, tapsAndPanGesture); // Trigger a reset when the canvas gets inactive, but only if it was already mounted before const mounted = useRef(false); @@ -289,14 +289,12 @@ function MultiGestureCanvas({ const containerStyles = useMemo(() => [styles.flex1, StyleUtils.getMultiGestureCanvasContainerStyle(canvasSize.width)], [StyleUtils, canvasSize.width, styles.flex1]); - const panGestureWrapper = externalGestureHandler ? panGesture.simultaneousWithExternalGesture(externalGestureHandler) : panGesture; - return ( - + { diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/useCanvasPanGesture.ts similarity index 90% rename from src/components/MultiGestureCanvas/usePanGesture.ts rename to src/components/MultiGestureCanvas/useCanvasPanGesture.ts index 9b1f5695cb7d..84b1cd4d1bca 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPanGesture.ts @@ -1,10 +1,10 @@ import {isMobile} from '@libs/Browser'; -import type {LegacyPanGesture} from 'react-native-gesture-handler'; +import type {PanGesture, PanGestureConfig} from 'react-native-gesture-handler'; import {useCallback} from 'react'; import {Dimensions} from 'react-native'; -import {Gesture} from 'react-native-gesture-handler'; +import {GestureStateManager, usePanGesture} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue, withDecay, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -21,7 +21,7 @@ const SCREEN_HEIGHT = Dimensions.get('screen').height; const SNAP_POINT = SCREEN_HEIGHT / 4; const SNAP_POINT_HIDDEN = SCREEN_HEIGHT / 1.2; -type UsePanGestureProps = Pick< +type UseCanvasPanGestureProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'contentSize' @@ -36,9 +36,12 @@ type UsePanGestureProps = Pick< | 'shouldDisableSwipeDownToClose' | 'onSwipeDown' | 'isSwipingDownToClose' ->; +> & { + /** Gestures outside of the canvas (e.g. the pager) that can run simultaneously with the pan */ + simultaneousWith: PanGestureConfig['simultaneousWith']; +}; -const usePanGesture = ({ +const useCanvasPanGesture = ({ canvasSize, contentSize, zoomScale, @@ -52,7 +55,8 @@ const usePanGesture = ({ isSwipingDownToClose, shouldDisableSwipeDownToClose, onSwipeDown, -}: UsePanGestureProps): LegacyPanGesture => { + simultaneousWith, +}: UseCanvasPanGestureProps): PanGesture => { // The content size after fitting it to the canvas and zooming const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.get(), [contentSize.width]); const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.get(), [contentSize.height]); @@ -181,16 +185,17 @@ const usePanGesture = ({ panVelocityY.set(0); }, [getBounds, isSwipingDownToClose, offsetX, offsetY, onSwipeDown, panTranslateX, panTranslateY, panVelocityX, panVelocityY, shouldDisableSwipeDownToClose, zoomScale]); - const panGesture = Gesture.Pan() - .manualActivation(true) - .averageTouches(true) - .onTouchesUp(() => { + const panGesture = usePanGesture({ + manualActivation: true, + averageTouches: true, + simultaneousWith, + onTouchesUp: () => { previousTouch.set(null); - }) - .onTouchesMove((evt, state) => { + }, + onTouchesMove: (evt) => { // We only allow panning when the content is zoomed in if (zoomScale.get() > 1 && !shouldDisableTransformationGestures.get()) { - state.activate(); + GestureStateManager.activate(evt.handlerTag); } // TODO: this needs tuning to work properly @@ -200,7 +205,7 @@ const usePanGesture = ({ const velocityY = (evt.allTouches.at(0)?.y ?? 0) - previousTouchValue.y; if (Math.abs(velocityY) > velocityX && velocityY > 20) { - state.activate(); + GestureStateManager.activate(evt.handlerTag); isSwipingDownToClose.set(true); previousTouch.set(null); @@ -215,11 +220,11 @@ const usePanGesture = ({ y: evt.allTouches.at(0)?.y ?? 0, }); } - }) - .onStart(() => { + }, + onActivate: () => { stopAnimation(); - }) - .onChange((evt) => { + }, + onUpdate: (evt) => { // Since we're running both pinch and pan gesture handlers simultaneously, // we need to make sure that we don't pan when we pinch since we track it as pinch focal gesture. if (evt.numberOfPointers > 1) { @@ -238,8 +243,8 @@ const usePanGesture = ({ if (enableSwipeDownToClose.get() || isSwipingDownToClose.get()) { panTranslateY.set((value) => value + evt.changeY); } - }) - .onEnd(() => { + }, + onDeactivate: () => { // Add pan translation to total offset and reset gesture variables offsetX.set((value) => value + panTranslateX.get()); offsetY.set((value) => value + panTranslateY.get()); @@ -255,9 +260,10 @@ const usePanGesture = ({ } finishPanGesture(); - }); + }, + }); return panGesture; }; -export default usePanGesture; +export default useCanvasPanGesture; diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts similarity index 89% rename from src/components/MultiGestureCanvas/usePinchGesture.ts rename to src/components/MultiGestureCanvas/useCanvasPinchGesture.ts index f2877fd3bd13..68de69fc6ead 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts @@ -1,7 +1,7 @@ -import type {LegacyPinchGesture} from 'react-native-gesture-handler'; +import type {PinchGesture} from 'react-native-gesture-handler'; import {useCallback, useEffect, useState} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {GestureStateManager, usePinchGesture} from 'react-native-gesture-handler'; import {useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -9,7 +9,7 @@ import type {MultiGestureCanvasVariables} from './types'; import {SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; -type UsePinchGestureProps = Pick< +type UseCanvasPinchGestureProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'zoomScale' @@ -25,7 +25,7 @@ type UsePinchGestureProps = Pick< | 'onScaleChanged' >; -const usePinchGesture = ({ +const useCanvasPinchGesture = ({ canvasSize, zoomScale, zoomRange, @@ -38,7 +38,7 @@ const usePinchGesture = ({ isTransformGestureActive, stopAnimation, onScaleChanged, -}: UsePinchGestureProps): LegacyPinchGesture => { +}: UseCanvasPinchGestureProps): PinchGesture => { // The current pinch gesture event scale const currentPinchScale = useSharedValue(1); @@ -113,23 +113,17 @@ const usePinchGesture = ({ setPinchEnabled(true); }, [pinchEnabled]); - const pinchGesture = Gesture.Pinch() - .enabled(pinchEnabled) - // The first argument is not used, but must be defined - .onTouchesDown((_evt, state) => { - // react-compiler optimization unintentionally make all the callbacks run on the JS thread. - // Adding the worklet directive here will make all the callbacks run on UI thread back. - - 'worklet'; - + const pinchGesture = usePinchGesture({ + enabled: pinchEnabled, + onTouchesDown: (evt) => { // We don't want to activate pinch gesture when we are swiping in the pager if (!shouldDisableTransformationGestures.get()) { return; } - state.fail(); - }) - .onStart((evt) => { + GestureStateManager.fail(evt.handlerTag); + }, + onActivate: (evt) => { stopAnimation(); isPinchSettling.set(false); isTransformGestureActive.set(true); @@ -138,10 +132,8 @@ const usePinchGesture = ({ const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY); pinchOrigin.x.set(adjustedFocal.x); pinchOrigin.y.set(adjustedFocal.y); - }) - .onChange((evt) => { - 'worklet'; - + }, + onUpdate: (evt) => { // Disable the pinch gesture if one finger is released, // to prevent the content from shaking/jumping if (evt.numberOfPointers !== 2) { @@ -174,8 +166,8 @@ const usePinchGesture = ({ pinchBounceTranslateX.set(newPinchTranslateX - pinchTranslateX.get()); pinchBounceTranslateY.set(newPinchTranslateY - pinchTranslateY.get()); } - }) - .onEnd(() => { + }, + onDeactivate: () => { // Add pinch translation to total offset and reset gesture variables offsetX.set((value) => value + pinchTranslateX.get()); offsetY.set((value) => value + pinchTranslateY.get()); @@ -204,16 +196,17 @@ const usePinchGesture = ({ pinchScale.set(zoomScale.get()); finishTransformGesture(); } - }) - .onFinalize(() => { + }, + onFinalize: () => { if (isPinchSettling.get()) { return; } isTransformGestureActive.set(false); - }); + }, + }); return pinchGesture; }; -export default usePinchGesture; +export default useCanvasPinchGesture; diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts similarity index 83% rename from src/components/MultiGestureCanvas/useTapGestures.ts rename to src/components/MultiGestureCanvas/useCanvasTapGestures.ts index ebc3b64cabee..89bfc18578a7 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts @@ -1,7 +1,7 @@ -import type {LegacyTapGesture} from 'react-native-gesture-handler'; +import type {PanGesture, TapGesture} from 'react-native-gesture-handler'; import {useCallback, useMemo} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {GestureStateManager, useTapGesture} from 'react-native-gesture-handler'; import {withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -10,7 +10,7 @@ import type {MultiGestureCanvasVariables} from './types'; import {DOUBLE_TAP_SCALE, SPRING_CONFIG} from './constants'; import * as MultiGestureCanvasUtils from './utils'; -type UseTapGesturesProps = Pick< +type UseCanvasTapGesturesProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'contentSize' @@ -27,9 +27,12 @@ type UseTapGesturesProps = Pick< | 'stopAnimation' | 'onScaleChanged' | 'onTap' ->; +> & { + /** The pan gesture of the canvas. A single tap waits for it to fail, and both taps can run simultaneously with it */ + panGesture: PanGesture; +}; -const useTapGestures = ({ +const useCanvasTapGestures = ({ canvasSize, contentSize, zoomRange, @@ -45,7 +48,8 @@ const useTapGestures = ({ isTransformGestureActive, onScaleChanged, onTap, -}: UseTapGesturesProps): {singleTapGesture: LegacyTapGesture; doubleTapGesture: LegacyTapGesture} => { + panGesture, +}: UseCanvasTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { // The content size after scaling it with minimum scale to fit the content into the canvas const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); @@ -132,23 +136,19 @@ const useTapGestures = ({ [stopAnimation, isTransformGestureActive, canvasSize.width, canvasSize.height, scaledContentWidth, scaledContentHeight, doubleTapScale, offsetX, offsetY, zoomScale, pinchScale], ); - const doubleTapGesture = Gesture.Tap() - // The first argument is not used, but must be defined - .onTouchesDown((_evt, state) => { - 'worklet'; - + const doubleTapGesture = useTapGesture({ + numberOfTaps: 2, + maxDelay: 150, + maxDistance: 20, + simultaneousWith: panGesture, + onTouchesDown: (evt) => { if (!shouldDisableTransformationGestures.get()) { return; } - state.fail(); - }) - .numberOfTaps(2) - .maxDelay(150) - .maxDistance(20) - .onEnd((evt) => { - 'worklet'; - + GestureStateManager.fail(evt.handlerTag); + }, + onDeactivate: (evt) => { const triggerScaleChangedEvent = () => { 'worklet'; @@ -168,27 +168,27 @@ const useTapGestures = ({ } else { zoomToCoordinates(evt.x, evt.y, triggerScaleChangedEvent); } - }); - - const singleTapGesture = Gesture.Tap() - .numberOfTaps(1) - .maxDuration(125) - .onBegin(() => { - 'worklet'; - + }, + }); + + const singleTapGesture = useTapGesture({ + numberOfTaps: 1, + maxDuration: 125, + requireToFail: [doubleTapGesture, panGesture], + simultaneousWith: panGesture, + onBegin: () => { stopAnimation(); - }) - .onFinalize((_evt, success) => { - 'worklet'; - - if (!success || onTap === undefined) { + }, + onFinalize: (evt) => { + if (evt.canceled || onTap === undefined) { return; } scheduleOnRN(onTap); - }); + }, + }); return {singleTapGesture, doubleTapGesture}; }; -export default useTapGestures; +export default useCanvasTapGestures; diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index 678adeb69d95..1b3f2e53ac16 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -134,7 +134,7 @@ function MobilePDFGestureCanvas({children, onScaleChanged}: MobilePDFGestureCanv canvasSize={canvasSize} contentSize={canvasSize} zoomRange={PDF_ZOOM_RANGE} - pagerRef={state?.pagerRef} + pagerGesture={state?.pagerGesture} isUsedInCarousel={!!state?.pagerRef} shouldDisableTransformationGestures={state?.isPagerScrolling ?? isPagerScrollingFallback} isTransformGestureActive={isTransformGestureActive} diff --git a/tests/unit/AttachmentCarouselViewTest.tsx b/tests/unit/AttachmentCarouselViewTest.tsx index 27d9a2beb652..80e37c8bc79a 100644 --- a/tests/unit/AttachmentCarouselViewTest.tsx +++ b/tests/unit/AttachmentCarouselViewTest.tsx @@ -232,29 +232,24 @@ describe('AttachmentCarouselView web', () => { dismiss.mockRestore(); }); - it('shares the exact undefined-initialized ref with the real pan gesture and retains it on rerender', () => { + it('shares the real pan gesture and the list ref with pager consumers and retains them on rerender', () => { // Given the production gesture passed to the detector, with only detector rendering replaced const {props, rerender} = renderCarousel(); const detectorProps = jest.mocked(GestureDetector).mock.calls.at(-1)?.[0]; expect(detectorProps).toBeDefined(); - if (!detectorProps || !pagerState || !('toGestureArray' in detectorProps.gesture)) { + const gesture = pagerState?.pagerGesture; + if (!detectorProps || !pagerState || !gesture) { throw new Error('Expected the real detector gesture and pager context'); } - const gestures = detectorProps.gesture.toGestureArray(); - expect(gestures).toHaveLength(1); - const gesture = gestures.at(0); - expect(gesture).toBeDefined(); - if (!gesture) { - throw new Error('Expected the single pan gesture'); - } - expect(gesture.handlerName).toBe('PanGestureHandler'); - const contextRef = pagerState.pagerRef; - expect(gesture.config.ref).toBe(contextRef); - expect(gesture.config.ref?.current).toBeUndefined(); - - // When Gesture Handler initializes the real gesture it writes the same ref exposed to pager consumers - gesture.initialize(); - expect(gesture.config.ref?.current).toBe(gesture); + + // Then pager consumers receive the exact enabled pan gesture given to the detector, and the list ref + expect(detectorProps.gesture).toBe(gesture); + expect(gesture.type).toBe('PanGestureHandler'); + expect(gesture.config.enabled).toBe(true); + expect(pagerState.pagerRef).toBe(mockScrollRef); + const handlerTag = gesture.handlerTag; + + // When the carousel rerenders with unrelated props rerender( { />, ); - // Then the provider keeps the original ref object rather than a snapshot or a second ref - expect(pagerState.pagerRef).toBe(contextRef); + // Then the provider keeps the same list ref and the gesture keeps its native handler identity + expect(pagerState.pagerRef).toBe(mockScrollRef); + expect(pagerState.pagerGesture?.handlerTag).toBe(handlerTag); + expect(jest.mocked(GestureDetector).mock.calls.at(-1)?.[0].gesture).toBe(pagerState.pagerGesture); }); it('enforces arrow bounds and bypasses the detector on a mouse device', () => {