React Beautiful DnD 學習筆記

tags: Web React
category: Front-End
description: React Beautiful DnD 學習筆記
created_at: 2022/02/08 12:30:00

cover image


前言

在前幾個禮拜有想到想做的 Side Project , 就想說自己來做一個類似 Agile Board 的東西來記錄一下(說不定以後有什麼想法也都可以先往上黏 在慢慢消化),那絕對會用到的功能就是 Drag & Drop 了,而 React 做拖拉比較熱門的是下面兩套:

兩套我都有稍微去碰過,但最後選擇使用 react-beautiful-dnd 以得到更好的體驗,可能是我在起初學習的時候只有大概跑過官方的教學流程,所以不太清楚一般的 dnd 實作 transition 的複雜度,不像是 beautiful-dnd 已經都先處理好了。

題外話: dnd範例是西洋棋的移動哦 有興趣可以去看

beautiful-dnd 的文件我自己是覺得看不太習慣(可能都在repo的關係),所以才打算整理成一篇,以後如果用到直接回來查這些常用的。


事前準備

  • 裝好 Node.js

建立 React 專案

$ npx create-react-app dnd-app
or
$ yarn create react-app dnd-app

然後再一次 promote 一下自己的 cmd,可以下

$ npx lai-cmd init js

去幫你設定好 eslint + prettier + jsconfig,這時只要你的 vscode,有安裝 EsLint 的擴充功能和相應的設定,應該就會運作正常。

如果你有使用 tailwindcss 也可以下指令幫你設定好哦!

$ npx lai-cmd init react-tailwindcss

先安裝套件

# yarn
yarn add react-beautiful-dnd

# npm
npm install react-beautiful-dnd --save

先簡單裝飾一下 App.js

init todo image

import React from 'react'

function App() {
  const items = ['A', 'B', 'C']
  return (
    <div className="border w-80 mx-auto mt-2 p-4">
      <h1 className="border-b mb-4 pb-2 text-4xl">Todo</h1>
      <main>
        {items.map((item, i) => (
          <div className="mt-4 p-2 border rounded-sm" key={i}>
            {item}
          </div>
        ))}
      </main>
    </div>
  )
}

export default App

再來加上 dnd 的功能

加上之前要先大概知道三個主要的元件與基本參數

  • DragDropContext
    • 可以綁定一些事件(關於生命週期)
      1. onBeforeCapture: 開始拖動之前,還可以對DOM做最後操作
      2. onBeforeDragStart: 開始拖動之前,這時已經蒐集完DOM的資訊
      3. onDragStart: 開始拖動
      4. onDragUpdate: 拖動中有變化,例如有換到順序
      5. onDragEnd (required): 拖動結束,這時應該要更新資料
  • Droppable
    • droppableId (required): 在同一個 Context 中唯一的 ID
    • direction: 方向,預設為 vertical,也可以設定成 horizontal
  • Draggable
    • draggableId (required): 在同一個 Context 中唯一的 ID
    • index (required):跟順序相同的索引值

<Droppable><Draggable> 中的 children 都必須為一個函數。

稍微修改一下,就會得到以下的版本。

import React from 'react'
import { Draggable, DragDropContext, Droppable } from 'react-beautiful-dnd'

function App() {
  const items = ['A', 'B', 'C']
  return (
    <div className="border w-80 mx-auto mt-2 p-4">
      <DragDropContext
        onBeforeCapture={(e) => console.log('onBeforeCapture: ', e)}
        onBeforeDragStart={(e) => console.log('onBeforeDragStart: ', e)}
        onDragStart={(e) => console.log('onDragStart: ', e)}
        onDragUpdate={(e) => console.log('onDragUpdate: ', e)}
        onDragEnd={(e) => console.log('onDragEnd: ', e)}
      >
        <h1 className="border-b mb-4 pb-2 text-4xl">Todo</h1>
        <main>
          <Droppable droppableId="drop-id">
            {(provided) => (
              <div ref={provided.innerRef} {...provided.droppableProps}>
                {items.map((item, i) => (
                  <div key={item}>
                    <Draggable draggableId={item} index={i}>
                      {(provided) => (
                        <div
                          {...provided.draggableProps}
                          {...provided.dragHandleProps}
                          ref={provided.innerRef}
                          className="mt-4 p-2 border rounded-sm"
                        >
                          {item}
                        </div>
                      )}
                    </Draggable>
                  </div>
                ))}
                {provided.placeholder}
              </div>
            )}
          </Droppable>
        </main>
      </DragDropContext>
    </div>
  )
}

export default App

這時會發現可以拖拉了,但是並沒有真正的更換位置,那是因為資料並沒有被更新,所以要在拖拉結束之後更新資料。

既然要更新資料,就該把 items 換成 state,所以包成

  const [items, setItems] = useState(['A', 'B', 'C'])

然後宣告一下 onDragEnd

  const onDragEnd = (result) => {
    const { source, destination } = result

    // 如果目的地都沒變就跳出
    if (
      source.droppableId === destination.droppableId &&
      source.index === destination.index
    ) {
      return
    }


    // 製作新的 items
    const newItems = [...items]

    // 把兩個索引的值對調,相當於 [a, b] = [b, a]
    ;[newItems[source.index], newItems[destination.index]] = [
      newItems[destination.index],
      newItems[source.index],
    ]

    // 設定新的 items
    setItems(newItems)
  }

