要素のドラッグ&ドロップは、身近にあって当たり前のUIでありながら、ブラウザで自前実装の難易度が高い UI です。通常の CSS レイアウトでは考慮点が多いため、独自レイアウトロジックを組みます。先日公開した大喜利スピンオフ作品は本作の応用編なので、こちらも確認してみて下さい。
code: github / $ yarn 1224
Record
まずは要素配列の定義です。
type Record = {
title: string
description: string
priority: string
}
この要素配列とは別に、レイアウトのための配列を2つ用意、次の Custom Hooks で算出していきます。
- 当たり判定点配列
- indexMapping 配列
useDragDropContainer
今回の Custom Hooks 内訳です。マウスイベントとタッチイベント両方の定義のためコード量が多くなっていますので、大まかな流れを解説します。
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
は、その計測に利用する座標点配列です。この配列は、画面のサイズが変更された時のみに更新されます。
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 に保持します。
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 を保持操作することにより、ロジックがシンプルになります。
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アニメーションで移動する仕組みです。
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
]
)
}