🙌

カルーセルやテーブルのテキスト選択などで行われるドラッグ操作でクリックイベントが発火されないようにした

2024/09/29に公開

始めに

Reactでクリックイベントを拾う場合onClickコールバックに設定すると思いますが、このコールバックは mousedown してから mouseup するまでにカーソルが移動しても同じ要素の上であればクリック判定がされます。

通常であればこの挙動で問題はありませんが、カルーセルでスワイプしたいのにクリック判定されてしまったり、テキストコピーしたくてドラッグしただけなのに選択状態になるのは違和感でした。

テーブルは選択されたところで操作の邪魔をされるわけではないので最悪大丈夫ですが、カルーセルは致命的です。ライブラリでも同じような挙動が起きるのかSwiperで試してみたところ、なんとドラッグ時のクリックイベントを防げていました。

ライブラリでどうやってクリックイベントをブロックしているか紐解けば自前でも設定できると思い、Swiperのコードリーディングして実際に設定することができたので備忘録としてまとめました。

Swiperのクリックイベントのブロックロジック

結論から言うと、ざっくり以下のようなコードが設定されていたことによって子要素のクリックイベントを止めているようです。

子要素のクリックイベントをブロックする設定の大枠
el.addEventListener(
  'click',
  (e) => {
    if (/* クリックイベントを抑制したい時 */) {
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
    }
  },
  true
)
参考コード

ちなみにこの「クリックイベントを抑制したい」というフラグがSwiperには存在するようで、 preventClickspreventClicksPropagationというフラグで制御できました。これのデフォルト値がtrueだったので表示しただけでドラッグ時のクリック判定をブロックできていたようです。

https://swiperjs.com/swiper-api#param-preventClicks

このコードから察するにe.stopPropagationによって子要素のクリックイベントの伝播を止めていることになりますが、普通は子から親への伝播を止めるはずで逆の動きになっています。これは第3引数をtrueにしているのがミソになります。ここをtrueにするとキャプチャフェーズで親から子へアクセスしていくことになるので、e.stopPropagationを実行するとこれ以上子へ流れなくなります。

https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener#usecapture

バブリングとキャプチャリングについての詳細は以下の記事が参考になります。

https://ja.javascript.info/bubbling-and-capturing
https://zenn.dev/antez/books/6da596a697aa86/viewer/d8a100

なお、Reactでキャプチャフェーズのイベントを拾いたい場合は onClickCapture など ~Capture で設定できます。試しにここでstopPropagationを実行したら子要素のクリックイベントを止めることができました。

Reactでキャプチャフェーズでイベントの伝播を止める例
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に設定して貰う方法にしました。
クリックイベントの伝播を止めるかはクリック開始と終了の差をみて、その差が一定の値を超えた時に止めるようにしており、具体的にコードに落とすと以下のようになりました。

ドラッグ時にクリックイベントの伝播を止めるhooks
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を使いました。

https://webfrontend.ninja/js-pointer-events/

また前のセクションでキャプチャフェーズにすることで子要素のイベントを止められると書きましたが、今回その設定をしなくてもイベントを止めることができました。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も載せています。

終わりに

以上がドラッグ操作でクリックイベントが発火されないようにする方法でした。親から子のイベントの発火を抑制できることが目から鱗でしたが、これによって設定がシンプルになって良かったです😊 ドラッグ操作とバッティングして意図しないクリックイベントが発火してしまう時の参考になれば幸いです。

GitHubで編集を提案

Discussion