🐈

ドラムロールUIで日付を選択するコンポーネントを作ってみた

2024/10/12に公開

始めに

日付の選択のUIでよくあるものはカレンダーだと思いますが、スマホアプリではドラムロールUIと呼ばれるスクロールして日付を選ぶものがあります。Webでもライブラリがないか調べたところ、いくつか出てきました。

https://www.npmjs.com/package/react-mobile-picker
https://www.plus-one.tech/vue-drumroll-datetime-picker/

しかし react-mobile-picker はwheelによるスクロールの移動量が多すぎる問題があり、 vue-drumroll-date-time-picker は操作感は近いものの文字をもうちょっと大きくしたいなどスタイルを調整したいのと、そもそもVue.jsのライブラリなのでReactでは使えない問題がありました。
そこで vue-drumroll-date-time-picker の挙動を参考にしつつ、MUIを使ってドラムロールUIを自作してみたので備忘録としてまとめました。

作ったもの

今回作ったものは以下のコンポーネントを作って、それを組み合わせて最終的に日付選択のドラムロールUIを作りました。

  • ScrollPicker: 一列分をスクロールで選択できるコンポーネント
  • DateScrollPicker: 年月日それぞれに対してScrollPickerを呼んで日付を選択するコンポーネント
  • InputDateByScrollPicker: ダイアログまたはポップアップでDateScrollPickerを呼び、選択した結果を確定して日付を入力するコンポーネント

動作確認ページは以下になります。

https://takanorionuma.github.io/trial-stackblitz-scroll-picker/

ソースコードは以下のStackBlitzのURLかリポジトリの方をご参照ください。
https://stackblitz.com/~/github.com/TakanoriOnuma/trial-stackblitz-scroll-picker?file=src/App.tsx
https://github.com/TakanoriOnuma/trial-stackblitz-scroll-picker

ScrollPickerの作成

ScrollPickerの土台を作成

まずはScrollPickerを作成します。以下のようにとりあえず項目を並べて、上下に白い影を乗せて見た目を整えます。

ScrollPickerの土台を作成
import { useRef, useEffect, CSSProperties } from "react";
import { Box, MenuList, MenuItem, useForkRef } from "@mui/material";

/** 1つの項目の高さ */
const SCROLL_ITEM_HEIGHT = 40

/** スクロールして選択する項目 */
export type ScrollItem<V> = {
  value: V;
  label: string;
  disabled?: boolean;
};

/**
 * 擬似要素に設定する影のスタイルを生成する
 * @param position - 配置させる位置
 */
const createPseudoShadowStyle = (
  position: "top" | "bottom"
): CSSProperties => ({
  content: '""',
  position: "absolute",
  zIndex: 1,
  top: position === "top" ? 0 : undefined,
  bottom: position === "bottom" ? 0 : undefined,
  left: 0,
  width: "100%",
  height: "40%",
  background: `linear-gradient(to ${position}, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))`,
  pointerEvents: "none",
});

export type ScrollPickerProps<V> = {
  /** 選択中の値 */
  value: V;
  /** 選択リスト */
  items: ScrollItem<V>[];
  /** スクローラーの高さ */
  height?: number;
  /**
   * 値が変更された時
   * @param newValue - 新しい値
   */
  onChangeValue: (newValue: V) => void;
};

export const ScrollPicker = function <V>({
  value,
  items,
  height = 5 * SCROLL_ITEM_HEIGHT,
  onChangeValue,
}: ScrollPickerProps<V>) {
  /** スクロールの始端・終端がピッタリ真ん中で収まるように調整する余白の高さ */
  const paddingHeight = (height - SCROLL_ITEM_HEIGHT) / 2;

  return (
    <Box
      sx={{
        position: "relative",
        height,
        "&::before": createPseudoShadowStyle("top"),
        "&::after": createPseudoShadowStyle("bottom"),
      }}
    >
      <MenuList
        sx={{
          height: "100%",
          overflowY: "scroll",
          "&::-webkit-scrollbar": {
            display: "none",
          },
        }}
        disablePadding
      >
        <MenuItem
          key={`pad-top`}
          sx={{
            height: paddingHeight,
            minHeight: "auto",
          }}
          disabled
        />
        {items.map((item) => (
          <MenuItem
            key={String(item.value)}
            style={{
              height: SCROLL_ITEM_HEIGHT,
              minHeight: "auto",
              textAlign: "center",
            }}
            selected={item.value === value}
            disabled={item.disabled}
            onClick={() => {
              onChangeValue(item.value)
            }}
          >
            <ListItemText
              sx={{
                "& > .MuiListItemText-primary": {
                  fontWeight: item.value === value ? "bold" : undefined,
                },
              }}
              primary={item.label}
            />
          </MenuItem>
        ))}
        <MenuItem
          key={`pad-bottom`}
          sx={{
            height: paddingHeight,
            minHeight: "auto",
          }}
          disabled
        />
      </MenuList>
    </Box>
  );
};

このコンポーネントを呼ぶと以下のような感じになります。

スクロール完了後に中心にある項目を選択する(wheelイベント)

