이전 글에 이어서 교육계획안 작성 도구를 만들며 지속적으로 실제 교사들에게 시험해보며 UI개선 작업을 진행하고 있다.
그 중에서도 현직 유치원 교사인 친구들이 피드백해주길, 보통 교사 발문의 순서나 활동 안내의 순서를 작성하다가 바꾸는 경우가 많다고 했다.
그런데 현재 방식으로는 순서를 바꾸기 위해 지우고 어딘가에 써놨다가 다시 써야 하는 문제가 있어서, 오히려 워드나 한글 문서에 쓰는 것보다 더 불편하다는 지적이 있었다. 이러한 문제점을 해결하고자 드래그 앤 드롭 기능을 추가하기로 했다.
처음에는 트렐로와 소스트리를 개발한 회사인 아틀라시안이 만든 react-beautiful-dnd
를 사용하려고 했지만, 공식 사이트를 확인해보니 라이브러리 지원이 중단되었다. ⛔️
해당 문서에서는 react-beautiful-dnd
의 포크 버전으로 마이그레이션하거나 아틀라시안에서 새로 개발한 Pragmatic drag and drop
을 사용하는 것을 권장했다.
나는 이에 대한 대안으로 react-beautiful-dnd
의 포크 라이브러리인 @hello-pangea/dnd
를 사용해보았다.
개인적으로 trello를 사용하면서 드래그앤드롭의 물리감과 애니메이션, 상호작용성에 반했기 때문에 이를 개발한 회사의 라이브러리를 사용할 수 있다는 것에 감사했다.
특히나 @hello-pangea/dnd
는 리스트(수직, 수평, 리스트 간 이동, 중첩 리스트 등)를 위해 기능을 더 간단하게 사용할 수 있도록 만들어진 추상화된 도구나 라이브러리이다.
결국에 교육계획안의 요소들은 리스트의 반복과 조합이기 때문에 리스트에 초점을 둔 해당 라이브러리를 채택하게 되었다.
우선 전체 코드를 한번 살펴보자
import { useState } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
const initialItems = [
{ id: 'item-1', content: 'Draggable1' },
{ id: 'item-2', content: 'Draggable2' },
{ id: 'item-3', content: 'Draggable3' },
];
export default function Page() {
const [items, setItems] = useState(initialItems);
const handleOnDragEnd = (result) => {
if (!result.destination) return;
const reorderedItems = [...items];
const [movedItem] = reorderedItems.splice(result.source.index, 1);
reorderedItems.splice(result.destination.index, 0, movedItem);
setItems(reorderedItems);
};
return (
<div className={'bg-blue-100 px-8 py-4'}>
<div className={'m-2'}>DragDropContext</div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`mb-4 p-4 border-slate-300 shadow-sm bg-violet-100 border-2`}
>
<div className={'m-2'}>Droppable</div>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="p-4 border-2 rounded mb-2 bg-pink-100 w-48 flex justify-center border-slate-300"
>
{item.content}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
);
}
dnd
에서 사용하는 요소들은 다음과 같다.
<DragDropContext />
: 드래그 앤 드롭을 활성화하려는 애플리케이션의 부분을 감싸는 컨텍스트이다. onDragEnd
속성을 통해 드래그 종료 시 호출될 함수를 설정할 수 있다.
<Droppable />
: 드래그한 항목이 놓일 수 있는 영역이다. droppableId 속성으로 해당 영역을 식별한다.
<Draggable />
: 드래그할 수 있는 요소이다.각 항목은 draggableId로 고유하게 식별되며, index는 배열 내 위치를 나타낸다.
const onDragEnd = (result) => {
if (!result.destination) return;
const reorderedItems = [...items];
const [movedItem] = reorderedItems.splice(result.source.index, 1);
reorderedItems.splice(result.destination.index, 0, movedItem);
setItems(reorderedItems);
};
handleOnDragEnd
함수는 드래그 앤 드롭 작업이 완료될 때 호출되며, result라는 매개변수를 통해 드래그 작업에 대한 정보를 받는다. 기본적인 개념은 배열을 재정렬하는 것이기 때문에 어렵지 않다. 여기서 핵심 역할은 항목들이 재정렬된 순서대로 업데이트되도록 만드는 것이다.
if (!result.destination) return;
드래그가 유효하지 않은 경우(예: 리스트 밖으로 드롭했을 때), 함수는 바로 종료된다. result.destination이 없다는 것은 항목이 유효하지 않은 위치에 드롭되었음을 의미한다.
const reorderedItems = [...items];
items 배열을 복사하여 reorderedItems라는 새 배열을 생성한다. 얕은 복사를 통해 items
배열의 원본 상태를 직접 변경하지 않게 한다.
const [movedItem] = reorderedItems.splice(result.source.index, 1);
reorderedItems.splice(result.destination.index, 0, movedItem);
splice
를 사용해 result.source.index
위치의 항목을 배열에서 제거하고, 이를 movedItem
에 저장한다.
이후 result.destination.index
위치에 movedItem
을 삽입한다. 이로써 항목이 새로운 위치에 배치될 수 있다.
setItems(reorderedItems);
setItems
를 호출해 items
상태를 reorderedItems
로 업데이트한다. 이로써 UI는 변경된 항목 순서에 맞춰 다시 렌더링된다.
draggableProps
는 드래그 가능한 요소에 필요한 모든 속성을 포함한다. 이 속성들을 DOM 요소에 전달하면, 해당 요소가 Draggable
로서의 기능을 수행할 수 있게 된다.
또한 여기에는 드래그 및 위치 설정과 관련된 애니메이션 속성들이 포함되어 있어, drag-and-drop
이 부드럽게 작동하도록 한다.
dragHandleProps는 사용자가 특정 영역을 통해서만 드래그할 수 있도록 설정하는 데 사용된다. 이 속성을 요소에 할당하면, 사용자는 이 속성이 할당된 부분을 클릭하고 드래그할 수 있게 된다.
아래 예시에서는 전체 요소에 dragHandleProps를 추가해 어디서나 드래그할 수 있게 설정할 수 있다.
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="p-4 border-2 rounded mb-2 bg-pink-100 w-48 flex justify-center border-slate-300"
>
{item.content}
</div>
)}
</Draggable>
이와 다르게 실제 프로젝트 구현에서는 카드의 특정 영역만 드래그 가능한 "핸들" 역할을 하게 하였다. 리스트 추가, 삭제, input 요소 등 다양한 핸들러 기능을 제공하고 있기 때문에 분리하는게 낫다는 판단이 들었다.
<div {...provided.dragHandleProps}
style={{ cursor: 'grab' }}>
<Image
src={'/icons/linesSlate.png'}
width={10}
height={10}
alt={'Drag Handle'}
className={'p-1 w-6 h-6 opacity-50'}
/>
</div>
처음에는 소제목 단위로 이동 가능하게 구현했지만
같은 소제목 내의 순서도 자유롭게 바꿀 수 있도록 중첩 dnd도 적용해보겠다.
가장 먼저 onDragEnd
를 손봐주겠다.
우선 전체 코드를 살펴보자.
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) {
if (source.droppableId === 'top-level') {
// 최상위 섹션 재배열
setContents((prevContents) =>
reorder(prevContents, source.index, destination.index),
);
} else {
// 특정 섹션 내 아이템 재배열
const contentId = source.droppableId;
setContents((prevContents) =>
prevContents.map((content) =>
content.id === contentId
? {
...content,
contents: reorder(
content.contents,
source.index,
destination.index,
),
}
: content,
),
);
}
}
};
가장 두드러지는 차이점으로는 source.droppableId
와 destination.droppableId
를 사용해 드래그가 이루어진 출발지와 목적지를 확인한다. 이는 최상위 섹션 간의 재배열인지, 섹션 내 개별 항목의 재배열인지 구분하여 처리하는 것을 도와준다.
즉, droppableId
를 통해 출발지와 목적지를 구분하고, 섹션 간 이동과 섹션 내 이동을 다르게 처리하는 구조를 갖고 있다.
<Droppable droppableId="top-level" type="top-level">
<Droppable droppableId={content.id} type="nested">
여기에서 겉에 있는 Droppable 컴포넌트는 "top-level"로 타입 지정을 해주었고, 중첩된 Droppable 컴포넌트는 "nested"로 지정하였다.
이후 상태를 업데이트할 때, 모든 섹션을 순회하면서 특정 섹션(content.id)을 찾고, 그 섹션 내의 아이템 리스트(contents)를 다시 재배열하는 과정을 거친다. 중첩된 상태 구조로 인해 변경된 항목을 업데이트하려면 여러 번의 상태 순회가 필요하다.
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list); // 리스트 복사
const [removed] = result.splice(startIndex, 1); // 드래그된 항목을 제거
result.splice(endIndex, 0, removed); // 새로운 위치에 삽입
return result;
};
중첩된 onDragEnd
를 구현하며 여러 수준의 상태를 관리해야 하므로 코드가 복잡해지는 것을 느꼈다.
조금이나마 코드의 재사용성과 일관성을 높이기 위해 리스트 재배열을 하나의 함수로 분리하였다. 이를 reorder
라는 함수를 별도로 정의하고, onDragEnd
함수에서는 이 함수를 호출하여 상태를 업데이트하는 형태로 리팩토링을 진행했다.
이렇게 하면 top-level에서도 이동이 가능하고 중첩된 단계에서도 이동 및 리스트 재정렬이 가능해진다.
다음은 참고용으로 간소화한 2중 중첩 구조이다.
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="top-level" type="top-level">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="p-4 border border-gray-300 bg-gray-50"
>
{contents.map((content, index) => (
<Draggable key={content.id} draggableId={content.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="p-4 m-2 border border-blue-300 bg-blue-50"
>
<div {...provided.dragHandleProps} className="cursor-grab">
{content.subtitle}
</div>
{/* 중첩 Droppable */}
<Droppable droppableId={content.id} type="nested">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="p-2 border border-green-300 bg-green-50"
>
{content.items.map((item, itemIndex) => (
<Draggable
key={item.id}
draggableId={item.id}
index={itemIndex}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="p-2 m-1 border border-red-300 bg-red-50"
>
{item.text}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
어쩐지 이 짤이 생각난다. 코드 평탄화 작업을 해야겠다..