From 14159a8e21de38f6951b51d421e0c1b6513ff86e Mon Sep 17 00:00:00 2001 From: Olivier Louvignes Date: Tue, 19 Dec 2023 17:26:40 +0100 Subject: [PATCH] chore(project): major refactoring, remove existing grid feature --- src/DndContext.ts | 8 +- src/DndProvider.tsx | 99 +++--- src/components/Draggable.tsx | 14 +- src/components/DraggableFlatList.tsx | 171 ---------- src/components/Droppable.tsx | 4 +- src/components/index.ts | 1 - src/features/grid/DraggableGridContext.ts | 13 - src/features/grid/DraggableGridProvider.tsx | 48 --- .../grid/components/DraggableGrid.tsx | 25 -- .../grid/components/DraggableGridItem.tsx | 93 ------ src/features/grid/components/index.ts | 2 - src/features/grid/hooks/index.ts | 1 - src/features/grid/hooks/useDraggableGrid.ts | 305 ------------------ src/features/grid/index.ts | 4 - src/hooks/index.ts | 1 - src/hooks/useDraggable.ts | 4 + src/types/common.ts | 1 + src/utils/array.ts | 17 + src/utils/geometry.ts | 57 +++- src/utils/index.ts | 1 + 20 files changed, 156 insertions(+), 713 deletions(-) delete mode 100644 src/components/DraggableFlatList.tsx delete mode 100644 src/features/grid/DraggableGridContext.ts delete mode 100644 src/features/grid/DraggableGridProvider.tsx delete mode 100644 src/features/grid/components/DraggableGrid.tsx delete mode 100644 src/features/grid/components/DraggableGridItem.tsx delete mode 100644 src/features/grid/components/index.ts delete mode 100644 src/features/grid/hooks/index.ts delete mode 100644 src/features/grid/hooks/useDraggableGrid.ts delete mode 100644 src/features/grid/index.ts create mode 100644 src/utils/array.ts diff --git a/src/DndContext.ts b/src/DndContext.ts index 0f11b89..2d3caf4 100644 --- a/src/DndContext.ts +++ b/src/DndContext.ts @@ -21,15 +21,15 @@ export type DndContextValue = { draggableOptions: SharedValue; droppableOptions: SharedValue; draggableOffsets: SharedValue; + draggableRestingOffsets: SharedValue; draggableStates: SharedValue; draggablePendingId: SharedValue; draggableActiveId: SharedValue; droppableActiveId: SharedValue; - panGestureState: SharedValue; - draggableActiveOffset: SharedPoint; - draggableActingOffset: SharedPoint; - draggableRestingOffset: SharedPoint; + draggableActiveLayout: SharedValue; + draggableInitialOffset: SharedPoint; draggableContentOffset: SharedPoint; + panGestureState: SharedValue; }; // @ts-expect-error ignore detached state diff --git a/src/DndProvider.tsx b/src/DndProvider.tsx index 4698bc4..15f5193 100644 --- a/src/DndProvider.tsx +++ b/src/DndProvider.tsx @@ -37,6 +37,7 @@ import { includesPoint, overlapsRectangle, Point, + Rectangle, } from "./utils"; export type DndProviderProps = { @@ -64,7 +65,7 @@ export type DndProviderProps = { export type DndProviderHandle = Pick< DndContextValue, - "draggableLayouts" | "draggableOffsets" | "draggableActiveId" | "draggableRestingOffset" + "draggableLayouts" | "draggableOffsets" | "draggableRestingOffsets" | "draggableActiveId" >; export const DndProvider = forwardRef>( @@ -91,13 +92,13 @@ export const DndProvider = forwardRef({}); const droppableOptions = useSharedValue({}); const draggableOffsets = useSharedValue({}); + const draggableRestingOffsets = useSharedValue({}); const draggableStates = useSharedValue({}); const draggablePendingId = useSharedValue(null); const draggableActiveId = useSharedValue(null); const droppableActiveId = useSharedValue(null); - const draggableActiveOffset = useSharedPoint(0, 0); - const draggableActingOffset = useSharedPoint(0, 0); - const draggableRestingOffset = useSharedPoint(0, 0); + const draggableActiveLayout = useSharedValue(null); + const draggableInitialOffset = useSharedPoint(0, 0); const draggableContentOffset = useSharedPoint(0, 0); const panGestureState = useSharedValue(0); @@ -126,14 +127,14 @@ export const DndProvider = forwardRef 0) { - draggablePendingId.value = activeId; - draggableStates.value[activeId].value = "pending"; - runOnJS(setActiveId)(activeId, activationDelay); - } else { - draggableActiveId.value = activeId; - draggableStates.value[activeId].value = "dragging"; - } // Record any ongoing current offset as our initial offset for the gesture + const activeLayout = layouts[activeId].value; const activeOffset = offsets[activeId]; + const restingOffset = restingOffsets[activeId]; const { value: activeState } = states[activeId]; - draggableActiveOffset.x.value = activeOffset.x.value; - draggableActiveOffset.y.value = activeOffset.y.value; + draggableInitialOffset.x.value = activeOffset.x.value; + draggableInitialOffset.y.value = activeOffset.y.value; // Cancel the ongoing animation if we just reactivated an acting/dragging item if (["dragging", "acting"].includes(activeState)) { cancelAnimation(activeOffset.x); cancelAnimation(activeOffset.y); // If not we should reset the resting offset to the current offset value // But only if the item is not currently still animating - } else if (activeState === "resting") { - draggableRestingOffset.x.value = activeOffset.x.value; - draggableRestingOffset.y.value = activeOffset.y.value; + } else { + // active or pending + // Record current offset as our natural resting offset for the gesture + restingOffset.x.value = activeOffset.x.value; + restingOffset.y.value = activeOffset.y.value; + } + // Update activeId directly or with an optional delay + const { activationDelay } = options[activeId]; + if (activationDelay > 0) { + draggablePendingId.value = activeId; + draggableStates.value[activeId].value = "pending"; + runOnJS(setActiveId)(activeId, activationDelay); + // @TODO activeLayout + } else { + draggableActiveId.value = activeId; + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + draggableStates.value[activeId].value = "dragging"; } if (onBegin) { - const activeLayout = layouts[activeId].value; onBegin(event, { activeId, activeLayout }); } } }) .onUpdate((event) => { // console.log(draggableStates.value); - const { state, translationX, translationY, x, y } = event; + const { state, translationX, translationY } = event; debug && console.log("update", { state, translationX, translationY }); // Track current state for cancellation purposes panGestureState.value = state; @@ -286,21 +296,19 @@ export const DndProvider = forwardRef { @@ -312,6 +320,7 @@ export const DndProvider = forwardRef { + ([finishedX, finishedY]) => { + // Cancel if we are interacting again with this item + if ( + panGestureState.value !== State.END && + panGestureState.value !== State.FAILED && + states[activeId].value !== "acting" + ) { + return; + } states[activeId].value = "resting"; + if (!finishedX || !finishedY) { + // console.log(`${activeId} did not finish to reach ${targetX.toFixed(2)} ${currentX}`); + } + // for (const [id, offset] of Object.entries(offsets)) { + // console.log({ [id]: [offset.x.value.toFixed(2), offset.y.value.toFixed(2)] }); + // } }, ); }) diff --git a/src/components/Draggable.tsx b/src/components/Draggable.tsx index d13fcfa..5256c1c 100644 --- a/src/components/Draggable.tsx +++ b/src/components/Draggable.tsx @@ -1,6 +1,6 @@ import React, { type FunctionComponent, type PropsWithChildren } from "react"; import { type ViewProps } from "react-native"; -import Animated, { useAnimatedProps, useAnimatedStyle, type AnimatedProps } from "react-native-reanimated"; +import Animated, { useAnimatedStyle, withSpring, type AnimatedProps } from "react-native-reanimated"; import { useDraggable, type DraggableConstraints, type UseDroppableOptions } from "../hooks"; import type { AnimatedStyleWorklet } from "../types"; @@ -60,10 +60,16 @@ export const Draggable: FunctionComponent> = ( zIndex, transform: [ { - translateX: offset.x.value, + // translateX: offset.x.value, + translateX: isActive + ? offset.x.value + : withSpring(offset.x.value, { damping: 100, stiffness: 1000 }), }, { - translateY: offset.y.value, + // translateY: offset.y.value, + translateY: isActive + ? offset.y.value + : withSpring(offset.y.value, { damping: 100, stiffness: 1000 }), }, ], }; @@ -71,7 +77,7 @@ export const Draggable: FunctionComponent> = ( Object.assign(style, animatedStyleWorklet(style, { isActive, isActing, isDisabled: !!disabled })); } return style; - }, [id, activeOpacity]); + }, [id, state, activeOpacity]); return ( diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx deleted file mode 100644 index 58e4872..0000000 --- a/src/components/DraggableFlatList.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { ComponentProps, ReactElement, useCallback, useRef, useState } from "react"; -import { CellRendererProps, FlatListProps, View } from "react-native"; -import { FlatList } from "react-native-gesture-handler"; -import Animated, { - AnimatedProps, - SharedValue, - runOnJS, - useAnimatedReaction, - useAnimatedRef, - useAnimatedScrollHandler, - useSharedValue, -} from "react-native-reanimated"; -import { useDndContext } from "../DndContext"; -import type { GridItem } from "../hooks"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnimatedFlatListProps = AnimatedProps>>; - -export type ViewableRange = { - first: number | null; - last: number | null; -}; - -export type DraggableFlatListProps = AnimatedFlatListProps & { - placeholderIndex: SharedValue; -}; -export const DraggableFlatList = ({ - data, - renderItem, - placeholderIndex, - ...otherProps -}: DraggableFlatListProps): ReactElement => { - const { - draggableActiveId: activeId, - // draggableActingId: actingId, - draggableContentOffset, - draggableActiveOffset, - } = useDndContext(); - const [scrollEnabled, setScrollEnabled] = useState(true); - const animatedFlatListRef = useAnimatedRef>(); - const { current: AnimatedFlatlist } = useRef(Animated.createAnimatedComponent(FlatList)); - - const scrollToIndex = useCallback( - (index: number) => { - animatedFlatListRef.current?.scrollToIndex({ - index, - viewPosition: 0, - animated: true, - }); - }, - [animatedFlatListRef], - ); - - const viewableRange = useSharedValue({ - first: null, - last: null, - }); - - useAnimatedReaction( - () => placeholderIndex.value, - (next, prev) => { - if (!Array.isArray(data)) { - return; - } - console.log(`placeholderIndex: ${prev} -> ${next}}, last visible= ${viewableRange.value.last}`); - const { - value: { first, last }, - } = viewableRange; - if (last !== null && next >= last && last < data.length - 1) { - if (next < data.length) { - runOnJS(scrollToIndex)(next + 1); - } - } else if (first !== null && first > 0 && next <= first) { - if (next > 0) { - runOnJS(scrollToIndex)(next - 1); - } - } - }, - ); - - const scrollHandler = useAnimatedScrollHandler((event) => { - if (activeId.value === null) { - draggableContentOffset.y.value = event.contentOffset.y; - } else { - draggableActiveOffset.y.value = event.contentOffset.y; - } - }); - - // useAnimatedReaction( - // () => actingId.value, - // (next, prev) => { - // console.log(`actingId: ${prev} -> ${next}}`); - // console.log(`translationY.value=${draggableContentOffset.y.value}`); - // }, - // [], - // ); - useAnimatedReaction( - () => activeId.value, - (next, prev) => { - console.log(`activeId: ${prev} -> ${next}}`); - }, - [], - ); - useAnimatedReaction( - () => activeId.value !== null, - (next, prev) => { - if (prev !== null && next !== prev) { - console.log("activeId.value", next); - runOnJS(setScrollEnabled)(!next); - } - }, - [], - ); - - const onViewableItemsChanged = useCallback["onViewableItemsChanged"]>>( - ({ viewableItems, changed: _changed }) => { - // console.log("Visible items are", viewableItems); - // console.log("Changed in this iteration", changed); - viewableRange.value = { - first: viewableItems[0].index, - last: viewableItems[viewableItems.length - 1].index, - }; - console.log( - `First viewable item index: ${viewableItems[0].index}, last: ${ - viewableItems[viewableItems.length - 1].index - }`, - ); - }, - [viewableRange], - ); - - return ( - // @ts-expect-error mismatched types - - ); -}; - -export const DraggableFlatListCellRenderer = function DraggableFlatListCellRenderer( - props: CellRendererProps, -) { - const { item, index, children, style, ...rest } = props; - const { draggablePendingId: pendingId, draggableActiveId: activeId } = useDndContext(); - const isActive = [pendingId.value, activeId.value].includes(item.id); - - return ( - - {children} - - ); -}; diff --git a/src/components/Droppable.tsx b/src/components/Droppable.tsx index 8016d39..3f9b0ce 100644 --- a/src/components/Droppable.tsx +++ b/src/components/Droppable.tsx @@ -1,10 +1,10 @@ import React, { type FunctionComponent, type PropsWithChildren } from "react"; import { type ViewProps } from "react-native"; -import Animated, { useAnimatedStyle, type AnimateProps } from "react-native-reanimated"; +import Animated, { useAnimatedStyle, type AnimatedProps } from "react-native-reanimated"; import { useDroppable, type UseDraggableOptions } from "../hooks"; import type { AnimatedStyleWorklet } from "../types"; -export type DroppableProps = AnimateProps & +export type DroppableProps = AnimatedProps & UseDraggableOptions & { animatedStyleWorklet?: AnimatedStyleWorklet; activeOpacity?: number; diff --git a/src/components/index.ts b/src/components/index.ts index 709a0bf..9606652 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,2 @@ export * from "./Draggable"; -export * from "./DraggableFlatList"; export * from "./Droppable"; diff --git a/src/features/grid/DraggableGridContext.ts b/src/features/grid/DraggableGridContext.ts deleted file mode 100644 index 9da4a41..0000000 --- a/src/features/grid/DraggableGridContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react"; - -export type DraggableGridContextValue = { - gridMode: number; - gridWidth: number; - gridHeight: number; -}; - -export const DraggableGridContext = createContext(null!); - -export const useDraggableGridContext = () => { - return useContext(DraggableGridContext); -}; diff --git a/src/features/grid/DraggableGridProvider.tsx b/src/features/grid/DraggableGridProvider.tsx deleted file mode 100644 index e31c2ef..0000000 --- a/src/features/grid/DraggableGridProvider.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { FunctionComponent, PropsWithChildren, useEffect, useState } from "react"; -import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native"; -import { DraggableGridContext, type DraggableGridContextValue } from "./DraggableGridContext"; -import { DraggableGrid } from "./components/DraggableGrid"; - -type DraggableGridProviderProps = { - mode: number; - width: number; - height: number; - containerStyle?: StyleProp; - style?: StyleProp; -}; - -export const DraggableGridProvider: FunctionComponent> = ({ - children, - width, - height, - mode, - containerStyle, - style, -}) => { - const [value, setValue] = useState({ - gridWidth: width, - gridHeight: height, - gridMode: mode, - }); - useEffect(() => { - setValue((value) => ({ ...value, gridMode: mode })); - }, [mode]); - return ( - - - {children} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - flexShrink: 1, - flexBasis: "auto", - justifyContent: "center", - aspectRatio: 1, - width: "100%", - }, -}); diff --git a/src/features/grid/components/DraggableGrid.tsx b/src/features/grid/components/DraggableGrid.tsx deleted file mode 100644 index 916adc5..0000000 --- a/src/features/grid/components/DraggableGrid.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useMemo, type FunctionComponent, type PropsWithChildren } from "react"; -import { StyleSheet, View, type ViewProps } from "react-native"; -import { getGridModeStyle } from "src/hooks"; -import { useDraggableGridContext } from "../DraggableGridContext"; - -export type DraggableGridProps = Pick; - -export const DraggableGrid: FunctionComponent> = ({ - children, - style, -}) => { - const { gridMode } = useDraggableGridContext(); - const gridStyle = useMemo(() => getGridModeStyle(gridMode), [gridMode]); - return {children}; -}; - -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - flexShrink: 1, - flexBasis: "auto", - flexDirection: "column", - flexWrap: "wrap", - }, -}); diff --git a/src/features/grid/components/DraggableGridItem.tsx b/src/features/grid/components/DraggableGridItem.tsx deleted file mode 100644 index a5b0467..0000000 --- a/src/features/grid/components/DraggableGridItem.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { FunctionComponent, PropsWithChildren } from "react"; -import { Pressable, PressableProps, StyleSheet } from "react-native"; -import Animated, { - Layout, - WithSpringConfig, - ZoomIn, - useAnimatedStyle, - useSharedValue, - withSpring, -} from "react-native-reanimated"; -import { useActiveDragReaction, useDraggable } from "src/hooks"; -import type { Data, UniqueIdentifier } from "src/types"; -import { getRandomInt } from "src/utils"; -import { useDraggableGridContext } from "../DraggableGridContext"; - -const AnimatedPressable = Animated.createAnimatedComponent(Pressable); - -export type DraggableGridItemProps = Omit & { - id: UniqueIdentifier; - value: string | number; - data?: Data; - springConfig?: WithSpringConfig; -}; - -export const DraggableGridItem: FunctionComponent> = ({ - children, - id, - style, - data, - ...otherPressableProps -}) => { - const { setNodeRef, setNodeLayout, activeId, offset } = useDraggable({ - id, - data, - }); - const { gridWidth, gridHeight } = useDraggableGridContext(); - - const rotateZ = useSharedValue(0); - const pressCount = useSharedValue(1); - useActiveDragReaction(id, (isActive) => { - "worklet"; - // pressCount.value++; - rotateZ.value = withSpring(isActive ? getRandomInt(-15 * pressCount.value, 15 * pressCount.value) : 0); - }); - - const animatedStyle = useAnimatedStyle(() => { - const isActive = activeId.value === id; - return { - opacity: isActive ? 0.9 : 1, - zIndex: isActive ? 999 : 1, - transform: [ - { - translateX: isActive ? offset.x.value : withSpring(offset.x.value), - }, - { - translateY: isActive ? offset.y.value : withSpring(offset.y.value), - }, - { - rotateZ: `${rotateZ.value}deg`, - }, - ], - }; - }, [id]); - - return ( - - {children} - - ); -}; - -const styles = StyleSheet.create({ - item: { - alignItems: "center", - justifyContent: "center", - flexWrap: "wrap", - }, -}); diff --git a/src/features/grid/components/index.ts b/src/features/grid/components/index.ts deleted file mode 100644 index 772f7f0..0000000 --- a/src/features/grid/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./DraggableGrid"; -export * from "./DraggableGridItem"; diff --git a/src/features/grid/hooks/index.ts b/src/features/grid/hooks/index.ts deleted file mode 100644 index 83752c9..0000000 --- a/src/features/grid/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useDraggableGrid"; diff --git a/src/features/grid/hooks/useDraggableGrid.ts b/src/features/grid/hooks/useDraggableGrid.ts deleted file mode 100644 index 53f2fa4..0000000 --- a/src/features/grid/hooks/useDraggableGrid.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { RefCallback, useMemo, useRef } from "react"; -import { LayoutRectangle, ViewStyle } from "react-native"; -import { runOnJS, useAnimatedReaction, useSharedValue } from "react-native-reanimated"; -import { DndProviderHandle, DndProviderProps } from "src/DndProvider"; -import { useLatestSharedValue } from "src/hooks/useLatestSharedValue"; -import { SharedPoint } from "src/hooks/useSharedPoint"; -import { UniqueIdentifier } from "src/types"; -import { applyOffset, centerPoint, includesPoint, moveArrayIndex } from "src/utils"; - -export type GridItem = { id: UniqueIdentifier; [s: string]: unknown }; - -export enum GridMode { - Row, - Column, - RowReverse, - ColumnReverse, -} - -export const getGridModeStyle = (mode: GridMode): ViewStyle => { - switch (mode) { - case GridMode.Row: - return { flexDirection: "row" }; - case GridMode.RowReverse: - return { flexDirection: "row-reverse" }; - case GridMode.Column: - return { flexDirection: "column" }; - case GridMode.ColumnReverse: - return { flexDirection: "column-reverse" }; - } -}; - -export type UseDraggableGridOptions = { - initialOrder?: UniqueIdentifier[]; - gridWidth?: number; - gridHeight?: number; - gridMode?: GridMode; - idExtractor?: ((item: ItemT, index: number) => UniqueIdentifier) | undefined; - onBegin?: DndProviderProps["onBegin"]; - onUpdate?: DndProviderProps["onUpdate"]; - onFinalize?: DndProviderProps["onFinalize"]; - onOrderUpdate?: (next: UniqueIdentifier[], prev: UniqueIdentifier[]) => void; - onOrderChange?: (next: UniqueIdentifier[], prev: UniqueIdentifier[]) => void; -}; - -export const useDraggableGrid = ( - items: ItemT[], - { - gridWidth = items.length, - // gridHeight = 1, - gridMode = GridMode.Row, - initialOrder: initialOrderProp, - idExtractor, - onBegin: onBeginProp, - onUpdate: onUpdateProp, - onFinalize: onFinalizeProp, - onOrderUpdate, - onOrderChange, - }: UseDraggableGridOptions = {}, -) => { - // Compute initial order - const initialOrder = useMemo( - () => initialOrderProp ?? items.map((item, index) => (idExtractor ? idExtractor(item, index) : item.id)), - [idExtractor, items, initialOrderProp], - ); - // Draggable related state - const draggableRef = useRef(null); - const draggablePlaceholderIndex = useSharedValue(-1); - const draggableSortOrder = useSharedValue(initialOrder); - const lastDraggableSortOrder = useSharedValue(initialOrder); - const draggableActiveStatus = useSharedValue>({}); - const latestItems = useLatestSharedValue(items); - const latestGridWidth = useLatestSharedValue(gridWidth); - - // Core placeholder index logic - const findlaceholderIndex = (activeLayout: LayoutRectangle): number => { - "worklet"; - if (!draggableRef.current) { - return -1; - } - const { draggableLayouts, draggableOffsets, draggableActiveId } = draggableRef.current; - 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); - for (let itemOrder = 0; itemOrder < sortOrder.length; itemOrder++) { - const itemId = sortOrder[itemOrder]; - if (itemId === activeId) { - continue; - } - if (!layouts[itemId]) { - console.warn(`Unexpected missing layout ${itemId} in layouts!`); - continue; - } - if (draggableActiveStatus.value[itemId] === false) { - continue; - } - const itemLayout = applyOffset(layouts[itemId].value, { - x: offsets[itemId].x.value, - y: offsets[itemId].y.value, - }); - if (includesPoint(itemLayout, activeCenterPoint)) { - // console.log({ itemId, itemOrder, itemLayout, activeCenterPoint }); - return itemOrder; - } - } - // Fallback to current index - return activeIndex; - }; - - // Update the draggable offset for a given item id at a given index - const updateOffsetForItemIdAtIndex = ( - offset: SharedPoint, - itemId: UniqueIdentifier, - index: number, - width: number, - height: number, - ) => { - "worklet"; - const { value: gridWidth } = latestGridWidth; - const { value: items } = latestItems; - const initIndex = items.findIndex((item, index) => - idExtractor ? idExtractor(item, index) : item.id === itemId, - ); - const initRow = Math.floor(initIndex / gridWidth); - const initCol = initIndex % gridWidth; - const nextRow = Math.floor(index / gridWidth); - const nextCol = index % gridWidth; - const moveCol = nextCol - initCol; - const moveRow = nextRow - initRow; - switch (gridMode) { - case GridMode.Row: - offset.x.value = moveCol * width; - offset.y.value = moveRow * height; - break; - case GridMode.RowReverse: - offset.x.value = moveCol * -1 * width; - offset.y.value = moveRow * height; - break; - case GridMode.Column: - offset.y.value = moveCol * width; - offset.x.value = moveRow * height; - break; - case GridMode.ColumnReverse: - offset.y.value = moveCol * -1 * width; - offset.x.value = moveRow * height; - break; - default: - break; - } - }; - - // Update draggable offsets when grid mode changes - useAnimatedReaction( - () => gridMode, - (_next, prev) => { - // Ignore initial reaction or non-ready ref - if (prev === null || !draggableRef.current) { - return; - } - const { draggableLayouts, draggableOffsets, draggableActiveId, draggableRestingOffset } = - draggableRef.current; - const { value: activeId } = draggableActiveId; - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const { value: sortOrder } = draggableSortOrder; - for (let index = 0; index < sortOrder.length; index++) { - const itemId = sortOrder[index]; - if (!layouts[itemId]) { - // Can happen on hot reload - console.warn(`Unexpected missing layouts[itemId]`); - continue; - } - const itemLayout = layouts[itemId].value; - const offset = itemId === activeId ? draggableRestingOffset : offsets[itemId]; - updateOffsetForItemIdAtIndex(offset, itemId, index, itemLayout.width, itemLayout.height); - } - }, - [gridMode], - ); - - // Update draggable offsets when placeholder index changes - useAnimatedReaction( - () => draggablePlaceholderIndex.value, - (next, prev) => { - // Ignore initial reaction or non-ready ref - if (prev === null || !draggableRef.current) { - return; - } - // Ignore tail moves - if (prev === -1 || next === -1) { - return; - } - // console.log(`placeholder: ${prev} -> ${next}`); - const { draggableLayouts, draggableOffsets, draggableActiveId, draggableRestingOffset } = - draggableRef.current; - const { value: activeId } = draggableActiveId; - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const { value: sortOrder } = draggableSortOrder; - if (activeId === null) { - return; - } - const nextOrder = moveArrayIndex(sortOrder, prev, next); - for (let prevIndex = 0; prevIndex < sortOrder.length; prevIndex++) { - const itemId = sortOrder[prevIndex]; - const itemLayout = layouts[itemId].value; - const nextIndex = nextOrder.findIndex((id) => id === itemId); - if (nextIndex !== prevIndex) { - const offset = itemId === activeId ? draggableRestingOffset : offsets[itemId]; - updateOffsetForItemIdAtIndex(offset, itemId, nextIndex, itemLayout.width, itemLayout.height); - } - } - // Finally update the sort order - draggableSortOrder.value = nextOrder; - }, - [gridMode], - ); - - // Reset offsets and order when items change - useAnimatedReaction( - () => items, - (_next, prev) => { - // Ignore initial reaction or non-ready ref - if (prev === null || !draggableRef.current) { - return; - } - // Reset sort order - draggableSortOrder.value = initialOrder; - // Reset offsets - const { draggableOffsets } = draggableRef.current; - const { value: offsets } = draggableOffsets; - for (const [, offset] of Object.entries(offsets)) { - offset.x.value = 0; - offset.y.value = 0; - } - }, - [items], - ); - - // Propagate order changes - useAnimatedReaction( - () => draggableSortOrder.value, - (next, prev) => { - // Ignore initial reaction or non-ready ref - if (prev === null || !draggableRef.current) { - return; - } - if (onOrderUpdate) { - runOnJS(onOrderUpdate)(next, prev); - } - }, - [], - ); - - const onBegin: DndProviderProps["onBegin"] = (event, meta) => { - "worklet"; - const { activeId } = meta; - const { value: sortOrder } = draggableSortOrder; - draggablePlaceholderIndex.value = sortOrder.findIndex((id) => id === activeId); - if (onBeginProp) { - onBeginProp(event, meta); - } - }; - - const onUpdate: DndProviderProps["onUpdate"] = (event, meta) => { - "worklet"; - const { activeLayout } = meta; - draggablePlaceholderIndex.value = findlaceholderIndex(activeLayout); - if (onUpdateProp) { - onUpdateProp(event, meta); - } - }; - - const onFinalize: DndProviderProps["onFinalize"] = (event, meta) => { - "worklet"; - draggablePlaceholderIndex.value = -1; - if (onFinalizeProp) { - onFinalizeProp(event, meta); - } - if (onOrderChange && lastDraggableSortOrder.value.toString() !== draggableSortOrder.value.toString()) { - runOnJS(onOrderChange)(draggableSortOrder.value, lastDraggableSortOrder.value); - lastDraggableSortOrder.value = draggableSortOrder.value; - } - }; - - const props: DndProviderProps & { ref: RefCallback } = { - onBegin, - onUpdate, - onFinalize, - ref: (value) => { - draggableRef.current = value; - }, - }; - - return { - draggableRef, - draggablePlaceholderIndex, - draggableSortOrder, - findlaceholderIndex, - updateOffsetForItemIdAtIndex, - props, - }; -}; diff --git a/src/features/grid/index.ts b/src/features/grid/index.ts deleted file mode 100644 index 5495eb7..0000000 --- a/src/features/grid/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./DraggableGridContext"; -export * from "./DraggableGridProvider"; -export * from "./components"; -export * from "./hooks"; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 689f9ab..8adc571 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,3 @@ -export * from "../features/grid/hooks/useDraggableGrid"; export * from "./useActiveDragReaction"; export * from "./useActiveDropReaction"; export * from "./useDraggable"; diff --git a/src/hooks/useDraggable.ts b/src/hooks/useDraggable.ts index 6a48616..63758b5 100644 --- a/src/hooks/useDraggable.ts +++ b/src/hooks/useDraggable.ts @@ -50,6 +50,7 @@ export const useDraggable = ({ const { draggableLayouts, draggableOffsets, + draggableRestingOffsets, draggableOptions, draggableStates, draggableActiveId, @@ -70,6 +71,7 @@ export const useDraggable = ({ height: 0, }); const offset = useSharedPoint(0, 0); + const restingOffset = useSharedPoint(0, 0); const state = useSharedValue("resting"); // Register early to allow proper referencing in useDraggableStyle draggableStates.value[id] = state; @@ -79,6 +81,7 @@ export const useDraggable = ({ "worklet"; draggableLayouts.value[id] = layout; draggableOffsets.value[id] = offset; + draggableRestingOffsets.value[id] = restingOffset; draggableOptions.value[id] = { id, data: sharedData, disabled, activationDelay, activationTolerance }; draggableStates.value[id] = state; }; @@ -88,6 +91,7 @@ export const useDraggable = ({ "worklet"; delete draggableLayouts.value[id]; delete draggableOffsets.value[id]; + delete draggableRestingOffsets.value[id]; delete draggableOptions.value[id]; delete draggableStates.value[id]; }; diff --git a/src/types/common.ts b/src/types/common.ts index 53b2d36..3bd9779 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,6 +2,7 @@ import type { HostComponent, ViewProps, ViewStyle } from "react-native"; import type { SharedValue, useAnimatedStyle } from "react-native-reanimated"; export type UniqueIdentifier = string | number; +export type ObjectWithId = { id: UniqueIdentifier; [s: string]: unknown }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyData = Record; export type Data = T | SharedValue; diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..e995cc6 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,17 @@ +export const arraysEqual = (a: unknown[], b: unknown[]): boolean => { + "worklet"; + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +}; diff --git a/src/utils/geometry.ts b/src/utils/geometry.ts index 9b260f0..720a0c4 100644 --- a/src/utils/geometry.ts +++ b/src/utils/geometry.ts @@ -15,6 +15,25 @@ export type Rectangle = { height: number; }; +/** + * @summary Split a `Rectangle` in two + * @worklet + */ +export const splitLayout = (layout: Rectangle, axis: "x" | "y") => { + "worklet"; + const { x, y, width, height } = layout; + if (axis === "x") { + return [ + { x, y, width: width / 2, height }, + { x: x + width / 2, y, width: width / 2, height }, + ]; + } + return [ + { x, y, width, height: height / 2 }, + { x, y: y + height / 2, width, height: height / 2 }, + ]; +}; + /** * @summary Checks if a `Point` is included inside a `Rectangle` * @worklet @@ -28,7 +47,7 @@ export const includesPoint = (layout: Rectangle, { x, y }: Point, strict?: boole }; /** - * @summary Checks if a `Rectange` overlaps with another `Rectangle` + * @summary Checks if a `Rectangle` overlaps with another `Rectangle` * @worklet */ export const overlapsRectangle = (layout: Rectangle, other: Rectangle) => { @@ -37,7 +56,21 @@ export const overlapsRectangle = (layout: Rectangle, other: Rectangle) => { layout.x < other.x + other.width && layout.x + layout.width > other.x && layout.y < other.y + other.height && - layout.y + layout.width > other.y + layout.y + layout.height > other.y + ); +}; + +/** + * @summary Checks if a `Rectange` overlaps with another `Rectangle` with a margin + * @worklet + */ +export const overlapsRectangleBy = (layout: Rectangle, other: Rectangle, by: number) => { + "worklet"; + return ( + layout.x < other.x + other.width - by && + layout.x + layout.width > other.x + by && + layout.y < other.y + other.height - by && + layout.y + layout.height > other.y + by ); }; @@ -67,6 +100,26 @@ export const centerPoint = (layout: Rectangle): Point => { }; }; +/** + * @summary Compute a center axis + * @worklet + */ +export const centerAxis = (layout: Rectangle, horizontal: boolean): number => { + "worklet"; + return horizontal ? layout.x + layout.width / 2 : layout.y + layout.height / 2; +}; + +/** + * @summary Checks if a `Rectangle` overlaps with an axis + * @worklet + */ +export const overlapsAxis = (layout: Rectangle, axis: number, horizontal: boolean) => { + "worklet"; + return horizontal + ? layout.x < axis && layout.x + layout.width > axis + : layout.y < axis && layout.y + layout.height > axis; +}; + export const getDistance = (x: number, y: number): number => { "worklet"; return Math.sqrt(Math.abs(x) ** 2 + Math.abs(y) ** 2); diff --git a/src/utils/index.ts b/src/utils/index.ts index 94544b6..fb86b16 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./array"; export * from "./assert"; export * from "./geometry"; export * from "./random";