続いてはScrollPickerの醍醐味であるスクロール完了後に中心にある項目を選択する機能を実装します。最初はscrollイベントで実装することを考えてましたが、タッチスクロールの移動量がAndroidとiOSで大きく異なっていて操作感に違いがあったのでスクロールが発生するイベントの方をハンドリングして自前でスクロールさせるようにしました。
スクロール完了を通知するhooksがあるとロジックの見通しが良くなるのでまずはそれ用のhooksを作ります。その中でも実装が簡単なマウスホイールイベントであるwheelイベントの方を先に実装すると、以下のようなコードになりました。

useHandleScroll.ts
import { useMemo } from "react";

/**
 * スクロールをJS側で制御するカスタムフック
 */
export const useHandleScroll = ({
  onFinishScroll,
}: {
  /** スクロールが終了した時 */
  onFinishScroll: () => void;
}) => {
  const ref = useMemo(() => {
    let cachedElement: HTMLElement | null = null;
    let timerId: number | undefined;
    const handleWheel = (event: WheelEvent) => {
      event.stopPropagation();
      if (cachedElement == null) {
        return;
      }
      cachedElement.scrollTop += event.deltaY;

      clearTimeout(timerId);
      timerId = window.setTimeout(() => {
        onFinishScroll();
      }, 100);
    };

    return (element: HTMLElement | null) => {
      if (element == null) {
        if (cachedElement != null) {
          cachedElement.removeEventListener("wheel", handleWheel);
        }
        clearTimeout(timerId);
        cachedElement = null;
        return;
      }

      cachedElement = element;
      cachedElement.addEventListener("wheel", handleWheel);
    };
  }, [onFinishScroll]);

  return {
    ref,
  };
};

これを先ほどのScrollPicker.tsxで使用すると以下のようになります。

useHandleScrollをScrollPickerで使用する
-import { Box, MenuList, MenuItem } from "@mui/material";
+import { Box, MenuList, MenuItem, useForkRef } from "@mui/material";
 import { useHandleScroll } from "./hooks/useHandleScroll";

 // 一部省略

 export const ScrollPicker = function <V>({
   value,
   items,
   height = 5 * SCROLL_ITEM_HEIGHT,
   onChangeValue,
 }: ScrollPickerProps<V>) {
+  const elMenuListRef = useRef<HTMLElement | null>(null);
   /** スクロールの始端・終端がピッタリ真ん中で収まるように調整する余白の高さ */
   const paddingHeight = (height - SCROLL_ITEM_HEIGHT) / 2;

+  const { ref: refScroller } = useHandleScroll({
+    onFinishScroll: () => {
+      const elMenuList = elMenuListRef.current;
+      if (elMenuList == null) {
+        return;
+      }
+      const index = Math.round(elMenuList.scrollTop / SCROLL_ITEM_HEIGHT);
+      const itemValue = items[index]?.value;
+      if (itemValue === undefined) {
+        return;
+      }
+      onChangeValue(itemValue);
+    },
+  });
+  const handleRef = useForkRef(elMenuListRef, refScroller);

   return (
     <Box
       sx={{
         position: "relative",
         height,
         "&::before": createPseudoShadowStyle("top"),
         "&::after": createPseudoShadowStyle("bottom"),
       }}
     >
       <MenuList
+        ref={handleRef}
         sx={{
           height: "100%",
           overflowY: "scroll",
           "&::-webkit-scrollbar": {
             display: "none",
           },
         }}
         disablePadding
       >
         {/* 中身は同じため省略 */}
       </MenuList>
     </Box>
   );
 };

これで以下のようにスクロールで値が選択されるようになりました。

選択された値が中心に配置されるようにスクロール位置を調整する

スクロールで値は選択されましたが、スクロール位置は中心にピッタリ位置づいていた方が良いと思うので、値が変わった時にuseEffectでスクロール位置を調整します。また値が変更されなかった場合、中途半端なスクロール位置のままだと違和感なので元の場所に戻るように調整します。

選択された値が中心に配置されるようにスクロール位置を調整する
 // 一部省略

+/**
+ * 対処の値にスクロールする
+ * @param elMenuList - ul要素
+ * @param items - 項目リスト
+ * @param targetValue - スクロール先の値
+ * @param options - オプション
+ */
+export const scrollToItemValue = function <V>(
+  elMenuList: HTMLElement,
+  items: ScrollItem<V>[],
+  targetValue: V,
+  {
+    disableAnimation,
+  }: {
+    /** アニメーションせず即時移動させるか */
+    disableAnimation?: boolean;
+  } = {}
+) {
+  const targetIndex = items.findIndex((item) => item.value === targetValue);
+  if (targetIndex < 0) {
+    return;
+  }
+
+  const scrollTop = targetIndex * SCROLL_ITEM_HEIGHT;
+  if (disableAnimation) {
+    elMenuList.scrollTop = scrollTop;
+    return;
+  }
+  elMenuList.scrollTo({
+    top: scrollTop,
+    behavior: "smooth",
+  });
+};

 export const ScrollPicker = function <V>({
   value,
   items,
   height = 5 * SCROLL_ITEM_HEIGHT,
   onChangeValue,
 }: ScrollPickerProps<V>) {
+  /** 初回のスクロールか(初回はアニメーションではなく直接scrollTopを変更する) */
+  const isFirstScrollRef = useRef<boolean>(true);
   const elMenuListRef = useRef<HTMLElement | null>(null);
   /** スクロールの始端・終端がピッタリ真ん中で収まるように調整する余白の高さ */
   const paddingHeight = (height - SCROLL_ITEM_HEIGHT) / 2;

+  useEffect(() => {
+    const elMenuList = elMenuListRef.current;
+    if (elMenuList == null) {
+      return;
+    }
+
+    const isFirstScroll = isFirstScrollRef.current;
+    isFirstScrollRef.current = false;
+
+    scrollToItemValue(elMenuList, items, value, {
+      disableAnimation: isFirstScroll,
+    });
+  }, [items, value]);

   const { ref: refScroller } = useHandleScroll({
     onFinishScroll: () => {
       const elMenuList = elMenuListRef.current;
       if (elMenuList == null) {
         return;
       }
       const index = Math.round(elMenuList.scrollTop / SCROLL_ITEM_HEIGHT);
       const itemValue = items[index]?.value;
       if (itemValue === undefined) {
         return;
       }
+      // 同じ値を算出した場合は同じ場所に戻るようにスクロールして終了する
+      if (itemValue === value) {
+        scrollToItemValue(elMenuList, items, itemValue);
+        return;
+      }
       onChangeValue(itemValue);
     },
   });
   const handleRef = useForkRef(elMenuListRef, refScroller);

   return (
     // render内容は一緒なので省略
   );
 };

