Search code examples
javascriptreactjsdrag-and-dropdraggablednd-kit

How to use useDraggable / useSortable hooks of dnd-kit library properly?


I'm trying to create a simple calculator with React and dnd-kit. Elements of calculator can be dragged to droppable area and can be sorted inside of it. You can see a problem on the gif: when I drag element from left side to droppable area, there is no animation of dragging but element can be dropped to area. And inside of droppable area elements can be beautifly sorted with animation of dragging.

So, I need drag animation to work when I drag elements from left side to droppable area.

enter image description here

Code for App component:

const App: FC = () => {

    const [selected, setSelected] = useState('Constructor')

    const [droppedElems, setDroppedElems] = useState<CalcElemListInterface[]>([])

    const handleActiveSwitcher = (id: string) => {
        setSelected(id)
    }

    const deleteDroppedElem = (item: CalcElemListInterface) => {
        const filtered = [...droppedElems].filter(elem => elem.id !== item.id)
        setDroppedElems(filtered)
    }

    const leftFieldStyles = cn(styles.left, {
        [styles.hidden]: selected === 'Runtime'
    })

    const calcElementsList = calcElemListArray.map((item) => {

        const index = droppedElems.findIndex(elem => elem.id === item.id)
        const layoutDisabledStyle = index !== -1

        return (
            <CalcElemLayout 
                key={item.id} 
                id={item.id} 
                item={item}
                layoutDisabledStyle={layoutDisabledStyle}
            />
        )
    })

    const handleDragEnd = (event: DragEndEvent) => {

        const { id, list }  = event.active.data.current as CalcElemListInterface
        const elem = {id, list}

        if (event.over && event.over.id === 'droppable') {
            setDroppedElems((prev) => {
                return [...prev, elem]
            })
        }
    }

    return (
        <div className={styles.layout}>
            <div className={styles.top}>
                <Switcher
                    selected={selected}
                    handleActiveSwitcher={handleActiveSwitcher}
                />
            </div>
            <DndContext
                onDragEnd={handleDragEnd}
            >
                <div className={styles.content}>
                    <div className={leftFieldStyles}>
                        {calcElementsList}
                    </div>
                    <DropElemLayout
                        deleteDroppedElem={deleteDroppedElem}
                        selected={selected}
                        droppedElems={droppedElems}
                        setDroppedElems={setDroppedElems}
                    />
                </div>
            </DndContext>
        </div>
    )
}

Code for droppable area:

const DropElemLayout: FC<DropElemLayoutInterface> = ({ selected, droppedElems, deleteDroppedElem, setDroppedElems }) => {

    const { isOver, setNodeRef } = useDroppable({
        id: 'droppable'
    })

    const sensors = useSensors(
        useSensor(PointerSensor),
        useSensor(KeyboardSensor, {
            coordinateGetter: sortableKeyboardCoordinates,
        })
    )

    const style = {
        backgroundColor: (isOver && !droppedElems.length) ? '#F0F9FF' : undefined,
    }

    const droppedRuntimeElemList = droppedElems.map((item) => {

        const layoutEnabledStyle = droppedElems.length ? true : false

        return (
            <CalcElemLayout 
                key={item.id}
                id={item.id}
                item={item}
                deleteDroppedElem={deleteDroppedElem} 
                selected={selected}
                layoutEnabledStyle={layoutEnabledStyle}
            />
        )
    })

    const droppedElemList = !droppedElems.length
        ?
            <div className={styles.rightContent}>
                <Icon name="#drop"/>
                <p>Перетащите сюда</p>
                <span>любой элемент</span>
                <span>из левой панели</span>
            </div>
        :
            droppedRuntimeElemList

    const className = !droppedElems.length ? styles.right : styles.left

    const handleDragEnd = (event: DragEndEvent) => {
        if (event.active.id !== event.over?.id) {
            setDroppedElems((items: CalcElemListInterface[]) => {
                const oldIndex = items.findIndex(item => item.id === event.active?.id)
                const newIndex = items.findIndex(item => item.id === event.over?.id)
                return arrayMove(items, oldIndex, newIndex)
            })
        }
    }

    return (
        <DndContext
            onDragEnd={handleDragEnd} 
            sensors={sensors} 
            collisionDetection={closestCenter}
        >
            <div 
                ref={setNodeRef} 
                className={className}  
                style={style}
            >
                <SortableContext
                    items={droppedElems}
                    strategy={verticalListSortingStrategy}
                >
                    {droppedElemList}
                </SortableContext>
            </div>
        </DndContext>
    )
}