把上面那段掛到 <DragDropContext>props 中就可以了。


使用 snapshot

<Droppable><Draggable> 中函數第二個參數為 snapshot,可以再多得到一些額外資訊,例如常用的

  • Draggable
    • snapshot.isDragging => 是否正在 dragging
  • Droppable
    • snapshot.isDraggingOver => 是否有東西拖進來

一些範例

改變背景顏色 (對 className 做了一點處理)

<DragDropContext onDragEnd={onDragEnd}>
  <h1 className="border-b mb-4 pb-2 text-4xl">Todo</h1>
  <main>
    <Droppable droppableId="drop-id">
      {(provided, snapshot) => (
        <div
          ref={provided.innerRef}
          {...provided.droppableProps}
          className={`${
            snapshot.isDraggingOver ? ' bg-neutral-200' : ''
          }`}
        >
          {items.map((item, i) => (
            <div key={item}>
              <Draggable draggableId={item} index={i}>
                {(provided, snapshot) => (
                  <div
                    {...provided.draggableProps}
                    {...provided.dragHandleProps}
                    ref={provided.innerRef}
                    className={`mt-4 p-2 border rounded-sm ${
                      snapshot.isDragging ? 'bg-blue-300' : ''
                    }`}
                  >
                    {item}
                  </div>
                )}
              </Draggable>
            </div>
          ))}
          {provided.placeholder}
        </div>
      )}
    </Droppable>
  </main>
</DragDropContext>

把可拖的對象換掉

重點在於把 {...provided.dragHandleProps} 給你想要綁的 DOM

<Draggable draggableId={item} index={i}>
  {(provided, snapshot) => (
    <div
      {...provided.draggableProps}
      ref={provided.innerRef}
      className={`flex mt-2 border items-center ${
        snapshot.isDragging ? 'bg-blue-300' : ''
      }`}
    >
      <div
        {...provided.dragHandleProps}
        className="w-10 h-10 bg-purple-500 mr-4"
      ></div>
      <div>{item}</div>
    </div>
  )}
</Draggable>

多個欄位間互相拖拉

這邊以兩個 Column 當範例

簡單設定一個 state 與一個常數

  const [columns, setColumns] = useState({
    A: ['A', 'B', 'C'],
    B: [],
  })

  const columnKeys = Object.keys(columns)

再來渲染的時候就要以 columnKeys 開始,裡面 items 就要根據不同的 keyrender

<div className="border w-fit mx-auto mt-2 p-4">
  <DragDropContext onDragEnd={onDragEnd}>
    <main className="flex">
      {columnKeys.map((column) => (
        <div key={column} className="mx-4">
          <h1 className="border-b mb-4 pb-2 text-4xl">Column {column}</h1>
          <Droppable droppableId={column}>
            {(provided, snapshot) => (
              <div
                ref={provided.innerRef}
                {...provided.droppableProps}
                className={`min-h-[100px] ${
                  snapshot.isDraggingOver ? ' bg-neutral-200' : ''
                }`}
              >
                {columns[column].map((item, i) => (
                  <div key={item}>
                    <Draggable draggableId={item} index={i}>
                      {(provided, snapshot) => (
                        <div
                          {...provided.draggableProps}
                          ref={provided.innerRef}
                          className={`flex mt-2 border items-center ${
                            snapshot.isDragging ? 'bg-blue-300' : ''
                          }`}
                        >
                          <div
                            {...provided.dragHandleProps}
                            className="w-10 h-10 bg-purple-500 mr-4"
                          ></div>
                          <div>{item}</div>
                        </div>
                      )}
                    </Draggable>
                  </div>
                ))}
                {provided.placeholder}
              </div>
            )}
          </Droppable>
        </div>
      ))}
    </main>
  </DragDropContext>
</div>

最後是核心邏輯(交換的部分)

  const onDragEnd = (result) => {
    const { source, destination, draggableId } = result

    // 如果沒變就跳出
    if (
      source.droppableId === destination.droppableId &&
      source.index === destination.index
    ) {
      return
    }

    // 如果在同一個容器中交換
    if (source.droppableId === destination.droppableId) {
      // 抓出對應的 items 之後做交換
      const items = [...columns[source.droppableId]]

      ;[items[source.index], items[destination.index]] = [
        items[destination.index],
        items[source.index],
      ]

      setColumns({
        ...columns,
        [source.droppableId]: items,
      })

      return
    }

    // 否則就是在不同容器中移動,要抓出對應的items做操作
    const fromItems = [...columns[source.droppableId]]
    const toItems = [...columns[destination.droppableId]]

    fromItems.splice(source.index, 1)
    toItems.splice(destination.index, 0, draggableId)

    setColumns({
      ...columns,
      [source.droppableId]: fromItems,
      [destination.droppableId]: toItems,
    })
  }

到這就大功告成了,也可以嘗試對 columns 多加幾個試試,功能也會正常。

其實看到這文件他的文件也差不多看習慣了,不過既然都整理好了就當作幫助給一開始跟我一樣不習慣的人吧

這邊只有紀錄常用的功能,更細節的還是要回到官方文件哦!




最後更新時間: 2022年02月08日.