これで選択した値が中心に来るようにスクロール位置を調整してくれるようになりました。

disabledの項目がスクロール位置に当たった時は前後で最も近い項目を選ぶようにする

disabledの項目がある場合はこの位置にスクロールが止まっても選択されてしまっては困るため、前後で最も近い選択可能な項目を選ぶように調整します。findSelectableScrollItemValueのロジックがかなりごちゃついていますが、ざっくり図にすると以下のような場所に対して変数をつけており、ここから最も近い選択可能な項目を探しています。

disabledの項目がスクロール位置に当たった時は前後で最も近い項目を選ぶようにする
 // 一部省略

+/**
+ * ul要素のscrollTopから選択可能な項目の値を取得する
+ * @param elMenuList - ul要素
+ * @param currentValue - 現在の値
+ * @param items - 項目リスト
+ */
+export const findSelectableScrollItemValue = function <V>(
+  elMenuList: HTMLElement,
+  currentValue: V,
+  items: ScrollItem<V>[]
+): V | undefined {
+  const index = Math.round(elMenuList.scrollTop / SCROLL_ITEM_HEIGHT);
+  const item = items[index];
+  if (item == null) {
+    return undefined;
+  }
+  if (!item.disabled) {
+    return item.value;
+  }
+
+  // スクロール位置にある項目がdisabledの場合、最も近い有効な項目を探す
+  const currentIndex = items.findIndex((item) => item.value === currentValue);
+  // 選択中の項目が見つからなかった場合は何もしない
+  if (currentIndex === -1) {
+    return undefined;
+  }
+  // 同じ場所を指した場合は現在の値を返す
+  if (currentIndex === index) {
+    return currentValue;
+  }
+
+  const possiblyTopItems = items.slice(0, index).reverse();
+  const possiblyTopItemIndex = possiblyTopItems.findIndex(
+    (item) => !item.disabled
+  );
+  const possiblyBottomItems = items.slice(index + 1);
+  const possiblyBottomItemIndex = possiblyBottomItems.findIndex(
+    (item) => !item.disabled
+  );
+
+  // どちらも見つからなかった場合は何もしない
+  if (possiblyTopItemIndex === -1 && possiblyBottomItemIndex === -1) {
+    return undefined;
+  }
+  // どちらかが見つからなかった場合は見つかった方を返す
+  if (possiblyTopItemIndex === -1) {
+    return possiblyBottomItems[possiblyBottomItemIndex]?.value;
+  }
+  if (possiblyBottomItemIndex === -1) {
+    return possiblyTopItems[possiblyTopItemIndex]?.value;
+  }
+  // どちらも見つかった場合は近い方を返す
+  if (possiblyTopItemIndex < possiblyBottomItemIndex) {
+    return possiblyTopItems[possiblyTopItemIndex]?.value;
+  }
+  if (possiblyTopItemIndex > possiblyBottomItemIndex) {
+    return possiblyBottomItems[possiblyBottomItemIndex]?.value;
+  }
+  // どちらも同じ距離の場合は、選択中の項目から近い方を返す
+  if (currentIndex < index) {
+    return possiblyTopItems[possiblyTopItemIndex]?.value;
+  }
+  if (currentIndex > index) {
+    return possiblyBottomItems[possiblyBottomItemIndex]?.value;
+  }
+  // それ以外のケースはあり得ないが、念のため現在の値を返す
+  return currentValue;
+};

 export const ScrollPicker = function <V>({
   value,
   items,
   height = 5 * SCROLL_ITEM_HEIGHT,
   onChangeValue,
 }: ScrollPickerProps<V>) {
   // 一部省略

   const { ref: refScroller } = useHandleScroll({
     onFinishScroll: () => {
       const elMenuList = elMenuListRef.current;
       if (elMenuList == null) {
         return;
       }
-      const index = Math.round(elMenuList.scrollTop / SCROLL_ITEM_HEIGHT);
-      const itemValue = items[index]?.value;
+      const itemValue = findSelectableScrollItemValue(elMenuList, value, items);
       if (itemValue === undefined) {
         return;
       }
       // 同じ値を算出した場合は同じ場所に戻るようにスクロールして終了する
       if (itemValue === value) {
         scrollToItemValue(elMenuList, items, itemValue);
         return;
       }
       onChangeValue(itemValue);
     },
   });
   const handleRef = useForkRef(elMenuListRef, refScroller);

   return (
     // render内容は一緒なので省略
   );
 };

