From d1bf84574ad691cefec2ad355518b7a399df66d9 Mon Sep 17 00:00:00 2001 From: Olivier Louvignes Date: Tue, 19 Dec 2023 17:32:27 +0100 Subject: [PATCH] feat(sort): add improved/updated sort implementation --- .../sort/components/DraggableGrid.tsx | 59 ++++++++ .../sort/components/DraggableStack.tsx | 57 +++++++ src/features/sort/hooks/useDraggableGrid.ts | 100 ++++++++++++ src/features/sort/hooks/useDraggableSort.ts | 143 ++++++++++++++++++ src/features/sort/hooks/useDraggableStack.ts | 97 ++++++++++++ src/utils/index.ts | 1 + src/utils/swap.ts | 31 ++++ 7 files changed, 488 insertions(+) create mode 100644 src/features/sort/components/DraggableGrid.tsx create mode 100644 src/features/sort/components/DraggableStack.tsx create mode 100644 src/features/sort/hooks/useDraggableGrid.ts create mode 100644 src/features/sort/hooks/useDraggableSort.ts create mode 100644 src/features/sort/hooks/useDraggableStack.ts create mode 100644 src/utils/swap.ts diff --git a/src/features/sort/components/DraggableGrid.tsx b/src/features/sort/components/DraggableGrid.tsx new file mode 100644 index 0000000..a9b458b --- /dev/null +++ b/src/features/sort/components/DraggableGrid.tsx @@ -0,0 +1,59 @@ +import React, { Children, useMemo, type FunctionComponent, type PropsWithChildren } from "react"; +import { View, type FlexStyle, type ViewProps } from "react-native"; +import type { UniqueIdentifier } from "../../../types"; +import { useDraggableGrid, type UseDraggableGridOptions } from "../hooks/useDraggableGrid"; + +export type DraggableGridProps = Pick & + Pick & { + direction?: FlexStyle["flexDirection"]; + size?: number; + gap?: number; + }; + +export const DraggableGrid: FunctionComponent> = ({ + children, + direction = "row", + gap = 0, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + size = 3, + style: styleProp, +}) => { + const initialOrder = useMemo( + () => + Children.map(children, (child) => { + // console.log("in"); + if (React.isValidElement(child)) { + return child.props.id; + } + return null; + })?.filter(Boolean) as UniqueIdentifier[], + [children], + ); + + const style = useMemo( + () => + Object.assign( + { + flexDirection: direction, + gap, + flexWrap: "wrap", + }, + styleProp, + ), + [gap, direction, styleProp], + ); + + useDraggableGrid({ + direction: style.flexDirection, + gap: style.gap, + initialOrder, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + size, + }); + + return {children}; +}; diff --git a/src/features/sort/components/DraggableStack.tsx b/src/features/sort/components/DraggableStack.tsx new file mode 100644 index 0000000..e92729b --- /dev/null +++ b/src/features/sort/components/DraggableStack.tsx @@ -0,0 +1,57 @@ +import React, { Children, useMemo, type FunctionComponent, type PropsWithChildren } from "react"; +import { View, type FlexStyle, type ViewProps } from "react-native"; +import type { UniqueIdentifier } from "../../../types"; +import { useDraggableStack, type UseDraggableStackOptions } from "../hooks/useDraggableStack"; + +export type DraggableStackProps = Pick & + Pick & { + direction?: FlexStyle["flexDirection"]; + gap?: number; + }; + +export const DraggableStack: FunctionComponent> = ({ + children, + direction = "row", + gap = 0, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + style: styleProp, +}) => { + const initialOrder = useMemo( + () => + Children.map(children, (child) => { + // console.log("in"); + if (React.isValidElement(child)) { + return child.props.id; + } + return null; + })?.filter(Boolean) as UniqueIdentifier[], + [children], + ); + + const style = useMemo( + () => + Object.assign( + { + flexDirection: direction, + gap, + }, + styleProp, + ), + [gap, direction, styleProp], + ); + + const horizontal = ["row", "row-reverse"].includes(style.flexDirection); + + useDraggableStack({ + gap: style.gap, + horizontal, + initialOrder, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + }); + + return {children}; +}; diff --git a/src/features/sort/hooks/useDraggableGrid.ts b/src/features/sort/hooks/useDraggableGrid.ts new file mode 100644 index 0000000..d158610 --- /dev/null +++ b/src/features/sort/hooks/useDraggableGrid.ts @@ -0,0 +1,100 @@ +import { type FlexStyle } from "react-native"; +import { useAnimatedReaction } from "react-native-reanimated"; +import { swapByItemCenterPoint } from "../../../utils"; +import { useDndContext } from "./../../../DndContext"; +import { useDraggableSort, type UseDraggableSortOptions } from "./useDraggableSort"; + +export type UseDraggableGridOptions = Pick< + UseDraggableSortOptions, + "initialOrder" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" +> & { + gap: number; + size: number; + direction: FlexStyle["flexDirection"]; +}; + +export const useDraggableGrid = ({ + initialOrder, + onOrderChange, + onOrderUpdate, + gap, + size, + direction = "row", + shouldSwapWorklet = swapByItemCenterPoint, +}: UseDraggableGridOptions) => { + const { draggableActiveId, draggableOffsets, draggableRestingOffsets, draggableLayouts } = useDndContext(); + const horizontal = ["row", "row-reverse"].includes(direction); + + const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ + horizontal, + initialOrder, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + }); + + // Track sort order changes and update the offsets + useAnimatedReaction( + () => draggableSortOrder.value, + (nextOrder, prevOrder) => { + // Ignore initial reaction + if (prevOrder === null) { + return; + } + const { value: activeId } = draggableActiveId; + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const { value: restingOffsets } = draggableRestingOffsets; + if (!activeId) { + return; + } + + const activeLayout = layouts[activeId].value; + const { width, height } = activeLayout; + const restingOffset = restingOffsets[activeId]; + // const prevOffset = applyOffset(activeLayout, { x: restingOffset.x.value, y: restingOffset.y.value }); + + for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { + const itemId = nextOrder[nextIndex]; + const prevIndex = prevOrder.findIndex((id) => id === itemId); + // Skip items that haven't changed position + if (nextIndex === prevIndex) { + continue; + } + + const prevRow = Math.floor(prevIndex / size); + const prevCol = prevIndex % size; + const nextRow = Math.floor(nextIndex / size); + const nextCol = nextIndex % size; + const moveCol = nextCol - prevCol; + const moveRow = nextRow - prevRow; + + const offset = itemId === activeId ? restingOffset : offsets[itemId]; + + switch (direction) { + case "row": + offset.x.value += moveCol * (width + gap); + offset.y.value += moveRow * (height + gap); + break; + case "row-reverse": + offset.x.value += -1 * moveCol * (width + gap); + offset.y.value += moveRow * (height + gap); + break; + case "column": + offset.y.value += moveCol * (width + gap); + offset.x.value += moveRow * (height + gap); + break; + case "column-reverse": + offset.y.value += -1 * moveCol * (width + gap); + offset.x.value += moveRow * (height + gap); + break; + default: + break; + } + } + }, + [direction, gap, size], + ); + + return { draggablePlaceholderIndex, draggableSortOrder }; +}; diff --git a/src/features/sort/hooks/useDraggableSort.ts b/src/features/sort/hooks/useDraggableSort.ts new file mode 100644 index 0000000..a69c12d --- /dev/null +++ b/src/features/sort/hooks/useDraggableSort.ts @@ -0,0 +1,143 @@ +import { LayoutRectangle } from "react-native"; +import { runOnJS, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; +import { useDndContext } from "../../../DndContext"; +import type { UniqueIdentifier } from "../../../types"; +import { + applyOffset, + arraysEqual, + centerAxis, + moveArrayIndex, + overlapsAxis, + type Rectangle, +} from "../../../utils"; + +export type UseDraggableSortOptions = { + initialOrder?: UniqueIdentifier[]; + horizontal?: boolean; + onOrderChange?: (value: UniqueIdentifier[]) => void; + onOrderUpdate?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; + shouldSwapWorklet?: (activeLayout: Rectangle, itemLayout: Rectangle) => boolean; +}; + +export const useDraggableSort = ({ + horizontal = false, + initialOrder = [], + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, +}: UseDraggableSortOptions) => { + const { draggableActiveId, draggableActiveLayout, draggableOffsets, draggableLayouts } = useDndContext(); + + const draggablePlaceholderIndex = useSharedValue(-1); + const draggableLastOrder = useSharedValue(initialOrder); + const draggableSortOrder = useSharedValue(initialOrder); + + // Core placeholder index logic + const findPlaceholderIndex = (activeLayout: LayoutRectangle): number => { + "worklet"; + const { value: activeId } = draggableActiveId; + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const { value: sortOrder } = draggableSortOrder; + const activeIndex = sortOrder.findIndex((id) => id === activeId); + // const activeCenterPoint = centerPoint(activeLayout); + // console.log(`activeLayout: ${JSON.stringify(activeLayout)}`); + for (let itemIndex = 0; itemIndex < sortOrder.length; itemIndex++) { + const itemId = sortOrder[itemIndex]; + if (itemId === activeId) { + continue; + } + if (!layouts[itemId]) { + console.warn(`Unexpected missing layout ${itemId} in layouts!`); + continue; + } + const itemLayout = applyOffset(layouts[itemId].value, { + x: offsets[itemId].x.value, + y: offsets[itemId].y.value, + }); + + if (shouldSwapWorklet) { + if (shouldSwapWorklet(activeLayout, itemLayout)) { + // console.log(`Found placeholder index ${itemIndex} using custom shouldSwapWorklet!`); + return itemIndex; + } + continue; + } + + // Default to center axis + const itemCenterAxis = centerAxis(itemLayout, horizontal); + if (overlapsAxis(activeLayout, itemCenterAxis, horizontal)) { + return itemIndex; + } + } + // Fallback to current index + // console.log(`Fallback to current index ${activeIndex}`); + return activeIndex; + }; + + // Track active layout changes and update the placeholder index + useAnimatedReaction( + () => [draggableActiveId.value, draggableActiveLayout.value] as const, + ([nextActiveId, nextActiveLayout], prev) => { + // Ignore initial reaction + if (prev === null) { + return; + } + const [_prevActiveId, _prevActiveLayout] = prev; + // No active layout + if (nextActiveLayout === null) { + return; + } + // Reset the placeholder index when the active id changes + if (nextActiveId === null) { + draggablePlaceholderIndex.value = -1; + return; + } + // const axis = direction === "row" ? "x" : "y"; + // const delta = prevActiveLayout !== null ? nextActiveLayout[axis] - prevActiveLayout[axis] : 0; + draggablePlaceholderIndex.value = findPlaceholderIndex(nextActiveLayout); + }, + [], + ); + + // Track placeholder index changes and update the sort order + useAnimatedReaction( + () => [draggableActiveId.value, draggablePlaceholderIndex.value] as const, + (next, prev) => { + // Ignore initial reaction + if (prev === null) { + return; + } + const [_prevActiveId, prevPlaceholderIndex] = prev; + const [nextActiveId, nextPlaceholderIndex] = next; + const { value: prevOrder } = draggableSortOrder; + // if (nextPlaceholderIndex !== prevPlaceholderIndex) { + // console.log(`Placeholder index changed from ${prevPlaceholderIndex} to ${nextPlaceholderIndex}`); + // } + if (prevPlaceholderIndex !== -1 && nextPlaceholderIndex === -1) { + // Notify the parent component of the order change + if (nextActiveId === null && onOrderChange) { + if (!arraysEqual(prevOrder, draggableLastOrder.value)) { + runOnJS(onOrderChange)(prevOrder); + } + draggableLastOrder.value = prevOrder; + } + } + // Only update the sort order when the placeholder index changes between two valid values + if (prevPlaceholderIndex === -1 || nextPlaceholderIndex === -1) { + return; + } + // Finally update the sort order + const nextOrder = moveArrayIndex(prevOrder, prevPlaceholderIndex, nextPlaceholderIndex); + // Notify the parent component of the order update + if (onOrderUpdate) { + runOnJS(onOrderUpdate)(nextOrder, prevOrder); + } + + draggableSortOrder.value = nextOrder; + }, + [onOrderChange], + ); + + return { draggablePlaceholderIndex, draggableSortOrder }; +}; diff --git a/src/features/sort/hooks/useDraggableStack.ts b/src/features/sort/hooks/useDraggableStack.ts new file mode 100644 index 0000000..cfd3c41 --- /dev/null +++ b/src/features/sort/hooks/useDraggableStack.ts @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useAnimatedReaction } from "react-native-reanimated"; +import { useDndContext } from "../../../DndContext"; +import { swapByItemHorizontalAxis, swapByItemVerticalAxis } from "../../../utils"; +import { useDraggableSort, type UseDraggableSortOptions } from "./useDraggableSort"; + +export type UseDraggableStackOptions = Pick< + UseDraggableSortOptions, + "initialOrder" | "onOrderChange" | "onOrderUpdate" | "shouldSwapWorklet" +> & { + gap: number; + horizontal: boolean; +}; +export const useDraggableStack = ({ + initialOrder, + onOrderChange, + onOrderUpdate, + gap, + horizontal, + shouldSwapWorklet, +}: UseDraggableStackOptions) => { + const { draggableActiveId, draggableOffsets, draggableRestingOffsets, draggableLayouts } = useDndContext(); + const axis = horizontal ? "x" : "y"; + const size = horizontal ? "width" : "height"; + const worklet = useMemo( + () => + shouldSwapWorklet ? shouldSwapWorklet : horizontal ? swapByItemHorizontalAxis : swapByItemVerticalAxis, + [horizontal, shouldSwapWorklet], + ); + + const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ + horizontal, + initialOrder, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet: worklet, + }); + + // Track sort order changes and update the offsets + useAnimatedReaction( + () => draggableSortOrder.value, + (nextOrder, prevOrder) => { + // Ignore initial reaction + if (prevOrder === null) { + return; + } + const { value: activeId } = draggableActiveId; + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const { value: restingOffsets } = draggableRestingOffsets; + if (!activeId) { + return; + } + + const activeLayout = layouts[activeId].value; + const prevActiveIndex = prevOrder.findIndex((id) => id === activeId); + const nextActiveIndex = nextOrder.findIndex((id) => id === activeId); + const nextActiveOffset = { x: 0, y: 0 }; + const restingOffset = restingOffsets[activeId]; + // return; + + for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { + const itemId = nextOrder[nextIndex]; + // Skip the active item + if (itemId === activeId) { + continue; + } + // @TODO grid x,y + + // Skip items that haven't changed position + const prevIndex = prevOrder.findIndex((id) => id === itemId); + if (nextIndex === prevIndex) { + continue; + } + // Calculate the directional offset + const moveCol = nextIndex - prevIndex; + // Apply the offset to the item from its resting position + offsets[itemId][axis].value = + restingOffsets[itemId][axis].value + moveCol * (activeLayout[size] + gap); + // Reset resting offsets to new updated position + restingOffsets[itemId][axis].value = offsets[itemId][axis].value; + + // Accummulate the directional offset for the active item + if (nextIndex < nextActiveIndex && prevIndex > prevActiveIndex) { + nextActiveOffset[axis] += layouts[itemId].value[size] + gap; + } else if (nextIndex > nextActiveIndex && prevIndex < prevActiveIndex) { + nextActiveOffset[axis] -= layouts[itemId].value[size] + gap; + } + } + // Update the active item offset + restingOffset[axis].value += nextActiveOffset[axis]; + }, + [horizontal], + ); + + return { draggablePlaceholderIndex, draggableSortOrder }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index fb86b16..bccb41d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from "./assert"; export * from "./geometry"; export * from "./random"; export * from "./reanimated"; +export * from "./swap"; diff --git a/src/utils/swap.ts b/src/utils/swap.ts new file mode 100644 index 0000000..522ae46 --- /dev/null +++ b/src/utils/swap.ts @@ -0,0 +1,31 @@ +import { + centerAxis, + centerPoint, + includesPoint, + overlapsAxis, + type Rectangle, +} from "@mgcrea/react-native-dnd"; + +export const swapByItemCenterPoint = (activeLayout: Rectangle, itemLayout: Rectangle) => { + "worklet"; + const itemCenterPoint = centerPoint(itemLayout); + return includesPoint(activeLayout, itemCenterPoint); +}; + +export const swapByItemAxis = (activeLayout: Rectangle, itemLayout: Rectangle, horizontal: boolean) => { + "worklet"; + const itemCenterAxis = centerAxis(itemLayout, horizontal); + return overlapsAxis(activeLayout, itemCenterAxis, horizontal); +}; + +export const swapByItemHorizontalAxis = (activeLayout: Rectangle, itemLayout: Rectangle) => { + "worklet"; + const itemCenterAxis = centerAxis(itemLayout, true); + return overlapsAxis(activeLayout, itemCenterAxis, true); +}; + +export const swapByItemVerticalAxis = (activeLayout: Rectangle, itemLayout: Rectangle) => { + "worklet"; + const itemCenterAxis = centerAxis(itemLayout, false); + return overlapsAxis(activeLayout, itemCenterAxis, false); +};