✍️

プラグインに頼らないカルーセルスライダーを素のJavaScriptで書いてみた

2022/08/08に公開約12,800字

この記事に辿り着いたという事は、プラグインやライブラリに頼らずにスライダーを書きたい、もしくはプラグインを使う事に抵抗はないけれど仕組みを理解しておきたいという方だと想定しています。

大前提

他人の書いたコードはクソ

今からお見せするのは「他人のコード」です。
(他人のコードといっても自分で書いてますが、読者からしたら他人だと思うので)
他人のコードは基本的に不正解だと思ってかかってください。私は正解を発表しているのではなく、アイディアの一つを投げかけているにすぎません。
もしかしたらバグが眠っているかもしれない、もっといい書き方があるかもしれないという視点を常に持ち、そしてにわかコーダー私にJavaScriptをご教示頂けたらと思います。

挙動

実装した機能

  • スライドの枚数に応じてスライダー内部のスタイル・ドットが自動で調整される(htmlを書き換えるだけでスライドの枚数を変更可能)
  • ドットクリックでスライド遷移に対応(アクティブなドットへの対応)
  • スワイプに対応
  • ドラッグに対応
  • 自動スライドに対応
  • 関数実行時に表示枚数を変更可能(PC用・Mobile用の出し分に対応)
  • リサイズによる表示スライド枚数の変更に対応
  • 前後の矢印でスライド遷移に対応(遷移先がない場合は非アクティブ化)
  • スライダーにマウスオーバー中はスライド停止
  • ユーザーの操作でスライドが遷移した場合、次のスライドに移行するカウントダウンをリセット

解説

ソースの中にコメントアウトを入れて意図の説明を行っています。

html

      <div class="slider js-slider">
        <div class="slider-inner js-sliderInner">
          <div class="slider-body js-sliderBody">

            <!-- スライド1枚目 開始 -->
            <div class="slider__slide js-slide">
              <!-- スライドの内容を記述 -->
              <a href="" class="slider__anchor">
                <img src="" alt="image1" class="slider__image">
              </a>
            </div>
            <!-- スライド1枚目 終了 -->

            <!-- スライド2枚目 開始 -->
            <div class="slider__slide js-slide">
              <!-- スライドの内容を記述 -->
              <a href="" class="slider__anchor">
                <img src="" alt="image2" class="slider__image">
              </a>
            </div>
            <!-- スライド2枚目 終了 -->

            <!-- スライド3枚目 開始 -->
            <div class="slider__slide js-slide">
              <!-- スライドの内容を記述 -->
              <a href="" class="slider__anchor">
                <img src="" alt="image3" class="slider__image">
              </a>
            </div>
            <!-- スライド3枚目 終了 -->

            <!-- スライド4枚目 開始 -->
            <div class="slider__slide js-slide">
              <!-- スライドの内容を記述 -->
              <a href="" class="slider__anchor">
                <img src="" alt="image4" class="slider__image">
              </a>
            </div>
            <!-- スライド4枚目 終了 -->
          </div>
        </div>

        <!-- インジケーター -->
        <ol class="slider-indicator"></ol>
        <!-- ナビゲーション -->
        <button class="slider-button slider-button--prev js-sliderButtonPrev"></button>
        <button class="slider-button slider-button--next js-sliderButtonNext"></button>
      </div>

scss

