🫥

cssスクロールスナップで複数要素を表示させつつ、縦スクロールで横に1要素ずつスライドするページを作る

2023/09/19に公開

やりたいこと

  • 複数要素を画面に表示していたい
  • マウスの縦スクロールで1スライドずつ送りたい
  • 以下で課題となっていた、トラックパッドでの横スクロールに対応したページにしたい
    https://zenn.dev/nanahiryu/articles/d1e8c36b9f5a15

完成品

見づらいので「open in new window」をクリックするなどして見てください

実装について

cssスクロールスナップの主要なプロパティは以下の二つ

  • scroll-snap-type
  • scroll-snap-align

それぞれ簡単に説明する

scroll-snap-typeとは

mdnによると

scroll-snap-type は CSS のプロパティで、スナップ点が存在する場合にスクロールコンテナーにどれだけ厳密にスナップ点を強制するかを設定します。
https://developer.mozilla.org/ja/docs/Web/CSS/scroll-snap-type

scroll-snap-typeは次の3種類が指定できるらしい

none: このスクロールコンテナーの視覚ビューポートがスクロールする時は、スナップ点を無視しなければなりません。
mandatory: このスクロールコンテナーの視覚ビューポートは、現在スクロール中でなければスナップ点に合わせられます。これはスクロールアクションが終了した際に、可能であればその点にはまるということを意味しています。内容が追加、移動、削除、リサイズされた場合、スクロール量のオフセットは、そのスナップ点に載り続けるよう調整されます。
proximity: このスクロールコンテナーの視覚ビューポートは、現在スクロール中でなければ、ユーザーエージェントのスクロール引数を考慮しつつスナップ点に載るよう動作する可能性があります。コンテンツが追加、移動、削除、リサイズされた場合、スクロール量のオフセットは、そのスナップ点に載り続けるよう調整されることがあります。

ビューポートの補足

ビューポート: ビューポートは、現在表示されているコンピューター画像の中の、多角形 (通常は長方形) の領域を表します。ウェブブラウザーの用語としては、閲覧中の文書のうち、ウィンドウ (または、文書が全画面モードで表示されている場合は画面) の中で現在見えている部分を指します。ビューポートの外にあるコンテンツは、スクロールによってビューの中に移動するまで画面上では見えません。

要するに,
noneを指定するとscroll-snap-typeを指定していない時と同様の挙動
mandatoryを指定するとスクロール中でない限り, ビューポートはスナップ点に合わせられる
proximityを指定するとスクロール中でない, かつ, スクロール引数を考慮してスナップ点に合わせられる(調整の詳細はよくわからん)

今回の実装では必ず, 指定した個数分見切れることなく表示したいのでmandatoryを指定する

scroll-snap-alignとは

mdnによると

scroll-snap-align プロパティは、ボックスのスナップ位置を、そのスナップコンテナーの (配置コンテナーとしての) スナップポート内における (配置主体としての) スナップ領域の配置として指定します。2つの値は、それぞれブロック軸とインライン軸内のスナップ位置合わせを指定します。値が1つだけ指定された場合、2番目の値は同じ値を既定値とします。

https://developer.mozilla.org/ja/docs/Web/CSS/scroll-snap-align
とのこと。

scroll-snap-alignは4種類の指定が可能

none: このボックスでは、その軸のスナップ位置を定義しません。
start: このスクロールコンテナーのスナップポートの中で、このボックスのスクロールスナップ領域の先頭位置がこの軸のスナップ位置になります。
end: このスクロールコンテナーのスナップポートの中で、このボックスのスクロールスナップ領域の末尾位置がこの軸のスナップ位置になります。
center: このスクロールコンテナーのスナップポートの中で、このボックスのスクロールスナップ領域の中央位置がこの軸のスナップ位置になります。

ブロック軸, インライン軸についての詳細

今回は複数枚見えるスライドの先頭の位置をスナップ点としたいのでstartを選択

コードの全体像

以上から以下のような実装になる

scrollableScreen.tsx
scrollableScreen.tsx
"use client";

import { useEffect, useRef } from "react";
import styles from "./scrollableScreen.module.scss";

interface ScrollableScreenProps {
  index: number;
  visibleScreenItemNum?: number;
  screenItemNum?: number;
}

