React-grid-layout로 내맘대로 위젯 drag and drop 기능 만들기

Hyeon·2024년 4월 10일
0

기능 구현

목록 보기
1/1
post-thumbnail


최근 프로젝트에서 drag-and-drop 기능을 만들어야 했다.
꽤 많은 애를 먹었었는데, 거의 마무리 단계라서 글을 적어보고자 한다. 엄청난 삽질의 흔적 주의. 사실 아직도 삽질중일지도

🔧 개발해야 했던 기능

  1. 4x4 대시보드에 위젯을 드래그 앤 드롭 가능해야 함
  2. 모바일에서는 편집 금지, 모바일은 대시보드가 2x8로 보여져야 함
  3. 위젯은 삭제 가능하고 드래그로 옮길 수 있어야 함.

초기에는 여기에 위젯 resize 기능도 있었기 때문에 (나중에 빠졌지만), 팀원들이랑 논의 결과로 react-grid-layout 라는 라이브러리를 사용하기로 했다.
https://github.com/react-grid-layout/react-grid-layout
내가 만드려고 하는 대쉬보드 모양도 유사했고, 사용하는 데에 어려움이 있지 않을거라고 생각했기 때문이다. (아니었다)

레이아웃 렌더링하기

GRL로 레이아웃을 그리는 방법은 그냥 GridLayout을 쓰는 방법과 ResponsiveGridLayout 을 쓰는 방법이 있다. 이 대시보드에서는 윈도우 사이즈마다 위젯의 크기가 달라져야했기 때문에 반응형 레이아웃인 ResponsiveGridLayout를 사용했다.

           <ResponsiveGridLayout
            breakpoints={{ xs: 492, xxs: 0 }}
            maxRows={colNum === 4 ? 4 : 8}
            className="layout"
            margin={[12, 12]}
            cols={{ xs: 4, xxs: 2 }}
            isResizable={false}
          >
            {renderLayouts?.map(
              ({ grid, type, size: wgSize, data: wgData, key }) => {
                return (
                  <Box
                    className={'layout-element'}
                    key={key}
                    data-grid={{ ...grid, isDraggable: edit }} //isDraggable 전체로 하는 방식있는데 안먹혀서 하나씩...
                    width={'100%'}
                    height={'100%'}
                  >
                    {/*위젯 type에 따라 렌더링*/}
                    <SelectedWidget
                      type={type}
                      wgData={wgData}
                      wgSize={wgSize}
                      wgKey={key}
                    />
                  </Box>
                )
              },
            )}
          </ResponsiveGridLayout>

대충 핵심코드만 걷어내서 적었다.

위젯 대시보드는 pc화면과 모바일 화면만 나뉘면 됐기 때문에 xs를 pc 크기로 정의하고 breakpoints를 정의했다. (나머지는 생략) colNum은 다른 부분에서 계산하기 위해서 빼놓은 값인데 그냥 열이 2개인지 4개인지를 담아놓은 변수다.

renderLayouts는 레이아웃 정보를 담은 배열이다. 우리의 경우, 레이아웃에 각 위젯의 정보도 담아야 했기 때문에 IWidget이라는 타입을 따로 정의하여 사용하였다.

