import React, { useEffect, useState } from 'react'; import { LayoutAnimation, Platform, StyleSheet, UIManager, View, } from 'react-native'; import { Gesture, GestureDetector, PanGestureHandlerEventPayload, } from 'react-native-gesture-handler'; import { useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { ANIMATE_TO_NEW_PLACE_DURATION, getSizeConstants } from './constants'; import Draggable from './Draggable'; if ( Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental ) { UIManager.setLayoutAnimationEnabledExperimental(true); } export interface DraggableItemData { data: T; isActive: boolean; draggingActive: boolean; tileSize: number; } interface DragAndDropProps { items: T[]; itemsInRowCount: number; onOrderUpdate: (items: T[]) => void; renderItem: (data: DraggableItemData) => React.ReactElement; rowGap?: number; columnGap: number; } function DragAndDrop({ items, renderItem, onOrderUpdate, itemsInRowCount, columnGap, rowGap = columnGap, }: DragAndDropProps) { const { TILE_SIZE, TILE_WITH_MARGIN_SIZE, ROW_HEIGHT } = getSizeConstants({ itemsInRowCount, rowGap, columnGap, }); const PlaceholderComponent = { id: 'dnd-placeholder' } as T; const [data, setData] = useState(items); const [dragActive, setDragActive] = useState(false); const [activeElementId, setActiveElementId] = useState(null); const [placeholderIndex, setPlaceholderIndex] = useState(null); const activeElementTranslation = { x: useSharedValue(0), y: useSharedValue(0), }; // used to calculate position of currently dragged element in relation to drag start position // we are using dragged element center as calculations point - then we just add translationX/Y from gesture // this way we can easily calculate proper index based of current gesture position const [ activeElementInitialPosition, setActiveElementInitialPosition, ] = useState({ x: 0, y: 0, }); const updateDataOnDragEnd = () => { const newData = [...data]; const activeElementIndex = getActiveElementIndex(); if (activeElementIndex !== null && placeholderIndex !== null) { newData.splice(activeElementIndex, 1); const activeElement = getActiveElement(); if (activeElement) { newData.splice(placeholderIndex, 0, activeElement); } } setData(newData); onOrderUpdate(newData); }; const enableDragging = (id: string) => { setActiveElementId(id); setDragActive(true); }; const updateDataOnEnd = () => { setDragActive(false); setActiveElementId(null); updateDataOnDragEnd(); activeElementTranslation.x.value = 0; activeElementTranslation.y.value = 0; }; const onDragEnd = () => { const newPlacePosition = getItemCenterPosition(placeholderIndex); if (newPlacePosition !== null) { activeElementTranslation.x.value = withTiming( newPlacePosition.x - activeElementInitialPosition.x, { duration: ANIMATE_TO_NEW_PLACE_DURATION } ); activeElementTranslation.y.value = withTiming( newPlacePosition.y - activeElementInitialPosition.y, { duration: ANIMATE_TO_NEW_PLACE_DURATION } ); // we want to wait till animation to a new place will finish // I think there's a bug in RN2 with how withTiming callback works so we can't use it :( setTimeout(updateDataOnEnd, ANIMATE_TO_NEW_PLACE_DURATION + 20); } }; const getActiveElement = () => { return data.find((el) => el.id === activeElementId); }; const getActiveElementIndex = () => { const index = data.findIndex((el) => el.id === activeElementId); return index !== -1 ? index : null; }; const getItemCenterPosition = (index: number | null) => { if (index === null) { return null; } const row = Math.floor(index / itemsInRowCount) + 1; const yCenterPosition = row * ROW_HEIGHT - TILE_SIZE / 2; const elementsBeforeCount = (row - 1) * itemsInRowCount; const xCenterPosition = TILE_WITH_MARGIN_SIZE * (index + 1 - elementsBeforeCount) - TILE_SIZE / 2; return { x: xCenterPosition, y: yCenterPosition }; }; const setActiveElementPositionFromIndex = () => { const index = getActiveElementIndex(); const activeElementCenterPosition = getItemCenterPosition(index); if (activeElementCenterPosition) { setActiveElementInitialPosition(activeElementCenterPosition); } }; const setPlaceholderIndexFromGesturePosition = ({ translationX, translationY, }: PanGestureHandlerEventPayload) => { const x = activeElementInitialPosition.x + translationX; const y = activeElementInitialPosition.y + translationY; const itemsCountInRowsAbove = Math.max( 0, Math.floor(y / ROW_HEIGHT) * itemsInRowCount ); const itemsCountInColumnBefore = Math.max( 0, Math.floor(x / TILE_WITH_MARGIN_SIZE) ); const newPlaceholderIndex = Math.min( Math.max(0, itemsCountInRowsAbove + itemsCountInColumnBefore), data.length ); if (newPlaceholderIndex !== placeholderIndex) { LayoutAnimation.easeInEaseOut(); setPlaceholderIndex(newPlaceholderIndex); } }; useEffect(() => { // react to activeElement change setActiveElementPositionFromIndex(); setPlaceholderIndex(getActiveElementIndex()); }, [activeElementId]); const onPositionUpdate = (e: PanGestureHandlerEventPayload) => { const { translationX, translationY } = e; setPlaceholderIndexFromGesturePosition(e); activeElementTranslation.x.value = withSpring(translationX, { mass: 0.5 }); activeElementTranslation.y.value = withSpring(translationY, { mass: 0.5 }); }; const onUpdateHandler = dragActive ? onPositionUpdate : () => { /* empty handler */ }; const dragGesture = Gesture.Pan().onUpdate(onUpdateHandler).onEnd(onDragEnd); const tapEndedGesture = Gesture.Tap().onEnd((_, isFinished) => { if (isFinished) { updateDataOnEnd(); } }); const _renderItems = () => { const newData = [...data]; const activeElementIndex = getActiveElementIndex(); if (placeholderIndex !== null) { if (activeElementIndex !== null) { newData.splice(activeElementIndex, 1); } newData.splice(placeholderIndex, 0, PlaceholderComponent); const activeElement = getActiveElement(); if (activeElement) { newData.push(activeElement); } } return newData.map(_renderItem); }; const _renderItem = (item: T) => { const isActive = activeElementId === item.id; return ( {renderItem({ data: item, isActive, draggingActive: dragActive, tileSize: TILE_SIZE, })} ); }; return ( {_renderItems()} ); } const styles = StyleSheet.create({ container: { flexDirection: 'row', flexWrap: 'wrap', backgroundColor: 'transparent', // needed for gestures to work on android }, }); export default DragAndDrop;