본 포스팅은 전편 [프로젝트] React Drag&Drop: 투두리스트를 칸반 보드로 (1)에 이어서 작성한 글입니다!
전편:
2021.08.25 - [Learn to Code] - [프로젝트] React Drag&Drop: 투두리스트를 칸반 보드로 (1)
지난번 편에선 Drag & Drop API를 사용해서 ⬅️요쪽에서 ➡️요쪽으로 (??) 옮기는 코드를 어떻게 작성하면 되는지 그 단계를 살펴보았다. 이번엔 그 후속으로, 리스트가 여러 개 있는 상황에서 각자 리스트에 있는 아이템들을 어떻게 하면 자유롭게 옮길 수 있게 하는지 살펴보도록 하겠다!
State 구조
아직까진 서버 통신 로직과 Redux를 통한 상태 관리 부분을 넣지 않고 더미 데이터로 진행하고 있기 때문에, 각 리스트에 들어가는 데이터는 부모 컴포넌트에서 props로 받아와서 쓰고 있다. 더미 데이터는 이렇게 생겼다:
{
todos: [
{id: 11, content: "투두랍니다"},
{id: 12, content: "저도 투두랍니다"},
{id: 13, content: "저도 투두요"}
],
inProgress: [
{id: 21, content: "진행중에 있던 아이입니다"},
{id: 22, content: "진행 중에 있던 아이입니다 222"},
{id: 23, content: "저도 진행 중에...333"}
],
completed: [
{id: 31, content: "완료된 태스크입니다"},
{id: 32, content: "완료된 태스크입니다222"},
{id: 33, content: "완료된 태스크입니다333"}
]
}
이걸 적용시키면 이렇게 된다:
주요 포인트
보통 우리가 여러 리스트들 내에 있는 아이템들을 드래그해서 이동시키고자 할 때, 직관적으로 하는 행동이 바로 이동시키고 싶은 장소에다가 마우스를 가지고가서 거기에 드래그한 요소를 넣으려고 하는 것이다. 그걸 구현하려면, 드래그한 아이템이 이동할 수 있는 여러 경우의 수들을 모두 고려해주어야 한다.
우선 가장 먼저, 아이템의 출신지역과 도착지점이 같은지 여부에 따라 아래 두가지 경우를 생각할 수 있다:
1. 다른 리스트로 이동할 때
2. 같은 리스트 내에서 이동할 때
지난편에서 작성한 사항이 바로 위의 경우까지를 고려한 코드였다. 이번에 추가해야하는 건 좀 더 디테일한, 그래서 정확히 드래그한 아이템이 새로 들어갈 위치가 어디냐에 대한 것을 추가해야한다.
그래서 제일 먼저 유저는 과연 이걸 어디에 놓고 싶었길래 드래그를 시작한걸까...? 를 생각해보았다. 유저는 분명, 드래그하는 현시각 마우스의 위치 아래에 놓인 아이템의 위 또는 아래에 놓고 싶을 것인데, 문제는 그 아래 놓인 엘리먼트의 위에 넣을 것인지 아래에 넣을 것인지 기준을 어떻게 정해야하냐는 것이었다.
그래서 검색도 해보고, 직접 깃헙 레포지토리의 프로젝트 보드에 들어가서 세상 할 일 없는 사람처럼 드래그 & 드롭을 열심히 하다가, 현재 마우스가 위치한 곳이 아래에 있는 엘리먼트의 윗쪽이냐 아래쪽이냐에 따라 적용하면 된다는 것을 알게 되었다. 아래 그림처럼 말이다!
그래서, 위와 같이 지금 현재 마우스가 아래에 깔린 (hover되고 있는 회색 엘리먼트)의 앞 또는 뒤 중 어디에 놓여야할지를 정하기 위해 새로운 함수를 하나 더 만들었다. 이 함수는 내가 자주 보는 유튜브 채널 Web Dev Simplified에서 바닐라JS로 드래그&드랍 구현하는 법을 설명한 곳에서 기본 로직을 빌려와서 내 코드에 맞춰 리턴 값을 변경한 것이다.
const beforeOrAfter = (element, y) => {
const box = element.getBoundingClientRect()
const offset = y - box.top - (box.height/2)
return offset < 0 ?
{ where: 'before', id: Number(element.id) } :
{ where: 'after', id: Number(element.id) }
}
여기서 중요한 역할을 하는 것이 바로 저 getBoundingClientRect() 이다. getBoundingClientRect()는 DOMRect 라는 객체를 반환하는데, 이 DOMRect는 해당되는 element의 박스 사이즈에 대한 정보를 담고 있다.
DOMRect가 가지고 있는 사이즈와 위치 정보를 기반으로, 현재 내 마우스의 위치가 아래 hover 되는 엘리먼트의 상단에 있는지 하단에 있는지를 알 수 있다. 그리고 이는 곧 밑에 드래그한 아이템을 그 위에 놓을 것인지 아래에 놓을 것인지에 관한 유저의 의사를 유추할 수 있게 해준다. 이 DOMRect에 있는 정보를 이용해서 계산한 변수가 offset이다.
// y는 드랍 이벤트가 발생한 시점의 clientY이다
const offset = y - box.top - (box.height/2)
/* 현재 마우스의 위치 - 아래 놓인 엘리먼트와 뷰포트 top의 차이 - 엘리먼트의 높이 / 2 (=중간지점) */
이렇게해서 계산한 offset이 0보다 작다는건 드랍할 때 마우스의 위치가 호버한 아이템의 상단이었다는 것을 뜻하고, 그게 아니라면 하단이었다는 것을 뜻한다. 그래서 offset 값에 따라 beforeOrAfter의 값이 달라지게 설정하였고, 이 함수는 handleDrop 안에서 아래와 같이 호출했다.
const handleDrop = (e) => {
const itemId = Number(e.dataTransfer.getData('itemId'))
const from = e.dataTransfer.getData('listName')
const to = e.target.tagName === 'LI' ? e.target.parentElement.id : e.target.id
const { where, id: hoveredElementId } = beforeOrAfter(e.target, e.clientY)
...
}
종합하자면, 아래와 같이 기준을 정하고 드래그되는 아이템의 상태를 변경한다고 생각하면 된다:
- from 과 to의 동일 여부를 통해 다른 리스트 간의 이동인지 같은 리스트 내 이동인지를 구별한다.
- 'before' 또는 'after'인 변수 where를 기준으로 호버된 엘리먼트의 위에 넣을 것인지 아래에 넣을 것인지 정한다.
그렇게 해서 만들어진 handleDrop 코드는 아래와 같다:
const handleDrop = (e) => {
const itemId = Number(e.dataTransfer.getData('itemId'))
const from = e.dataTransfer.getData('listName')
const to = e.target.tagName === 'LI' ? e.target.parentElement.id : e.target.id
const { where, id: hoveredElementId } = beforeOrAfter(e.target, e.clientY)
const updatedList = {...items}
const movingData = updatedList[from].filter(el => el.id === itemId)
let newFrom = updatedList[from].filter(el => el.id !== itemId)
let newTo;
if (from !== to) {
newTo = updatedList[to].reduce((acc, el) => {
if (el.id === hoveredElementId) {
if (where === 'before') return [...acc, ...movingData, el];
if (where === 'after') return [...acc, el, ...movingData];
}
return [...acc, el] }, []) updatedList[from] = newFrom updatedList[to] = newTo
} else {
newFrom = updatedList[from].filter(el => el.id !== itemId)
newTo = newFrom.reduce((acc, el) => {
if (el.id === hoveredElementId) {
if (where === 'before') return [...acc, ...movingData, el];
if (where === 'after') return [...acc, el, ...movingData];
}
return [...acc, el] }, []) updatedList[to] = newTo
}
handleItemMovement(updatedList)
}
그런데 이게 완성이냐?! 슬프게도 아니다 🥲 왜냐면 바로 우리 ul이의 미세하고 소중한 0.5rem의 row-gap으로 인해 간혹 드랍하는 위치가 ul로 설정이 되어서, ul로 드랍되는 경우를 고려해주지 않으면 드래그하던 아이템이 공중분해되는 일이 발생하기 때문이다. 그래서 아래 코드에선 getHoveredElement라는, 혹시 드랍된 위치가 ul일 경우에 마우스 위치가 가장 근접한 li를 리턴해주는 함수를 만들어서 적용 시켰다. 최종 코드는 아래와 같다.
// 드랍 위치가 ul일 경우, 마우스 위치가 가장 가까운 li를 찾아서 리턴해준다
const getHoveredElement = (ul, y) => {
let closestLi = {offset: Number.NEGATIVE_INFINITY, element: null}
ul.childNodes.forEach(node => {
const box = node.getBoundingClientRect()
const offset = y - box.top - (box.height / 2)
if (offset < 0 && offset > closestLi.offset) {
closestLi.offset = offset closestLi.element = node }
})
return closestLi.element
}
const beforeOrAfter = (element, y) => {
let box;
if (element.tagName !== 'LI') {
box = getHoveredElement(element, y).getBoundingClientRect()
} else {
box = element.getBoundingClientRect()
}
const offset = y - box.top - (box.height/2)
return offset < 0 ? { where: 'before', id: Number(element.id) }
: { where: 'after', id: Number(element.id) }
}
const handleDrop = (e) => {
const itemId = Number(e.dataTransfer.getData('itemId'))
const from = e.dataTransfer.getData('listName')
const to = e.target.tagName === 'LI' ? e.target.parentElement.id : e.target.id
const { where, id: hoveredElementId } = beforeOrAfter(e.target, e.clientY)
const updatedList = {...items}
const movingData = updatedList[from].filter(el => el.id === itemId)
let newFrom = updatedList[from].filter(el => el.id !== itemId)
let newTo;
// 다른 리스트로의 이동일 경우
if (from !== to) {
newTo = updatedList[to].reduce((acc, el) => {
if (el.id === hoveredElementId) {
if (where === 'before') return [...acc, ...movingData, el];
if (where === 'after') return [...acc, el, ...movingData];
}
return [...acc, el] }, [])
updatedList[from] = newFrom
updatedList[to] = newTo
// 같은 리스트 내 이동일 경우
} else {
newFrom = updatedList[from].filter(el => el.id !== itemId)
newTo = newFrom.reduce((acc, el) => {
if (el.id === hoveredElementId) {
if (where === 'before') return [...acc, ...movingData, el];
if (where === 'after') return [...acc, el, ...movingData];
}
return [...acc, el] }, []) updatedList[to] = newTo
}
// 마지막으로 상태를 업데이트해주는 함수 😃
handleItemMovement(updatedList)
}
'Learn to Code' 카테고리의 다른 글
[JS] 웹팩(Webpack)과 바벨(Babel): 바닐라 자바스크립트에 처음부터 적용시켜보기 (2) | 2021.09.09 |
---|---|
[프로젝트] React component: 마운트(mount)될 때와 언마운트(unmount)될 때 각기 다른 애니메이션 적용하기 (feat. onanimationend 프로퍼티) (0) | 2021.09.01 |
[프로젝트] React Drag&Drop: 투두리스트를 칸반 보드로 (1) (0) | 2021.08.25 |
웹 성능 개선을 위한 브라우저 작동 원리 이해 (1) | 2021.08.24 |
[JS] 즉시실행함수(IIFE)란? (0) | 2021.08.19 |
댓글