기존의 라이브러리에 정의된 Layout 타입

    interface Layout {
        i: string;
        x: number;
        y: number;
        w: number;
        h: number;
		...

Layout의 정보를 담은 IWidget 정의

export interface IWidget {
  key: number | string
  size: SizeType // S/M/L
  grid: Layout // x,y,w,h
  type: WidgetType
  ...
  data: any
}

RGL에는 레이아웃을 렌더링하는 방식을 크게 두가지이다.
1.레이아웃의 정보를 담은 Layout 형태의 객체를 layouts에 넘겨준다
2. children에 레이아웃에 대한 정보를 직접 전달한다. 이런 식으로.

      <GridLayout className="layout" cols={12} rowHeight={30} width={1200}>
        <div key="a" data-grid={{ x: 0, y: 0, w: 1, h: 2, static: true }}>
          a
        </div>
        <div key="b" data-grid={{ x: 1, y: 0, w: 3, h: 2, minW: 2, maxW: 4 }}>
          b
        </div>
        <div key="c" data-grid={{ x: 4, y: 0, w: 1, h: 2 }}>
          c
        </div>
      </GridLayout>

나는 두번째 방식을 채택했다. 첫번째 방식으로 했는데 내 마음대로 layouts 값이 변하지 않았다. 위젯 삭제나 위젯 추가 같은 기능이 들어가려면 layouts 값을 임의로 조정해줘야 했는데 layouts의 값을 변경해도 업데이트가 되지 않았고 무엇보다 레이아웃 정보와 위젯 정보를 같이 들고 다녀야 했는데 구조상 어려웠다.

바깥의 아이템을 레이아웃에 드래그&드롭 하기

내가 만들어야 하는 기능에는 위젯 리스트를 바깥에서 드래그해서 해당 레이아웃에 드롭해야만 했다.

Drop 예제가 있었다.
https://react-grid-layout.github.io/react-grid-layout/examples/15-drag-from-outside.html
근데 이 예제는 실제로 드롭은 레이아웃에 반영 안되고 onDrop 이라는 이벤트가 호출 되기만 하는 예제다. 처음엔 예제를 보고 어쩌라는 거지? 🙄 했다.

그래서 onDrop이라는 이벤트를 사용해서 직접 drop된 아이템을 업데이트 해주기로 했다.

일단 drop을 하려면 dropping 되는 아이템을 설정해줘야 한다.
드롭될 수 있는 요소가 여러개이기 때문에 요소들마다 drop 시 일어날 액션을 달아준다.

//위젯 리스트
<Stack padding={'1rem'}>
      {typeList?.map((typeValue: WidgetType) => (
        <div key={typeValue}>
          <Stack
            key={typeValue}
            className="droppable-element"
            draggable={true}
            unselectable="on"
            onDragStart={(e) => onDragStart(e, typeValue)}
            onDragEnd={() => {
              console.log('onDragEnd')
              setIsDropping(false)
            }}
          >
            <SelectedWidget
              type={typeValue}
              wgData={null}
              wgSize={toolSize[typeValue] ?? 'S'}
              wgKey={-1}
            />
          </Stack>
        </div>
      ))}
    </Stack>

draggable={true}을 달아준 아이템은 레이아웃으로 drag 할 수 있다.
onDragStart와 onDragEnd는 Drag의 시작과 끝에 호출된다.
onDragStart에서는 지금 선택된 위젯의 타입과 사이즈를 각각 type, size라는 변수에 저장했다.

  const onDrop = useCallback(
    (layout: Layout[], layoutItem: Layout) => {
      const res = {
        key: index,
        grid: {
          ...layoutItem,
          i: index.toString(),
        },
        type,
        size,
        createdAt: new Date(),
        updatedAt: new Date(),
        data: undefined,
      }
      setRenderLayouts([...currentLayoutRef.current, res])
      setIndex(index + 1) //위젯의 인덱스를 카운트
    },
    [edit, isValidLayout, index, type, size],
  )

그리고 onDrop 함수에서는 드롭된 아이템의 정보가 LayoutItem으로 들어오는데, LayoutItem의 정보를 바탕으로 IWidget 타입의 형식으로 가공해주고 renderLayout을 업데이트 해준다.
+) 참고로 currentLayoutRef는 현재 레이아웃 정보를 가지고 있는 ref 값이다. 추후 발생했던 오류를 수정하기 위해 만들었다. index는 위젯들의 i값을 중복없이 설정해주기 위해 추가로 만든 값이다.

어쨌든 이렇게 하면 아이템을 레이아웃에 드래그 앤 드롭할 수 있다.

아이템 삭제

  const removeWidget = useCallback((idx: string) => {
    currentLayoutRef.current = currentLayoutRef.current.filter(
      (widget) => widget?.grid?.i !== idx,
    )
    setRenderLayouts([...currentLayoutRef.current])
  }, [])

아이템 삭제는 해당 아이템의 삭제버튼을 클릭했을 때 removeWidget이 호출되도록 만들었다. 지금 다시보니까 굳이 현재/이전 배열을 ref로 저장할 필요는 없었을 듯...

레이아웃 유효성 검사

prevLayoutsRef, currentLayoutRef 값이 필요했던 이유. 바로 4x4 레이아웃을 벗어나서 위젯을 드롭하면 안 되기 때문이었다.

이상하게 col의 경우 ResponsiveGridLayout에 설정해둔 cols값까지만 위젯을 놓을 수 있도록 제한이 되는데, row는 무한대로 위젯을 놓을 수 있었다.

따라서 4x4 레이아웃을 벗어나서 위젯을 드롭하면 레이아웃을 이전 상태로 되돌리도록 만들었다. 그러려면 두 가지 경우를 검사해야 했다. 1. 외부 위젯을 drop 시 레이아웃을 벗어나지 않았는지 2. 내부 위젯을 drag and drop 시 레이아웃을 벗어나지 않았는지

1. 외부 위젯을 drop 시 레이아웃을 벗어나지 않았는지
이걸 검사하는 건 어렵지 않았다.
그냥 onDrop 할 때 drop 후 레이아웃을 검사한 뒤 레이아웃을 벗어났다면 return을 했다. drop 시 레이아웃에 외부 위젯을 추가하는 것은 내가 만든 action이기 때문에 return만 해도 가능했다.

  const onDrop = useCallback(
    (layout: Layout[], layoutItem: Layout) => {
      if (!isValidLayout(layout)) {
        return
      }
      ...
    },
    [edit, isValidLayout, index, type, size],
  )

