본문 바로가기

FrontEnd/React

[React] 트렐로 따라 만들기 (2)

react-dnd의 useDrop, useDrag 훅 알아보기

 react-dnd 패키지의 기능으로 제공되는 드래그 앤 드롭으로 기능인 useDrop 훅, useDrag 훅을 알아보자.

 

 useDrop 훅은 튜플 타입 반환값에서 두번 째 멤버변수인 drop 함수를 얻어 사용하는게 기본 사용법이며 accept는 대상을 구분하는 용도로 사용되는 문자열이다.  또한 drop함수를 호출해 사용할 수도 있다.

// useDrop 훅의 기본 사용법
const [, drop] = useDrop(() => ({
    accept: 'card'
})
<div ref={(node) => drop(node)} />  // ref를 통해 drop함수를 원하는 요소설정


// drop 함수를 호출하는방식으로 구현
const divRef = useRef<HTMLDivElement>(null)
drop(divRef)

 

 

react-dnd의 useDrag 훅의 사용법은 아래와 같다.  useDrop, useDrop의 type(드롭타겟)은 동일해야 상호작용이 가능하다.

const [{isDragging}, drag] = useDrag({ //{isDragging} 드래그상태 추적, drag: 드래그기능DOM연결
    type: 'card',   // 드롭타겟 설정
    item: () => {    // 드래그가 시작될 때 전달할 데이터를 반환
      return {id, index} // 드래그 상태와 드롭 타겟간의 통신에 사용되는 데이터
    },
    collect: (monitor: any) => ({  // 드래그 상태를 추적하고 필요한 데이터를 반환
      isDragging: monitor.isDragging() // 현재 요소가 드래그 중인지 반환
    })
  })

  const opacity = isDragging ? 0 : 1 // 드래그 중일시 불투명도 위한 설정
  drag(drop(ref))  // ref요소를 드래그&드롭 가능하게 설정
  return (
    <div ref={ref} style={{...style, opactiry}} data-handler-id={handlerId}>
      {text}
    </div>
  )

 

 

ListDragable 컴포넌트 구현하기

 앞서본 Drag&Drop 훅 설정을 토대로 ListDraggable 컴포넌트를 추가한다.  하단의 소스의 실행순서를 확인하자.

  1. 컴포넌트 초기화 : useRef로 <div> DOM 참조 생성 및 useDrag와 useDrop으로 드래그/드롭 설정
  2. 드래그 시작 : 사용자가 항목을 클릭하고 드래그하면 useDrag의 item 함수가 호출하고 isDragging상태  true로 변경
  3. 드래그 중 : 항목이 다른 항목 위로 이동하면 useDrop의 hover 함수 호출되며 onMove함수로 인덱스를 변경
  4. 드래그 종료 : 항목을 놓으면 상태가 업데이트되고 드래그 종료
  5. 렌더링 : 항목의 스타일 및 드래그 상태 반영(opacity 등)
import type {FC} from 'react'
import {useRef} from 'react'
import type {DivProps} from './Div'
import {useDrag, useDrop} from 'react-dnd'
import type {Identifier} from 'dnd-core'

export type MoveFunc = (dragIndex: number, hoverIndex: number) => void

export type ListDraggableProps = DivProps & {id: any; index: number; onMove: MoveFunc}

interface DragItem {index: number, id: string, type: string}

export const ListDraggable: FC<ListDraggableProps> = ({id, index, onMove, style, className, ...props}) => {
  const ref = useRef<HTMLDivElement>(null)

  // 드래그 시 리스트 항목 데이터를 제공하고, 드래그 상태를 추적.
  const [{isDragging}, drag] = useDrag({
    type: 'list',
    item: () => { // 드래그가 시작될 때 전달할 데이터를 정의
      return {id, index}
    },
    collect: (monitor: any) => ({      // 현재 드래그 상태를 모니터링
      isDragging: monitor.isDragging() // 해당 항목이 그래그중인지 여부 
    })
  })

  // 다른 리스트 항목이 자신 위로(hover) 드래그되었을 때 발생, 인덱스를 비교해 위치를 업데이트.
  const [{handlerId}, drop] = useDrop<DragItem, void, {handlerId: Identifier | null}>({
    accept: 'list',
    collect(monitor) {  // 드롭상태 모니터링(handlerId: 현재 드롭 타켓에 할당된 핸들러ID)
      return { handlerId: monitor.getHandlerId() }
    },
    hover(item: DragItem) {  // 드래그된 항목이 드롭 타겟 위로 올라갈 때 발생
      if (!ref.current) return
      
      const dragIndex = item.index  // 드래그중인 인덱스
      const hoverIndex = index      // 현대 드롭 타켓 인덱스

      if (dragIndex === hoverIndex) return
      onMove(dragIndex, hoverIndex) // 현재 두 항목의 위치를 업데이트하는 함수
      item.index = hoverIndex       // item.index를 hoverIndex로 바꿔 다음 드래그에서 최신상태 유지
    }
  })

  const opacity = isDragging ? 0 : 1  // 드래그 중 투명도 변경
  drag(drop(ref))  // 드래그와 드랍을 연결
  return (
    <div ref={ref} {...props} className={[className, 'cursor-move'].join(' ')} style={{...style, opacity}} data-handler-id={handlerId} />
  )
}

 

 BoardList에 ListDraggable 컴포넌트를 추가하자.  ListDraggable은 index와 onMoveList 함수를 Board로부터 얻어오기 때문에 이 2개 속성을 추가로 설정해야 한다.

...(생략)...
import type {MoveFunc} from '../../components'
import {ListDraggable} from '../../components'

export type BoardListProps = {list: List, RemoveList?: () => void
  index: number, onMoveList: MoveFunc  // 추가
}
const BoardList: FC<BoardListProps> = ({list,onRemoveList,
  index, onMoveList, // 추가
  ...props
}) => {
  ...(생략)...
  return (
    <ListDraggable id={list.uuid} index={index} onMove={onMoveList}>
      ...(생략)...
    </ListDraggable>
  )
}

export default BoardList

 

 

 useLists 커스텀 훅의 ListDraggable 컴포넌트에 추가한 onMoveList 속성을 Board로부터 얻기위해 코드를 추가한다.

...(생략)...
export const useLists = () => {
  ...(생략)...

  const listidOrders = useSelector<AppState, LO.State>(({listidOrders}) => listidOrders)
  
  ...(생략)...

  const onMoveList = useCallback(
    (dragIndex: number, hoverIndex: number) => {
      const newOrders = listidOrders.map((item, index) =>
        index === dragIndex
          ? listidOrders[hoverIndex]
          : index === hoverIndex
          ? listidOrders[dragIndex]
          : item
      )
      dispatch(LO.setListidOrders(newOrders))
    },
    [dispatch, listidOrders]
  )

  return {lists, onCreateList, onRemoveList, onMoveList}
}

 

 마지막으로 Board 컴포넌트의 코드를 수정해 드래그앤 드롭이 실행가능하게 변경한다.  ListDraggable에서 drag&drop을 선언했지만 Board컴포넌트에도 drop영역을 설정하는 이유는 드래그와 드롭의 책임과 동작 영역이 서로 다르기 때문이다.

ListDraggable컴포넌트는 목록을 드래그 할 수 있도록 설정하기 때문에 개별 항목의 드롭 영역을 설정한다.  Board 컴포넌트는 목록 전체를 포함하는 큰 영역을 드롭 영역으로 설정한다.   즉 사용자가 리스트를 드래그해 전체 보드 영역 내에서만 작업하도록 제한한다.

import {useMemo, useRef} from 'react'
import {useDrop} from 'react-dnd'
import {Title} from '../../components'
import {useLists} from '../../store/useLists'
import BoardList from '../BoardList'
import CreateListForm from './CreateListForm'
export default function Board() {
  // useDrop type인 list의 드랍가능한 가장 큰 영역을 설정하기 위함
  const divRef = useRef<HTMLDivElement>(null)
  const [, drop] = useDrop({
    accept: 'list'
  })
  drop(divRef)

  const {lists, onCreateList, onRemoveList, onMoveList} = useLists()

  const children = useMemo(
    () =>
      lists.map((list, index) => (
        <BoardList
          key={list.uuid}
          list={list}
          onRemoveList={onRemoveList(list.uuid)}
          index={index}
          onMoveList={onMoveList}
        />
      )),
    [lists, onRemoveList, onMoveList]
  )

  return (
    <section className="mt-4">
      <Title>Board</Title>
      <div className="flex flex-wrap p-2 mt-4">
        {children}
        <CreateListForm onCreateList={onCreateList} />
      </div>
    </section>
  )
}

 

 

ListDroppable 컴포넌트 구현하기

 ListDraggable 컴포넌트와 마찬가지로 Board컴포넌트에 선언된 drop을 ListDroppable 컴포넌트를 분리해 Board 컴포넌트에 추가한다.

import type {FC} from 'react'
import {useRef} from 'react'
import type {DivProps} from './Div'
import {useDrop} from 'react-dnd'

export type ListDroppableProps = DivProps & {}

export const ListDroppable: FC<ListDroppableProps> = ({...props}) => {
  const divRef = useRef<HTMLDivElement>(null)
  const [, drop] = useDrop({
    accept: 'list'
  })
  drop(divRef)
  return <div ref={divRef} {...props} />
}
import {useMemo, useRef} from 'react'
import {useDrop} from 'react-dnd'
import {Title} from '../../components'
import {useLists} from '../../store/useLists'
import BoardList from '../BoardList'
import CreateListForm from './CreateListForm'
import {ListDroppable} from '../../components'
export default function Board() {
  const {lists, onCreateList, onRemoveList, onMoveList} = useLists()

  const children = useMemo(
    () =>
      lists.map((list, index) => (
        <BoardList
          key={list.uuid}
          list={list}
          onRemoveList={onRemoveList(list.uuid)}
          index={index}
          onMoveList={onMoveList}
        />
      )),
    [lists, onRemoveList, onMoveList]
  )

  return (
    <section className="mt-4">
      <Title>Board</Title>
      <ListDroppable className="flex flex-row p-2 mt-4">
        <div className="flex flex-wrap p-2 mt-4">
          {children}
          <CreateListForm onCreateList={onCreateList} />
        </div>
      </ListDroppable>
    </section>
  )
}

 

 

react-beautful-dnd 패키지 이해하기

 react-beautiful-dnd 패키지는 DragDropcontext와 Droppable, Draggable 컴포넌트를 제공한다.  이 컴포넌트를 통해 목록에 포함되어있는 카드를 Drag&Drop을 통해 옮기는 기능을 구현해보도록 하겠다.

 

 

 이 패키지 또한 컨텍스트를 기반으로 하고 있으며 기본 사용법은 아래와 같다.  

import { DragDropContext, Droppable, Draggable } form 'react-beautiful-dnd"
import type {DropResult} from 'react-beautiful-dnd'

const onDragEnd = (result: DropResult) => {}

<DragDropContext onDragEnd={onDragEnd}>
  /* Droppable과  Draggable을 사용하는 컴포넌트 */
</DragDropContext>

 

 

 이 코드를 Board컴포넌트에 추가한다.  Listdropable 밖을 DragDropContext가 동작하기 위해서는 onDragEnd라는 콜백함수를 onDragend속성에 추가해야 한다.

 

import {DragDropContext} from 'react-beautiful-dnd'
...(생략)...

export default function Board() {
  const {lists, onCreateList, onRemoveList, onMoveList, onDragEnd} = useLists() // onDragEnd 추가

  const children = useMemo(
    () =>
      lists.map((list, index) => (
        <BoardList
          key={list.uuid}
          list={list}
          onRemoveList={onRemoveList(list.uuid)}
          index={index}
          onMoveList={onMoveList}
        />
      )),
    [lists, onRemoveList, onMoveList]
  )

  return (
    <section className="mt-4">
      <Title>Board</Title>
      <DragDropContext onDragEnd={onDragEnd}>  // 추가
        <ListDroppable className="flex flex-row p-2 mt-4">
          <div className="flex flex-wrap p-2 mt-4">
            {children}
            <CreateListForm onCreateList={onCreateList} />
          </div>
        </ListDroppable>
      </DragDropContext>
    </section>
  )
}

CardDraggable 컴포넌트 구현하기 (ListCard 컴포넌트에 적용)

 react-beautiful-dnd 패키지는 Draggable 컴포넌트를 제공하며 사용법이 독특하므로 CardDraggable 컴포넌트로 분리해 구현하자.  

import type {FC, PropsWithChildren} from 'react'
import {Draggable} from 'react-beautiful-dnd'

// draggableId : 카드의 uuid, index: 카드순서
export type CardDraggableProps = {draggableId: string; index: number}
export const CardDraggable: FC<PropsWithChildren<CardDraggableProps>> = ({
  draggableId,
  index,
  children
}) => {
  return (
    <Draggable draggableId={draggableId} index={index}>
      {provided => {
        return (
          <div
            // DOM 노드를 드래그 상태와 연결하는데 사용되는 ref
            ref={provided.innerRef}        
            // 드래그 가능한 항목을 올바르게 렌더링하기 위해 필요한 속성들
            {...provided.draggableProps}   
            // 드래그를 시작할 때 필요한 속성을 제공하며 이속성을 전달해야 드래그 핸들이 활성화된다.
            {...provided.dragHandleProps}> 
            {children}
          </div>
        )
      }}
    </Draggable>
  )
}

 

CardDroppable 컴포넌트 구현하기 (BoardList 컴포넌트에 적용)

react-beautiful-dnd 패키지는 Droppable 컴포넌트를 제공하며 이 또한 사용법이 독특하므로 CardDroppable 컴포넌트로 분리해 구현하자.

import type {FC, PropsWithChildren} from 'react'
import {Droppable} from 'react-beautiful-dnd'

// droppableId: 목록의 uuid
export type CardDroppableProps = {
  droppableId: string
}
export const CardDroppable: FC<PropsWithChildren<CardDroppableProps>> = ({
  droppableId,
  children
}) => {
  return (
    // Droppable : 컴포넌트를 감싸 드래그된 항목을 드롭할 수 있는 영역을 생성 
    <Droppable droppableId={droppableId}>
      {provided => (
        <div
          ref={provided.innerRef}      // DOM 요소와 react-beautiful-dnd의 드롭 상태를 연결
          {...provided.droppableProps} // 드롭가능한 영역의 필요한 props
          className="flex flex-col p-2">
          {provided.placeholder}       // 드래그 중인 항목이 드롭될 공간을 유지하고 상태를 업데이트
          {children} 
        </div>
      )}
    </Droppable>
  )
}

배열 관련 유틸리티 함수 구현하기

 DragDropContext 컴포넌트가 요구하는 onDragEnd 속성 설정할 콜백함수에 적용할 유틸리티 함수를 추가한다.

// 현재 카드의 위치를 변경할 때 사용된다.
export const swapItemsInArray = <T>(array: T[], index1: number, index2: number) =>
  array.map((item, index) =>
    index === index1 ? array[index2] : index === index2 ? array[index1] : item
  )
// 특정된 카드를 삭제할 때 사용한다.
export const removeItemAtIndexInArray = <T>(array: T[], removeIindex: number) =>
  array.filter((notUsed, index) => index !== removeIindex)

// 현재 목록이 아닌 다른목록의 카드목록에 새 카드를 추가한다.
export const insertItemAtIndexInArray = <T>(array: T[], insertIndex: number, item: T) => {
  const before = array.filter((item, index) => index < insertIndex)
  const after = array.filter((item, index) => index >= insertIndex)
  return [...before, item, ...after]
}

onDragEnd 콜백 함수 구현하기