LoginSignup
6

More than 3 years have passed since last update.

posted at

updated at

Organization

Drag Drop Section

要素のドラッグ&ドロップは、身近にあって当たり前の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
    ]
  )
}

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
What you can do with signing up
6