これで以下のようにdisabledな項目がある場合に近い項目が選択されるようになりました。

スクロール完了後に中心にある項目を選択する(touch/mouseイベント)

以上でwheel操作でスクロールする部分は終わりました。ここからはタッチも対応したいと思います。実装自体はmouseのドラッグも同じように書けるのでついでに実装します。
タッチ操作によるスクロールがiOSとAndroidでスピードが異なっていて中々思った場所に止めることができなかったので自前でスクロールを実装することにしました。実装自体は単純でタッチの開始位置から差分を見て、その変化量だけスクロール位置を調整します。

スワイプやドラッグでスクロールができるようにする
 /**
  * スクロールをJS側で制御するカスタムフック
  */
 export const useHandleScroll = ({
   onFinishScroll,
 }: {
   /** スクロールが終了した時 */
   onFinishScroll: () => void;
 }) => {
   const ref = useMemo(() => {
     let cachedElement: HTMLElement | null = null;
     // wheelの定義は省略

+    /** タッチ開始座標 */
+    let startPosY: number | null = null;
+    /** タッチ開始時のスクロール位置 */
+    let startScrollTop: number | null = null;
+    const handleTouchStart = (event: TouchEvent | MouseEvent) => {
+      event.preventDefault();
+      startPosY =
+        "touches" in event ? event.touches[0]?.clientY ?? null : event.clientY;
+      startScrollTop = cachedElement?.scrollTop ?? null;
+    };
+    const handleTouchMove = (event: TouchEvent | MouseEvent) => {
+      if (
+        startPosY == null ||
+        startScrollTop == null ||
+        cachedElement == null
+      ) {
+        return;
+      }
+      const posY =
+        "touches" in event ? event.touches[0]?.clientY : event.clientY;
+      if (posY == null) {
+        return;
+      }
+      const diff = startPosY - posY;
+      cachedElement.scrollTop = startScrollTop + diff;
+    };
+    const handleTouchEnd = (event: TouchEvent | MouseEvent) => {
+      event.preventDefault();
+
+      startPosY = null;
+      startScrollTop = null;
+      onFinishScroll();
+    };
     return (element: HTMLElement | null) => {
       if (element == null) {
         if (cachedElement != null) {
           cachedElement.removeEventListener("wheel", handleWheel);
+          cachedElement.removeEventListener("touchstart", handleTouchStart);
+          cachedElement.removeEventListener("touchmove", handleTouchMove);
+          cachedElement.removeEventListener("touchend", handleTouchEnd);
+          cachedElement.removeEventListener("mousedown", handleTouchStart);
+          cachedElement.removeEventListener("mousemove", handleTouchMove);
+          cachedElement.removeEventListener("mouseup", handleTouchEnd);
+          cachedElement.removeEventListener("mouseleave", handleTouchEnd);
         }
         clearTimeout(timerId);
         cachedElement = null;
         return;
       }

       cachedElement = element;
       cachedElement.addEventListener("wheel", handleWheel);
+      cachedElement.addEventListener("touchstart", handleTouchStart);
+      cachedElement.addEventListener("touchmove", handleTouchMove);
+      cachedElement.addEventListener("touchend", handleTouchEnd);
+      cachedElement.addEventListener("mousedown", handleTouchStart);
+      cachedElement.addEventListener("mousemove", handleTouchMove);
+      cachedElement.addEventListener("mouseup", handleTouchEnd);
+      cachedElement.addEventListener("mouseleave", handleTouchEnd);
     };
   }, [onFinishScroll]);

   return {
     ref,
   };
 };

これでタッチ操作でもスクロール完了後に値が選択されるようになりました。ただスクロール操作を自前で実装するためにevent.preventDefaultしたことによってclickイベントが拾えなくなってタップで項目が選択できなくなりました。これの対応については後ほど対応したいと思います。

慣性スクロールができるようにする

これでとりあえずタッチ操作でスクロールできるようになりましたが、慣性スクロールがないためちょっと違和感がありました。ライブラリでも特に慣性スクロールはなさそうで、目的の値にスクロールすることが目的なので慣性スクロールで勢い余って次の項目の方に移動されるかなと思って初めは実装していませんでしたが、やはり触っていてあった方が良さそうだったので作ってみました。
実装方法としてはtouchend時にtouchstartからの距離と時間を求めて推定の初速を求めて、その速度をrequestAnimationFrameで減速させながらスクロール位置を調整するようにします。速度が一定の量を下回ったらrequestAnimationFrameを終了してonFinishScrollを呼びます。後は細かいところですが慣性スクロール中にタップした場合はスクロールが停止できるようにキャンセルするメソッドを提供してtouchstart時にそれを実行するようにします。
この簡易実装だと最初大きくスワイプして移動を止めてから指を離しても、計算上最初と最後の距離と時間で均されて初速が計算されてしまうため、思ってもいない慣性スクロールが出る可能性があります。これを調整するのはあまりにも計算を難しくするので対応はしてませんが、直前のtouchmoveとtouchendの間で一定の時間を超えたら慣性スクロールせずに直ちにスクロールを終了するように調整しました。これはPCでスマホ操作のシミュレートをした際は上手く機能しましたが実際スマホで試すと指を離す瞬間にtouchmoveが発生してしまうことが多かったのであまり効果がないかもしれないです。。

