react-native-sortables
Version:
Powerful Sortable Components for Flexible Content Reordering in React Native
411 lines (388 loc) • 14.9 kB
JavaScript
"use strict";
import { useCallback } from 'react';
import { clamp, interpolate, useAnimatedReaction, withTiming } from 'react-native-reanimated';
import { useHaptics } from '../../integrations/haptics';
import { clearAnimatedTimeout, setAnimatedTimeout, useMutableValue } from '../../integrations/reanimated';
import { DragActivationState, LayerState } from '../../types';
import { areVectorsDifferent, calculateSnapOffset, getItemDimensions, getKeyToIndex } from '../../utils';
import { createProvider } from '../utils';
import { useAutoScrollContext } from './AutoScrollProvider';
import { useCommonValuesContext } from './CommonValuesProvider';
import { useCustomHandleContext } from './CustomHandleProvider';
import { useLayerContext } from './LayerProvider';
import { useMultiZoneContext } from './MultiZoneProvider';
import { usePortalContext } from './PortalProvider';
const INITIAL_STATE = {
activationTimeoutId: -1,
dragStartIndex: -1,
dragStartItemTouchOffset: null,
dragStartTouchPosition: null,
touchStartTouch: null
};
const {
DragProvider,
useDragContext
} = createProvider('Drag')(({
hapticsEnabled,
onActiveItemDropped,
onDragEnd: stableOnDragEnd,
onDragMove,
onDragStart,
onOrderChange,
overDrag,
reorderTriggerOrigin
}) => {
const {
activationAnimationDuration,
activationState,
activeAnimationProgress,
activeItemDimensions,
activeItemDropped,
activeItemKey,
activeItemPosition,
containerHeight,
containerId,
containerWidth,
dragActivationDelay,
dragActivationFailOffset,
dropAnimationDuration,
enableActiveItemSnap,
inactiveAnimationProgress,
inactiveItemOpacity,
inactiveItemScale,
indexToKey,
itemHeights,
itemPositions,
itemWidths,
keyToIndex,
prevActiveItemKey,
snapOffsetX,
snapOffsetY,
sortEnabled,
touchPosition,
usesAbsoluteLayout
} = useCommonValuesContext();
const {
updateLayer
} = useLayerContext() ?? {};
const {
isVerticalScroll,
scrollOffsetDiff
} = useAutoScrollContext() ?? {};
const {
activeHandleMeasurements,
activeHandleOffset,
updateActiveHandleMeasurements
} = useCustomHandleContext() ?? {};
const {
activeItemAbsolutePosition
} = usePortalContext() ?? {};
const {
activeContainerId,
activeItemDimensions: multiZoneActiveItemDimensions
} = useMultiZoneContext() ?? {};
const haptics = useHaptics(hapticsEnabled);
const hasHorizontalOverDrag = overDrag === 'horizontal' || overDrag === 'both';
const hasVerticalOverDrag = overDrag === 'vertical' || overDrag === 'both';
const context = useMutableValue(INITIAL_STATE);
const currentTouch = useMutableValue(null);
// Used to trigger order change of items when the active item is dragged around
const triggerOriginPosition = useMutableValue(null);
// ACTIVE ITEM POSITION UPDATER
useAnimatedReaction(() => ({
activeDimensions: activeItemDimensions.value,
containerH: containerHeight.value,
containerW: containerWidth.value,
enableSnap: enableActiveItemSnap.value,
itemTouchOffset: context.value.dragStartItemTouchOffset,
key: activeItemKey.value,
offsetX: snapOffsetX.value,
offsetY: snapOffsetY.value,
progress: activeAnimationProgress.value,
scrollDiff: scrollOffsetDiff?.value,
snapItemDimensions: activeHandleMeasurements?.value ?? activeItemDimensions.value,
snapItemOffset: activeHandleOffset?.value,
startTouch: context.value.touchStartTouch,
startTouchPosition: context.value.dragStartTouchPosition,
touch: currentTouch.value
}), ({
activeDimensions,
containerH,
containerW,
enableSnap,
itemTouchOffset,
key,
offsetX,
offsetY,
progress,
scrollDiff,
snapItemDimensions,
snapItemOffset,
startTouch,
startTouchPosition,
touch
}) => {
if (key === null || containerH === null || containerW === null || !activeDimensions || !snapItemDimensions || !itemTouchOffset || !startTouchPosition || !touch || !startTouch) {
touchPosition.value = null;
triggerOriginPosition.value = null;
return;
}
// Touch position
const newTouchPosition = {
x: startTouchPosition.x + (touch.absoluteX - startTouch.absoluteX),
y: startTouchPosition.y + (touch.absoluteY - startTouch.absoluteY)
};
if (scrollDiff) {
if (isVerticalScroll) {
newTouchPosition.y += scrollDiff;
} else {
newTouchPosition.x += scrollDiff;
}
}
if (!touchPosition.value || areVectorsDifferent(newTouchPosition, touchPosition.value)) {
touchPosition.value = newTouchPosition;
}
// Active item position
const translate = (from, to) => from === to ? from : interpolate(progress, [0, 1], [from, to]);
const maybeClampPosition = (x, y) => ({
x: hasHorizontalOverDrag ? x : clamp(x, 0, containerW - activeDimensions.width),
y: hasVerticalOverDrag ? y : clamp(y, 0, containerH - activeDimensions.height)
});
const snapOffset = enableSnap ? calculateSnapOffset(offsetX, offsetY, snapItemDimensions, snapItemOffset) : itemTouchOffset;
const snapX = translate(itemTouchOffset.x, snapOffset.x);
const snapY = translate(itemTouchOffset.y, snapOffset.y);
const unclampedActiveX = touchPosition.value.x - snapX;
const unclampedActiveY = touchPosition.value.y - snapY;
activeItemPosition.value = maybeClampPosition(unclampedActiveX, unclampedActiveY);
// Trigger origin position
if (reorderTriggerOrigin === 'touch') {
triggerOriginPosition.value = touchPosition.value;
} else {
const activeItemTargetPosition = maybeClampPosition(touchPosition.value.x - snapOffset.x, touchPosition.value.y - snapOffset.y);
triggerOriginPosition.value = {
x: activeItemTargetPosition.x + activeDimensions.width / 2,
y: activeItemTargetPosition.y + activeDimensions.height / 2
};
}
// Portal-related values
if (activeItemAbsolutePosition) {
const activePosition = activeItemPosition.value;
activeItemAbsolutePosition.value = {
x: touch.absoluteX + activePosition.x - unclampedActiveX - snapX,
y: touch.absoluteY + activePosition.y - unclampedActiveY - snapY
};
}
});
/**
* DRAG HANDLERS
*/
const handleDragStart = useCallback((touch, key, itemPosition, itemDimensions, activationAnimationProgress) => {
'worklet';
const ctx = context.value;
activeAnimationProgress.value = 0;
activeItemDropped.value = false;
prevActiveItemKey.value = null;
activeItemKey.value = key;
activeItemPosition.value = itemPosition;
activeItemDimensions.value = itemDimensions;
activationState.value = DragActivationState.ACTIVE;
ctx.dragStartIndex = keyToIndex.value[key] ?? -1;
if (activeContainerId) {
activeContainerId.value = containerId;
}
if (multiZoneActiveItemDimensions) {
multiZoneActiveItemDimensions.value = itemDimensions;
}
updateLayer?.(LayerState.FOCUSED);
// 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);
// Touch position relative to the top-left corner of the sortable
// container
const dragStartItemTouchOffset = {
x: touch.x + (activeHandleOffset?.value?.x ?? 0),
y: touch.y + (activeHandleOffset?.value?.y ?? 0)
};
ctx.dragStartItemTouchOffset = dragStartItemTouchOffset;
// Must be calculated relative to the item position instead of using
// measure on the containerRef, because measure doesn't work properly
// on Android on Fabric and returns wrong value on the second measurement
touchPosition.value = {
x: itemPosition.x + dragStartItemTouchOffset.x,
y: itemPosition.y + dragStartItemTouchOffset.y
};
ctx.dragStartTouchPosition = touchPosition.value;
const hasInactiveAnimation = inactiveItemOpacity.value !== 1 || inactiveItemScale.value !== 1;
const animate = () => withTiming(1, {
duration: activationAnimationDuration.value
});
inactiveAnimationProgress.value = hasInactiveAnimation ? animate() : 0;
activeAnimationProgress.value = animate();
activationAnimationProgress.value = animate();
haptics.medium();
// Use timeout to ensure that the callback is called after all animated
// reactions are computed in the library (e.g. for the portal and collapsible
// items case when the size of the active item must change after it is teleported)
setAnimatedTimeout(() => {
onDragStart({
fromIndex: ctx.dragStartIndex,
indexToKey: indexToKey.value,
key,
keyToIndex: keyToIndex.value
});
});
}, [activationAnimationDuration, activeAnimationProgress, activeContainerId, activeHandleOffset, activeItemDimensions, activeItemDropped, activationState, activeItemKey, activeItemPosition, context, containerId, haptics, inactiveAnimationProgress, inactiveItemOpacity, inactiveItemScale, indexToKey, keyToIndex, multiZoneActiveItemDimensions, prevActiveItemKey, onDragStart, touchPosition, updateLayer, updateActiveHandleMeasurements]);
const handleTouchStart = useCallback((e, key, activationAnimationProgress, activate, fail) => {
'worklet';
const touch = e.allTouches[0];
if (!touch ||
// Sorting is disabled
!sortEnabled.value ||
// Absolute layout is not applied yet
!usesAbsoluteLayout.value ||
// Another item is already being touched/activated
activationAnimationProgress.value > 0 || activeItemKey.value !== null) {
fail();
return;
}
const ctx = context.value;
ctx.touchStartTouch = touch;
currentTouch.value = touch;
activationState.value = DragActivationState.TOUCHED;
clearAnimatedTimeout(ctx.activationTimeoutId);
// Start handling touch after a delay to prevent accidental activation
// e.g. while scrolling the ScrollView
ctx.activationTimeoutId = setAnimatedTimeout(() => {
if (!usesAbsoluteLayout.value) {
return;
}
const itemPosition = itemPositions.value[key];
const itemDimensions = getItemDimensions(key, itemWidths.value, itemHeights.value);
if (!itemPosition || !itemDimensions) {
return;
}
handleDragStart(touch, key, itemPosition, itemDimensions, activationAnimationProgress);
activate();
}, dragActivationDelay.value);
}, [activeItemKey, activationState, context, currentTouch, dragActivationDelay, handleDragStart, itemHeights, itemWidths, itemPositions, sortEnabled, usesAbsoluteLayout]);
const handleTouchesMove = useCallback((e, fail) => {
'worklet';
const touch = e.allTouches[0];
currentTouch.value = touch ?? null;
if (!touch) {
fail();
return;
}
const ctx = context.value;
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);
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) {
fail();
return;
}
}
}, [activationState, activeItemKey, dragActivationFailOffset, currentTouch, context, onDragMove]);
const handleDragEnd = useCallback((key, activationAnimationProgress) => {
'worklet';
if (activeItemKey.value && activeItemKey.value !== key) {
return;
}
const ctx = context.value;
clearAnimatedTimeout(ctx.activationTimeoutId);
ctx.touchStartTouch = null;
currentTouch.value = null;
activationState.value = DragActivationState.INACTIVE;
if (activeItemKey.value === null) {
return;
}
if (activeHandleMeasurements) {
activeHandleMeasurements.value = null;
}
if (activeContainerId) {
activeContainerId.value = null;
}
if (multiZoneActiveItemDimensions) {
multiZoneActiveItemDimensions.value = null;
}
const fromIndex = ctx.dragStartIndex;
const toIndex = keyToIndex.value[key];
prevActiveItemKey.value = activeItemKey.value;
ctx.dragStartItemTouchOffset = null;
ctx.dragStartTouchPosition = null;
activeItemPosition.value = null;
activeItemDimensions.value = null;
touchPosition.value = null;
activeItemKey.value = null;
ctx.dragStartIndex = -1;
// This ensures that the drop duration is reduced if the item activation
// animation didn't complete yet
const dropDuration = activeAnimationProgress.value * dropAnimationDuration.value;
const animate = callback => withTiming(0, {
duration: dropDuration
}, callback);
activationAnimationProgress.value = animate();
inactiveAnimationProgress.value = animate();
activeAnimationProgress.value = animate();
updateLayer?.(LayerState.INTERMEDIATE);
haptics.medium();
stableOnDragEnd({
fromIndex,
indexToKey: indexToKey.value,
key,
keyToIndex: keyToIndex.value,
toIndex
});
setAnimatedTimeout(() => {
activeItemDropped.value = true;
updateLayer?.(LayerState.IDLE);
onActiveItemDropped({
fromIndex,
indexToKey: indexToKey.value,
key,
keyToIndex: keyToIndex.value,
toIndex
});
}, dropDuration);
}, [activeContainerId, activeItemKey, activeItemDimensions, activeItemDropped, activeItemPosition, prevActiveItemKey, context, activeAnimationProgress, activationState, currentTouch, dropAnimationDuration, haptics, inactiveAnimationProgress, indexToKey, keyToIndex, multiZoneActiveItemDimensions, onActiveItemDropped, stableOnDragEnd, touchPosition, updateLayer, activeHandleMeasurements]);
const handleOrderChange = useCallback((key, fromIndex, toIndex, newOrder) => {
'worklet';
indexToKey.value = newOrder;
haptics.light();
onOrderChange({
fromIndex,
indexToKey: indexToKey.value,
key,
keyToIndex: getKeyToIndex(newOrder),
toIndex
});
}, [indexToKey, onOrderChange, haptics]);
return {
value: {
handleDragEnd,
handleOrderChange,
handleTouchesMove,
handleTouchStart,
triggerOriginPosition
}
};
});
export { DragProvider, useDragContext };
//# sourceMappingURL=DragProvider.js.map