From 45574123f57c3fe516e23b66a6f72891c7107ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 26 May 2025 15:01:45 +0200 Subject: [PATCH 1/3] Start gesture issue fixing refactor --- .../src/components/shared/CustomHandle.tsx | 135 ------------------ .../shared/DraggableView/DraggableView.tsx | 16 ++- .../src/components/shared/SortableHandle.tsx | 95 ++++++++++++ .../src/components/shared/index.ts | 9 +- .../src/hooks/reanimated/index.ts | 1 + .../src/hooks/reanimated/useUICallback.ts | 13 ++ .../providers/shared/CustomHandleProvider.ts | 57 ++++++-- .../src/providers/shared/DragProvider.ts | 108 ++++++-------- .../providers/shared/ItemContextProvider.ts | 11 +- .../src/providers/shared/hooks/index.ts | 2 +- ...Gesture.ts => useItemPanGestureFactory.ts} | 18 +-- .../src/types/providers/shared.ts | 12 +- 12 files changed, 238 insertions(+), 239 deletions(-) delete mode 100644 packages/react-native-sortables/src/components/shared/CustomHandle.tsx create mode 100644 packages/react-native-sortables/src/components/shared/SortableHandle.tsx create mode 100644 packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts rename packages/react-native-sortables/src/providers/shared/hooks/{useItemPanGesture.ts => useItemPanGestureFactory.ts} (73%) diff --git a/packages/react-native-sortables/src/components/shared/CustomHandle.tsx b/packages/react-native-sortables/src/components/shared/CustomHandle.tsx deleted file mode 100644 index 7c45a3a4..00000000 --- a/packages/react-native-sortables/src/components/shared/CustomHandle.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { type PropsWithChildren, useCallback, useEffect, useMemo } from 'react'; -import { View } from 'react-native'; -import { GestureDetector } from 'react-native-gesture-handler'; -import { measure, useAnimatedRef } from 'react-native-reanimated'; - -import { - useCommonValuesContext, - useCustomHandleContext, - useDragContext, - useItemContext, - usePortalOutletContext -} from '../../providers'; -import { error } from '../../utils'; - -/** Props for the Sortable Handle component */ -export type CustomHandleProps = PropsWithChildren<{ - /** Controls how the item behaves in the sortable component - * - 'draggable': Item can be dragged and moves with reordering (default) - * - 'non-draggable': Item cannot be dragged but moves with reordering - * - 'fixed': Item stays in place and cannot be dragged - * @default 'draggable' - */ - mode?: 'draggable' | 'fixed' | 'non-draggable'; -}>; - -export default function CustomHandle(props: CustomHandleProps) { - // The item is teleported when it is rendered within the PortalOutlet - // component. Because PortalOutlet creates a context, we can use it to - // check if the item is teleported - const isTeleported = !!usePortalOutletContext(); - - // In case of teleported handle items, we want to render just the - // handle component without any functionality - return isTeleported ? ( - {props.children} - ) : ( - - ); -} - -function CustomHandleComponent({ - children, - mode = 'draggable' -}: CustomHandleProps) { - const customHandleContext = useCustomHandleContext(); - if (!customHandleContext) { - throw error( - 'Please add a `customHandle` property on the Sortable component to use a custom handle component.' - ); - } - - const { activeItemKey, containerRef, itemPositions } = - useCommonValuesContext(); - const { setDragStartValues } = useDragContext(); - const { gesture, itemKey } = useItemContext(); - const viewRef = useAnimatedRef(); - - const { - activeHandleMeasurements, - activeHandleOffset, - makeItemFixed, - removeFixedItem - } = customHandleContext; - const dragEnabled = mode === 'draggable'; - - useEffect(() => { - if (mode === 'fixed') { - makeItemFixed(itemKey); - } - - return () => removeFixedItem(itemKey); - }, [mode, itemKey, makeItemFixed, removeFixedItem]); - - const measureHandle = useCallback( - (mustBeActive: boolean) => { - 'worklet'; - if (mustBeActive && activeItemKey.value !== itemKey) { - return; - } - - const handleMeasurements = measure(viewRef); - const containerMeasurements = measure(containerRef); - const itemPosition = itemPositions.value[itemKey]; - - if (!handleMeasurements || !containerMeasurements || !itemPosition) { - return; - } - - const { pageX, pageY } = handleMeasurements; - const { pageX: containerPageX, pageY: containerPageY } = - containerMeasurements; - const { x: activeX, y: activeY } = itemPosition; - - activeHandleMeasurements.value = handleMeasurements; - activeHandleOffset.value = { - x: pageX - containerPageX - activeX, - y: pageY - containerPageY - activeY - }; - - setDragStartValues(itemKey); - }, - [ - activeHandleOffset, - activeHandleMeasurements, - activeItemKey, - containerRef, - itemPositions, - itemKey, - setDragStartValues, - viewRef - ] - ); - - const gestureWithMeasure = useMemo( - () => - gesture.onBegin(() => { - 'worklet'; - measureHandle(false); - }), - [gesture, measureHandle] - ); - - return ( - - measureHandle(true) : undefined}> - {children} - - - ); -} diff --git a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx index 7d6a7416..e5dcdc13 100644 --- a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx +++ b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren, ReactNode } from 'react'; import { Fragment, memo, useEffect } from 'react'; -import { GestureDetector } from 'react-native-gesture-handler'; import { LayoutAnimationConfig, useDerivedValue, @@ -13,7 +12,7 @@ import { useCommonValuesContext, useItemDecorationStyles, useItemLayoutStyles, - useItemPanGesture, + useItemPanGestureFactory, useMeasurementsContext, usePortalContext } from '../../../providers'; @@ -25,6 +24,7 @@ import { type MeasureCallback } from '../../../types'; import { getContextProvider } from '../../../utils'; +import { InternalHandle } from '../SortableHandle'; import ActiveItemPortal from './ActiveItemPortal'; import ItemCell from './ItemCell'; import TeleportedItemCell from './TeleportedItemCell'; @@ -62,8 +62,10 @@ function DraggableView({ activationAnimationProgress, portalState ); - - const gesture = useItemPanGesture(key, activationAnimationProgress); + const createItemPanGesture = useItemPanGestureFactory( + key, + activationAnimationProgress + ); useEffect(() => { return () => removeItemMeasurements(key); @@ -72,7 +74,7 @@ function DraggableView({ const withItemContext = (component: ReactNode) => ( {component} @@ -97,9 +99,9 @@ function DraggableView({ customHandle ? ( innerComponent ) : ( - + {innerComponent} - + ) ); }; diff --git a/packages/react-native-sortables/src/components/shared/SortableHandle.tsx b/packages/react-native-sortables/src/components/shared/SortableHandle.tsx new file mode 100644 index 00000000..41bb0132 --- /dev/null +++ b/packages/react-native-sortables/src/components/shared/SortableHandle.tsx @@ -0,0 +1,95 @@ +import { type PropsWithChildren, useCallback, useEffect, useMemo } from 'react'; +import { View } from 'react-native'; +import type { GestureType } from 'react-native-gesture-handler'; +import { GestureDetector } from 'react-native-gesture-handler'; +import { useAnimatedRef } from 'react-native-reanimated'; + +import { + useCustomHandleContext, + useItemContext, + usePortalOutletContext +} from '../../providers'; +import { error } from '../../utils'; + +/** Props for the Sortable Handle component */ +export type CustomHandleProps = PropsWithChildren<{ + /** Controls how the item behaves in the sortable component + * - 'draggable': Item can be dragged and moves with reordering (default) + * - 'non-draggable': Item cannot be dragged but moves with reordering + * - 'fixed': Item stays in place and cannot be dragged + * @default 'draggable' + */ + mode?: 'draggable' | 'fixed' | 'non-draggable'; +}>; + +export function CustomHandle(props: CustomHandleProps) { + // The item is teleported when it is rendered within the PortalOutlet + // component. Because PortalOutlet creates a context, we can use it to + // check if the item is teleported + const isTeleported = !!usePortalOutletContext(); + + // In case of teleported handle items, we want to render just the + // handle component without any functionality + return isTeleported ? ( + {props.children} + ) : ( + + ); +} + +function CustomHandleComponent({ + children, + mode = 'draggable' +}: CustomHandleProps) { + const customHandleContext = useCustomHandleContext(); + if (!customHandleContext) { + throw error( + 'Please add a `customHandle` property on the Sortable component to use a custom handle component.' + ); + } + + const { createItemPanGesture, itemKey } = useItemContext(); + const handleRef = useAnimatedRef(); + const gesture = useMemo( + () => createItemPanGesture(handleRef), + [createItemPanGesture, handleRef] + ); + + const { makeItemFixed, removeFixedItem, updateActiveHandleMeasurements } = + customHandleContext; + const dragEnabled = mode === 'draggable'; + + useEffect(() => { + if (mode === 'fixed') { + makeItemFixed(itemKey); + } + + return () => removeFixedItem(itemKey); + }, [mode, itemKey, makeItemFixed, removeFixedItem]); + + const onLayout = useCallback( + () => updateActiveHandleMeasurements(itemKey, handleRef), + [itemKey, handleRef, updateActiveHandleMeasurements] + ); + + return ( + + + {children} + + + ); +} + +type InternalHandleProps = PropsWithChildren<{ + createItemPanGesture: () => GestureType; +}>; + +export function InternalHandle({ + children, + createItemPanGesture +}: InternalHandleProps) { + const gesture = useMemo(() => createItemPanGesture(), [createItemPanGesture]); + + return {children}; +} diff --git a/packages/react-native-sortables/src/components/shared/index.ts b/packages/react-native-sortables/src/components/shared/index.ts index 85dea470..3a35b850 100644 --- a/packages/react-native-sortables/src/components/shared/index.ts +++ b/packages/react-native-sortables/src/components/shared/index.ts @@ -1,10 +1,11 @@ export { default as AnimatedOnLayoutView } from './AnimatedOnLayoutView'; -export { - default as CustomHandle, - type CustomHandleProps -} from './CustomHandle'; export * from './DraggableView'; export { default as SortableContainer } from './SortableContainer'; +export { + CustomHandle, + type CustomHandleProps, + InternalHandle +} from './SortableHandle'; export { default as SortableLayer, type SortableLayerProps diff --git a/packages/react-native-sortables/src/hooks/reanimated/index.ts b/packages/react-native-sortables/src/hooks/reanimated/index.ts index 48e87fab..bda542b2 100644 --- a/packages/react-native-sortables/src/hooks/reanimated/index.ts +++ b/packages/react-native-sortables/src/hooks/reanimated/index.ts @@ -1,3 +1,4 @@ export { default as useAnimatableValue } from './useAnimatableValue'; export { default as useStableCallbackValue } from './useStableCallbackValue'; +export { default as useUICallback } from './useUICallback'; export { default as useUIStableCallback } from './useUIStableCallback'; diff --git a/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts b/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts new file mode 100644 index 00000000..b7b19773 --- /dev/null +++ b/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts @@ -0,0 +1,13 @@ +import type { DependencyList } from 'react'; +import { useCallback } from 'react'; +import { runOnUI } from 'react-native-reanimated'; + +import type { AnyFunction } from '../../types'; + +export default function useUICallback( + callback: C, + deps: DependencyList +) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(runOnUI(callback), deps); +} diff --git a/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts b/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts index 1aa93f98..0c2dc939 100644 --- a/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts @@ -1,11 +1,14 @@ -import type { ReactNode } from 'react'; -import type { MeasuredDimensions } from 'react-native-reanimated'; -import { useSharedValue } from 'react-native-reanimated'; +import { type ReactNode } from 'react'; +import type { View } from 'react-native'; +import type { AnimatedRef, MeasuredDimensions } from 'react-native-reanimated'; +import { measure, useSharedValue } from 'react-native-reanimated'; -import { useUIStableCallback } from '../../hooks'; +import { useUICallback } from '../../hooks'; import type { CustomHandleContextType, Vector } from '../../types'; import { useAnimatedDebounce } from '../../utils'; import { createProvider } from '../utils'; +import { useActiveItemValuesContext } from './ActiveItemValuesProvider'; +import { useCommonValuesContext } from './CommonValuesProvider'; type CustomHandleProviderProps = { children?: ReactNode; @@ -15,24 +18,53 @@ const { CustomHandleProvider, useCustomHandleContext } = createProvider( 'CustomHandle', { guarded: false } )(() => { - const activeHandleOffset = useSharedValue(null); + const { containerRef } = useCommonValuesContext(); + const { activeItemKey, activeItemPosition } = useActiveItemValuesContext(); + const debounce = useAnimatedDebounce(); + + const fixedItemKeys = useSharedValue>({}); const activeHandleMeasurements = useSharedValue( null ); - const fixedItemKeys = useSharedValue>({}); - const debounce = useAnimatedDebounce(); + const activeHandleOffset = useSharedValue(null); - const makeItemFixed = useUIStableCallback((key: string) => { + const makeItemFixed = useUICallback((key: string) => { 'worklet'; fixedItemKeys.value[key] = true; debounce(fixedItemKeys.modify, 100); - }); + }, []); - const removeFixedItem = useUIStableCallback((key: string) => { + const removeFixedItem = useUICallback((key: string) => { 'worklet'; delete fixedItemKeys.value[key]; debounce(fixedItemKeys.modify, 100); - }); + }, []); + + const updateActiveHandleMeasurements = useUICallback( + (key: string, handleRef: AnimatedRef) => { + if (key !== activeItemKey.value) { + return; + } + + const handleMeasurements = measure(handleRef); + const containerMeasurements = measure(containerRef); + if ( + !handleMeasurements || + !containerMeasurements || + !activeItemPosition.value + ) { + return; + } + + activeHandleMeasurements.value = handleMeasurements; + const { x: activeX, y: activeY } = activeItemPosition.value; + activeHandleOffset.value = { + x: handleMeasurements.pageX - containerMeasurements.pageX - activeX, + y: handleMeasurements.pageY - containerMeasurements.pageY - activeY + }; + }, + [] + ); return { value: { @@ -40,7 +72,8 @@ const { CustomHandleProvider, useCustomHandleContext } = createProvider( activeHandleOffset, fixedItemKeys, makeItemFixed, - removeFixedItem + removeFixedItem, + updateActiveHandleMeasurements } }; }); diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index a2e70228..8bd594ac 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -1,9 +1,10 @@ import { type PropsWithChildren, useCallback } from 'react'; +import type { View } from 'react-native'; import type { GestureTouchEvent, TouchData } from 'react-native-gesture-handler'; -import type { SharedValue } from 'react-native-reanimated'; +import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { clamp, interpolate, @@ -70,7 +71,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')< containerHeight, containerRef, containerWidth, - customHandle, dragActivationDelay, dragActivationFailOffset, dropAnimationDuration, @@ -92,8 +92,11 @@ const { DragProvider, useDragContext } = createProvider('Drag')< const { updateLayer } = useLayerContext() ?? {}; const { scrollOffsetDiff, updateStartScrollOffset } = useAutoScrollContext() ?? {}; - const { activeHandleMeasurements, activeHandleOffset } = - useCustomHandleContext() ?? {}; + const { + activeHandleMeasurements, + activeHandleOffset, + updateActiveHandleMeasurements + } = useCustomHandleContext() ?? {}; const { activeItemAbsolutePosition } = usePortalContext() ?? {}; const haptics = useHaptics(hapticsEnabled); @@ -131,9 +134,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< offsetX: snapOffsetX.value, offsetY: snapOffsetY.value, progress: activeAnimationProgress.value, - snapDimensions: customHandle - ? activeHandleMeasurements?.value - : activeItemDimensions.value, + snapDimensions: + activeHandleMeasurements?.value ?? activeItemDimensions.value, snapOffset: activeHandleOffset?.value, startTouch: touchStartTouch.value, startTouchPosition: dragStartTouchPosition.value, @@ -228,53 +230,9 @@ const { DragProvider, useDragContext } = createProvider('Drag')< * DRAG HANDLERS */ - // If custom handle is used, it must be called after handle is measured - const setDragStartValues = useCallback( - (key: string) => { - 'worklet'; - const itemPosition = itemPositions.value[key]; - - if (!itemPosition || !currentTouch.value) { - return; - } - - let touchItemPosition = itemPosition; - if (customHandle) { - const containerMeasurements = measure(containerRef); - if (!activeHandleMeasurements?.value || !containerMeasurements) { - return; - } - - touchItemPosition = { - x: activeHandleMeasurements.value.pageX - containerMeasurements.pageX, - y: activeHandleMeasurements.value.pageY - containerMeasurements.pageY - }; - } - - const touchX = touchItemPosition.x + currentTouch.value.x; - const touchY = touchItemPosition.y + currentTouch.value.y; - - touchPosition.value = { x: touchX, y: touchY }; - dragStartTouchPosition.value = touchPosition.value; - dragStartItemTouchOffset.value = { - x: touchX - itemPosition.x, - y: touchY - itemPosition.y - }; - }, - [ - activeHandleMeasurements, - containerRef, - currentTouch, - customHandle, - dragStartItemTouchOffset, - dragStartTouchPosition, - itemPositions, - touchPosition - ] - ); - const handleDragStart = useCallback( ( + touch: TouchData, key: string, activationAnimationProgress: SharedValue, fail: () => void @@ -282,8 +240,9 @@ const { DragProvider, useDragContext } = createProvider('Drag')< 'worklet'; const itemPosition = itemPositions.value[key]; const dimensions = itemDimensions.value[key]; + const containerMeasurements = measure(containerRef); - if (!itemPosition || !dimensions) { + if (!itemPosition || !dimensions || !containerMeasurements) { fail(); return; } @@ -300,11 +259,22 @@ const { DragProvider, useDragContext } = createProvider('Drag')< updateLayer?.(LayerState.FOCUSED); updateStartScrollOffset?.(); - // If a custom handle is used, these values will be set in the - // handle component after the handle is measured - if (!customHandle) { - setDragStartValues(key); - } + // Use custom handle position if the custom handle is used + // (touch position is relative to the handle in this case) + const touchedItemPosition = + activeHandleMeasurements?.value ?? itemPosition; + + // Touch position relative to the top-left corner of the sortable + // container + const touchX = touchedItemPosition.x + touch.x; + const touchY = touchedItemPosition.y + touch.y; + + touchPosition.value = { x: touchX, y: touchY }; + dragStartTouchPosition.value = touchPosition.value; + dragStartItemTouchOffset.value = { + x: touchX - itemPosition.x, + y: touchY - itemPosition.y + }; const hasInactiveAnimation = inactiveItemOpacity.value !== 1 || inactiveItemScale.value !== 1; @@ -327,13 +297,16 @@ const { DragProvider, useDragContext } = createProvider('Drag')< [ activationAnimationDuration, activeAnimationProgress, + activeHandleMeasurements, activeItemDimensions, activeItemDropped, activationState, activeItemKey, activeItemPosition, - customHandle, + containerRef, dragStartIndex, + dragStartItemTouchOffset, + dragStartTouchPosition, haptics, inactiveAnimationProgress, inactiveItemOpacity, @@ -343,8 +316,8 @@ const { DragProvider, useDragContext } = createProvider('Drag')< itemPositions, keyToIndex, prevActiveItemKey, - setDragStartValues, stableOnDragStart, + touchPosition, updateLayer, updateStartScrollOffset ] @@ -355,6 +328,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< e: GestureTouchEvent, key: string, activationAnimationProgress: SharedValue, + handleRef: AnimatedRef | undefined, activate: () => void, fail: () => void ) => { @@ -389,7 +363,11 @@ const { DragProvider, useDragContext } = createProvider('Drag')< if (absoluteLayoutState.value !== AbsoluteLayoutState.COMPLETE) { return; } - handleDragStart(key, activationAnimationProgress, fail); + // We need to measure the active item handle if the custom handle is used + if (handleRef && updateActiveHandleMeasurements) { + updateActiveHandleMeasurements(key, handleRef); + } + handleDragStart(touch, key, activationAnimationProgress, fail); activate(); }, dragActivationDelay.value); }, @@ -401,9 +379,10 @@ const { DragProvider, useDragContext } = createProvider('Drag')< currentTouch, dragActivationDelay, handleDragStart, - sortEnabled, measureContainer, - touchStartTouch + sortEnabled, + touchStartTouch, + updateActiveHandleMeasurements ] ); @@ -572,8 +551,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< handleDragEnd, handleOrderChange, handleTouchStart, - handleTouchesMove, - setDragStartValues + handleTouchesMove } }; }); diff --git a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts index 3c24f009..180522da 100644 --- a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts @@ -9,7 +9,7 @@ type ItemContextProviderProps = PropsWithChildren< itemKey: string; } & Pick< ItemContextType, - 'activationAnimationProgress' | 'gesture' | 'isActive' + 'activationAnimationProgress' | 'createItemPanGesture' | 'isActive' > >; @@ -17,7 +17,12 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = createProvider('ItemContext', { guarded: true })< ItemContextProviderProps, ItemContextType - >(({ activationAnimationProgress, gesture, isActive, itemKey }) => { + >(({ + activationAnimationProgress, + createItemPanGesture, + isActive, + itemKey + }) => { const { activationState, activeItemKey, @@ -31,7 +36,7 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = activationAnimationProgress, activationState, activeItemKey, - gesture, + createItemPanGesture, indexToKey, isActive, itemKey, diff --git a/packages/react-native-sortables/src/providers/shared/hooks/index.ts b/packages/react-native-sortables/src/providers/shared/hooks/index.ts index 2b2449be..5ffae31e 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/index.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/index.ts @@ -1,7 +1,7 @@ export { default as useDebugBoundingBox } from './useDebugBoundingBox'; export { default as useItemDecorationStyles } from './useItemDecorationStyles'; export { default as useItemLayoutStyles } from './useItemLayoutStyles'; -export { default as useItemPanGesture } from './useItemPanGesture'; +export { default as useItemPanGestureFactory } from './useItemPanGestureFactory'; export { default as useItemZIndex } from './useItemZIndex'; export { default as useLayoutDebugRects } from './useLayoutDebugRects'; export { default as useOrderUpdater } from './useOrderUpdater'; diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts similarity index 73% rename from packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts rename to packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts index 4459dfe2..70689341 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts @@ -1,24 +1,26 @@ -import { useMemo } from 'react'; +import { useCallback } from 'react'; +import type { View } from 'react-native'; import { Gesture } from 'react-native-gesture-handler'; -import type { SharedValue } from 'react-native-reanimated'; +import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { useDragContext } from '../DragProvider'; -export default function useItemPanGesture( +export default function useItemPanGestureFactory( key: string, activationAnimationProgress: SharedValue ) { const { handleDragEnd, handleTouchStart, handleTouchesMove } = useDragContext(); - return useMemo( - () => + return useCallback( + (handleRef?: AnimatedRef) => Gesture.Manual() .onTouchesDown((e, manager) => { handleTouchStart( e, key, activationAnimationProgress, + handleRef, manager.activate, manager.fail ); @@ -36,11 +38,11 @@ export default function useItemPanGesture( handleDragEnd(key, activationAnimationProgress); }), [ - key, - activationAnimationProgress, handleDragEnd, handleTouchStart, - handleTouchesMove + handleTouchesMove, + key, + activationAnimationProgress ] ); } diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 3944f034..7fd31ea2 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -119,6 +119,7 @@ export type DragContextType = { e: GestureTouchEvent, key: string, activationAnimationProgress: SharedValue, + handleRef: AnimatedRef | undefined, activate: () => void, fail: () => void ) => void; @@ -133,13 +134,12 @@ export type DragContextType = { toIndex: number, newOrder: Array ) => void; - setDragStartValues: (key: string) => void; }; // ITEM export type ItemContextType = { - gesture: GestureType; + createItemPanGesture: (handleRef?: AnimatedRef) => GestureType; } & DeepReadonly< { itemKey: string; @@ -161,11 +161,15 @@ export type LayerContextType = { // CUSTOM HANDLE export type CustomHandleContextType = { - activeHandleOffset: SharedValue; - activeHandleMeasurements: SharedValue; fixedItemKeys: SharedValue>; + activeHandleMeasurements: SharedValue; + activeHandleOffset: SharedValue; makeItemFixed: (key: string) => void; removeFixedItem: (key: string) => void; + updateActiveHandleMeasurements: ( + key: string, + handleRef: AnimatedRef + ) => void; }; // PORTAL From a512195359e163abc29ae6bd807055b59c8193a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 26 May 2025 17:27:56 +0200 Subject: [PATCH 2/3] Works properly now --- .../{SortableHandle.tsx => CustomHandle.tsx} | 44 +++-------- .../shared/DraggableView/DraggableView.tsx | 15 ++-- .../src/components/shared/index.ts | 9 +-- .../providers/shared/CustomHandleProvider.ts | 76 +++++++++++-------- .../src/providers/shared/DragProvider.ts | 28 +++---- .../providers/shared/ItemContextProvider.ts | 11 +-- .../src/providers/shared/hooks/index.ts | 2 +- ...GestureFactory.ts => useItemPanGesture.ts} | 12 ++- .../src/types/providers/shared.ts | 13 ++-- 9 files changed, 98 insertions(+), 112 deletions(-) rename packages/react-native-sortables/src/components/shared/{SortableHandle.tsx => CustomHandle.tsx} (63%) rename packages/react-native-sortables/src/providers/shared/hooks/{useItemPanGestureFactory.ts => useItemPanGesture.ts} (77%) diff --git a/packages/react-native-sortables/src/components/shared/SortableHandle.tsx b/packages/react-native-sortables/src/components/shared/CustomHandle.tsx similarity index 63% rename from packages/react-native-sortables/src/components/shared/SortableHandle.tsx rename to packages/react-native-sortables/src/components/shared/CustomHandle.tsx index 41bb0132..614ec8e8 100644 --- a/packages/react-native-sortables/src/components/shared/SortableHandle.tsx +++ b/packages/react-native-sortables/src/components/shared/CustomHandle.tsx @@ -1,6 +1,5 @@ -import { type PropsWithChildren, useCallback, useEffect, useMemo } from 'react'; +import { type PropsWithChildren, useCallback, useEffect } from 'react'; import { View } from 'react-native'; -import type { GestureType } from 'react-native-gesture-handler'; import { GestureDetector } from 'react-native-gesture-handler'; import { useAnimatedRef } from 'react-native-reanimated'; @@ -22,7 +21,7 @@ export type CustomHandleProps = PropsWithChildren<{ mode?: 'draggable' | 'fixed' | 'non-draggable'; }>; -export function CustomHandle(props: CustomHandleProps) { +export default function CustomHandle(props: CustomHandleProps) { // The item is teleported when it is rendered within the PortalOutlet // component. Because PortalOutlet creates a context, we can use it to // check if the item is teleported @@ -48,29 +47,23 @@ function CustomHandleComponent({ ); } - const { createItemPanGesture, itemKey } = useItemContext(); + const { gesture, isActive, itemKey } = useItemContext(); const handleRef = useAnimatedRef(); - const gesture = useMemo( - () => createItemPanGesture(handleRef), - [createItemPanGesture, handleRef] - ); - const { makeItemFixed, removeFixedItem, updateActiveHandleMeasurements } = + const { registerHandle, updateActiveHandleMeasurements } = customHandleContext; const dragEnabled = mode === 'draggable'; useEffect(() => { - if (mode === 'fixed') { - makeItemFixed(itemKey); - } - - return () => removeFixedItem(itemKey); - }, [mode, itemKey, makeItemFixed, removeFixedItem]); + return registerHandle(itemKey, handleRef, mode === 'fixed'); + }, [handleRef, itemKey, registerHandle, mode]); - const onLayout = useCallback( - () => updateActiveHandleMeasurements(itemKey, handleRef), - [itemKey, handleRef, updateActiveHandleMeasurements] - ); + const onLayout = useCallback(() => { + 'worklet'; + if (isActive.value) { + updateActiveHandleMeasurements(itemKey); + } + }, [itemKey, isActive, updateActiveHandleMeasurements]); return ( @@ -80,16 +73,3 @@ function CustomHandleComponent({ ); } - -type InternalHandleProps = PropsWithChildren<{ - createItemPanGesture: () => GestureType; -}>; - -export function InternalHandle({ - children, - createItemPanGesture -}: InternalHandleProps) { - const gesture = useMemo(() => createItemPanGesture(), [createItemPanGesture]); - - return {children}; -} diff --git a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx index e5dcdc13..1e7a9994 100644 --- a/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx +++ b/packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren, ReactNode } from 'react'; import { Fragment, memo, useEffect } from 'react'; +import { GestureDetector } from 'react-native-gesture-handler'; import { LayoutAnimationConfig, useDerivedValue, @@ -12,7 +13,7 @@ import { useCommonValuesContext, useItemDecorationStyles, useItemLayoutStyles, - useItemPanGestureFactory, + useItemPanGesture, useMeasurementsContext, usePortalContext } from '../../../providers'; @@ -24,7 +25,6 @@ import { type MeasureCallback } from '../../../types'; import { getContextProvider } from '../../../utils'; -import { InternalHandle } from '../SortableHandle'; import ActiveItemPortal from './ActiveItemPortal'; import ItemCell from './ItemCell'; import TeleportedItemCell from './TeleportedItemCell'; @@ -62,10 +62,7 @@ function DraggableView({ activationAnimationProgress, portalState ); - const createItemPanGesture = useItemPanGestureFactory( - key, - activationAnimationProgress - ); + const gesture = useItemPanGesture(key, activationAnimationProgress); useEffect(() => { return () => removeItemMeasurements(key); @@ -74,7 +71,7 @@ function DraggableView({ const withItemContext = (component: ReactNode) => ( {component} @@ -99,9 +96,9 @@ function DraggableView({ customHandle ? ( innerComponent ) : ( - + {innerComponent} - + ) ); }; diff --git a/packages/react-native-sortables/src/components/shared/index.ts b/packages/react-native-sortables/src/components/shared/index.ts index 3a35b850..85dea470 100644 --- a/packages/react-native-sortables/src/components/shared/index.ts +++ b/packages/react-native-sortables/src/components/shared/index.ts @@ -1,11 +1,10 @@ export { default as AnimatedOnLayoutView } from './AnimatedOnLayoutView'; +export { + default as CustomHandle, + type CustomHandleProps +} from './CustomHandle'; export * from './DraggableView'; export { default as SortableContainer } from './SortableContainer'; -export { - CustomHandle, - type CustomHandleProps, - InternalHandle -} from './SortableHandle'; export { default as SortableLayer, type SortableLayerProps diff --git a/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts b/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts index 0c2dc939..8c5f293a 100644 --- a/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/CustomHandleProvider.ts @@ -1,13 +1,11 @@ -import { type ReactNode } from 'react'; +import { type ReactNode, useCallback } from 'react'; import type { View } from 'react-native'; import type { AnimatedRef, MeasuredDimensions } from 'react-native-reanimated'; -import { measure, useSharedValue } from 'react-native-reanimated'; +import { measure, runOnUI, useSharedValue } from 'react-native-reanimated'; -import { useUICallback } from '../../hooks'; import type { CustomHandleContextType, Vector } from '../../types'; import { useAnimatedDebounce } from '../../utils'; import { createProvider } from '../utils'; -import { useActiveItemValuesContext } from './ActiveItemValuesProvider'; import { useCommonValuesContext } from './CommonValuesProvider'; type CustomHandleProviderProps = { @@ -18,52 +16,71 @@ const { CustomHandleProvider, useCustomHandleContext } = createProvider( 'CustomHandle', { guarded: false } )(() => { - const { containerRef } = useCommonValuesContext(); - const { activeItemKey, activeItemPosition } = useActiveItemValuesContext(); + const { containerRef, itemPositions } = useCommonValuesContext(); const debounce = useAnimatedDebounce(); const fixedItemKeys = useSharedValue>({}); + const handleRefs = useSharedValue>>({}); const activeHandleMeasurements = useSharedValue( null ); const activeHandleOffset = useSharedValue(null); - const makeItemFixed = useUICallback((key: string) => { - 'worklet'; - fixedItemKeys.value[key] = true; - debounce(fixedItemKeys.modify, 100); - }, []); + const registerHandle = useCallback( + (key: string, handleRef: AnimatedRef, fixed: boolean) => { + runOnUI(() => { + 'worklet'; + handleRefs.value[key] = handleRef; + if (fixed) { + fixedItemKeys.value[key] = true; + debounce(fixedItemKeys.modify, 100); + } + })(); - const removeFixedItem = useUICallback((key: string) => { - 'worklet'; - delete fixedItemKeys.value[key]; - debounce(fixedItemKeys.modify, 100); - }, []); + const unregister = () => { + 'worklet'; + delete handleRefs.value[key]; + if (fixed) { + fixedItemKeys.value[key] = false; + debounce(fixedItemKeys.modify, 100); + } + }; + + return runOnUI(unregister); + }, + [debounce, fixedItemKeys, handleRefs] + ); - const updateActiveHandleMeasurements = useUICallback( - (key: string, handleRef: AnimatedRef) => { - if (key !== activeItemKey.value) { + const updateActiveHandleMeasurements = useCallback( + (key: string) => { + 'worklet'; + const handleRef = handleRefs.value[key]; + if (!handleRef) { return; } const handleMeasurements = measure(handleRef); const containerMeasurements = measure(containerRef); - if ( - !handleMeasurements || - !containerMeasurements || - !activeItemPosition.value - ) { + const itemPosition = itemPositions.value[key]; + + if (!handleMeasurements || !containerMeasurements || !itemPosition) { return; } activeHandleMeasurements.value = handleMeasurements; - const { x: activeX, y: activeY } = activeItemPosition.value; + const { x: itemX, y: itemY } = itemPosition; activeHandleOffset.value = { - x: handleMeasurements.pageX - containerMeasurements.pageX - activeX, - y: handleMeasurements.pageY - containerMeasurements.pageY - activeY + x: handleMeasurements.pageX - containerMeasurements.pageX - itemX, + y: handleMeasurements.pageY - containerMeasurements.pageY - itemY }; }, - [] + [ + activeHandleMeasurements, + activeHandleOffset, + containerRef, + handleRefs, + itemPositions + ] ); return { @@ -71,8 +88,7 @@ const { CustomHandleProvider, useCustomHandleContext } = createProvider( activeHandleMeasurements, activeHandleOffset, fixedItemKeys, - makeItemFixed, - removeFixedItem, + registerHandle, updateActiveHandleMeasurements } }; diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index 8bd594ac..da435cbd 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -1,10 +1,9 @@ import { type PropsWithChildren, useCallback } from 'react'; -import type { View } from 'react-native'; import type { GestureTouchEvent, TouchData } from 'react-native-gesture-handler'; -import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; import { clamp, interpolate, @@ -259,10 +258,18 @@ const { DragProvider, useDragContext } = createProvider('Drag')< updateLayer?.(LayerState.FOCUSED); updateStartScrollOffset?.(); - // Use custom handle position if the custom handle is used - // (touch position is relative to the handle in this case) - const touchedItemPosition = - activeHandleMeasurements?.value ?? itemPosition; + let touchedItemPosition = itemPosition; + + // We need to update the custom handle measurements if the custom handle + // is used (touch position is relative to the handle in this case) + updateActiveHandleMeasurements?.(key); + if (activeHandleMeasurements?.value) { + const { pageX, pageY } = activeHandleMeasurements.value; + touchedItemPosition = { + x: pageX - containerMeasurements.pageX, + y: pageY - containerMeasurements.pageY + }; + } // Touch position relative to the top-left corner of the sortable // container @@ -319,6 +326,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< stableOnDragStart, touchPosition, updateLayer, + updateActiveHandleMeasurements, updateStartScrollOffset ] ); @@ -328,7 +336,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')< e: GestureTouchEvent, key: string, activationAnimationProgress: SharedValue, - handleRef: AnimatedRef | undefined, activate: () => void, fail: () => void ) => { @@ -363,10 +370,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')< if (absoluteLayoutState.value !== AbsoluteLayoutState.COMPLETE) { return; } - // We need to measure the active item handle if the custom handle is used - if (handleRef && updateActiveHandleMeasurements) { - updateActiveHandleMeasurements(key, handleRef); - } handleDragStart(touch, key, activationAnimationProgress, fail); activate(); }, dragActivationDelay.value); @@ -381,8 +384,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< handleDragStart, measureContainer, sortEnabled, - touchStartTouch, - updateActiveHandleMeasurements + touchStartTouch ] ); diff --git a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts index 180522da..3c24f009 100644 --- a/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/ItemContextProvider.ts @@ -9,7 +9,7 @@ type ItemContextProviderProps = PropsWithChildren< itemKey: string; } & Pick< ItemContextType, - 'activationAnimationProgress' | 'createItemPanGesture' | 'isActive' + 'activationAnimationProgress' | 'gesture' | 'isActive' > >; @@ -17,12 +17,7 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = createProvider('ItemContext', { guarded: true })< ItemContextProviderProps, ItemContextType - >(({ - activationAnimationProgress, - createItemPanGesture, - isActive, - itemKey - }) => { + >(({ activationAnimationProgress, gesture, isActive, itemKey }) => { const { activationState, activeItemKey, @@ -36,7 +31,7 @@ const { ItemContextProvider, useItemContextContext: useItemContext } = activationAnimationProgress, activationState, activeItemKey, - createItemPanGesture, + gesture, indexToKey, isActive, itemKey, diff --git a/packages/react-native-sortables/src/providers/shared/hooks/index.ts b/packages/react-native-sortables/src/providers/shared/hooks/index.ts index 5ffae31e..2b2449be 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/index.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/index.ts @@ -1,7 +1,7 @@ export { default as useDebugBoundingBox } from './useDebugBoundingBox'; export { default as useItemDecorationStyles } from './useItemDecorationStyles'; export { default as useItemLayoutStyles } from './useItemLayoutStyles'; -export { default as useItemPanGestureFactory } from './useItemPanGestureFactory'; +export { default as useItemPanGesture } from './useItemPanGesture'; export { default as useItemZIndex } from './useItemZIndex'; export { default as useLayoutDebugRects } from './useLayoutDebugRects'; export { default as useOrderUpdater } from './useOrderUpdater'; diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts similarity index 77% rename from packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts rename to packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts index 70689341..6b670804 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGestureFactory.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemPanGesture.ts @@ -1,26 +1,24 @@ -import { useCallback } from 'react'; -import type { View } from 'react-native'; +import { useMemo } from 'react'; import { Gesture } from 'react-native-gesture-handler'; -import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; import { useDragContext } from '../DragProvider'; -export default function useItemPanGestureFactory( +export default function useItemPanGesture( key: string, activationAnimationProgress: SharedValue ) { const { handleDragEnd, handleTouchStart, handleTouchesMove } = useDragContext(); - return useCallback( - (handleRef?: AnimatedRef) => + return useMemo( + () => Gesture.Manual() .onTouchesDown((e, manager) => { handleTouchStart( e, key, activationAnimationProgress, - handleRef, manager.activate, manager.fail ); diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 7fd31ea2..2102d372 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -119,7 +119,6 @@ export type DragContextType = { e: GestureTouchEvent, key: string, activationAnimationProgress: SharedValue, - handleRef: AnimatedRef | undefined, activate: () => void, fail: () => void ) => void; @@ -139,7 +138,7 @@ export type DragContextType = { // ITEM export type ItemContextType = { - createItemPanGesture: (handleRef?: AnimatedRef) => GestureType; + gesture: GestureType; } & DeepReadonly< { itemKey: string; @@ -164,12 +163,12 @@ export type CustomHandleContextType = { fixedItemKeys: SharedValue>; activeHandleMeasurements: SharedValue; activeHandleOffset: SharedValue; - makeItemFixed: (key: string) => void; - removeFixedItem: (key: string) => void; - updateActiveHandleMeasurements: ( + registerHandle: ( key: string, - handleRef: AnimatedRef - ) => void; + handleRef: AnimatedRef, + fixed: boolean + ) => () => void; + updateActiveHandleMeasurements: (key: string) => void; }; // PORTAL From 656c722f0cfaa4c71dfc5e6fe5a8e07a6a9782dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 26 May 2025 17:31:08 +0200 Subject: [PATCH 3/3] Remove useUICallback hook --- .../src/hooks/reanimated/index.ts | 1 - .../src/hooks/reanimated/useUICallback.ts | 13 ------------- 2 files changed, 14 deletions(-) delete mode 100644 packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts diff --git a/packages/react-native-sortables/src/hooks/reanimated/index.ts b/packages/react-native-sortables/src/hooks/reanimated/index.ts index bda542b2..48e87fab 100644 --- a/packages/react-native-sortables/src/hooks/reanimated/index.ts +++ b/packages/react-native-sortables/src/hooks/reanimated/index.ts @@ -1,4 +1,3 @@ export { default as useAnimatableValue } from './useAnimatableValue'; export { default as useStableCallbackValue } from './useStableCallbackValue'; -export { default as useUICallback } from './useUICallback'; export { default as useUIStableCallback } from './useUIStableCallback'; diff --git a/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts b/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts deleted file mode 100644 index b7b19773..00000000 --- a/packages/react-native-sortables/src/hooks/reanimated/useUICallback.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { DependencyList } from 'react'; -import { useCallback } from 'react'; -import { runOnUI } from 'react-native-reanimated'; - -import type { AnyFunction } from '../../types'; - -export default function useUICallback( - callback: C, - deps: DependencyList -) { - // eslint-disable-next-line react-hooks/exhaustive-deps - return useCallback(runOnUI(callback), deps); -}