From e6116ad43e23b203c8b400a4e14aad673ef01bf2 Mon Sep 17 00:00:00 2001 From: Olivier Louvignes Date: Thu, 12 Dec 2024 16:55:34 +0100 Subject: [PATCH] chore(sort): revert to measureLayout, only sort on children ids --- example/src/pages/DraggableStackExample.tsx | 19 ++- src/DndContext.ts | 4 +- src/DndProvider.tsx | 6 +- src/components/Draggable.tsx | 4 +- src/components/Droppable.tsx | 4 +- .../sort/components/DraggableStack.tsx | 6 +- src/features/sort/hooks/useDraggableGrid.ts | 6 +- src/features/sort/hooks/useDraggableSort.ts | 20 ++-- src/features/sort/hooks/useDraggableStack.ts | 26 ++-- src/hooks/useDraggable.ts | 50 ++++---- src/hooks/useDroppable.ts | 51 ++++---- src/utils/reanimated.ts | 112 ++++++++---------- 12 files changed, 153 insertions(+), 155 deletions(-) diff --git a/example/src/pages/DraggableStackExample.tsx b/example/src/pages/DraggableStackExample.tsx index 689f0d8..fb73977 100644 --- a/example/src/pages/DraggableStackExample.tsx +++ b/example/src/pages/DraggableStackExample.tsx @@ -16,6 +16,7 @@ const data = items.map((letter, index) => ({ value: letter, id: `${index}-${letter}`, })) satisfies ObjectWithId[]; +let id = items.length; export const DraggableStackExample: FunctionComponent = () => { const [items, setItems] = useState(data); @@ -62,9 +63,10 @@ export const DraggableStackExample: FunctionComponent = () => { onPress={() => { setItems(prevItems => { const randomIndex = 2; //Math.floor(Math.random() * prevItems.length); + id++; prevItems.splice(randomIndex, 0, { value: '🤪', - id: `${prevItems.length}-🤪`, + id: `${id}-🤪`, }); return prevItems.slice(); }); @@ -109,13 +111,22 @@ export const DraggableStackExample: FunctionComponent = () => { const styles = StyleSheet.create({ container: { flexGrow: 1, - alignItems: 'center', - justifyContent: 'center', + // alignItems: 'stretch', + // justifyContent: 'st', + flexDirection: 'column', + }, + view: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'flex-end', + backgroundColor: 'rgba(255,0,255,0.1)', + flexGrow: 1, }, stack: { backgroundColor: 'rgba(0,0,0,0.1)', alignItems: 'center', - justifyContent: 'flex-start', + justifyContent: 'center', + // flexGrow: 1, padding: 32, borderRadius: 32, }, diff --git a/src/DndContext.ts b/src/DndContext.ts index e448b09..9184f7e 100644 --- a/src/DndContext.ts +++ b/src/DndContext.ts @@ -15,9 +15,7 @@ export type DraggableState = "resting" | "pending" | "dragging" | "dropping" | " export type DraggableStates = Record>; export type DndContextValue = { - containerRef: RefObject; - draggableIds: SharedValue; - droppableIds: SharedValue; + containerRef: RefObject; // AnimatedRef; draggableLayouts: SharedValue; droppableLayouts: SharedValue; draggableOptions: SharedValue; diff --git a/src/DndProvider.tsx b/src/DndProvider.tsx index d7114ce..7ee97b9 100644 --- a/src/DndProvider.tsx +++ b/src/DndProvider.tsx @@ -94,8 +94,6 @@ export const DndProvider = forwardRef(null); - const draggableIds = useSharedValue([]); - const droppableIds = useSharedValue([]); const draggableLayouts = useSharedValue({}); const droppableLayouts = useSharedValue({}); const draggableOptions = useSharedValue({}); @@ -113,8 +111,6 @@ export const DndProvider = forwardRef({ containerRef, - draggableIds, - droppableIds, draggableLayouts, droppableLayouts, draggableOptions, @@ -216,7 +212,7 @@ export const DndProvider = forwardRef { - const { state, absoluteX: x, absoluteY: y } = event; + const { state, x, y } = event; debug && console.log("begin", { state, x, y }); // Gesture is globally disabled if (disabled) { diff --git a/src/components/Draggable.tsx b/src/components/Draggable.tsx index a323c3c..6c04f3a 100644 --- a/src/components/Draggable.tsx +++ b/src/components/Draggable.tsx @@ -43,7 +43,7 @@ export const Draggable: FunctionComponent> = ( animatedStyleWorklet, ...otherProps }) => { - const { animatedRef, setNodeLayout, offset, state } = useDraggable({ + const { props, offset, state } = useDraggable({ id, data, disabled, @@ -83,7 +83,7 @@ export const Draggable: FunctionComponent> = ( }, [id, state, activeOpacity]); return ( - + {children} ); diff --git a/src/components/Droppable.tsx b/src/components/Droppable.tsx index e4b3db9..96c46a7 100644 --- a/src/components/Droppable.tsx +++ b/src/components/Droppable.tsx @@ -38,7 +38,7 @@ export const Droppable: FunctionComponent> = ( animatedStyleWorklet, ...otherProps }) => { - const { animatedRef, setNodeLayout, activeId } = useDroppable({ + const { props, activeId } = useDroppable({ id, disabled, data, @@ -56,7 +56,7 @@ export const Droppable: FunctionComponent> = ( }, [id, activeOpacity]); return ( - + {children} ); diff --git a/src/features/sort/components/DraggableStack.tsx b/src/features/sort/components/DraggableStack.tsx index 8fbd2a2..fbc85f3 100644 --- a/src/features/sort/components/DraggableStack.tsx +++ b/src/features/sort/components/DraggableStack.tsx @@ -32,7 +32,7 @@ export const DraggableStack = forwardRef @@ -51,7 +51,7 @@ export const DraggableStack = forwardRef { // Refresh offsets when children change runOnUI(refreshOffsets)(); - }, [initialOrder, refreshOffsets]); + }, [childrenIds, refreshOffsets]); return {children}; }, diff --git a/src/features/sort/hooks/useDraggableGrid.ts b/src/features/sort/hooks/useDraggableGrid.ts index 6f6b1e9..a5a2fbd 100644 --- a/src/features/sort/hooks/useDraggableGrid.ts +++ b/src/features/sort/hooks/useDraggableGrid.ts @@ -6,7 +6,7 @@ import { useDraggableSort, type UseDraggableSortOptions } from "./useDraggableSo export type UseDraggableGridOptions = Pick< UseDraggableSortOptions, - "initialOrder" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" + "childrenIds" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" > & { gap?: number; size: number; @@ -14,7 +14,7 @@ export type UseDraggableGridOptions = Pick< }; export const useDraggableGrid = ({ - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, gap = 0, @@ -27,7 +27,7 @@ export const useDraggableGrid = ({ const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ horizontal, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet, diff --git a/src/features/sort/hooks/useDraggableSort.ts b/src/features/sort/hooks/useDraggableSort.ts index 9f37ed6..96637e8 100644 --- a/src/features/sort/hooks/useDraggableSort.ts +++ b/src/features/sort/hooks/useDraggableSort.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { LayoutRectangle } from "react-native"; import { runOnJS, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useDndContext } from "../../../DndContext"; @@ -18,7 +19,7 @@ export type ShouldSwapWorklet = ( ) => boolean; export type UseDraggableSortOptions = { - initialOrder: UniqueIdentifier[]; + childrenIds: UniqueIdentifier[]; horizontal?: boolean; onOrderChange?: (order: UniqueIdentifier[]) => void; onOrderUpdate?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; @@ -27,18 +28,17 @@ export type UseDraggableSortOptions = { export const useDraggableSort = ({ horizontal = false, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet = doesOverlapOnAxis, }: UseDraggableSortOptions) => { - const { draggableIds, draggableActiveId, draggableActiveLayout, draggableOffsets, draggableLayouts } = - useDndContext(); + const { draggableActiveId, draggableActiveLayout, draggableOffsets, draggableLayouts } = useDndContext(); const direction = horizontal ? "horizontal" : "vertical"; const draggablePlaceholderIndex = useSharedValue(-1); - const draggableLastOrder = useSharedValue(initialOrder); - const draggableSortOrder = useSharedValue(initialOrder); + const draggableLastOrder = useSharedValue(childrenIds); + const draggableSortOrder = useSharedValue(childrenIds); // Core placeholder index logic const findPlaceholderIndex = (activeLayout: LayoutRectangle): number => { @@ -77,7 +77,7 @@ export const useDraggableSort = ({ // Track added/removed draggable items and update the sort order useAnimatedReaction( - () => draggableIds.value, + () => childrenIds, (next, prev) => { if (prev === null || prev.length === 0) { return; @@ -131,11 +131,15 @@ export const useDraggableSort = ({ draggablePlaceholderIndex.value = -1; return; } + // Only track our own children + if (!childrenIds.includes(nextActiveId)) { + return; + } // const axis = direction === "row" ? "x" : "y"; // const delta = prevActiveLayout !== null ? nextActiveLayout[axis] - prevActiveLayout[axis] : 0; draggablePlaceholderIndex.value = findPlaceholderIndex(nextActiveLayout); }, - [], + [childrenIds], ); // Track placeholder index changes and update the sort order diff --git a/src/features/sort/hooks/useDraggableStack.ts b/src/features/sort/hooks/useDraggableStack.ts index 1e350b9..06c8f45 100644 --- a/src/features/sort/hooks/useDraggableStack.ts +++ b/src/features/sort/hooks/useDraggableStack.ts @@ -7,33 +7,27 @@ import { useDraggableSort, type UseDraggableSortOptions } from "./useDraggableSo export type UseDraggableStackOptions = Pick< UseDraggableSortOptions, - "initialOrder" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" + "childrenIds" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" > & { gap?: number; horizontal?: boolean; }; export const useDraggableStack = ({ - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, gap = 0, horizontal = false, shouldSwapWorklet = doesOverlapOnAxis, }: UseDraggableStackOptions) => { - const { - draggableIds, - draggableStates, - draggableActiveId, - draggableOffsets, - draggableRestingOffsets, - draggableLayouts, - } = useDndContext(); + const { draggableStates, draggableActiveId, draggableOffsets, draggableRestingOffsets, draggableLayouts } = + useDndContext(); const axis = horizontal ? "x" : "y"; const size = horizontal ? "width" : "height"; const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ horizontal, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet, @@ -47,7 +41,7 @@ export const useDraggableStack = ({ const { value: sortOrder } = draggableSortOrder; const nextIndex = sortOrder.findIndex((itemId) => itemId === id); - const prevIndex = initialOrder.findIndex((itemId) => itemId === id); + const prevIndex = childrenIds.findIndex((itemId) => itemId === id); let offset = 0; // Accumulate the directional offset for the current item accross its siblings in the stack @@ -62,7 +56,7 @@ export const useDraggableStack = ({ // Can happen if some items are being removed from the stack continue; } - const prevSiblingIndex = initialOrder.findIndex((itemId) => itemId === siblingId); + const prevSiblingIndex = childrenIds.findIndex((itemId) => itemId === siblingId); // Accummulate the directional offset for the active item if (nextSiblingIndex < nextIndex && prevSiblingIndex > prevIndex) { // console.log( @@ -78,7 +72,7 @@ export const useDraggableStack = ({ } return offset; }, - [draggableLayouts, draggableSortOrder, gap, horizontal, initialOrder], + [draggableLayouts, draggableSortOrder, gap, horizontal, childrenIds], ); const refreshOffsets = useCallback(() => { @@ -121,7 +115,7 @@ export const useDraggableStack = ({ // Track items being added or removed from the stack useAnimatedReaction( - () => draggableIds.value, + () => childrenIds, (next, prev) => { // Ignore initial reaction if (prev === null) { @@ -133,7 +127,7 @@ export const useDraggableStack = ({ // Refresh all offsets refreshOffsets(); }, - [initialOrder], + [childrenIds], ); // Track sort order changes and update the offsets diff --git a/src/hooks/useDraggable.ts b/src/hooks/useDraggable.ts index fcaf15b..2b5bcbd 100644 --- a/src/hooks/useDraggable.ts +++ b/src/hooks/useDraggable.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ -import { useLayoutEffect } from "react"; -import { LayoutRectangle, ViewProps } from "react-native"; -import { runOnUI, useAnimatedReaction, useAnimatedRef, useSharedValue } from "react-native-reanimated"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { LayoutRectangle, View, ViewProps } from "react-native"; +import { runOnUI, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { DraggableState, useDndContext } from "../DndContext"; -import { useLatestSharedValue } from "../hooks"; +import { useEvent, useLatestSharedValue } from "../hooks"; import { Data, UniqueIdentifier } from "../types"; -import { isReanimatedSharedValue, updateLayoutValue, waitForLayout } from "../utils"; +import { isReanimatedSharedValue } from "../utils"; import { useSharedPoint } from "./useSharedPoint"; export type DraggableConstraints = { @@ -42,7 +42,6 @@ export const useDraggable = ({ activationTolerance = Infinity, }: UseDraggableOptions) => { const { - draggableIds, draggableLayouts, draggableOffsets, draggableRestingOffsets, @@ -50,10 +49,11 @@ export const useDraggable = ({ draggableStates, draggableActiveId, draggablePendingId, - // containerRef, + containerRef, panGestureState, } = useDndContext(); - const animatedRef = useAnimatedRef(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ref = useRef(null!); // eslint-disable-next-line react-hooks/rules-of-hooks const sharedData = isReanimatedSharedValue(data) ? data : useLatestSharedValue(data); @@ -72,18 +72,11 @@ export const useDraggable = ({ useLayoutEffect(() => { const runLayoutEffect = () => { "worklet"; - // Wait for the layout to be available by requesting two consecutive animation frames - waitForLayout(() => { - // Try to recover the layout from the ref if it's not available yet - if (layout.value.width === 0 || layout.value.height === 0) { - // console.log(`Recovering layout for ${id} from ref`); - updateLayoutValue(layout, animatedRef); - } + requestAnimationFrame(() => { draggableLayouts.value[id] = layout; draggableOffsets.value[id] = offset; draggableRestingOffsets.value[id] = restingOffset; draggableStates.value[id] = state; - draggableIds.value = [...draggableIds.value, id]; draggableOptions.value[id] = { id, data: sharedData, @@ -105,7 +98,6 @@ export const useDraggable = ({ delete draggableRestingOffsets.value[id]; delete draggableOptions.value[id]; delete draggableStates.value[id]; - draggableIds.value = draggableIds.value.filter((draggableId) => draggableId !== id); }); }; runOnUI(cleanupLayoutEffect)(); @@ -113,10 +105,15 @@ export const useDraggable = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); - const onLayout: ViewProps["onLayout"] = (_event) => { - // console.log(`onLayout: ${id}`, event.nativeEvent.layout); - runOnUI(updateLayoutValue)(layout, animatedRef); - }; + const onLayout: ViewProps["onLayout"] = useEvent((_event) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ref.current || !containerRef.current) { + return; + } + ref.current.measureLayout(containerRef.current, (x, y, width, height) => { + layout.value = { x, y, width, height }; + }); + }); // Track disabled prop changes useAnimatedReaction( @@ -130,14 +127,21 @@ export const useDraggable = ({ [disabled], ); + const props = useMemo( + () => ({ + ref, + onLayout, + }), + [onLayout], + ); + return { offset, state, - animatedRef, activeId: draggableActiveId, pendingId: draggablePendingId, setNodeLayout: onLayout, panGestureState, - // setDisabled, + props, }; }; diff --git a/src/hooks/useDroppable.ts b/src/hooks/useDroppable.ts index 8ae5d3d..236693e 100644 --- a/src/hooks/useDroppable.ts +++ b/src/hooks/useDroppable.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ -import { useLayoutEffect } from "react"; -import { type LayoutRectangle, type ViewProps } from "react-native"; -import { runOnUI, useAnimatedReaction, useAnimatedRef, useSharedValue } from "react-native-reanimated"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { View, type LayoutRectangle, type ViewProps } from "react-native"; +import { runOnUI, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useDndContext } from "../DndContext"; -import { useLatestSharedValue } from "../hooks"; +import { useEvent, useLatestSharedValue } from "../hooks"; import type { Data, UniqueIdentifier } from "../types"; -import { isReanimatedSharedValue, updateLayoutValue, waitForLayout } from "../utils"; +import { isReanimatedSharedValue } from "../utils"; export type UseDroppableOptions = { id: UniqueIdentifier; data?: Data; disabled?: boolean }; @@ -23,8 +23,10 @@ export type UseDroppableOptions = { id: UniqueIdentifier; data?: Data; disabled? * @param {boolean} [options.disabled=false] - A flag that indicates whether the droppable component is disabled. */ export const useDroppable = ({ id, data = {}, disabled = false }: UseDroppableOptions) => { - const { droppableLayouts, droppableOptions, droppableActiveId, panGestureState } = useDndContext(); - const animatedRef = useAnimatedRef(); + const { containerRef, droppableLayouts, droppableOptions, droppableActiveId, panGestureState } = + useDndContext(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ref = useRef(null!); // eslint-disable-next-line react-hooks/rules-of-hooks const sharedData = isReanimatedSharedValue(data) ? data : useLatestSharedValue(data); @@ -38,16 +40,8 @@ export const useDroppable = ({ id, data = {}, disabled = false }: UseDroppableOp useLayoutEffect(() => { const runLayoutEffect = () => { "worklet"; - waitForLayout(() => { - // Try to recover the layout from the ref if it's not available yet - if (layout.value.width === 0 || layout.value.height === 0) { - // console.log(`Recovering layout for ${id} from ref`); - updateLayoutValue(layout, animatedRef); - } - droppableLayouts.value[id] = layout; - // Options - droppableOptions.value[id] = { id, data: sharedData, disabled }; - }); + droppableLayouts.value[id] = layout; + droppableOptions.value[id] = { id, data: sharedData, disabled }; }; runOnUI(runLayoutEffect)(); @@ -65,10 +59,15 @@ export const useDroppable = ({ id, data = {}, disabled = false }: UseDroppableOp // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); - const onLayout: ViewProps["onLayout"] = (_event) => { - // console.log(`onLayout: ${id}`, event.nativeEvent.layout); - updateLayoutValue(layout, animatedRef); - }; + const onLayout: ViewProps["onLayout"] = useEvent(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ref.current || !containerRef.current) { + return; + } + ref.current.measureLayout(containerRef.current, (x, y, width, height) => { + layout.value = { x, y, width, height }; + }); + }); // Track disabled prop changes useAnimatedReaction( @@ -82,5 +81,13 @@ export const useDroppable = ({ id, data = {}, disabled = false }: UseDroppableOp [disabled], ); - return { animatedRef, setNodeLayout: onLayout, activeId: droppableActiveId, panGestureState }; + const props = useMemo( + () => ({ + ref, + onLayout, + }), + [onLayout], + ); + + return { props, activeId: droppableActiveId, panGestureState }; }; diff --git a/src/utils/reanimated.ts b/src/utils/reanimated.ts index fb7f321..9c0d1b0 100644 --- a/src/utils/reanimated.ts +++ b/src/utils/reanimated.ts @@ -1,13 +1,8 @@ -import { type Component } from "react"; import { type LayoutRectangle } from "react-native"; import { - measure, - runOnUI, withSpring, type AnimatableValue, - type AnimatedRef, type AnimationCallback, - type MeasuredDimensions, type SharedValue, type WithSpringConfig, } from "react-native-reanimated"; @@ -149,62 +144,51 @@ export const isReanimatedSharedValue = (value: unknown): value is SharedValue { - "worklet"; - return { - x: measurement.pageX, - y: measurement.pageY, - width: measurement.width, - height: measurement.height, - }; -}; - -export const updateLayoutValue = ( - layout: SharedValue, - animatedRef: AnimatedRef, -) => { - "worklet"; - const measurement = measure(animatedRef); - if (measurement === null) { - return; - } - layout.value = getLayoutFromMeasurement(measurement); -}; - -export const waitForLayout = (fn: (lastTime: number, time: number) => void) => { - "worklet"; - let lastTime = 0; - - function loop() { - requestAnimationFrame((time) => { - if (lastTime > 0) { - fn(lastTime, time); - return; - } - lastTime = time; - requestAnimationFrame(loop); - }); - } - - loop(); -}; - -/* - -function loopAnimationFrame(fn: (lastTime: number, time: number) => void) { - let lastTime = 0; - - function loop() { - requestAnimationFrame((time) => { - if (lastTime > 0) { - fn(lastTime, time); - } - lastTime = time; - requestAnimationFrame(loop); - }); - } - - loop(); -} - -*/ +// export const getLayoutFromMeasurement = (measurement: MeasuredDimensions): LayoutRectangle => { +// "worklet"; +// return { +// x: measurement.x, +// y: measurement.y, +// width: measurement.width, +// height: measurement.height, +// }; +// }; + +// export const updateLayoutValue = ( +// layout: SharedValue, +// animatedRef: AnimatedRef, +// ) => { +// "worklet"; +// const measurement = measure(animatedRef); +// if (measurement === null) { +// return; +// } +// layout.value = { +// x: measurement.x, +// y: measurement.y, +// width: measurement.width, +// height: measurement.height, +// }; +// }; + +// export const updateRelativeLayoutValue = ( +// layout: SharedValue, +// animatedRef: AnimatedRef, +// containerRef: AnimatedRef, +// ) => { +// "worklet"; +// const measurement = measure(animatedRef); +// if (measurement === null) { +// return; +// } +// const coords = getRelativeCoords(containerRef, measurement.pageX, measurement.pageY); +// if (coords === null) { +// return; +// } +// layout.value = { +// x: coords.x, +// y: coords.y, +// width: measurement.width, +// height: measurement.height, +// }; +// };