カルーセルやテーブルのテキスト選択などで行われるドラッグ操作でクリックイベントが発火されないようにした
始めに
Reactでクリックイベントを拾う場合onClick
コールバックに設定すると思いますが、このコールバックは mousedown してから mouseup するまでにカーソルが移動しても同じ要素の上であればクリック判定がされます。
通常であればこの挙動で問題はありませんが、カルーセルでスワイプしたいのにクリック判定されてしまったり、テキストコピーしたくてドラッグしただけなのに選択状態になるのは違和感でした。
テーブルは選択されたところで操作の邪魔をされるわけではないので最悪大丈夫ですが、カルーセルは致命的です。ライブラリでも同じような挙動が起きるのかSwiperで試してみたところ、なんとドラッグ時のクリックイベントを防げていました。
ライブラリでどうやってクリックイベントをブロックしているか紐解けば自前でも設定できると思い、Swiperのコードリーディングして実際に設定することができたので備忘録としてまとめました。
Swiperのクリックイベントのブロックロジック
結論から言うと、ざっくり以下のようなコードが設定されていたことによって子要素のクリックイベントを止めているようです。
el.addEventListener(
'click',
(e) => {
if (/* クリックイベントを抑制したい時 */) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
},
true
)
参考コード
ちなみにこの「クリックイベントを抑制したい」というフラグがSwiperには存在するようで、 preventClicks
とpreventClicksPropagation
というフラグで制御できました。これのデフォルト値がtrueだったので表示しただけでドラッグ時のクリック判定をブロックできていたようです。
このコードから察するにe.stopPropagation
によって子要素のクリックイベントの伝播を止めていることになりますが、普通は子から親への伝播を止めるはずで逆の動きになっています。これは第3引数をtrueにしているのがミソになります。ここをtrueにするとキャプチャフェーズで親から子へアクセスしていくことになるので、e.stopPropagation
を実行するとこれ以上子へ流れなくなります。
バブリングとキャプチャリングについての詳細は以下の記事が参考になります。
なお、Reactでキャプチャフェーズのイベントを拾いたい場合は onClickCapture
など ~Capture
で設定できます。試しにここでstopPropagation
を実行したら子要素のクリックイベントを止めることができました。
return (
<div
// 親要素でキャプチャフェーズで伝播を止める
onClickCapture={(event) => {
event.stopPropagation();
}}
>
<div
onClick={() => {
// ここのクリックイベントが発火しなくなった
}}
/>
</div>
);
ちなみに余談ですが、Swiperの場合は mousedown イベントなども伝播を抑制していそうで、MUIのCardにデフォルト設定されているクリック時のrippleエフェクトも発火しなくなっていました。今回自前で実装する際はそこまでブロックするつもりはないので、個別でdisableRipple
を設定してrippleエフェクトが出ないようにしています。
Reactでドラッグ時にクリックイベントの伝播を止める機能を実装
ドラッグ時にクリックイベントの伝播を止めるhooksを実装
Swiperの実装ロジックを参考に、今回はhooksでこの実装を表現しようと思います。onClickCapture
などコールバックメソッドを返すパターンもありますが、個人的にはDOMに対してaddEventListener
した方が同じDOMに複数のイベントの設定が必要になった時に困らないと思ったためrefのみ返してそれを対象のDOMに設定して貰う方法にしました。
クリックイベントの伝播を止めるかはクリック開始と終了の差をみて、その差が一定の値を超えた時に止めるようにしており、具体的にコードに落とすと以下のようになりました。
import { useState, useEffect } from "react";
type UseStopPropagationClickByDragOption = {
/**
* ドラッグと判定される変化量(px)
* @default 0
*/
dragThreshold?: number;
/** 機能をOFFにするか */
disabled?: boolean;
};
type UseStopPropagationClickByDragReturn = {
/** 監視対象のDOMのref */
ref: (element: HTMLElement | null) => void;
};
/**
* ドラッグの場合はクリックイベントの発火を抑制するhooks
*/
export const usePreventClicksByDrag = ({
dragThreshold = 0,
disabled,
}: UseStopPropagationClickByDragOption = {}): UseStopPropagationClickByDragReturn => {
const [element, setElement] = useState<HTMLElement | null>(null);
useEffect(() => {
if (element == null || disabled) {
return;
}
/** ドラッグをしたか */
let isDragged = false;
/** クリック開始座標 */
let startPos: { x: number; y: number } | null = null;
const handlePointerDown = (event: PointerEvent) => {
isDragged = false;
startPos = {
x: event.clientX,
y: event.clientY,
};
};
const handlePointerUp = (event: PointerEvent) => {
if (startPos == null) {
return;
}
// ドラッグの距離がdragThresholdを超えたらドラッグ判定にする
const dx = startPos.x - event.clientX;
const dy = startPos.y - event.clientY;
if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
isDragged = true;
}
startPos = null;
};
const handlePointerLeave = () => {
// ポインタが外れてしまった時はクリック開始座標をリセットする
startPos = null;
};
const handleClick = (event: MouseEvent) => {
// ドラッグ判定されている場合はクリックイベントを止める
if (isDragged) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
};
element.addEventListener("pointerdown", handlePointerDown);
element.addEventListener("pointerup", handlePointerUp);
element.addEventListener("pointerleave", handlePointerLeave);
element.addEventListener("click", handleClick);
return () => {
element.removeEventListener("pointerdown", handlePointerDown);
element.removeEventListener("pointerup", handlePointerUp);
element.removeEventListener("pointerleave", handlePointerLeave);
element.removeEventListener("click", handleClick);
};
}, [element, disabled]);
return {
ref: setElement,
};
};
PointerEvents
はマウス、タッチ、ペンなどの様々なデバイスに対応したイベントで、詳細はこちらなどをご参考ください。タッチデバイスの場合、touchdownからtouchupの間で座標がずれているとclickイベントは発火しなさそうな感じでしたが、一応そちらも考慮して mousedown ではなく pointerdown などのPointerEventsを使いました。
また前のセクションでキャプチャフェーズにすることで子要素のイベントを止められると書きましたが、今回その設定をしなくてもイベントを止めることができました。Reactのon~
よりDOMに直接イベントを設定したものの方が優先されるせいなのかもしれないですが、確信はありません。一旦これで進めますが、もし問題があればaddEventListener
の第3引数にtrueを入れてキャプチャフェーズで行うようにしようと思っています。
作成したhooksを使用する
上記のコードを自作カルーセル、テーブルに対してそれぞれ設定します。
自作カルーセルについては既にrefを使っているためMUIが提供しているuseForkRef
を使って複数のrefを統合してから渡すことで設定できます。
import { ReactNode } from 'react';
import { useScratch } from 'react-use';
import { clamp } from 'lodash-es';
import { Box, IconButton, useForkRef } from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { usePreventClicksByDrag } from '../hooks/usePreventClicksByDrag';
/** 次へ遷移するかの閾値 */
const THRESHOLD = 10;
export type CarouselProps<Item extends object> = {
/** 現在表示してるindex */
currentIndex: number;
/** 項目リスト */
items: Item[];
/** 項目のユニークとなるパラメータが入っているキー名 */
itemIdKey: keyof Item;
/** 高さ */
height?: string;
/** 幅 */
width?: string;
+ /** ドラッグ時にクリック判定を抑制させるか */
+ isPreventClicks?: boolean;
/**
* 表示するindex値が変更される時
* @param newIndex - 新しい表示先index
*/
onChangeCurrentIndex: (newIndex: number) => void;
/** カルーセル要素の描画 */
children: (args: { item: Item }) => ReactNode;
};
export const Carousel = function <Item extends object>({
currentIndex,
items,
itemIdKey,
height = '100%',
width = '100%',
+ isPreventClicks,
onChangeCurrentIndex,
children,
}: CarouselProps<Item>) {
/**
* 現在の位置からdelta量だけ移動する
* @param delta - 移動量
*/
const addCurrentIndex = (delta: number) => {
// 範囲内に収まるように調整した結果同じ値の場合は何もしない
const nextCurrentIndex = clamp(currentIndex + delta, 0, items.length - 1);
if (nextCurrentIndex === currentIndex) {
return;
}
// 値が変わった場合や変更イベントを発火する
onChangeCurrentIndex(nextCurrentIndex);
};
const [refScratch, { isScratching, dx }] = useScratch({
onScratchEnd: ({ dx = 0 }) => {
if (dx < -THRESHOLD) {
addCurrentIndex(1);
} else if (dx > THRESHOLD) {
addCurrentIndex(-1);
}
},
});
+ const { ref: refStopPropagationClickByDrag } = usePreventClicksByDrag({
+ dragThreshold: THRESHOLD,
+ disabled: isPreventClicks === false,
+ });
+ const handleRef = useForkRef(refScratch, refStopPropagationClickByDrag);
return (
<Box
+ ref={handleRef}
sx={{ position: 'relative', height, width, overflow: 'hidden' }}
>
{items.map((item, index) => {
return (
<Box
key={String(item[itemIdKey])}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: `translate3d(${
-50 + (-currentIndex + index) * 100
}%, -50%, 0) translate3d(${dx ?? 0}px, 0, 0)`,
transition: isScratching ? 'none' : 'transform 0.25s',
}}
>
{children({ item })}
</Box>
);
})}
<IconButton
sx={{
position: 'absolute',
top: '50%',
left: 0,
transform: 'translate(0, -50%)',
}}
disabled={currentIndex <= 0}
onClick={() => {
addCurrentIndex(-1);
}}
>
<ChevronLeftIcon fontSize="large" />
</IconButton>
<IconButton
sx={{
position: 'absolute',
top: '50%',
right: 0,
transform: 'translate(0, -50%)',
}}
disabled={currentIndex + 1 >= items.length}
onClick={() => {
addCurrentIndex(1);
}}
>
<ChevronRightIcon fontSize="large" />
</IconButton>
</Box>
);
};
テーブルについてはMUIのテーブルを使っていますが行単位で設定することができなかったためガッツリテーブル全体に設定しました。この影響でチェックボックスもドラッグ判定されるとクリックできなくなりますがそもそもドラッグっぽい動きのクリックでチェックされる必要がないと思うのでそこまで気にならないと思います。
設定ありなしの挙動を比較できるようにチェックボックスも用意しました。
const TableSection: FC = () => {
+ const [isPreventClicks, setIsPreventClicks] = useState(false);
+ const { ref } = usePreventClicksByDrag({
+ dragThreshold: 5,
+ disabled: isPreventClicks === false,
+ });
return (
<Box>
+ <FormControlLabel
+ control={
+ <Checkbox
+ checked={isPreventClicks}
+ onChange={(_, checked) => {
+ setIsPreventClicks(checked);
+ }}
+ />
+ }
+ label="ドラッグ時にクリック判定を抑制する"
+ />
<Typography fontWeight="bold">テーブル</Typography>
<DataGrid
+ ref={ref}
rows={CARD_ITEMS}
columns={[
{ field: 'id', headerName: 'ID', width: 80 },
{ field: 'title', headerName: 'タイトル' },
{ field: 'description', headerName: '説明' },
]}
checkboxSelection
disableColumnFilter
disableColumnMenu
disableColumnSorting
hideFooter
/>
</Box>
);
};
動作結果と検証コード
以上のコードで動かしたところ、以下のようにドラッグ時はクリックイベントを抑制できるようになりました🎉
今回検証で書いたコードは以下のStackBlitzにありますので、詳細のコードや動作を確認したい方は是非ご参照ください。StackBlitzではついでにSwiperも載せています。
終わりに
以上がドラッグ操作でクリックイベントが発火されないようにする方法でした。親から子のイベントの発火を抑制できることが目から鱗でしたが、これによって設定がシンプルになって良かったです😊 ドラッグ操作とバッティングして意図しないクリックイベントが発火してしまう時の参考になれば幸いです。
Discussion