慣性スクロール機能を入れる
+/**
+ * 慣性スクロールする
+ */
+const inertiaScroll = ({
+  element,
+  initialSpeed,
+  onFinishScroll,
+}: {
+  /** スクロール対象の要素 */
+  element: HTMLElement;
+  /** 初速 */
+  initialSpeed: number;
+  /** スクロールが完了した時 */
+  onFinishScroll: () => void;
+}): (() => void) => {
+  let speed = initialSpeed;
+  let frameId: number | undefined;
+  const frame = () => {
+    element.scrollTop += speed;
+    speed *= 0.85;
+    if (Math.abs(speed) < 0.5) {
+      onFinishScroll();
+      return;
+    }
+    frameId = requestAnimationFrame(frame);
+  };
+  frame();
+
+  const cancelInertiaScroll = () => {
+    if (frameId != null) {
+      cancelAnimationFrame(frameId);
+    }
+  };
+  return cancelInertiaScroll;
+};

 /**
  * スクロールをJS側で制御するカスタムフック
  */
 export const useHandleScroll = ({
   onFinishScroll,
 }: {
   /** スクロールが終了した時 */
   onFinishScroll: () => void;
 }) => {
   const ref = useMemo(() => {
     let cachedElement: HTMLElement | null = null;
     // wheelの定義は省略

     /** タッチ開始座標 */
     let startPosY: number | null = null;
     /** タッチ開始時のスクロール位置 */
     let startScrollTop: number | null = null;
+    /** タッチ開始時刻 */
+    let touchStartTime: number | null = null;
+    /** タッチ操作の最終時刻 */
+    let touchLastMoveTime: number | null = null;
+    /** 慣性スクロールをキャンセルするメソッド */
+    let cancelInertiaScroll: (() => void) | undefined;
     const handleTouchStart = (event: TouchEvent | MouseEvent) => {
       event.preventDefault();
+      if (cancelInertiaScroll != null) {
+        cancelInertiaScroll();
+        cancelInertiaScroll = undefined;
+      }
       startPosY =
         "touches" in event ? event.touches[0]?.clientY ?? null : event.clientY;
       startScrollTop = cachedElement?.scrollTop ?? null;
+      touchStartTime = performance.now();
     };
     const handleTouchMove = (event: TouchEvent | MouseEvent) => {
       if (
         startPosY == null ||
         startScrollTop == null ||
         cachedElement == null
       ) {
         return;
       }
       const posY =
         "touches" in event ? event.touches[0]?.clientY : event.clientY;
       if (posY == null) {
         return;
       }
       const diff = startPosY - posY;
       cachedElement.scrollTop = startScrollTop + diff;
+      touchLastMoveTime = performance.now();
     };
     const handleTouchEnd = (event: TouchEvent | MouseEvent) => {
+      const reset = () => {
+        startPosY = null;
+        startScrollTop = null;
+        touchStartTime = null;
+        touchLastMoveTime = null;
+        cancelInertiaScroll = undefined;
+      };
       event.preventDefault();
-
-      startPosY = null;
-      startScrollTop = null;
-      onFinishScroll();

+      const endTime = performance.now();
+      if (
+        cachedElement == null ||
+        startPosY == null ||
+        startScrollTop == null ||
+        touchStartTime == null ||
+        touchLastMoveTime == null ||
+        // touchmoveからの経過時間が50ms以上だった場合も慣性スクロールは行わず、その場でスクロールを終了する
+        endTime - touchLastMoveTime > 50
+      ) {
+        reset();
+        onFinishScroll();
+        return;
+      }
+
+      const elapsedTime = endTime - touchStartTime;
+      const distance = cachedElement.scrollTop - startScrollTop;
+      reset();
+      cancelInertiaScroll = inertiaScroll({
+        element: cachedElement,
+        initialSpeed: (20 * distance) / elapsedTime,
+        onFinishScroll,
+      });
     };
     return (element: HTMLElement | null) => {
       // 差分がないため省略
     };
   }, [onFinishScroll]);

   return {
     ref,
   };
 };

これで以下のように慣性スクロールができるようになりました。マジックナンバーの部分を調整することで初速や減衰量を調整することができます。

項目をタップで選択できるようにする

自前でスクロールをするためにevent.preventDefaultをしたことでタップでクリック判定ができなくなってしまったため、タップ判定も自作する必要があります。単純にtouchendでクリック判定しようとするとドラッグ操作でもクリック判定されてしまうため、はtouchstartからtouchendの間で座標のずれが小さければクリックとして判定するようにします。今回はmouse操作も合わせて反応できるようにpointerイベントで実装しました。

ドラッグ操作は無効にして、純粋なクリック操作のみを検知するhooks
import { useEffect, useState, useRef } from "react";

export type UseJustClickArgs = {
  /**
   * ドラッグと判定される変化量(px)
   * @default 5
   */
  dragThreshold?: number;
  /** 機能をOFFにするか */
  disabled?: boolean;
  /**
   * クリック時
   */
  onJustClick: () => void;
};

