본문 바로가기

React/응용프로젝트

Sortable 컴포넌트 만들기 (drag and drop)

 

리액트 (React) 로 Sortable 컴포넌트 만들기입니다.

강의 보고 이해가 안가서, 복습할 겸 정리하는 글이다보니

정확도가 높지않을 수 있습니다. 그래도 구현은 했으니, 참고해주세요

https://www.npmjs.com/package/@imsmallgirl/sortable-list

 

@imsmallgirl/sortable-list

This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).. Latest version: 0.1.0, last published: 10 minutes ago. Start using @imsmallgirl/sortable-list in your project by running `npm i @imsmallgirl/sortable-list

www.npmjs.com


완성본


폴더 구성


data

export const data = [
    {content : 'react'},
    {content : 'vue'},
    {content : 'angular'},
    {content : 'js'},
    {content : 'redux'}
]

CSS 기본 세팅

 

SortableList.css
TestItem.css
index.css


SortableListItem.jsx

 

받아올 props 와 useRef() 설정해주기

import React, { useRef } from 'react'

function SortableListItem({index, draggable, children, onDragStart, onDropItem, onClickItem}) {
    const itemRef = useRef(null)

  return (
    <li ref={itemRef}
    className="item"
	>
    
    </li>
  )
}

export default SortableListItem
받아올 props 에 대한 설명
1. index : 데이터를 map() 을 사용해서 SortableListItem 을 동적으로 생성할 때, index 의 값을 받아올 수 있기때문에 index 를 받아온다.
2. draggable : 드래그 여부를 알기 위해서 받아오는 값

3. children :  자식 컴포넌트 또는 html 엘리먼트가 어떻게 구성되어있는지 모르기때문에, 사용함 (밑에서 자세히 설명)
4. onDragStart : 리스트 아이템이 (li) 가 드래그 시작할 때 실행시킬 함수
5. onDropItem : 드래그가 완료되어서, 순서가 바뀔 때 데이터를 변화시키는 함수
6. onClickItem : 아이템을 클릭했을 때 해당 아이템의 인덱스를 alert () 메서드로 알려주기 위한 함수 ( 즉, 인덱스를 사용해 아이템 마다 실행시킬 함수를 만들어도됨 )
useRef() 을 사용하는 이유
1. 우리가 선택하려는 아이템을 사용하기 위해서 ( current ) 사용
2. 리렌더링을 하지 않고 컴포넌트의 속성만 조회하고 수정할 수 있기 때문에
props.children 
- react 안의 keyword

- 주로 자식 컴포넌트 또는 html 엘리멘트가 어떻게 구성되어있는지 모르는 경우, 화면에 표시해야하는 경우에 사용함.
- 태그와 태그 사이의 모든 내용을 표시하기 위해 사용되는 특수한 props.
- 코드의 재사용성을 향상하며 JSX 요소를 좀 더 유연하고 밀접하게 다룰 수 있음.
- 태그와 태그 사이의 모든 요소들을 자식 취급하지는 않음.

사용방법 참고영상 : https://www.youtube.com/watch?v=C9GwW7lvg2o
참고 문서 : https://developer-talk.tistory.com/226

 

리스트 아이템 (li) 들의 드래그 이벤트에 맞춰서 클래스 추가해주기

import React, { useRef } from 'react'

function SortableListItem({index, draggable, children, onDragStart, onDropItem, onClickItem}) {
    const itemRef = useRef(null)
    const onDragStartItem = () => {
        itemRef.current.classList.add('dragstart')
        onDragStart(index)
    }
    const onDragEnd = () =>
        itemRef.current.classList.remove('dragstart')

    const onDragEnter = () =>
        itemRef.current.classList.add('dragover')

    const onDragLeave = () =>
        itemRef.current.classList.remove('dragover')

    const onDragOver = (e) => e.preventDefault() 

    const onDrop = () => {
        itemRef.current.classList.remove('dragover')
        onDropItem(index)
    }

    const onClick = () => onClickItem(index)


  return (
    <li ref={itemRef}
    className="item"
    draggable={draggable ? draggable : false}
    onDragStart={onDragStartItem}
    onDragEnd={onDragEnd}
    onDragEnter={onDragEnter}
    onDragLeave={onDragLeave}
    onDragOver={onDragOver}
    onDrop={onDrop}
    onClick={onClick}
    >
        {children}
    </li>
  )
}

export default SortableListItem
이벤트에 대한 설명
1. draggable : 받아온 draggable 이 true 이면 , 요소와 상호동작을 할때마다 드래그 드롭 이벤트들이 발생하게 한다.
    draggable={draggable ? draggable : false}

2. onDragStart : 드래그하려고 시작할 때 드래그 하려는 요소에게 "dragstart" 클래스를 추가해주고,
SortableList.jsx 에서 받아온 onDragStart() 라는 함수에게 index 를 파라미터로 전달해주어 실행시킨다. ( SortableList.jsx, App.js 참고  )
    const onDragStartItem = () => {
        itemRef.current.classList.add('dragstart')
        onDragStart(index)
    }

3. onDragEnd : 드래그하다가 마우스 버튼을 놓는 순간에 "dragstart" 클래스를 제거해준다.

    const onDragEnd = () =>
        itemRef.current.classList.remove('dragstart')

4. onDragEnter : 마우스가 대상 요소의 위로 처음 진입할 때 "dragover" 클래스를 추가해준다. (border-top-color 변경 클래스)

    const onDragEnter = () =>
        itemRef.current.classList.add('dragover')

5. onDragLeave : 드래그가 끝나서 마우스가 대상 요소의 위에서 벗어날 때 "dragover" 클래스를 제거한다 (border-top-color 제거)

    const onDragLeave = () =>
        itemRef.current.classList.remove('dragover')

6. onDragOver : 드래그하면서 마우스가 대상 요소의 영역 위치에 자리 잡고 있을 때 기본 동작을 막아주는 event.preventDefault() 를 실행시켜준다.
* 기본적으로 HTML 요소는 다른 요소의 위에 위치할 수 없음
=> 다른 요소 위에 위치할 수 있도록 만들기 위해서는 놓일 장소에 있는 요소의 기본 동작을 막아야함.

    const onDragOver = (e) => e.preventDefault()

7. onDrop : dragover 이벤트가 실행되고 있는 요소의 드래그를 끝내면 "dragover" 라는 클래스를 제거해준다 (border-color 제거)
SortableList.jsx 에서 받은 onDropItem() 에 index를 넣어 실행시켜준다 ( SortableList.jsx, App.js 참고 )

    const onDrop = () => {
        itemRef.current.classList.remove('dragover')
        onDropItem(index)
    }

8. onClick : 클릭했을 때, 클릭한 인덱스를 alert () 메서드로 알려주기 위해 onClickItem () 함수 실행 ( SortableList.jsx, App.js 참고 )

    const onClick = () => onClickItem(index)
주의할 점
- drop, dragover 이벤트는 필수로 사용해야하는 이벤트
- dragover 이벤트를 적용하지 않으면 drop 이벤트가 작동하지 않음

dragleave
- dragenter 이벤트와 동작이 겹칠 수 있기 때문에 preventDefault() 로 제한하며 둘이 결합하여 사용해야함

drop
- drop 이벤트 역시 드롭될 요소에는 preventDefault() 를 사용하지 않으면 정상적인 동작이 되지 않을 수 있음

SortableList.jsx

 

App.js 에서 받아 올 props 설정

import React, { useCallback, useState } from 'react'
import SortableListItem from './SortableListItem'
import "./SortableList.css"

function SortableList({data, onDropItem, onClickItem, renderItem}) {

  return (
  
  )
}

export default SortableList
props 에 대한 설명
1. data : testData.js 에서 받아오는 data 들
2. onDropItem : 드래그가 끝났을 때 발생할 함수
3. onClickItem : list item (li) 를 클릭할 때 발생할 함수
4. renderItem : App.js 에서 전달한 함수 ( App.js 에서 자세히 설명 )

 

기본 값 설정하기 (useState)

function SortableList({data, onDropItem, onClickItem, renderItem}) {
    const [startIndex, setStartIndex] = useState(0)
    const [listData, setListData] = useState(data)

  return (

  )
}

export default SortableList
1.  SortableListItem.jsx 에서 클릭한 index 의 값을 받아서 저장할 곳 ( 드래그를 시작한 index )
2. 바뀌는 데이터를 listData 에 저장해, SortableListItem 을 동적으로 생성하기위해 만들어주고, 기본 값은 data 로 한다.

 

onDragStart ()

function SortableList({data, onDropItem, onClickItem, renderItem}) {
    const [startIndex, setStartIndex] = useState(0)
    const [listData, setListData] = useState(data)

    const onDragStart = (index) => setStartIndex(index)
    
  return (

  )
}

export default SortableList
1. SortableListItem.jsx에서 받아온 index 값을 startIndex 에 저장해준다

 

onDrop () - 드래그가 완료되었을 때, 실행 될 함수

import React, { useCallback, useState } from 'react'
import SortableListItem from './SortableListItem'
import "./SortableList.css"

function SortableList({data, onDropItem, onClickItem, renderItem}) {
    const [startIndex, setStartIndex] = useState(0)
    const [listData, setListData] = useState(data)

    const onDragStart = (index) => setStartIndex(index)
    
    const onDrop = useCallback((dropIndex) => {
        const dragItem = listData[startIndex]
        const list = [...listData]
        list.splice(startIndex, 1)
        const newListData = startIndex < dropIndex ?
        [...list.slice(0, dropIndex-1), dragItem, ...list.slice(dropIndex-1, list.length)]
        : [...list.slice(0, dropIndex), dragItem, ...list.slice(dropIndex, list.length)]
        setListData(newListData)
        onDropItem(newListData)
    },[startIndex, onDropItem, listData])

  return (

  )
}

export default SortableList
1. SortableListItem.jsx 에서 받아온 dropIndex(드래그가 완료되어서 바뀐 index) 값을 매개변수로 받아온다.
2. dragItem 은 listData의 데이터에서 dropIndex (드래그가 완료되어서 바뀐 index) 를 저장해준다.
3. list 는 listData 의 모든 데이터를 잠시 저장해놓는다
4. list 에서 splice() 메서드를 사용해 드래그 되는 데이터를 삭제해준다.
5. newListData 는 드래그가 된 새로운 데이터 (즉, 바뀐 데이터) 를 저장해두는 곳이다.
6. startIndex (처음 드래그한 아이템 index) 보다 dropIndex (드래그해서 둔 곳의 index) 보다 작을 경우를 삼항 연산자를 통해서 코드를 작성해준다 ( 자세한 설명은 바로 밑에서 )
7. ListData 에 새로 수정된 데이터를 넣어준다.
8. onDropItem() 함수에도 새로 수정된 데이터를 넣어서 console.log를 통해서 새로 바뀐 데이터의 정보를 볼 수 있게 한다.
9. useCallback 을 사용해 자식 컴포넌트에게 props 로 전달하는 함수의 재실행이 일어나지 않도록 해준다. ( 밑에서 자세한 설명 )
=> startIndex ( 드래그를 시작할 때 바뀌는 값) , onDropItem (newListData 가 바뀌기 때문에), listData (받아오는 data 가 달라질 수 있기 때문에)

 

8번 설명

- true 일 경우 저장되는 데이터들에 대한 순서 설명

1. 잠시 저장해놓은 모든 list 데이터에서 첫번째부터 dropIndex (드래그해서 둔 곳의 index) 에서 1을 뺀 개수의 데이터를 반환한 배열

console.log(dropIndex, dropIndex - 1)
=> 4 , 3

첫번째 데이터 (0) 을 4번째로 이동했을 때 dropIndex
...list.slice(0, dropIndex-1)

드래그 완료된 곳에 앞부분만 잘라서 배열로 반환한다


2. 처음으로 드래그를 시작한 아이템 ( 위치를 옮기려고 하는 데이터 )

3. 모든 list 에서 dropIndex (드래그해서 둔 곳의 index) 에서 1을 뺀 순서 (index) 에서 list.length 만큼 자른 뒷 데이터를 반환한 배열

console.log(...list.slice(dropIndex-1, list.length))
=> {content: 'js'} {content:'redux'}

첫번째 데이터 (0) 을 index 2번째로 이동했을 때 반환된 배열
...list.slice(dropIndex-1, list.length)

드래그 완료된 곳이 index 2번째라고 하면 1번째까지 list 의 개수만큼 잘라낸 데이터를 반환한다.
list.length 는 4로 처음부터 list에서 드래그 시작한 데이터를 잘라낸 데이터를 반환했기 때문에
(즉, 드래그를 시작한 데이터는 제외하고 계산)

 

 

false 일 경우 저장되는 데이터들에 대한 순서 설명 ( false 일 경우라면 드래그를 해서 위치 이동을 안했다는 의미 )

1. 드래그를 시작한 데이터를 제외한 모든 list 에서 첫번째 index까지 dropIndex 개수 만큼 잘라내 배열을 반환한다.

console.log(...list.slice(0, dropIndex))
=> {content:'react'}

예를 들어 index 2번째 요소를 1번째로 옮겼을 때, 첫번째 순서에서 1개 잘라준 배열을 반환함

 

2. 처음으로 드래그를 시작한 아이템 ( 위치를 옮기려고 하는 데이터 )

3. dropIndex 까지 list.length(4) 개수 만큼 잘라내 배열을 반환한다

console.log(...list.slice(dropIndex, list.length))
=> {content: 'vue'} {content: 'js'} {content: 'redux'}

index 2번째 데이터를 1번째로 이동했을 때, 1번째 다음부터 list.length(4) 만큼 잘라준 데이터를 반환함

이걸 이해하려면 slice() 배열 함수에 대해 더 알아야합니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/slice

 

Array.prototype.slice() - JavaScript | MDN

slice() 메서드는 어떤 배열의 begin 부터 end 까지(end 미포함)에 대한 얕은 복사본을 새로운 배열 객체로 반환합니다. 원본 배열은 바뀌지 않습니다.

developer.mozilla.org

useCallback
- 특정 함수를 새로 만들지 않고 재사용하고 싶을 때 사용
- 함수를 useCallback 으로 감싸주면 react가 함수를 저장하고 두 번째 매개변수인 dependencies가 변경되지 않는다면 함수를 재생성하지 않기 때문에 해당 함수를 props로 가지는 자식 컴포넌트들은 함수가 바뀌었다고 생각하지 않게 된다.
useCallback(fn, deps)​

fn : 첫번째 인자로는 인라인 콜백과 의존성 값의 배열을 받음
deps : 의존성 배열인 deps에 변경을 감지해야할 값을 넣어주게 되면 deps 값이 변경될 때마다 콜백 함수를 새로 생성

참고 : https://velog.io/@rjsdnql123/TIL-React.useCallback-%EC%9D%B4%EB%9E%80

 

listData.map() 을 사용해, SortableListItem 동적으로 생성하기

import React, { useCallback, useState } from 'react'
import SortableListItem from './SortableListItem'
import "./SortableList.css"

function SortableList({data, onDropItem, onClickItem, renderItem}) {
    const [startIndex, setStartIndex] = useState(0)
    const [listData, setListData] = useState(data)

    const onDragStart = (index) => setStartIndex(index)

    const onDrop = useCallback((dropIndex) => {
        const dragItem = listData[startIndex]
        const list = [...listData]
        list.splice(startIndex, 1)
        console.log(...list.slice(dropIndex, list.length))
        const newListData = startIndex < dropIndex ?
        [...list.slice(0, dropIndex-1), dragItem, ...list.slice(dropIndex-1, list.length)]
        : [...list.slice(0, dropIndex), dragItem, ...list.slice(dropIndex, list.length)]
        setListData(newListData)
        onDropItem(newListData)
    },[startIndex, onDropItem, listData])

  return (
    <ul className='sortable-list'>
        {listData.map((item,index) => (
            <SortableListItem
            key={index}
            index={index}
            draggable={true}
            onDropItem={onDrop}
            onDragStart={onDragStart}
            onClickItem={onClickItem}
            >
                {renderItem(item,index)}
            </SortableListItem>
        ))}
        <SortableListItem
        key={listData.length}
        index={listData.length}
        draggable={false}
        onDropItem={onDrop}
        />
    </ul>
  )
}

export default SortableList
1. ul 에 className 으로 'sortable-list' 를 추가해준다. (css)
2. listData 의 배열 데이터들을 map () 을 사용해 item (data) , index 를 매개변수로 받아와준다.
3. SortableListItem 을 import 해와서 컴포넌트를 동적으로 생성해준다.
4. renderItem 함수는 App.js 를 보면서 이해하면 좋을 듯. renderItem 안에 매개변수로 map 으로 돌려서 반환된 값을 넣어준다.
SortableListItem 에게 보내는 props 에 대한 설명

1. key : 매개변수로 받은 index 를 넣어서 유니크한 Key 값을 준다
2. index : 매개변수로 받은 index 를 넣어서 index 값을 props 로 전달 ( current index 값을 알아낼 수 있음 )
3. draggable : true 를 주는 아이는 드래그를 할 실제 요소고, false 는 마지막 요소에 두어서, 마지막 요소 위로 드래그 할 수 있도록 해주기 위해서 가상으로 만들었다고 생각하면 됨. (어차피 드래그 이벤트 안됨)
4. onDropItem : onDrop에서 드래그 완료한 index 값을 받아와야하기 때문에 onDrop 함수를 props 로 보낸다.
5. onDragStart : 드래그를 시작한 index 를 받아와야하기 때문에 onDragStart 함수를 props로 보낸다.
6. onClickItem : 아이템을 클릭했을 때, index 값을 알람으로 보여줘야하기 때문에 App.js 에서 받아온 함수 (onClickItem) 을 보낸다.

App.js

import SortableList from "./lib/SortableList";
import {data} from "./TestItem/testData"
import TestItem from "./TestItem/TestItem";

function App() {
  const onClickItem = (index) => alert(index)
  const onDropItem = (newList) => console.log(newList)
  return (
    <SortableList
    data={data}
    renderItem={(item,index) => <TestItem data={item} index={index}/>}
    onDropItem={onDropItem}
    onClickItem={onClickItem}
    />
  );
}

export default App;
1. 클릭한 index 를 SortableListItem.jsx 받아와, alert() 으로 몇번 째 데이터를 클릭했는지 알려준다.
2. 바뀐 데이터를 보여줄 수 있도록 SortableList.jsx 에서 데이터를 받아와, console.log 로 데이터를 확인할 수 있도록 한다.
3. SortableList 컴포넌트를 가져와준다.
SortableList 에 전달하는 props 에 대한 설명

1. data : testData.js 에서 data 를 가져와서 props로 전달
2. renderItem : SortableList.jsx 에서 item,index를 받아와 TestItem (밑에서 만듬) 에 전달해주는 함수
3. onDropItem : index 값을 알아내기 위해서 자식 컴포넌트에게 함수를 전달한다.

4. onClickItem : 위와 동일함

TestItem.jsx

import React from 'react'
import "./TestItem.css"
function TestItem({data, index}) {
  return (
    <div className="test-item">
        <div>
            <p>content : {data.content}</p>
            <p>index : {index}</p>
        </div>
    </div>
  )
}

export default TestItem
1. 만들어놓은 TestItem.css 를 import 해준다.
2. App.js 에서 props 로 받아온 데이터를 설정해준다.
3. test-item 이라는 클래스를 div 에게 추가해준다. ( 데이터 개수만큼 동적으로 생성 = SortableListItem )
4. content 에는 data (item)의 content 데이터를 동적으로 넣어준다.
5. index 에는 현재 index 의 값을 넣어준다. ( 드래그를 할때마다 동적으로 변경됨 )