isValidLayout 함수는 이렇게 생겼다.

  const isValidLayout = useCallback((newLayout: Layout[] | IWidget[]) => {
    const checkY = newLayout.some(
      (item) =>
        ('grid' in item ? item?.grid?.y + item?.grid?.h : item?.y + item?.h) >
        4,
    )
    return !checkY
  }, [])

col값은 계산해주지 않아도 되기 때문에 row만 체크했다.
하나씩 돌면서, y + h 값이 4를 넘어가는 경우가 있는지를 체크했다.
(4x4 레이아웃의 위젯 최대 값은 y: 3, h:1 이기 때문)
'grid' in item 를 체크하는 이유는 위 함수를 두 군데에서 호출하는데, 두 개의 레이아웃 타입이 달라서 따로 처리를 위해서 체크했다.

2. 내부 위젯을 drag and drop 시 레이아웃을 벗어나지 않았는지

useEffect(() => {
    if (
      currentLayoutRef.current &&
      edit &&
      !isDropping &&
      !isValidLayout(currentLayoutRef.current)
    ) {
      setTimeout(() => {
        setRenderLayouts(prevLayoutsRef.current)
      }, 50)
    }
  }, [renderLayouts])

내부 위젯을 drag 할 때는 onDrop 처럼 처리하기가 어려웠다. 그러니까, 반영되기 전에 레이아웃의 유효성을 확인하고 유효하지 않은 레이아웃이면 업데이트를 막는 방식이 먹히지 않았다. 아무래도 React-grid-layout의 내부에서도 레이아웃의 값을 저장하고 관리하는데, 그 부분을 건드리려고 하니 충돌이 일어나는 것 같았다.

그래서 마음에 들는 방식은 아니지만... onLayoutChange에서 renderLayout를 업데이트 시키고 useEffect에서 renderLayout이 변하면 layout을 검사하고 만약 유효하지 않은 layout이면 미리 저장해둔 prevLayout으로 되돌렸다.
더 좋은 방법을 찾고 싶었으나 찾지 못했다...😢

breakpoint마다 col을 다르게 렌더링

pc인지 모바일인지에 따라 레이아웃 4x4, 2x8로 렌더링 되어야 했다.
반응형 RGL을 쓰고 있기 때문에 레이아웃이 4x4에서 2x8로 변하는 건 어렵지 않았다. 그냥 cols 값을 const cols = { xs: 4, xxs: 2 } 이렇게 주면 됐다.
문제는 4x4에서 2x8로 갈 때 내부의 위젯의 값은 바뀌지 않기 때문에, 4x4 위젯 정보에 맞춰 2x8 레이아웃이 렌더링되었고, 예상치 못하게 레이아웃이 깨진다는 점이었다.

그래서 mobile layout을 따로 계산하기로 했다.

  const mLayouts = useMemo(() => {
    return currentLayoutRef.current?.map((layout) => {
      if (layout.grid.x > 1) {
        return {
          ...layout,
          grid: {
            ...layout.grid,
            x: layout.grid.x > 2 ? 0 : 1,
            y: layout.grid.y + 1,
          },
        }
      }
      return layout
    })
  }, [])

x의 값이 2를 넘어가면 (3번째 줄) 다음 줄로 넘겨주는 형식으로 계산했다.

useEffect(() => {
    //colNum이 4면 그대로 렌더링, 2면 모바일 렌더링
    if (colNum == 4) {
      //만약 변경된 레이아웃이 유효하지 않다면 이전 레이아웃으로 변경
      if (!isValidLayout(currentLayoutRef.current))
        setRenderLayouts(prevLayoutsRef.current)
      else setRenderLayouts(currentLayoutRef.current)
    } else setRenderLayouts(mLayouts)
  }, [colNum])

그리고 breakpoint에 따라 계산한 colNum이 변하면 (pc와 mobile만 감지) renderLayout의 값을 교체해줬다.
이것 또한... useEffect를 사용한 방식이라 마음에 들지 않았지만 다른 방법을 찾지 못해서 이렇게 처리했다.

마무리하며

매번 crud나 하던 나에게... dnd 기능은 새로운 벽이었다. 비교적 자료가 적은 라이브러리를 이용하여 만들다보니 우여곡절이 많았던 것 같다. 내가 원하는 기능을 가장 좋은 방법으로 구현했다고는 말하지 못하겠지만, 그래도 정해진 기능을 구현할 수 있어 기뻤다.

profile
어 왜 되지? 에 대한 고찰

0개의 댓글