diff --git a/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts b/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts index 11c5be4c..98fa276c 100644 --- a/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts @@ -97,6 +97,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } = const activeAnimationProgress = useMutableValue(0); const inactiveAnimationProgress = useMutableValue(0); const activeItemDropped = useMutableValue(true); + const activeItemBroughtToFront = useMutableValue(false); // ITEM ACTIVATION SETTINGS const dragActivationDelay = useAnimatableValue(_dragActivationDelay); @@ -145,6 +146,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } = activationAnimationDuration, activationState, activeAnimationProgress, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activeItemKey, diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index a8c1702e..9b527ca3 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -81,6 +81,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activationAnimationDuration, activationState, activeAnimationProgress, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activeItemKey, @@ -289,6 +290,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activeItemPosition.value = itemPosition; activeItemDimensions.value = itemDimensions; activationState.value = DragActivationState.ACTIVE; + activeItemBroughtToFront.value = false; ctx.dragStartIndex = keyToIndex.value[key] ?? -1; if (activeContainerId) { @@ -298,7 +300,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< multiZoneActiveItemDimensions.value = itemDimensions; } - updateLayer?.(LayerState.FOCUSED); + // zIndex is elevated later, on the first drag move (see handleTouchesMove) // We need to update the custom handle measurements if the custom handle // is used (touch position is relative to the handle in this case) @@ -349,6 +351,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< activeAnimationProgress, activeContainerId, activeHandleOffset, + activeItemBroughtToFront, activeItemDimensions, activeItemDropped, activationState, @@ -366,7 +369,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')< prevActiveItemKey, onDragStart, touchPosition, - updateLayer, updateActiveHandleMeasurements ] ); @@ -457,6 +459,14 @@ const { DragProvider, useDragContext } = createProvider('Drag')< } const ctx = context.value; + const startTouch = ctx.touchStartTouch; + + let movedDistance = 0; + if (startTouch) { + const dX = touch.absoluteX - startTouch.absoluteX; + const dY = touch.absoluteY - startTouch.absoluteY; + movedDistance = Math.sqrt(dX * dX + dY * dY); + } if (activeItemKey.value) { onDragMove({ @@ -464,25 +474,20 @@ const { DragProvider, useDragContext } = createProvider('Drag')< key: activeItemKey.value, touchData: touch }); - } - if (activationState.value === DragActivationState.TOUCHED) { - if (!ctx.touchStartTouch) { - fail(); - return; - } - const dX = touch.absoluteX - ctx.touchStartTouch.absoluteX; - const dY = touch.absoluteY - ctx.touchStartTouch.absoluteY; - - // Cancel touch if the touch moved too far from the initial position - // before the item activation animation starts - const r = Math.sqrt(dX * dX + dY * dY); + // Bring the item to the front only once it is actually dragged, so a + // long press alone (e.g. to open a native context menu) doesn't change + // the stacking context and dismiss the menu. if ( - // activeItemKey is set after the drag activation delay passes - // and we don't want to cancel the touch anymore after this time - activeItemKey.value === null && - r >= dragActivationFailOffset.value + !activeItemBroughtToFront.value && + movedDistance >= dragActivationFailOffset.value ) { + activeItemBroughtToFront.value = true; + updateLayer?.(LayerState.FOCUSED); + } + } else if (activationState.value === DragActivationState.TOUCHED) { + // Cancel the touch if it moves before activation (it is a scroll) + if (!startTouch || movedDistance >= dragActivationFailOffset.value) { fail(); return; } @@ -490,11 +495,13 @@ const { DragProvider, useDragContext } = createProvider('Drag')< }, [ activationState, + activeItemBroughtToFront, activeItemKey, dragActivationFailOffset, currentTouch, context, - onDragMove + onDragMove, + updateLayer ] ); @@ -549,7 +556,13 @@ const { DragProvider, useDragContext } = createProvider('Drag')< inactiveAnimationProgress.value = animate(); activeAnimationProgress.value = animate(); - updateLayer?.(LayerState.INTERMEDIATE); + // Restore the layer zIndex only if it was elevated. The flag is left as + // is and reset on the next handleDragStart - resetting it here would race + // with a new overlapping drag and drop its item below its siblings. + const wasBroughtToFront = activeItemBroughtToFront.value; + if (wasBroughtToFront) { + updateLayer?.(LayerState.INTERMEDIATE); + } haptics.medium(); stableOnDragEnd({ @@ -562,7 +575,9 @@ const { DragProvider, useDragContext } = createProvider('Drag')< setAnimatedTimeout(() => { activeItemDropped.value = true; - updateLayer?.(LayerState.IDLE); + if (wasBroughtToFront) { + updateLayer?.(LayerState.IDLE); + } onActiveItemDropped({ fromIndex, indexToKey: indexToKey.value, @@ -574,6 +589,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< }, [ activeContainerId, + activeItemBroughtToFront, activeItemKey, activeItemDimensions, activeItemDropped, diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts new file mode 100644 index 00000000..b92ab706 --- /dev/null +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.test.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react-hooks'; +import type { SharedValue } from 'react-native-reanimated'; +import { makeMutable } from 'react-native-reanimated'; + +import { useCommonValuesContext } from '../CommonValuesProvider'; +import useItemZIndex from './useItemZIndex'; + +jest.mock('../CommonValuesProvider', () => ({ + useCommonValuesContext: jest.fn() +})); + +const mockedUseCommonValuesContext = useCommonValuesContext as jest.Mock; + +type ContextOverrides = { + activeItemBroughtToFront?: boolean; + activeItemKey?: null | string; + prevActiveItemKey?: null | string; + isStackingOrderDesc?: boolean; + indexToKey?: Array; +}; + +function setupContext({ + activeItemBroughtToFront = false, + activeItemKey = null, + indexToKey = ['a', 'b', 'c'], + isStackingOrderDesc = false, + prevActiveItemKey = null +}: ContextOverrides) { + const keyToIndex = Object.fromEntries(indexToKey.map((key, i) => [key, i])); + mockedUseCommonValuesContext.mockReturnValue({ + activeItemBroughtToFront: makeMutable(activeItemBroughtToFront), + activeItemKey: makeMutable(activeItemKey), + indexToKey: makeMutable(indexToKey), + isStackingOrderDesc, + keyToIndex: makeMutable(keyToIndex), + prevActiveItemKey: makeMutable(prevActiveItemKey) + }); +} + +function renderZIndex(key: string, activationProgress: number): number { + const progress = makeMutable(activationProgress); + const { result } = renderHook(() => useItemZIndex(key, progress)) as { + result: { current: SharedValue }; + }; + return result.current.value; +} + +// itemCount = 3 in all cases below + +describe(useItemZIndex, () => { + describe('when the active item has NOT been brought to front (long press)', () => { + it('keeps the active item at its plain order zIndex', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a' + }); + // active + activation running, but not brought to front => order zIndex + expect(renderZIndex('a', 1)).toBe(0); + }); + + it('keeps inactive items at their plain order zIndex', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a' + }); + expect(renderZIndex('b', 1)).toBe(1); + expect(renderZIndex('c', 1)).toBe(2); + }); + + it('respects descending stacking order', () => { + setupContext({ + activeItemBroughtToFront: false, + activeItemKey: 'a', + isStackingOrderDesc: true + }); + // desc order zIndex for index 0 of 3 items => 3 - 0 - 1 = 2 + expect(renderZIndex('a', 1)).toBe(2); + }); + }); + + describe('when the active item HAS been brought to front (real drag)', () => { + it('elevates the active item above everything', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: 'a' + }); + // 2 * itemCount + 1 = 7 + expect(renderZIndex('a', 1)).toBe(7); + }); + + it('lifts inactive items by itemCount while the activation runs', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: 'a' + }); + // itemCount + orderZIndex + expect(renderZIndex('b', 1)).toBe(4); + expect(renderZIndex('c', 1)).toBe(5); + }); + + it('keeps the previously active item above siblings during the drop', () => { + setupContext({ + activeItemBroughtToFront: true, + activeItemKey: null, + prevActiveItemKey: 'a' + }); + // 2 * itemCount = 6 for the dropping item + expect(renderZIndex('a', 0.5)).toBe(6); + // other items are lifted by itemCount while the drop animates + expect(renderZIndex('b', 0.5)).toBe(4); + }); + }); + + describe('at rest (no active item, animation finished)', () => { + it.each([false, true])( + 'returns the plain order zIndex (broughtToFront=%s)', + broughtToFront => { + setupContext({ + activeItemBroughtToFront: broughtToFront, + activeItemKey: null, + prevActiveItemKey: 'a' + }); + // a stale broughtToFront=true is harmless at rest + expect(renderZIndex('a', 0)).toBe(0); + expect(renderZIndex('b', 0)).toBe(1); + expect(renderZIndex('c', 0)).toBe(2); + } + ); + }); +}); diff --git a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts index fc411a13..6870ac00 100644 --- a/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts +++ b/packages/react-native-sortables/src/providers/shared/hooks/useItemZIndex.ts @@ -8,6 +8,7 @@ export default function useItemZIndex( activationAnimationProgress: SharedValue ): SharedValue { const { + activeItemBroughtToFront, activeItemKey, indexToKey, isStackingOrderDesc, @@ -17,16 +18,21 @@ export default function useItemZIndex( return useDerivedValue(() => { const itemCount = indexToKey.value.length; - - if (activeItemKey.value === key) { - return 2 * itemCount + 1; - } - const realIndex = keyToIndex.value[key] ?? 0; const orderZIndex = isStackingOrderDesc ? itemCount - realIndex - 1 : realIndex; + // Keep the order zIndex until the item is actually dragged. See + // https://github.com/MatiPl01/react-native-sortables/issues/417 + if (!activeItemBroughtToFront.value) { + return orderZIndex; + } + + if (activeItemKey.value === key) { + return 2 * itemCount + 1; + } + if (activationAnimationProgress.value > 0) { if (prevActiveItemKey.value === key) { return 2 * itemCount; diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 4121405d..469b4ec9 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -88,6 +88,8 @@ export type CommonValuesContextType = activeAnimationProgress: SharedValue; inactiveAnimationProgress: SharedValue; activeItemDropped: SharedValue; + // True once the active item is dragged (not just long pressed) + activeItemBroughtToFront: SharedValue; // OTHER containerRef: AnimatedRef;