Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -145,6 +146,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } =
activationAnimationDuration,
activationState,
activeAnimationProgress,
activeItemBroughtToFront,
activeItemDimensions,
activeItemDropped,
activeItemKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')<
activationAnimationDuration,
activationState,
activeAnimationProgress,
activeItemBroughtToFront,
activeItemDimensions,
activeItemDropped,
activeItemKey,
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -349,6 +351,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')<
activeAnimationProgress,
activeContainerId,
activeHandleOffset,
activeItemBroughtToFront,
activeItemDimensions,
activeItemDropped,
activationState,
Expand All @@ -366,7 +369,6 @@ const { DragProvider, useDragContext } = createProvider('Drag')<
prevActiveItemKey,
onDragStart,
touchPosition,
updateLayer,
updateActiveHandleMeasurements
]
);
Expand Down Expand Up @@ -457,44 +459,49 @@ 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({
fromIndex: ctx.dragStartIndex,
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;
}
}
},
[
activationState,
activeItemBroughtToFront,
activeItemKey,
dragActivationFailOffset,
currentTouch,
context,
onDragMove
onDragMove,
updateLayer
]
);

Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -574,6 +589,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')<
},
[
activeContainerId,
activeItemBroughtToFront,
activeItemKey,
activeItemDimensions,
activeItemDropped,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
};

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<number> };
};
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);
}
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default function useItemZIndex(
activationAnimationProgress: SharedValue<number>
): SharedValue<number> {
const {
activeItemBroughtToFront,
activeItemKey,
indexToKey,
isStackingOrderDesc,
Expand All @@ -17,16 +18,21 @@ export default function useItemZIndex(

return useDerivedValue<number>(() => {
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;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-sortables/src/types/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export type CommonValuesContextType =
activeAnimationProgress: SharedValue<number>;
inactiveAnimationProgress: SharedValue<number>;
activeItemDropped: SharedValue<boolean>;
// True once the active item is dragged (not just long pressed)
activeItemBroughtToFront: SharedValue<boolean>;

// OTHER
containerRef: AnimatedRef<View>;
Expand Down
Loading