드디어, 대망의 DND 이벤트를 구현해보도록 하자!
칸반보드, 목차 편집 기능이 실용적이지만
재미삼아서 퍼즐 맞추기 게임 느낌의 이벤트를 먼저 만들어 보자.
https://www.happyclicks.net/drag-drop-games/games_numbers.php
어쩌다보니 글이 좀 깁니다...
TL;DR
cloneNode를 통해 타겟을 복사하여 drag시킨다.
document.elementFromPoint(x, y)을 활용하여 특정 영역의 element 정보를 얻는다.
획득한 element의 정보 바탕으로onDrop
이벤트를 구현한다.
DOM API를 직접적으로 다뤄야하다 보니
이를 별도의 스크립트에서 vanilla로 코드를 작성하는 것이 편할 것 같다.
먼저,
NextJS에서는 CSR에서 스크립트가 실행되도록 훅을 만들어 준다.
DNDMatchExample.tsx
const [ready, setReady] = useState(false);
useEffect(() => {
if(!ready) {
setReady(true);
return;
}
// 이벤트를 등록한다.
const cleanup = registDND(...);
// 이벤트를 해제해준다.
return () => cleanup();
}, [ready]);
if (!ready) return <></>;
스크립트는 DNDMatchExample.drag.ts
파일로 작성했다.
모바일에서도 동작이 가능하도록 설정했다.
관련 로직은 이전 포스트를 참고하면 될 것 같다.
export const registDND = (...) => {
// 모바일 기기에서의 Touch 이벤트
const isTouchScreen =
typeof window !== 'undefined' &&
window.matchMedia('(hover: none) and (pointer: coarse)').matches;
const startEventName = isTouchScreen ? 'touchstart' : 'mousedown';
const moveEventName = isTouchScreen ? 'touchmove' : 'mousemove';
const endEventName = isTouchScreen ? 'touchend' : 'mouseup';
// 마우스 움직임 변화를 측정하는 유틸
const getDelta = (startEvent: MouseEvent | TouchEvent, moveEvent: MouseEvent | TouchEvent) => {
if (isTouchScreen) {
const se = startEvent as TouchEvent;
const me = moveEvent as TouchEvent;
return {
deltaX: me.touches[0].pageX - se.touches[0].pageX,
deltaY: me.touches[0].pageY - se.touches[0].pageY,
};
}
const se = startEvent as MouseEvent;
const me = moveEvent as MouseEvent;
return {
deltaX: me.pageX - se.pageX,
deltaY: me.pageY - se.pageY,
};
};
// DND 등록 이벤트
const startHandler = (startEvent: MouseEvent | TouchEvent) => {
const item = startEvent.target as HTMLElement;
// Drag 대상이 아니면 이벤트를 종료해준다.
if (!item.classList.contains('dnd-drag-item')) {
return;
}
// Drag 시작 이벤트 관련 동작
// {...}
// Drag 움직임 이벤트 관련 동작
const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
// {...}
};
// Drag 종료(Drop) 이벤트 관련 동작
const endHandler = () => {
// {...}
document.removeEventListener(moveEventName, moveHandler);
};
document.addEventListener(moveEventName, moveHandler);
document.addEventListener(endEventName, endHandler, { once: true });
}
// document에 DND 이벤트를 등록해준다.
document.addEventListener(startEventName, startHandler);
return () => document.removeEventListener(startEventName, startHandler);
};
jsx에서처럼 이벤트를 등록할 수 없기에
이전과는 다르게 이벤트 위임를 활용하여 document에 등록해줬다.
이제 로직을 하나 하나 구현을 해보자.
핵심은 기존 엘리먼트을 그대로 두고
clone
한ghost
를 움직이게 한다.
const startHandler = (startEvent: MouseEvent | TouchEvent) => {
const item = clickEvent.currentTarget as HTMLElement;
if (
!item.classList.contains('dnd-drag-item') ||
item.classList.contains('ghost') ||
item.classList.contains('placeholder')
) {
return;
}
const itemRect = item.getBoundingClientRect();
// --- Ghost 만들기
const ghostItem = item.cloneNode(true) as HTMLElement;
ghostItem.classList.add('ghost');
ghostItem.style.position = 'fixed';
ghostItem.style.top = `${itemRect.top}px`;
ghostItem.style.left = `${itemRect.left}px`;
ghostItem.style.pointerEvents = 'none';
ghostItem.style.textShadow = '0 30px 60px rgba(0, 0, 0, .3)';
ghostItem.style.transform = 'scale(1.05)';
ghostItem.style.transition = 'transform 200ms ease';
item.style.opacity = '0.5';
item.style.cursor = 'grabbing';
document.body.style.cursor = 'grabbing';
document.body.appendChild(ghostItem);
// --- Ghost 만들기 END
const mouseMoveHandler = (moveEvent: MouseEvent) => {
// --- Ghost Drag
const deltaX = moveEvent.pageX - clickEvent.pageX;
const deltaY = moveEvent.pageY - clickEvent.pageY;
ghostItem.style.top = `${itemRect.top + deltaY}px`;
ghostItem.style.left = `${itemRect.left + deltaX}px`;
// --- Ghost Drag END
};
const mouseUpHandler = (moveEvent: MouseEvent) => {
// --- Ghost 제자리 복귀
ghostItem.style.transition = 'all 200ms ease';
ghostItem.style.left = `${itemRect.left}px`;
ghostItem.style.top = `${itemRect.top}px`;
ghostItem.style.transform = 'none';
ghostItem.addEventListener(
'transitionend',
() => {
item.removeAttribute('style');
document.body.removeAttribute('style');
ghostItem.remove();
},
{ once: true },
);
// --- Ghost 제자리 복귀 END
// ...
};
}}
ghost
가 마우스(포인터) 이벤트에 관여되지 않도록 pointer-event none
으로 설정해줬다.placeholder
클래스를 둬서 드레그되고 있음을 인지시킨다.핵심은
docuemnt.elementFromPoint
을 활용해서 특정 위치에 어떤 엘리먼트가 있는지 확인 한다.
ghost가 항상 잡히기에pointer-event: none;
으로 설정하여 회피해준다.
elementFromPoint - returns the topmost Element at the specified coordinates (relative to the viewport).
//...
const dropAreaList = document.querySelectorAll<HTMLElement>('.dnd-drop-area');
//...
const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
// ...
//--- Drop 영역 확인
const ghostItemRect = ghostItem.getBoundingClientRect();
const ghostCenterX = ghostItemRect.left + ghostItemRect.width / 2;
const ghostCenterY = ghostItemRect.top + ghostItemRect.height / 2;
const dropItem = document
.elementFromPoint(ghostCenterX, ghostCenterY)
?.closest<HTMLElement>('.dnd-drop-area');
dropAreaList.forEach((area) => {
area.classList.remove('active');
area.removeAttribute('style');
});
if (dropItem) {
dropItem.classList.add('active');
dropItem.style.filter = 'drop-shadow(16px 16px 16px gray)';
}
//--- Drop 영역 확인 END
};
drop
영역 위인지 여부를 파악했다.active
클래스명을 추가했다. 이를 아래 onDrop 이벤트에서 활용한다.
active
영역이 있을 경우onDrop
로직을 수행한다.
export const registDND = (
onDrop: (props: { source: string; destination: string; isCorrect: boolean }) => void,
) => {
//...
const endHandler = () => {
const dropItem = document.querySelector<HTMLElement>('.dnd-drop-area.active');
const isCorrect = item.innerText === dropItem?.innerText;
if (isCorrect) {
// 해당 영역으로 이동
const dropItemRect = dropItem.getBoundingClientRect();
ghostItem.style.left = `${dropItemRect.left}px`;
ghostItem.style.top = `${dropItemRect.top}px`;
} else {
// 제자리 복귀
ghostItem.style.left = `${itemRect.left}px`;
ghostItem.style.top = `${itemRect.top}px`;
}
ghostItem.style.transition = 'all 200ms ease';
ghostItem.style.transform = 'none';
ghostItem.addEventListener(
'transitionend',
() => {
item.classList.remove('placeholder');
item.removeAttribute('style');
document.body.removeAttribute('style');
if (dropItem) {
// 영역 스타일 초기화
dropItem.classList.remove('active');
dropItem.removeAttribute('style');
//
if (isCorrect) {
item.classList.add('opacity-50');
dropItem.classList.remove('text-white');
dropItem.classList.add('text-stone-700');
}
}
ghostItem.remove();
// onDrop 콜벡을 수행
onDrop({
source: item.innerText,
destination: dropItem?.innerText ?? '',
isCorrect,
});
},
{ once: true },
);
document.removeEventListener(moveEventName, moveHandler);
};
//...
};
콜벡에서 위치가 맞는 겨우 상태를 수정해준다.
useEffect(() => {
if (!ready) {
setReady(true);
return;
}
const cleanup = registDND(({ destination, isCorrect }) => {
if (isCorrect) {
setCorrectWords((list) => [...list, destination]);
}
});
return () => cleanup();
}, [ready]);
이렇게 DND 기능 구현이 완료 👏🏻
게임 컨셉의 DND이기 때문에 추가적인 에니메이션을 구현해보자.
알맞지 않은 알파벳으로 이동할 경우 해당 알파벳이 흔들리도록 하자.
먼저 global.css
에서 관련 에니메이션 css를 작성한다.
50%
지점에서 가장 크게 흔들리는 것이 포인트이다.
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
엘리먼트에 shake
클래스를 추가하면 에니메이션이 실행되고,
animationend
에서 다시 shake
클래스를 제거해준다.
ghostItem.addEventListener(
'transitionend',
() => {
//...
if (dropItem) {
//...
if (!isCorrect) {
// 틀린 경우 shake
item.classList.add('shake');
item.addEventListener(
'animationend',
() => {
item.classList.remove('shake');
},
{ once: true },
);
} else {
item.classList.add('opacity-50');
dropItem.classList.remove('text-white');
dropItem.classList.add('text-stone-700');
}
}
//...
},
{ once: true },
);
게임 클리어할 경우 격하게 축하해주고 싶다.
아래에서 관련 로직을 참고했다.
https://codepen.io/l2zeo/pen/ZEBLepW
너무 레거시한 코드 구현방식이여서 NextJS, Typescript에 맞게 리팩토링했다.
생각보다 코드는 간단하다.
Confettiful.ts
const confettiFrequency = 40;
const confettiColors = ['#B1B2FF', '#AAC4FF', '#2D87B0', '#D2DAFF', '#EEF1FF'];
const confettiAnimations = ['slow', 'medium', 'fast'];
const getRandomListItem = (list: any[]) => list[Math.floor(Math.random() * list.length)];
const Confettiful = function () {
const el = document.createElement('div');
el.style.position = 'fixed';
el.style.pointerEvents = 'none';
el.style.width = '100%';
el.style.height = '100%';
const containerEl = document.createElement('div');
containerEl.style.position = 'absolute';
containerEl.style.overflow = 'hidden';
containerEl.style.top = '0';
containerEl.style.right = '0';
containerEl.style.bottom = '0';
containerEl.style.left = '0';
el.appendChild(containerEl);
const confettiInterval = setInterval(() => {
const confettiEl = document.createElement('div');
confettiEl.style.position = 'absolute';
confettiEl.style.zIndex = '1';
confettiEl.style.top = '-10px';
confettiEl.style.borderRadius = '0%';
const confettiSize = Math.floor(Math.random() * 3) + 7 + 'px';
const confettiLeft = Math.floor(Math.random() * el.offsetWidth) + 'px';
const confettiBackground = getRandomListItem(confettiColors);
const confettiAnimation = getRandomListItem(confettiAnimations);
confettiEl.classList.add('confetti', `confetti--animation-${confettiAnimation}`);
confettiEl.style.left = confettiLeft;
confettiEl.style.width = confettiSize;
confettiEl.style.height = confettiSize;
confettiEl.style.backgroundColor = confettiBackground;
setTimeout(function () {
confettiEl.parentNode?.removeChild(confettiEl);
}, 3000);
containerEl.appendChild(confettiEl);
}, 1000 / confettiFrequency);
document.querySelector('#__next')?.prepend(el);
return () => {
clearInterval(confettiInterval);
setTimeout(function () {
el.remove();
}, 3000);
};
};
export default Confettiful;
global.css
에도 관련 스타일을 추가해줘야 한다.
slow
, medium
, fast
3가지를 정의하여 각각 routate
되는 정도를 조정해준다.keyframe
이 100%
되었을 때 가루가 화면 밖으로 떨어지도록 105vh
설정해준다./* confetti */
@keyframes confetti-slow {
0% {
transform: translate3d(0, 0, 0) rotateX(0) rotateY(0);
}
100% {
transform: translate3d(25px, 105vh, 0) rotateX(360deg) rotateY(180deg);
}
}
@keyframes confetti-medium {
0% {
transform: translate3d(0, 0, 0) rotateX(0) rotateY(0);
}
100% {
transform: translate3d(100px, 105vh, 0) rotateX(100deg) rotateY(360deg);
}
}
@keyframes confetti-fast {
0% {
transform: translate3d(0, 0, 0) rotateX(0) rotateY(0);
}
100% {
transform: translate3d(-50px, 105vh, 0) rotateX(10deg) rotateY(250deg);
}
}
.confetti--animation-slow {
animation: confetti-slow 2.25s linear 1 forwards;
}
.confetti--animation-medium {
animation: confetti-medium 1.75s linear 1 forwards;
}
.confetti--animation-fast {
animation: confetti-fast 1.25s linear 1 forwards;
}
/* confetti end */
컴포넌트가 unmounded
될 때 Confetii를 지워주면 된다.
let cleanConfetti: () => void | undefined;
//...
const [words, setWords] = useState<string[]>(['D', 'R', 'A', 'G']);
const [correctWords, setCorrectWords] = useState<string[]>([]);
const isClear = useMemo(() => correctWords.length === words.length, [correctWords, words]);
useEffect(() => {
if (isClear) {
cleanConfetti = Confettiful();
} else {
cleanConfetti?.();
}
return () => {
cleanConfetti?.();
};
}, [isClear]);
실제 동작은 아래 링크에서 볼 수 있다.
https://dnd-playground.vercel.app/dnd
style, 전체 코드는 아래 깃허브에서 살펴보면 될 것 같다.
https://github.com/bepyan/dnd-playground
글 이전
https://bepyan.github.io/blog/dnd-master/5-drag-and-drop
This article is really amazing. Thanks for the sharing.
JCPenney Kiosk