.slider {
  width: 70%; //希望のスライダー横幅
  aspect-ratio: 3/1; //希望のスライダーアスペクト比
  margin-inline: auto; // デバッグ用中央寄せ
  margin-top: 100px; // デバッグ用上部margin
  position: relative;
  .slider-inner {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden; //左右のスライドを非表示に
    border: 1px solid #cccccc; // デバッグ用border
    .slider-body {
      position: relative;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      transition: 0.5s; // スライド速度を設定
      .slider__slide {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        .slider__image {
          width: 100%;
          height: 100%;
          object-fit: cover;
          object-position: center;
        }
      }
    }
  }
  .slider-button {
    position: absolute;
    bottom: -30px; //ナビゲーションの位置
    width: 20px; // ナビゲーションの大きさ
    aspect-ratio: 1;
    border-bottom: 1px solid #2b2b2b; // ナビゲーションの色
    cursor: pointer;
    &.is-disabled {
      // 無効時のスタイル
      display: none;
    }
    &--prev {
      left: 0;
      border-left: 1px solid #2b2b2b; // ナビゲーションの色
      transform: rotate(45deg);
    }
    &--next {
      right: 0;
      border-right: 1px solid #2b2b2b; // ナビゲーションの色
      transform: rotate(-45deg);
    }
  }
  .slider-indicator {
    width: 100%;
    margin: 20px auto 0;
    display: flex;
    justify-content: center;
    gap: 2%;
    .slider-indicator__anchor {
      font-size: 0;
      display: block;
      width: 12px; // ドットの大きさ
      aspect-ratio: 1;
      border-radius: 50%;
      background: #2b2b2b; // ドットの色
      &.is-active {
        // 無効時のスタイル
        background: #808080;
        cursor: auto;
      }
    }
  }
}

JavaScript

/* js処理用メディアクエリ */
let mediaFlag;
let width;
media();
function media() {
  width = window.innerWidth;
  if (width > 1024) {
    mediaFlag = "pc";
  } else if (width >= 600) {
    mediaFlag = "tablet";
  } else if (width <= 599) {
    mediaFlag = "sp";
  }
}
window.addEventListener("resize", media);