Code for Element itself:

const CalcElemLayout: FC<CalcElemLayoutInterface> = ({ item, id, deleteDroppedElem, selected, layoutDisabledStyle, layoutEnabledStyle }) => {

    const { current } = useAppSelector(state => state.calculator)

    // const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
    //     id: id,
    //     data: {...item},
    //     disabled: selected === 'Runtime'
    // })

    const {
        attributes,
        listeners,
        setNodeRef,
        transform,
        transition,
        isDragging
    } = useSortable({
        id: id,
        data: {...item},
        disabled: selected === 'Runtime'
    })

    const style = {
        transform: CSS.Translate.toString(transform),
        transition: transition
    } 

    const handleDeleteDroppedElem = () => {
        deleteDroppedElem?.(item)
    }

    const doubleClickCondition = selected === 'Constructor' ? handleDeleteDroppedElem : undefined

    const layoutStyle = cn(styles.elemLayout, {
        [styles.operators]: item.id === 'operators',
        [styles.digits]: item.id === 'digits',
        [styles.equal]: item.id === 'equal',
        [styles.disabled]: layoutDisabledStyle,
        [styles.enabled]: layoutEnabledStyle,
    })

    const buttonList = item.list?.map(elem => (
        <Button 
            key={elem.name} 
            elem={elem.name}
            selected={selected!}
        />
    ))

    const resultStyle = cn(styles.result, {
        [styles.minified]: current.length >= 10
    })

    const elemList = item.id === 'result'
        ? 
            <div className={resultStyle}>{current}</div>
        :
            buttonList

    const overlayStyle = {  
        opacity: '0.5',
    }

    return (
        <>
            <div 
                ref={setNodeRef} 
                className={layoutStyle}
                onDoubleClick={doubleClickCondition}
                style={style}
                {...attributes}
                {...listeners}
            >
                {elemList}
            </div>
        </>
    )
}

Solution

  • All you need to do is to add DragOverlay component properly in Element like so:

    const CalcElemLayout: FC<CalcElemLayoutInterface> = ({ item, id, deleteDroppedElem, selected, layoutDisabledStyle, layoutEnabledStyle }) => {
    
        const { current } = useAppSelector(state => state.calculator)
    
        const {
            attributes,
            listeners,
            setNodeRef,
            transform,
            transition,
            isDragging
        } = useSortable({
            id: id,
            data: {...item},
            disabled: selected === 'Runtime',
        })
    
        const handleDeleteDroppedElem = () => {
            deleteDroppedElem?.(item)
        }
    
        const doubleClickCondition = selected === 'Constructor' ? handleDeleteDroppedElem : undefined
    
        const layoutStyle = cn(styles.elemLayout, {
            [styles.operators]: item.id === 'operators',
            [styles.digits]: item.id === 'digits',
            [styles.equal]: item.id === 'equal',
            [styles.disabled]: layoutDisabledStyle,
            [styles.enabled]: layoutEnabledStyle,
            [styles.transparent]: isDragging 
        })
    
        const style = {
            transform: CSS.Translate.toString(transform),
            transition: transition
        } 
    
        const buttonList = item.list?.map(elem => (
            <Button 
                key={elem.name} 
                elem={elem.name}
                selected={selected!}
            />
        ))
    
        const resultStyle = cn(styles.result, {
            [styles.minified]: current.length >= 10
        })
    
        const elemList = item.id === 'result'
            ? 
                <div className={resultStyle}>{current}</div>
            :
                buttonList
    
        const dragOverlayContent = isDragging 
            ?
                <div 
                    className={layoutStyle} 
                    style={{
                        opacity: isDragging ? '1' : '',
                        boxShadow: isDragging ? '0px 2px 4px rgba(0, 0, 0, 0.06), 0px 4px 6px rgba(0, 0, 0, 0.1)' : ''
                    }}
                >
                    {elemList}
                </div> 
            : 
                null
    
        return (
            <>
                <div 
                    ref={setNodeRef} 
                    className={layoutStyle}
                    onDoubleClick={doubleClickCondition}
                    style={style}
                    {...attributes}
                    {...listeners}
                >
                    {elemList}
                </div>
                <DragOverlay dropAnimation={null}>
                    {dragOverlayContent}
                </DragOverlay>
            </>
        )
    }