export type UseJustClickReturn = {
  /** 監視対象のDOMのref */
  ref: (element: HTMLElement | null) => void;
};

/**
 * ドラッグ操作は無効にして、純粋なクリック操作のみを検知するhooks
 */
export const useJustClick = ({
  dragThreshold = 5,
  disabled,
  onJustClick,
}: UseJustClickArgs): UseJustClickReturn => {
  const [element, setElement] = useState<HTMLElement | null>(null);

  // depsの対象に含まれないようにrefで保持する
  const onJustClickRef = useRef(onJustClick);
  onJustClickRef.current = onJustClick;

  useEffect(() => {
    if (element == null || disabled) {
      return;
    }

    /** クリック開始座標 */
    let startPos: { x: number; y: number } | null = null;
    const handlePointerDown = (event: PointerEvent) => {
      // イベントをブロックするとmousedownイベントが発火しなくなるのでpreventDefaultはしない
      // event.preventDefault();
      startPos = {
        x: event.clientX,
        y: event.clientY,
      };
    };
    const handlePointerUp = (event: PointerEvent) => {
      // イベントをブロックするとmouseupイベントが発火しなくなるのでpreventDefaultはしない
      // event.preventDefault();
      if (startPos == null) {
        return;
      }

      // ドラッグの距離がdragThresholdを超えたらドラッグ判定にしてクリックイベントを発火しない
      const dx = startPos.x - event.clientX;
      const dy = startPos.y - event.clientY;
      startPos = null;
      if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
        return;
      }

      onJustClickRef.current();
    };
    const handlePointerLeave = () => {
      // ポインタが外れてしまった時はクリック開始座標をリセットする
      startPos = null;
    };

    element.addEventListener("pointerdown", handlePointerDown);
    element.addEventListener("pointerup", handlePointerUp);
    element.addEventListener("pointerleave", handlePointerLeave);

    return () => {
      element.removeEventListener("pointerdown", handlePointerDown);
      element.removeEventListener("pointerup", handlePointerUp);
      element.removeEventListener("pointerleave", handlePointerLeave);
    };
  }, [element, disabled, dragThreshold]);

  return {
    ref: setElement,
  };
};

余談ですがこの実装は以下の記事の逆の実装になっています。

https://zenn.dev/numa_san/articles/34ef632e1cf373

このhooksを各項目ごとに設定する必要があるため、コンポーネントに切り出します。

各項目ごとに表示するコンポーネントにuseJustClickを設定する
import { ListItemText, MenuItem } from "@mui/material";
import type { FC, ReactNode } from "react";

import { SCROLL_ITEM_HEIGHT } from "./constants/ScrollItemHeight";
import { useJustClick } from "./hooks/useJustClick";

export type ScrollPickerItemProps = {
  /** 選択されているか */
  selected?: boolean;
  /** 非活性か */
  disabled?: boolean;
  /** 子要素 */
  children: ReactNode;
  /**
   * ドラッグ操作は無効にして純粋なクリック操作のみを検知した時
   */
  onJustClick: () => void;
};

export const ScrollPickerItem: FC<ScrollPickerItemProps> = ({
  selected,
  disabled,
  children,
  onJustClick,
}) => {
  const { ref } = useJustClick({
    disabled,
    onJustClick: () => {
      // ScrollPickerのスクロールの判定後にclickイベントを発火させたいのでワンサイクル遅らせる
      setTimeout(() => {
        onJustClick();
      });
    },
  });

  return (
    <MenuItem
      ref={ref}
      style={{
        height: SCROLL_ITEM_HEIGHT,
        minHeight: "auto",
        textAlign: "center",
      }}
      selected={selected}
      disabled={disabled}
    >
      <ListItemText
        sx={{
          "& > .MuiListItemText-primary": {
            fontWeight: selected ? "bold" : undefined,
          },
        }}
        primary={children}
      />
    </MenuItem>
  );
};

このコンポーネントに差し替えることで完成です。

ScrollPickerItemコンポーネントを使うように差し替える
 // 省略
+import { ScrollPickerItem } from "./ScrollPickerItem";

 export const ScrollPicker = function <V>({
   value,
   items,
   height = 5 * SCROLL_ITEM_HEIGHT,
   onChangeValue,
 }: ScrollPickerProps<V>) {
   // 一部省略

   return (
     <Box
       sx={{
         position: "relative",
         height,
         "&::before": createPseudoShadowStyle("top"),
         "&::after": createPseudoShadowStyle("bottom"),
       }}
     >
       <MenuList
         ref={handleRef}
         sx={{
           height: "100%",
           overflowY: "scroll",
           "&::-webkit-scrollbar": {
             display: "none",
           },
         }}
         disablePadding
       >
         <MenuItem
           key={`pad-top`}
           sx={{
             height: paddingHeight,
             minHeight: "auto",
           }}
           disabled
         />
         {items.map((item) => (
-          <MenuItem
-            key={String(item.value)}
-            style={{
-              height: SCROLL_ITEM_HEIGHT,
-              minHeight: "auto",
-              textAlign: "center",
-            }}
-            selected={item.value === value}
-            disabled={item.disabled}
-            onClick={() => {
-              onChangeValue(item.value)
-            }}
-          >
-            <ListItemText
-              sx={{
-                "& > .MuiListItemText-primary": {
-                  fontWeight: item.value === value ? "bold" : undefined,
-                },
-              }}
-              primary={item.label}
-            />
-          </MenuItem>
+          <ScrollPickerItem
+            key={String(item.value)}
+            selected={item.value === value}
+            disabled={item.disabled}
+            onJustClick={() => {
+              onChangeValue(item.value);
+            }}
+          >
+            {item.label}
+          </ScrollPickerItem>
         ))}
         <MenuItem
           key={`pad-bottom`}
           sx={{
             height: paddingHeight,
             minHeight: "auto",
           }}
           disabled
         />
       </MenuList>
     </Box>
   );
 };