/* スライダー記述 */
function startSlide(target, split = 1, splitSP = 1) {
  /* スライダー要素が存在するか判定 */
  if (document.querySelector(target) != null) {
    const saveSplit = split; // リサイズ対応用に表示枚数を保存
    const slideCount = 4000; // スライドする秒数を設定
    const targetSlider = document.querySelector(target); // スライダー要素を引数から取得
    const prevButton = targetSlider.querySelector(".js-sliderButtonPrev"); // 戻るボタン
    const nextButton = targetSlider.querySelector(".js-sliderButtonNext"); // 進むボタン
    const slideIndicator = targetSlider.querySelector(".slider-indicator"); // 自動生成するインジケータの親要素を取得
    const slideEach = targetSlider.querySelectorAll(".js-slide"); // スライド要素を全て取得
    const slideLength = targetSlider.querySelectorAll(".js-slide").length; // スライド要素の合計数を取得
    const slideShow = targetSlider.querySelector(".js-sliderBody"); // 各スライダーを格納するコンテナ要素を取得
    let currentIndex = 0; // 何枚目のスライドを表示しているか判定する数値
    let indicatorHtml = ""; // 自動生成するインジケータを空の状態で宣言
    let indicatorLength; // インジケータの合計数を格納するための変数を宣言
    let slideIndicatorEach; // インジケータ要素を全て格納するための変数を宣言
    let slideIndicatorLength; // インジケータ要素の数を取得するための変数を宣言

    /* スライダー要素のサイズを設定 */
    function setSize() {
      slideEach.forEach(function (elem, index) {
        elem.style.width = 100 / split + "%";
        elem.style.left = (100 / split) * index + "%";
      });
      /* スライドの数に合わせてインジケーターを自動生成 */
      const indicatorAmout = slideLength - split + 1;
      indicatorHtml = ""; // インジケータを初期化
      for (let i = 0; i < indicatorAmout; i++) {
        indicatorHtml += "<li class='slider-indicator__list'><a href='#' class='slider-indicator__anchor js-sliderIndicatorAnchor'>" + (i + 1) + "</a></li>";
      }
      slideIndicator.innerHTML = indicatorHtml;
      indicatorLength = document.querySelectorAll(".js-sliderIndicatorAnchor").length;
      slideIndicatorEach = slideIndicator.querySelectorAll(".js-sliderIndicatorAnchor");
      slideIndicatorLength = slideIndicatorEach.length;

      /* インジケーターの挙動を設定 */
      slideIndicatorEach.forEach(function (elem, index) {
        elem.addEventListener("click", function (event) {
          event.preventDefault();
          if (!elem.classList.contains("is-active")) {
            slideFunction(index);
            resetTimer();
          }
        });
      });
    }

    /* windowサイズに合わせてサイズ設定を実行 */
    if (mediaFlag === "sp") {
      split = splitSP;
      setSize();
    } else {
      split = saveSplit;
      setSize();
    }

    /* スライダー要素のサイズをリサイズ時に変更 */
    window.addEventListener("resize", function () {
      if (mediaFlag === "sp") {
        split = splitSP;
        setSize();
      } else {
        split = saveSplit;
        setSize();
      }
    });

    /* スライドを実行する関数 */
    function slideFunction(index) {
      slideShow.style.left = (-100 / split) * index + "%"; // 表示中のスライドに合わせて移動
      currentIndex = index; // 表示中のスライドを格納する変数に関数を実行した際の第一引数を代入
      controlInterface();
    }

    /* ボタン・インジケータを制御する関数を設定 */
    function controlInterface() {
      /* 1枚目表示中は戻るボタンを非アクティブ & 2枚目以降はアクティブ化 */
      if (currentIndex == 0) {
        prevButton.classList.add("is-disabled");
      } else {
        prevButton.classList.remove("is-disabled");
      }
      /* 最後のスライド表示中は進むボタンを非アクティブ & 最後-1枚目まではアクティブ化 */
      if (currentIndex == indicatorLength - 1) {
        nextButton.classList.add("is-disabled");
      } else {
        nextButton.classList.remove("is-disabled");
      }

      /* インジケータの状態を制御 */
      for (let i = 0; i < slideIndicatorLength; i++) {
        /* 一旦全てのインジケータを非アクティブに */
        slideIndicatorEach[i].classList.remove("is-active");
        /* 表示中のスライドと連動するインジケータをアクティブに */
        if (i == currentIndex) {
          slideIndicatorEach[currentIndex].classList.add("is-active");
        }
      }
    }

    /* リサイズ時にスライダーを再実行 */
    window.addEventListener("resize", function () {
      slideFunction(currentIndex);
    });

    /* 進む・戻るボタンの挙動を設定 */
    prevButton.addEventListener("click", function () {
      slideFunction(currentIndex - 1);
      resetTimer();
    });
    nextButton.addEventListener("click", function () {
      slideFunction(currentIndex + 1);
      resetTimer();
    });

    /* スライドのループ処理を設定 */
    let timer; // スライドアニメーションをループ処理を格納する変数
    let nextIndex; // 次のスライドを判定する変数

    /* ループ開始 */
    function startTimer() {
      timer = setInterval(function () {
        nextIndex = (currentIndex + 1) % indicatorLength; // 現在のスライド+1番目をスライド全体の枚数で割った余り = 次のスライド
        slideFunction(nextIndex);
      }, slideCount);
    }
    /* ループ終了 */
    function stopTimer() {
      clearInterval(timer);
    }

    /* 表示スライド変更時に次スライドへのカウントダウンをリセットする処理 */
    function resetTimer() {
      stopTimer();
      startTimer();
    }

    /* スライドにマウスオーバー時はスライド関数を停止 */
    const slideItem = targetSlider.querySelectorAll(".js-slide");
    slideItem.forEach(function (elem, index) {
      elem.addEventListener("mouseover", function () {
        stopTimer();
      });
      elem.addEventListener("mouseleave", function () {
        startTimer();
      });
    });

    /* スワイプに対応 */
    let direction, position;

    //横方向の座標を取得
    function getPosition(event) {
      return event.changedTouches[0].pageX;
    }

    //スワイプ開始時の横方向の座標を格納
    function onTouchStart(event) {
      position = getPosition(event);
      direction = ""; //一度リセットする
    }

    //スワイプの方向(left/right)を取得
    function onTouchMove(event) {
      if (position - getPosition(event) > 70) {
        // 70px以上移動しなければスワイプと判断しない
        direction = "left"; //左と検知
      } else if (position - getPosition(event) < -70) {
        // 70px以上移動しなければスワイプと判断しない
        direction = "right"; //右と検知
      }
    }

    function onTouchEnd(event) {
      if (direction == "right" && currentIndex > 0) {
        slideFunction(currentIndex - 1);
        resetTimer();
      } else if (direction == "left" && currentIndex < slideIndicatorLength - 1) {
        slideFunction(currentIndex + 1);
        resetTimer();
      }
    }

    /* スワイプ対応処理を実行 */
    slideItem.forEach(function (elem, index) {
      elem.addEventListener("touchstart", function (event) {
        onTouchStart(event);
      });
      elem.addEventListener("touchmove", function (event) {
        onTouchMove(event);
      });
      elem.addEventListener("touchend", function (event) {
        onTouchEnd(event);
      });
    });

    /* ドラッグ対応 */
    let isDown = false; // クリックされているか判定するための変数
    let isRightMove = false; // ドラッグで右に動かされているか判定するための変数
    let startX; // クリック開始位置を変数に格納

    /* クリック開始時の処理 */
    const startFunc = function (event) {
      event.preventDefault();
      startX = event.pageX;
      isDown = true;
    };
    /* ドラッグ時の処理 */
    const moveFunc = function (event) {
      //マウスダウン、タッチスタート時のみ処理
      if (!isDown) {
        return;
      }
      event.preventDefault();
      let moveX = (startX - event.pageX) * -1; // 移動距離を取得
      /* 左右どちらに移動しているか判別 */
      if (moveX < 0) {
        isRightMove = true;
      } else {
        isRightMove = false;
      }
      slideShow.style.transform = `translateX(${moveX}px)`;
    };

    /* ドラッグ終了時の処理 */
    const endFunc = function (event) {
      event.preventDefault();
      // クリック判定をリセット
      isDown = false;
      if (isRightMove === true) {
        if (currentIndex + 1 < slideIndicatorLength) {
          slideFunction(currentIndex + 1);
          slideShow.style.transform = `translateX(0px)`;
          resetTimer();
        } else {
          slideShow.style.transform = `translateX(0px)`;
        }
      } else {
        if (currentIndex > 0) {
          slideFunction(currentIndex - 1);
          slideShow.style.transform = `translateX(0px)`;
          resetTimer();
        } else {
          slideShow.style.transform = `translateX(0px)`;
        }
      }
    };

    /* ドラッグ対応処理を実行 */
    slideShow.addEventListener("mousedown", startFunc);
    slideShow.addEventListener("mousemove", moveFunc);
    slideShow.addEventListener("mouseup", endFunc);
    slideShow.addEventListener("mouseleave", endFunc);

    //スライド関数を実行
    slideFunction(currentIndex);
    //スライドのループを実行
    startTimer();
  }
}

// スライド対象の要素を指定してスライダー発火
startSlide(".js-slider", 2, 1); // 第一引数:スライド要素 第二引数:時表示枚数 第二引数:SP時表示枚数

目的

スライダーの仕組みを理解・分解・再構築できるようになることでJavaScriptへの理解をチョット深める

結論

スライドの挙動を勉強するために、Swiper等で書いていたスライドの挙動を素のJavaScriptで書いてみましたが、スライダーを実装するならライブラリ・プラグインを使った方が良いと考えています。(多くの場合、ライブラリ開発者の書くJavaScriptの方が優れた記述であるため)
コンストラクタを自作してスライダーをインスタンス化する際にオプションを渡して実行できるようになりたいものですね。Swiperのパクr

Discussion

ログインするとコメントできます