export const ScrollableScreen = (props: ScrollableScreenProps) => {
  const { index, visibleScreenItemNum = 4, screenItemNum = 8 } = props;
  const screenWindowRef = useRef<HTMLDivElement>(null);
  const screenSize = 250;
  const screenGap = 4;
  const screenWindowWidth =
    screenSize * visibleScreenItemNum + screenGap * (visibleScreenItemNum - 1);
  const screenWrapperWidth =
    screenSize * screenItemNum + screenGap * (screenItemNum - 1);
  const scrollWidth = screenSize + screenGap;

  useEffect(() => {
    if (!screenWindowRef.current) return;

    let cooltimeIgnore = false;
    let ignore = false;
    let total = 0;

    screenWindowRef.current.onwheel = (e: WheelEvent) => {
      // 縦スクロールの場合, ページのスクロールをさせない
      if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
        e.preventDefault();
      }
      // クールタイムが終わっていなければreturn
      if (cooltimeIgnore) return;
      // deltaYが十分大きければtotalに加算
      if (Math.abs(e.deltaY) > 10) {
        total += e.deltaY;
      }
      // 一定時間deltaYの増分を加算する
      if (ignore) return;
      ignore = true;
      setTimeout(() => {
        ignore = false;
        // 一定時間で溜まったスクロール量が閾値を超えていなければreturn
        if (Math.abs(total) < 80) return;
        console.log("scroll: ", total);
        cooltimeIgnore = true;
        if (!screenWindowRef.current) return;
        const delta = (e.deltaY / Math.abs(e.deltaY)) * scrollWidth;
        screenWindowRef.current.scrollLeft += delta;
        // 一定時間のクールタイムを設ける
        setTimeout(() => {
          cooltimeIgnore = false;
          console.log("cooltime end");
        }, 300);
        total = 0;
      }, 200);
    };
  }, []);

  const indexArray: number[] = Array.from(
    { length: screenItemNum },
    (_, index) => index + 1
  );

  return (
    <div className={styles.scrollable_screen_field}>
      <p className={styles.scrollable_screen_index}>{index}</p>
      <div
        className={styles.scrollable_window}
        style={{ width: screenWindowWidth }}
        ref={screenWindowRef}
      >
        <div
          className={styles.scrollable_screen_wrapper}
          style={{ width: screenWrapperWidth, gap: screenGap }}
        >
          {indexArray.map((i) => (
            <div
              key={i}
              className={styles.scrollable_screen}
              style={{
                width: screenSize,
                height: screenSize
              }}
            >
              <p className={styles.display_index}>{i}</p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

scrollableScreen.module.scss
scrollableScreen.module.scss
.scrollable_screen_field {
  display: flex;
  flex-direction: column;
  gap: 40px;
  align-items: center;
  justify-content: center;
  width: 100%;
  margin: 80px 0;
  .scrollable_screen_index {
    color: black;
    font-size: 24px;
    font-style: normal;
    font-weight: 400;
    line-height: normal;
  }
  .scrollable_window {
    overflow: auto;
    scroll-behavior: smooth;
    scroll-snap-type: x mandatory;
    &::-webkit-scrollbar {
      display: none;
    }
    .scrollable_screen_wrapper {
      display: flex;
      .scrollable_screen {
        // display: flex;
        // align-items: center;
        // justify-content: center;
        scroll-snap-align: start;
        background-color: gray;
        .display_index {
          font-size: xx-large;
          color: white;
        }
      }
    }
  }
}

簡易な説明を付け加えておく
この実装ではトラックパッドの横スクロールとマウスホイールの縦スクロールの制御を別で行っている

トラックパッドの横スクロール

これはscssで該当のhtml要素にoverflow: autoを設定することで簡単に実装できる
なお本題とは関係ないが, scroll-behavior: smoothにすることで滑らかにスクロールできるように, &::-webkit-scrollbardisplay: noneを指定することで横スクロールのスクロールバーを消している

scrollableScreen.module.scss
  .scrollable_window {
    overflow: auto;
    scroll-behavior: smooth;
    scroll-snap-type: x mandatory;
    &::-webkit-scrollbar {
      display: none;
    }

マウスホイールの縦スクロール

scrollableScreen.tsx
  useEffect(() => {
    if (!screenWindowRef.current) return;

    let cooltimeIgnore = false;
    let ignore = false;
    let total = 0;

    screenWindowRef.current.onwheel = (e: WheelEvent) => {
      // 縦スクロールの場合, ページのスクロールをさせない
      if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
        e.preventDefault();
      }
      // クールタイムが終わっていなければreturn
      if (cooltimeIgnore) return;
      // deltaYが十分大きければtotalに加算
      if (Math.abs(e.deltaY) > 10) {
        total += e.deltaY;
      }
      // 一定時間deltaYの増分を加算する
      if (ignore) return;
      ignore = true;
      setTimeout(() => {
        ignore = false;
        // 一定時間で溜まったスクロール量が閾値を超えていなければreturn
        if (Math.abs(total) < 80) return;
        console.log("scroll: ", total);
        cooltimeIgnore = true;
        if (!screenWindowRef.current) return;
        const delta = (e.deltaY / Math.abs(e.deltaY)) * scrollWidth;
        screenWindowRef.current.scrollLeft += delta;
        // 一定時間のクールタイムを設ける
        setTimeout(() => {
          cooltimeIgnore = false;
          console.log("cooltime end");
        }, 300);
        total = 0;
      }, 200);
    };
  }, []);

こちらが非常に難儀した部分なので, 出会った課題と解決策を簡単に説明してからコードの説明に移る。

出会った課題と解決策

reactのonWheelイベントではトラックパッドからスクロールが入力されているのか, マウスホイールからスクロールが入力されているのかがわからない。そして, スクロールの入力のされ方に大きな違いがある。
以下は感覚的に等量のスクロールをマウスホイールとトラックパッドで行った場合のe.deltaYの値である


トラックパッドの例


マウスホイールの例

このようにトラックパッドは小さなdeltaYが数百回に分かれて入力, マウスホイールは大きなdeltaYが数回に分かれて入力されているように見える
また、scroll-behavior: smoothを指定している場合、スクロール中にscreenWindowRef.current.scrollLeftが書き変わってしまうことになり、画面が固まったように見えてしまう。

そこで, スクロールが終わる前に次の入力が来ないように, 制御することでこの問題を解消している。

具体的には, 一定時間(この場合は0.2秒)deltaYの値を加算していき, その合計が閾値を超えた場合に実際にスクロールを行うようにしている。 これにより, スクロールが行われる前にscreenWindowRef.current.scrollLeftが更新される問題は解消された。

しかし, この入力受付時間を0.2秒以下に設定してしまうと, トラックパッドの縦スクロールの慣性が残ってしまい, 軽くスクロールしただけでも2スライド分スクロールされてしまうことがあった。

ただ一方で, 入力受付時間をこれ以上長くしてしまうと, スクロール操作をしてからスライドがスクロールされるまでの時間が長すぎて, かなり違和感が出てしまった。

そこで, スライドスクロール後にクールタイムを設けることでこの問題を解消している。

実装の説明

まず, 以降で使用する変数の初期化

    let cooltimeIgnore = false;
    let ignore = false;
    let total = 0;

スクロールイベントを検知する

    screenWindowRef.current.onwheel = (e: WheelEvent) => {
	...
    };

縦スクロールの場合, これを入れないとページ全体が縦にスクロールされてしまうのでpreventDefaultする必要がある。逆に横スクロールの場合, preventDefaultされてしまうと, スクロールイベントが中止されスクロールできないのでpreventDefaultしないようにする必要がある。
ここではe.deltaXとe.deltaYを比較し, 絶対値が大きい方を優先させることにしている。

      // 縦スクロールの場合, ページのスクロールをさせない
      if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
        e.preventDefault();
      }

クールタイム中はtotalにスクロール量を加算したくないので, totalへ加算処理する前にreturnさせるようにしている

      // クールタイムが終わっていなければreturn
      if (cooltimeIgnore) return;
      // deltaYが十分大きければtotalに加算
      if (Math.abs(e.deltaY) > 20) {
        total += e.deltaY;
      }

入力受付時間の制御部分。ignoreは基本的にtrueとしておき, 0.2秒後に一度だけtrueとするように制御している。

      // 一定時間deltaYの増分を加算する
      if (ignore) return;
      ignore = true;
      setTimeout(() => {
        ignore = false;
	...
      }, 200);

スクロール量の合計が閾値(80)を超えていなければreturn。超えていればクールタイムのflagを立て, スクロールさせる処理を行う。

      // 一定時間で溜まったスクロール量が閾値を超えていなければreturn
      if (Math.abs(total) < 80) return;
      cooltimeIgnore = true;
      if (!screenWindowRef.current) return;
      const delta = (e.deltaY / Math.abs(e.deltaY)) * scrollWidth;
      screenWindowRef.current.scrollLeft += delta;

クールタイムの制御部分。ここでtotalも0にリセットする。

// 一定時間のクールタイムを設ける
      setTimeout(() => {
        cooltimeIgnore = false;
      }, 500);
      total = 0;

Discussion