LoginSignup
6
6

More than 5 years have passed since last update.

要素のドラッグ&ドロップは、身近にあって当たり前のUIでありながら、ブラウザで自前実装の難易度が高い UI です。通常の CSS レイアウトでは考慮点が多いため、独自レイアウトロジックを組みます。先日公開した大喜利スピンオフ作品は本作の応用編なので、こちらも確認してみて下さい。
code: github / $ yarn 1224

1224.jpg 1224-1.jpg

Record

まずは要素配列の定義です。

components/record.ts
type Record = {
  title: string
  description: string
  priority: string
}

この要素配列とは別に、レイアウトのための配列を2つ用意、次の Custom Hooks で算出していきます。

  • 当たり判定点配列
  • indexMapping 配列

useDragDropContainer

今回の Custom Hooks 内訳です。マウスイベントとタッチイベント両方の定義のためコード量が多くなっていますので、大まかな流れを解説します。

components/useDragDropContainer.ts
const useDragDropContainer = (props: Props) => {
  const [state, update] = useState<State>(...)
  const handleStart = useCallback(...)
  const handleMouseDownElement = useCallback(...)
  const handleTouchStartElement = useCallback(...)
  const handleMove = useCallback(...)
  const handleTouchMoveElement = useCallback(...)
  const handleMouseMoveElement = useCallback(...)
  const handleEndMove = useCallback(...)
  useEffect(...) // 当たり判定点座標を更新する effect
  useEffect(...) // 当たり判定点から indexMapping を更新する effect
  return {
    isMouseDown: state.isMouseDown,
    target: state.target,
    indexMapping: state.indexMapping,
    handleMouseDownElement,
    handleMouseMoveElement,
    handleMouseUpElement,
    handleTouchStartElement,
    handleTouchMoveElement,
    handleTouchEndElement
  }
}

当たり判定点配列

要素が押下されドラッグしている最中は、指の位置と、ドロップ可能な場所の距離を常に測っています。state に保持しているhitPointsは、その計測に利用する座標点配列です。この配列は、画面のサイズが変更された時のみに更新されます。

components/useDragDropContainer.ts
useEffect(
  () => {
    update(_state => ({
      ..._state,
      hitPoints: props.records.map((record, index) => {
        const x = props.left + props.itemWidth * 0.5
        const y =
          index * props.itemHeight +
          props.top +
          props.itemHeight * 0.5
        return { x, y }
      })
    }))
  },
  [props.itemWidth]
)

当たり判定点の捻出

先の hitPoints 各点座標と、現在マウス座標位置の距離を計測します。閾値以下の座標を「当たり」と判定し、当たり点の index を state に保持します。

components/useDragDropContainer.ts
const handleMove = useCallback(
  ({ center, pointOffset }: HandleMoveProps) => {
    update(_state => {
      if (!_state.isMouseDown) return _state
      const hitIndex = _state.hitPoints
        .map(
          point =>
            Math.sqrt(
              Math.pow(center.x - point.x, 2) +
                Math.pow(center.y - point.y, 2)
            ) <
            props.itemHeight * 0.5
        )
        .findIndex(flag => flag)
      return {
        ..._state,
        target: { ..._state.target, pointOffset },
        hitIndex:
          hitIndex === -1 ? _state.hitIndex : hitIndex
      }
    })
  },
  [state.isMouseDown]
)

indexMapping の更新

hitIndex が得られたので、動かしているアイコン index を hitIndex に位置する場所へ移動させます。要素配列を操作するのではなく「i番目の要素はn番地に配置する」という indexMapping を保持操作することにより、ロジックがシンプルになります。

components/useDragDropContainer.ts
useEffect(
  () => {
    update(_state => {
      const direction =
        _state.hitIndex - _state.prevHitIndex
      if (direction === 0) return _state
      const indexMapping = [..._state.indexMapping]
      const index = indexMapping.findIndex(
        index => index === _state.target.index
      )
      const item = indexMapping[index]
      if (direction === -1) {
        indexMapping.splice(_state.hitIndex, 0, item)
        indexMapping.splice(
          indexMapping.lastIndexOf(item),
          1
        )
      } else {
        indexMapping.splice(index, 1)
        indexMapping.splice(_state.hitIndex, 0, item)
      }
      return {
        ..._state,
        indexMapping,
        prevHitIndex: _state.hitIndex
      }
    })
  },
  [state.hitIndex]
)

ドラッグ対象要素

先の Custom Hooks を組み込んだ Provider から、useContext でハンドラ・状態を参照します。ドラッグ中の要素以外 transitionDuration が与えらているので、自身が参照する「n番地」座標に変更があると、CSSアニメーションで移動する仕組みです。

components/contexts.ts

export default (props: Props) => {
  const {
    isMouseDown,
    target,
    indexMapping,
    handleMouseDownElement,
    handleMouseMoveElement,
    handleMouseUpElement,
    handleTouchStartElement,
    handleTouchMoveElement,
    handleTouchEndElement
  } = useContext(IconDashboardContext)
  return useMemo(
    () => {
      const isMoveElement = target.index === props.index
      const index = indexMapping.findIndex(
        index => props.index === index
      ) // indexMapping から、自身の座標を参照
      const x = 0
      const y = index * props.height
      const style = {
        width: props.width,
        height: props.height,
        top: isMoveElement
          ? target.startRectPoint.y + target.pointOffset.y
          : y,
        left: isMoveElement
          ? target.startRectPoint.x + target.pointOffset.x
          : x,
        zIndex: isMoveElement ? 1 : 0,
        transitionDuration:
          isMoveElement && isMouseDown ? '0s' : '.2s',
        transitionProperty: 'top,left'
      }
      if (window.ontouchstart === null) {
        return (
          <TouchView
            style={style}
            onTouchStart={event =>
              handleTouchStartElement(event, props.index)
            }
            onTouchMove={handleTouchMoveElement}
            onTouchEnd={handleTouchEndElement}
          >
            {props.children}
          </TouchView>
        )
      } else {
        return (
          <MouseView
            style={style}
            onMouseDown={event =>
              handleMouseDownElement(event, props.index)
            }
            onMouseMove={handleMouseMoveElement}
            onMouseUp={handleMouseUpElement}
          >
            {props.children}
          </MouseView>
        )
      }
    },
    [
      props.width,
      isMouseDown,
      target,
      indexMapping,
      handleMouseDownElement,
      handleMouseMoveElement,
      handleMouseUpElement,
      handleTouchStartElement,
      handleTouchMoveElement,
      handleTouchEndElement
    ]
  )
}
6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6