Skip to content

Commit ace7037

Browse files
authored
fix: Long drop duration reordering issues (#406)
## Description This PR fixes plain and teleported item animation when sortable items are reordered before the item reached its drop position. What was wrong? - items reordering duration was the same as active item drop animation duration, which is not correct, - items drop animation was rapidly changed without smooth animation when the drop target position was modified (e.g. because of the items order change), - app crashed when teleport was enabled/disabled during the drop animation, ## Example recordings ### Without portal | Before | After | |-|-| | <video src="https://github.com/user-attachments/assets/0fceeb96-b5d2-4b1e-8062-1685056ddc28" /> | <video src="https://github.com/user-attachments/assets/445e04e6-729c-4e91-bb81-157340b044e8" /> | ### With portal | Before | After | |-|-| | <video src="https://github.com/user-attachments/assets/7746658a-b653-4bb1-a4fb-e52eab1d90b8" /> | <video src="https://github.com/user-attachments/assets/dd791d10-dbf3-46cb-b9a7-42c3305c0e81" /> |
1 parent 1413af2 commit ace7037

7 files changed

Lines changed: 174 additions & 58 deletions

File tree

packages/react-native-sortables/src/components/shared/DraggableView/ActiveItemPortal.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@ type ActiveItemPortalProps = PropsWithChildren<{
1212
teleportedItemId: string;
1313
activationAnimationProgress: SharedValue<number>;
1414
renderTeleportedItemCell: () => ReactNode;
15-
setIsTeleported: (isTeleported: boolean) => void;
1615
}>;
1716

1817
export default function ActiveItemPortal({
1918
activationAnimationProgress,
2019
children,
2120
renderTeleportedItemCell,
22-
setIsTeleported,
2321
teleportedItemId
2422
}: ActiveItemPortalProps) {
2523
const { teleport } = usePortalContext()!;
@@ -36,18 +34,16 @@ export default function ActiveItemPortal({
3634

3735
const enableTeleport = () => {
3836
teleport(teleportedItemId, renderTeleportedItemCell());
39-
setIsTeleported(true);
4037
};
4138

4239
const disableTeleport = () => {
4340
runOnJS(teleport)(teleportedItemId, null);
44-
setIsTeleported(false);
4541
};
4642

4743
useAnimatedReaction(
4844
() => activationAnimationProgress.value,
49-
progress => {
50-
if (progress > 0 && !teleportEnabled.value) {
45+
(progress, prevProgress) => {
46+
if (prevProgress && progress > prevProgress && !teleportEnabled.value) {
5147
teleportEnabled.value = true;
5248
runOnJS(enableTeleport)();
5349
} else if (progress === 0 && teleportEnabled.value) {

packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ function DraggableView({
6363
return () => removeItemMeasurements(key);
6464
}, [key, removeItemMeasurements, teleportedItemId]);
6565

66+
useEffect(() => {
67+
if (!portalContext) {
68+
setIsTeleported(false);
69+
}
70+
71+
const unsubscribe = portalContext?.subscribe?.(
72+
teleportedItemId,
73+
setIsTeleported
74+
);
75+
76+
return () => {
77+
portalContext?.teleport?.(teleportedItemId, null);
78+
unsubscribe?.();
79+
};
80+
}, [portalContext, teleportedItemId]);
81+
6682
const withItemContext = (component: ReactNode) => (
6783
<ItemContextProvider
6884
activationAnimationProgress={activationAnimationProgress}
@@ -133,7 +149,6 @@ function DraggableView({
133149
<ActiveItemPortal
134150
activationAnimationProgress={activationAnimationProgress}
135151
renderTeleportedItemCell={renderTeleportedItemCell}
136-
setIsTeleported={setIsTeleported}
137152
teleportedItemId={teleportedItemId}>
138153
{children}
139154
</ActiveItemPortal>

packages/react-native-sortables/src/providers/shared/PortalProvider.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { ReactNode } from 'react';
2-
import { Fragment, useCallback, useState } from 'react';
2+
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
33
import { useSharedValue } from 'react-native-reanimated';
44

5-
import type { PortalContextType, Vector } from '../../types';
5+
import type {
6+
PortalContextType,
7+
PortalSubscription,
8+
Vector
9+
} from '../../types';
610
import { createProvider } from '../utils';
711
import { PortalOutletProvider } from './PortalOutletProvider';
812

@@ -17,20 +21,47 @@ const { PortalProvider, usePortalContext } = createProvider('Portal', {
1721
const [teleportedNodes, setTeleportedNodes] = useState<
1822
Record<string, React.ReactNode>
1923
>({});
24+
const subscribersRef = useRef<Record<string, Set<PortalSubscription>>>({});
2025

2126
const activeItemAbsolutePosition = useSharedValue<null | Vector>(null);
2227

23-
const teleport = useCallback((id: string, node: React.ReactNode) => {
24-
if (node) {
25-
setTeleportedNodes(prev => ({ ...prev, [id]: node }));
26-
} else {
27-
setTeleportedNodes(prev => {
28-
const { [id]: _, ...rest } = prev;
29-
return rest;
30-
});
28+
useEffect(() => {
29+
if (!enabled) {
30+
setTeleportedNodes({});
3131
}
32+
}, [enabled]);
33+
34+
const notifySubscribers = useCallback((id: string, isTeleported: boolean) => {
35+
subscribersRef.current[id]?.forEach(subscriber => subscriber(isTeleported));
3236
}, []);
3337

38+
const teleport = useCallback(
39+
(id: string, node: React.ReactNode) => {
40+
if (node) {
41+
setTeleportedNodes(prev => ({ ...prev, [id]: node }));
42+
notifySubscribers(id, true);
43+
} else {
44+
setTeleportedNodes(prev => {
45+
const { [id]: _, ...rest } = prev;
46+
return rest;
47+
});
48+
notifySubscribers(id, false);
49+
}
50+
},
51+
[notifySubscribers]
52+
);
53+
54+
const subscribe = useCallback(
55+
(id: string, subscriber: PortalSubscription) => {
56+
subscribersRef.current[id] = subscribersRef.current[id] ?? new Set();
57+
subscribersRef.current[id].add(subscriber);
58+
return () => {
59+
subscribersRef.current[id]?.delete(subscriber);
60+
};
61+
},
62+
[]
63+
);
64+
3465
return {
3566
children: (
3667
<Fragment>
@@ -45,6 +76,7 @@ const { PortalProvider, usePortalContext } = createProvider('Portal', {
4576
enabled,
4677
value: {
4778
activeItemAbsolutePosition,
79+
subscribe,
4880
teleport
4981
}
5082
};

packages/react-native-sortables/src/providers/shared/hooks/useItemLayoutStyles.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { ViewStyle } from 'react-native';
22
import type { SharedValue } from 'react-native-reanimated';
33
import {
4+
interpolate,
45
useAnimatedReaction,
56
useAnimatedStyle,
67
useSharedValue,
78
withTiming
89
} from 'react-native-reanimated';
910

10-
import { type AnimatedStyleProp } from '../../../types';
11+
import type { AnimatedStyleProp, Vector } from '../../../types';
12+
import { areVectorsDifferent } from '../../../utils';
1113
import { useCommonValuesContext } from '../CommonValuesProvider';
1214
import useItemZIndex from './useItemZIndex';
1315

@@ -32,45 +34,73 @@ export default function useItemLayoutStyles(
3234
activeItemKey,
3335
activeItemPosition,
3436
animateLayoutOnReorderOnly,
35-
dropAnimationDuration,
3637
itemPositions,
3738
shouldAnimateLayout,
3839
usesAbsoluteLayout
3940
} = useCommonValuesContext();
4041

4142
const zIndex = useItemZIndex(key, activationAnimationProgress);
43+
const dropStartValues = useSharedValue<null | {
44+
position: Vector;
45+
progress: number;
46+
}>(null);
4247

4348
const translateX = useSharedValue<null | number>(null);
4449
const translateY = useSharedValue<null | number>(null);
4550

4651
// Inactive item updater
4752
useAnimatedReaction(
4853
() => ({
54+
activationProgress: activationAnimationProgress.value,
4955
isActive: activeItemKey.value === key,
50-
position: itemPositions.value[key]
56+
itemPosition: itemPositions.value[key]
5157
}),
52-
({ isActive, position }) => {
53-
if (isActive || !position) {
58+
({ activationProgress, isActive, itemPosition }, prev) => {
59+
if (isActive || !itemPosition) {
60+
dropStartValues.value = null;
5461
return;
5562
}
5663

5764
if (
5865
translateX.value === null ||
5966
translateY.value === null ||
6067
!shouldAnimateLayout.value ||
61-
(activeItemKey.value === null &&
62-
animateLayoutOnReorderOnly.value &&
68+
(animateLayoutOnReorderOnly.value &&
69+
activationProgress === 0 &&
6370
activeItemDropped.value)
6471
) {
65-
translateX.value = position.x;
66-
translateY.value = position.y;
72+
// No animation case
73+
translateX.value = itemPosition.x;
74+
translateY.value = itemPosition.y;
75+
} else if (activationProgress > 0) {
76+
// Drop animation case
77+
if (
78+
!dropStartValues.value ||
79+
(prev?.itemPosition &&
80+
areVectorsDifferent(prev.itemPosition, itemPosition))
81+
) {
82+
dropStartValues.value = {
83+
position: {
84+
x: translateX.value,
85+
y: translateY.value
86+
},
87+
progress: activationProgress
88+
};
89+
}
90+
91+
const {
92+
position: { x, y },
93+
progress
94+
} = dropStartValues.value;
95+
const animate = (from: number, to: number) =>
96+
interpolate(activationProgress, [progress, 0], [from, to]);
97+
98+
translateX.value = animate(x, itemPosition.x);
99+
translateY.value = animate(y, itemPosition.y);
67100
} else {
68-
translateX.value = withTiming(position.x, {
69-
duration: dropAnimationDuration.value
70-
});
71-
translateY.value = withTiming(position.y, {
72-
duration: dropAnimationDuration.value
73-
});
101+
// Order change animation case
102+
translateX.value = withTiming(itemPosition.x);
103+
translateY.value = withTiming(itemPosition.y);
74104
}
75105
}
76106
);

packages/react-native-sortables/src/providers/shared/hooks/useTeleportedItemStyles.ts

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,63 +21,100 @@ export default function useTeleportedItemStyles(
2121
isActive: SharedValue<boolean>,
2222
activationAnimationProgress: SharedValue<number>
2323
): StyleProp<AnimatedStyle<ViewStyle>> {
24-
const { activeItemAbsolutePosition } = usePortalContext()!;
25-
const { portalOutletMeasurements } = usePortalOutletContext()!;
24+
const { activeItemAbsolutePosition } = usePortalContext() ?? {};
25+
const { portalOutletMeasurements } = usePortalOutletContext() ?? {};
2626
const { containerRef, itemPositions } = useCommonValuesContext();
2727

2828
const zIndex = useItemZIndex(key, activationAnimationProgress);
29-
const dropStartTranslation = useSharedValue<null | Vector>(null);
3029

31-
// Inactive item updater (for drop animation)
30+
const dropStartValues = useSharedValue<null | {
31+
fromAbsolute: Vector;
32+
progress: number;
33+
toRelative: Vector;
34+
}>(null);
35+
36+
// Drop start values calculation reaction
3237
useAnimatedReaction(
3338
() => ({
3439
active: isActive.value
3540
}),
3641
({ active }) => {
37-
if (!active) {
38-
dropStartTranslation.value = activeItemAbsolutePosition.value;
42+
if (active) {
43+
dropStartValues.value = null;
44+
} else if (
45+
activeItemAbsolutePosition?.value &&
46+
itemPositions.value[key]
47+
) {
48+
dropStartValues.value = {
49+
fromAbsolute: activeItemAbsolutePosition.value,
50+
progress: activationAnimationProgress.value,
51+
toRelative: itemPositions.value[key]
52+
};
3953
}
4054
}
4155
);
4256

4357
const absoluteItemPosition = useDerivedValue(() => {
44-
let absolutePosition: null | Vector = null;
45-
4658
if (isActive.value) {
47-
absolutePosition = activeItemAbsolutePosition.value;
48-
} else if (dropStartTranslation.value) {
49-
const animate = (from: number, to: number) =>
50-
interpolate(activationAnimationProgress.value, [1, 0], [from, to]);
51-
const { x: startX, y: startY } = dropStartTranslation.value;
52-
53-
const containerMeasurements = measure(containerRef);
54-
const itemPosition = itemPositions.value[key];
59+
return activeItemAbsolutePosition?.value ?? null;
60+
}
5561

56-
if (!containerMeasurements || !itemPosition) {
57-
// This should never happen
62+
if (dropStartValues.value) {
63+
const measurements = measure(containerRef);
64+
if (!measurements) {
5865
return null;
5966
}
6067

61-
absolutePosition = {
62-
x: animate(startX, containerMeasurements.pageX + itemPosition.x),
63-
y: animate(startY, containerMeasurements.pageY + itemPosition.y)
68+
const { fromAbsolute, progress, toRelative } = dropStartValues.value;
69+
70+
const animate = (source: number, target: number) =>
71+
interpolate(
72+
activationAnimationProgress.value,
73+
[progress, 0],
74+
[source, target]
75+
);
76+
77+
return {
78+
x: animate(fromAbsolute.x, measurements.pageX + toRelative.x),
79+
y: animate(fromAbsolute.y, measurements.pageY + toRelative.y)
6480
};
6581
}
6682

67-
return absolutePosition;
83+
return null;
6884
});
6985

86+
// Drop start values updater on target position change
87+
useAnimatedReaction(
88+
() => itemPositions.value[key],
89+
position => {
90+
if (
91+
isActive.value ||
92+
activationAnimationProgress.value === 0 ||
93+
!position ||
94+
!absoluteItemPosition.value
95+
) {
96+
return;
97+
}
98+
99+
dropStartValues.value = {
100+
fromAbsolute: absoluteItemPosition.value,
101+
progress: activationAnimationProgress.value,
102+
toRelative: position
103+
};
104+
}
105+
);
106+
70107
const animatedStyle = useAnimatedStyle(() => {
71-
if (!portalOutletMeasurements.value || !absoluteItemPosition.value) {
108+
if (!portalOutletMeasurements?.value || !absoluteItemPosition.value) {
72109
// This should never happen
73-
return { opacity: 0 };
110+
return { display: 'none' };
74111
}
75112

76113
const { pageX: outletX, pageY: outletY } = portalOutletMeasurements.value;
77114
const { x: itemX, y: itemY } = absoluteItemPosition.value;
78115

79116
return {
80-
opacity: 1,
117+
display: 'flex',
81118
transform: [
82119
{ translateX: itemX - outletX },
83120
{ translateY: itemY - outletY }

packages/react-native-sortables/src/types/providers/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export type PortalSubscription = (isTeleported: boolean) => void;
184184
export type PortalContextType = {
185185
activeItemAbsolutePosition: SharedValue<null | Vector>;
186186
teleport: (id: string, node: ReactNode) => void;
187+
subscribe: (id: string, subscriber: PortalSubscription) => () => void;
187188
};
188189

189190
// PORTAL OUTLET

0 commit comments

Comments
 (0)