프론트엔드 개발자
류준열

바탕화면 구현을 위해 Draggable 컴포넌트 만들기

바탕화면에서 아이콘을 드래그 할 수 있도록 Draggable 컴포넌트를 만들었다. 바탕화면드래그

그리고 이 Draggable 컴포넌트를 재사용하여 폴더내에서도 똑같은 기능을 구현하였다.

폴더드래그

코드는 다음과 같다.

// 바탕화면
export const Home = () => {
  ...
  return (
    <div className={styles.home}>
      <Draggable icons={ICONS} />
      ...
    </div>
  );
};

// window 창 : 폴더이면 Draggable, 파일이면 markdown
export const WindowBox = ({ icon, index }: WindowBoxProps) => {
    ...
    return (
    <section>
        {icon.type===IconType.FOLDER ? <Draggable icons={icon.children || []} /> : <Markdown markdown={icon.markdown} />
    </section>
}

Draggable 컴포넌트 내부

Draggable 컴포넌트 내부는 2개의 로컬state와 1개의 전역 state가 있다.

export const Draggable = ({ icons: _icons }: DraggableProps) => {

  const [draggingIcon, setDraggingIcon] = useState<{ id: number } | null>(null); 
 	
  const isDraggable = useDraggableStore(state => state.isDraggable);
	const [icons, setIcons] = useState(_icons);


  ...
  • draggingIcon은 지금 끌고 있는 icon
  • isDraggable은 드래그 가능한 상태인지를 나타내는 boolean값이다. 이름변경, 우클릭 등이 발생했을때는 false이다.
  • icons는 현재 Draggable내에 있는 각 icon들의 이름,위치,zIndex등을 갖고 있는 state이다. 아래는 예시코드
const ICONS = [ {
        type: 'file',
        windowState: 'closed',
        id: 5,
        src: CodestatesLogo,
        alt: '지원선발 시스템',
        left: 50,
        top: 50,
        zIndex: 100,
        markdown: '/markdown/admission-admin.md',
      },{
        type: 'folder',
        windowState: 'closed',
        id: 3,
        src: FolderSVG,
        alt: '프로젝트',
        left: 50,
        top: 350,
        zIndex: 100,
        children: [
					{
						type: 'file',
						windowState: 'closed',
						id: 4,
						src: CodestatesLogo,
						alt: '코드스테이츠 랜딩페이지',
						left: 50,
						top: 150,
						zIndex: 100,
						markdown: '/markdown/landing-page.md',
					}]}],

Icon을 드래그 하기 시작 할 때 : onDragStart

드래그 할 Icon.tsx는 다음과 같다.

export const Icon = () => {
    return (
    <div draggable={isDraggable} onDragStart={e=>handleDragStart(e,icon.id}>
        <img className = 'icon_img' .. />
        <input className = 'icon_name' .. /> 
    </div>
    
    )
}

Icon을 드래그 할 때 실행되는 handleDragStart는 dragEvent와 icon.id를 매개변수로 받고 다음과 같이 작동한다.

export const handleDragStart = (
  e: DragEvent,
  id: number
) => {
  if (isDraggable) {
  // 드래그 중인 아이콘을 확인하고 이동한 위치에 해당 아이콘을 배치하기 위해 id를 추적한다.
    e.dataTransfer.setData('id', id.toString());
    setDraggingIcon({ id });
  }
};

Icon을 Draggable 컴포넌트 상에서 드래그 할 때 : onDragover

일단 Dragglable.tsx 컴포넌트 리턴문은 다음과 같다.

export const Draggable = () => {
    ...
    return (
        <div
            onDragOver={handleDragOver}
            onDrop={handleDrop}
        >
            <div>
                {icons.map(icon=>(<Icon key = {icon.id} .... />))}
            </div>
        </div>)
}

onDragOver는 MDN에서 보면 엘리먼트가 유효한 드래그 영역 내에서 드래그 될 때 every few hundreds 타임마다 발생하는 이벤트이다.

브라우저 고유의 이벤트가 발생하는 것을 방지하기 위해 e.prevetDefault()만 깔아주었다.

export const Draggable = () => {
    ...
    const handleDragOver = (e: DragEvent) => {
        e.preventDefault();
    };

    ...
    return (
    <div>
        onDragOver={handleDragOver}
        onDrop={handleDrop}
    </div>)
    }

Icon을 Drop 할 때 : onDrop

그리고 drop할 때 발생하는 handleDrop까지 추가해주었다. 역시 브라우저 고유의 이벤트때문에 방해받지 않도록 e.preventDefault()를 사용했다. 그리고 Icon.tsx 의 onDragStart에서 추적하던 icon.id를 받아서 drop할 icon를 결정하고 drag event에 내장된 마우스 위치를 icon.left, icon.top에 할당한다.

    export const Draggable = () => {
        ...
        const handleDragOver = (e: DragEvent) => {
            e.preventDefault();
        };
        
        const handleDrop = (
          e: DragEvent
        ) => {
          if (isDraggable) {
            e.preventDefault();
            if (draggingIcon) {
              const id = Number(e.dataTransfer.getData('id'));
              const icon = icons.find(i => i.id === id);
              if (icon) {
                const rect = e.currentTarget.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;
        
                const updatedIcons = icons.map(i => {
                  if (i.id === id) {
                    return { ...i, left: x - 30, top: y - 25 };
                  }
                  return i;
                });
                setIcons(updatedIcons);
              }
              setDraggingIcon(null);
            }
          }
        };
        ...
        return (
        <div>
            onDragOver={handleDragOver}
            onDrop={handleDrop}
        </div>)
    }

그리고 icon.left와 icon.top을 Icon.tsx에서 Icon의 left,top값으로 넣어준다.

export const Icon = (...) => {
    return (
        <>
            <div
                draggable={isDraggable}
                onDragStart={e => handleDragStart(e, icon.id)}
                style={{
                  left: icon.left,
                  top: icon.top,
                }}
                >
                ...
            </div>
        </>

이렇게 하여 Draggable.tsx를 만들었다.

Draggable 컴포넌트 예시

실제 구현체는 더 많은 코드가 있는데, Icon을 Drag하는 기능만 들어간 Draggable 컴포넌트는 다음과 같다.

import React, { useState } from 'react';

const Desktop: React.FC = () => {
  const [icons, setIcons] = useState([
    { id: 1, icon: '🌟', left: 50, top: 50 },
    { id: 2, icon: '📁', left: 150, top: 50 },
    { id: 3, icon: '📎', left: 250, top: 50 },
  ]);

  const [draggingIcon, setDraggingIcon] = useState<{ id: number } | null>(null);

  const handleDragStart = (e: React.DragEvent, id: number) => {
    e.dataTransfer.setData('id', id.toString());
    setDraggingIcon({ id });
  };

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    if (draggingIcon) {
      const id = Number(e.dataTransfer.getData('id'));
      const icon = icons.find((i) => i.id === id);
      if (icon) {
        const rect = e.currentTarget.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;

        const updatedIcons = icons.map((i) => {
          if (i.id === id) {
            return { ...i, left: x, top: y };
          }
          return i;
        });

        setIcons(updatedIcons);
      }
      setDraggingIcon(null);
    }
  };

  const iconElements = icons.map((icon) => (
    <div
      key={icon.id}
      style={{
        position: 'absolute',
        left: icon.left,
        top: icon.top,
        cursor: 'pointer',
      }}
      draggable
      onDragStart={(e) => handleDragStart(e, icon.id)}
    >
      {icon.icon}
    </div>
  ));

  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        position: 'relative',
        backgroundColor: 'lightgray',
      }}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      {iconElements}
    </div>
  );
};

export default Desktop;