これでタップで選択できるようになりました。ドラッグ判定時にはクリック判定にならなくしたのでtouchstart後に少し指を動かしてからtouchendした場合は選択されず、選択のキャンセルをすることができます。

DateScrollPickerの作成

前のセクションで作ったScrollPickerを使って年月日それぞれを設定できるようにします。日付の選択において、以下についてケアする必要があります。

  • 最小日付と最大日付の範囲を超えてを選択してしまわないように調整する必要がある
  • 31日の月と29日の月など日にちが違う場合に存在しない日にちが選択されたままにならないようにする(その月の最大日にちに調整する)
DateScrollPicker.tsx
import { FC, useMemo, useCallback } from "react";
import { Stack, Typography } from "@mui/material";
import { ScrollPicker } from "./ScrollPicker";
import { range } from "lodash-es";
import { addYears, clamp as clampDate } from "date-fns";

const CURRENT_DATE = new Date();
const DEFAULT_MIN_DATE = addYears(CURRENT_DATE, -100);
const DEFAULT_MAX_DATE = addYears(CURRENT_DATE, 100);

export type DateScrollPickerProps = {
  /** 日付 */
  value: Date;
  /** 最小日付 */
  minDate?: Date;
  /** 最大日付 */
  maxDate?: Date;
  /**
   * 日付が変更された時
   * @param newValue - 新しい日付
   */
  onChangeValue: (newValue: Date) => void;
};

export const DateScrollPicker: FC<DateScrollPickerProps> = ({
  value,
  minDate = DEFAULT_MIN_DATE,
  maxDate = DEFAULT_MAX_DATE,
  onChangeValue,
}) => {
  const { year, month, day } = useMemo(() => {
    return {
      year: value.getFullYear(),
      month: value.getMonth() + 1,
      day: value.getDate(),
    };
  }, [value]);
  const maxDayOfCurrentMonth = useMemo(() => {
    return new Date(year, month, 0).getDate();
  }, [month, year]);

  const yearItems = useMemo(() => {
    return range(minDate.getFullYear(), maxDate.getFullYear() + 1).map(
      (year) => ({
        value: year,
        label: `${year}`,
      })
    );
  }, [minDate, maxDate]);

  const monthItems = useMemo(() => {
    return range(1, 13).map((month) => {
      const yearMonthFirst = new Date(year, month - 1, 1, 0, 0, 0);
      const yearMonthLast = new Date(year, month, 0, 23, 59, 59);
      return {
        value: month,
        label: `${month}`,
        disabled: yearMonthLast < minDate || yearMonthFirst > maxDate,
      };
    });
  }, [maxDate, minDate, year]);

  const dayItems = useMemo(() => {
    return range(1, maxDayOfCurrentMonth + 1).map((day) => {
      const dateFirst = new Date(year, month - 1, day, 0, 0, 0);
      const dateLast = new Date(year, month - 1, day, 23, 59, 59);
      return {
        value: day,
        label: `${day}`,
        disabled: dateLast < minDate || dateFirst > maxDate,
      };
    });
  }, [maxDate, maxDayOfCurrentMonth, minDate, month, year]);

  const handleChangeValue = useCallback(
    (newDate: Date) => {
      onChangeValue(
        clampDate(newDate, {
          start: minDate,
          end: maxDate,
        })
      );
    },
    [maxDate, minDate, onChangeValue]
  );

  return (
    <Stack direction="row" spacing={1} alignItems="center">
      <ScrollPicker
        value={year}
        items={yearItems}
        onChangeValue={(newYear) => {
          /** 次の年月の最大日数 */
          const maxDayOfNextMonthYear = new Date(newYear, month, 0).getDate();
          handleChangeValue(
            new Date(
              newYear,
              month - 1,
              // 最大日数を超えないように調整
              Math.min(day, maxDayOfNextMonthYear)
            )
          );
        }}
      />
      <Typography></Typography>
      <ScrollPicker
        value={month}
        items={monthItems}
        onChangeValue={(newMonth) => {
          /** 次の月の最大日数 */
          const maxDayOfNextMonth = new Date(year, newMonth, 0).getDate();
          handleChangeValue(
            new Date(
              year,
              newMonth - 1,
              // 最大日数を超えないように調整
              Math.min(day, maxDayOfNextMonth)
            )
          );
        }}
      />
      <Typography></Typography>
      <ScrollPicker
        value={day}
        items={dayItems}
        onChangeValue={(newDay) => {
          handleChangeValue(new Date(year, month - 1, newDay));
        }}
      />
      <Typography></Typography>
    </Stack>
  );
};

動作は以下のようなります。

InputDateByScrollPickerの作成

最後にダイアログまたはポップアップで DateScrollPicker を表示して、日付を選択できるようにするコンポーネントを作ります。今までScrollPickerでは初期値がないと最初に表示するものが分からないためrequiredにしていましたが、このコンポーネントだとnullableにすることができ、バツアイコンを用意して選択した日付を消すこともできるようになりました。

InputDateByScrollPicker.tsx
import { FC, useState } from "react";
import { formatDate } from "date-fns";
import {
  TextField,
  InputAdornment,
  IconButton,
  Button,
  Dialog,
  DialogContent,
  DialogActions,
  Popover,
} from "@mui/material";
import EventIcon from "@mui/icons-material/Event";
import CloseIcon from "@mui/icons-material/Close";

import { DateScrollPicker, DateScrollPickerProps } from "./DateScrollPicker";

const InputDateContent: FC<
  {
    initialDate: Date;
    onCancel: () => void;
    onSubmit: (newDate: Date) => void;
  } & Pick<DateScrollPickerProps, "minDate" | "maxDate">
> = ({ initialDate, onCancel, onSubmit, ...restProps }) => {
  const [currentDate, setCurrentDate] = useState(initialDate);

  return (
    <>
      <DialogContent dividers>
        <DateScrollPicker
          value={currentDate}
          onChangeValue={setCurrentDate}
          {...restProps}
        />
      </DialogContent>
      <DialogActions>
        <Button onClick={onCancel}>キャンセル</Button>
        <Button
          variant="contained"
          onClick={() => {
            onSubmit(currentDate);
          }}
        >
          決定
        </Button>
      </DialogActions>
    </>
  );
};

const DEFAULT_INITIAL_PICKER_DATE = new Date();

export type InputDateByScrollPickerProps = {
  /** 日付 */
  value: Date | null;
  /**
   * Pickerを表示する際に使うUI
   * - "dialog": ダイアログ
   * - "popover": ポップアップ
   */
  pickerUi: "dialog" | "popover";
  /** valueがnullの時にPickerに初期表示する日付 */
  initialPickerDate?: Date;
  /**
   * 日付が変更された時
   * @param newValue - 新しい日付
   */
  onChangeValue: (newValue: Date | null) => void;
} & Pick<DateScrollPickerProps, "minDate" | "maxDate">;

export const InputDateByScrollPicker: FC<InputDateByScrollPickerProps> = ({
  value,
  pickerUi,
  initialPickerDate = DEFAULT_INITIAL_PICKER_DATE,
  onChangeValue,
  ...restProps
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [elAnchor, setElAnchor] = useState<HTMLElement | null>(null);

  return (
    <>
      <TextField
        value={value ? formatDate(value, "yyyy/MM/dd") : ""}
        variant="outlined"
        size="small"
        placeholder="選択してください"
        fullWidth
        slotProps={{
          input: {
            readOnly: true,
            endAdornment: (
              <InputAdornment position="end">
                {value != null && (
                  <IconButton
                    onClick={(event) => {
                      event.stopPropagation();
                      onChangeValue(null);
                    }}
                  >
                    <CloseIcon />
                  </IconButton>
                )}
                <EventIcon />
              </InputAdornment>
            ),
            sx: {
              cursor: "pointer",
              "& > .MuiInputBase-input": {
                cursor: "pointer",
              },
            },
          },
        }}
        onClick={(event) => {
          setIsOpen(true);
          setElAnchor(event.currentTarget);
        }}
      />
      {pickerUi === "dialog" && (
        <Dialog
          open={isOpen}
          onClose={() => {
            setIsOpen(false);
          }}
        >
          <InputDateContent
            {...restProps}
            initialDate={value ?? initialPickerDate}
            onCancel={() => {
              setIsOpen(false);
            }}
            onSubmit={(newDate) => {
              onChangeValue(newDate);
              setIsOpen(false);
            }}
          />
        </Dialog>
      )}
      {pickerUi === "popover" && (
        <Popover
          open={elAnchor != null && isOpen}
          anchorEl={elAnchor}
          anchorOrigin={{
            vertical: "bottom",
            horizontal: "center",
          }}
          transformOrigin={{
            vertical: "top",
            horizontal: "center",
          }}
          onClose={() => {
            setIsOpen(false);
            setElAnchor(null);
          }}
        >
          <InputDateContent
            {...restProps}
            initialDate={value ?? initialPickerDate}
            onCancel={() => {
              setIsOpen(false);
              setElAnchor(null);
            }}
            onSubmit={(newDate) => {
              onChangeValue(newDate);
              setIsOpen(false);
              setElAnchor(null);
            }}
          />
        </Popover>
      )}
    </>
  );
};

動作は以下のようになります。スマホだとダイアログの方が良いかなと思いつつ、PCだとポップアップの方が良さそうでスマホもこっちでも問題なさそうな気がするので、実際に使用する際はポップアップで統一しても良さそうな印象でした。


終わりに

以上がドラムロールUIで日付を選択するコンポーネントを実装する内容でした。ScrollPickerがマウスとタッチ両方対応し、かつ慣性スクロールも実装したことによって結構長くなってしまいましたが、操作感はかなり良さそうでした。ドラムロールUIを実装したい方の参考になれたら幸いです。

GitHubで編集を提案

Discussion