さぁ!検索しよう!

通常の機能に加えて、ドラッグ&ドロップでスライドの移動が可能でレスポンシブ対応のスライドショーの作り方です。

Demo

スライドショーの骨組みを作る

始めに次のHTMLを書いてスライドショー全体の構造を定義します。

<div id="slideshow" class="slideshow">
    <div id="slideshow-container" class="slideshow-container"></div>
    <ul id="pager" class="pager">
        <li>
            <a href="#" class="pager-btn pager-btn-prev">
                <</a>
        </li>
        <li><a href="#" class="pager-btn pager-btn-next">></a></li>
    </ul>
    <div id="nav" class="nav"></div>
</div>

今回のスライドショーは全体を示す#slideshowを始め、画像が挿入されていく#slideshow-container、ページ送り#pager、現在何番目の画像が表示されているのかが分かるナビゲーション#navで構成されています。

画像は後にJavaScriptで挿入するため、現時点で#slideshow-containerは空の状態です。

スライドショーの見た目を作る

次に以下のCSS(SCSS)を書いてスライドショーの見た目を作ります。

.slideshow {
    background-color: #ccc;
    height: 40vw;
    overflow: hidden;
    position: absolute;
    top: 0%;
    left: 50%;
    -webkit-transform: translateX(-50%);
    transform: translateX(-50%);
    width: 60vw;
    @media (min-width: 800px) {
        height: 300px;
        width: 500px;
    }
    &-container {
        position: absolute;
        top: 0;
        width: 100%;
        > img {
            display: block;
            height: auto;
            max-width: 100%;
            position: absolute;
            user-select: none;
            -webkit-user-select: none;
            -webkit-user-drag: none;
        }
    }
}

.pager {
    list-style: none;
    margin: 0;
    padding: 0;
    position: absolute;
    top: 50%;
    left: 50%;
    -webkit-transform: translate(-50%, -50%);
    transform: translate(-50%, -50%);
    width: 100%;
    &-btn {
        background: #333;
        border-radius: 100%;
        color: #fff;
        font-size: 2vw;
        height: 8vw;
        line-height: 8vw;
        position: absolute;
        top: 0;
        -webkit-transform: translateY(-50%);
        transform: translateY(-50%);
        text-decoration: none;
        text-align: center;
        width: 8vw;
        @media (min-width: 800px) {
            height: 44px;
            line-height: 44px;
            width: 44px;
        }
        &-prev {
            left: 1vw;
        }
        &-next {
            right: 1vw;
        }
    }
}

.nav {
    position: absolute;
    bottom: 0;
    left: 0;
    text-align: center;
    width: 100%;
    &-btn {
        background: #ccc;
        border-radius: 100%;
        display: inline-block;
        height: 4vw;
        margin: 0 5px;
        width: 4vw;
        &.active {
            background: #333;
            cursor: default;
        }
        @media (min-width: 800px) {
            border-radius: 50%;
            height: 20px;
            width: 20px;
        }
    }
}

CSSのポイントは次の通りです。

アクティブ状態のナビボタンを表現する
ナビボタン.nav-btn.activeクラスが追加されるとアクティブ状態となります。

ナビボタン.nav-btnは基本灰色としていますが、現在表示されているスライドの位置を示すアクティブなナビボタンは濃い色で表現します。また、アクティブなナビボタンはcursor: default;でカーソルの形状が変わらないようにします。

レスポンシブ対応
要素のサイズやスペースの単位をvw(vh)にして常にブラウザのサイズに対する割合となるようにします。

スライドショーの機能を実装

最後に以下のJavaScriptで画像やナビボタンの生成を始め、ナビゲーションやページ送りのクリックやスライドをスワイプすることによるスライドの移動や、スライドの自動再生といったスライドショーの機能を実装します。

(() => {

    //画像に関するクラス
    class Asset {
        constructor() {

            //URL
            this.urls = [
                "https://tsukulog.net/wp-content/uploads/2015/08/a0006_001899.jpg",
                "https://tsukulog.net/wp-content/uploads/2015/06/GAK88_nekokafeneko-thumb-1000xauto-16206.jpg",
                "https://tsukulog.net/wp-content/uploads/2015/06/HIRA86_MBAkey-thumb-1000xauto-17206.jpg",
                "https://tsukulog.net/wp-content/uploads/2015/06/N825_benchinouenoshironeko-thumb-autox1000-14809.jpg",
                "https://tsukulog.net/wp-content/uploads/2014/05/https-www.pakutaso.com-assets_c-2015-05-26NJ_eigonobisuketto-thumb-1000xauto-14009.jpg"
            ];

            //生成した画像を格納する配列
            this._images = [];

            //画像の枚数
            this._imageLength = 0;
        }

        get images() {
            return this._images;
        }

        get imageLength() {
            return this._imageLength;
        }

        //画像を生成するメソッド
        createImage() {

            //URLの数だけ画像を生成
            for(let i = 0; i < this.urls.length; i++) {
                const image = new Image();
                image.src = this.urls[i];
                this._images[i] = image;
            }

            this._imageLength = this._images.length;
            //console.log(this._images);
            //console.log(this._imageLength);
        }
    }

    //スライドの集合に関するクラス
    class Slides {
        constructor() {
            this.elem = document.getElementById("slideshow-container");

            //現在表示されているスライドの番号
            this._currentIndex = 0;

            //#slideshow-containerをmousedownしたときのポインターのx座標
            this.downX = 0;

            //#slideshow-containerをmousemoveしたときのポインターのx座標
            this.moveX = 0;

            //downX~moveXまでの距離
            this.differenceX = 0;

            //ドラッグしているかどうか
            this.isDragging = false;
        }

        get currentIndex() {
            return this._currentIndex;
        }

        //_currentIndexを更新するメソッド
        set currentIndex(value) {
            if(typeof value === 'number') {
                this._currentIndex = value;
            }
        }

        //ドラッグ&ドロップでスライドを移動できる状態にするメソッド
        setupListener(timer, nav) {
            this.elem.addEventListener("mousedown", e => this.mouseDown(e));
            document.body.addEventListener("mousemove", e => this.mouseMove(e));
            document.body.addEventListener("mouseup", e => this.mouseUp(e, nav));
            document.body.addEventListener("mouseleave", e => this.mouseUp(e, nav));

            this.elem.addEventListener("touchstart", e => this.mouseDown(e));
            document.body.addEventListener("touchmove", e => this.mouseMove(e));
            document.body.addEventListener("touchend", e => this.mouseUp(e, nav));
            document.body.addEventListener("touchleave", e => this.mouseUp(e, nav));

            this.elem.addEventListener('mouseover', (e) => timer.stop());
            this.elem.addEventListener('mouseout', (e) => timer.start(this, nav));
        }

        //#slideshow-containerに画像を挿入するメソッド
        insertImages(images) {
            images.forEach((image) => this.elem.appendChild(image));
        }

        //挿入された画像を横並びに配置するメソッド
        setImages(images) {
            images.forEach((image, index) => image.style.left = `${100 * index}%`);
        }

        //スライドを移動させるメソッド
        move(index) {
            const image = this.elem.getElementsByTagName('img');
            const max = image.length - 1;

            //先頭のスライドを左に移動させると最後のスライドを表示する
            if (index < 0) {
                index = max;
            }

            //最後のスライドを右に移動させると先頭のスライドを表示する
            if (index > max) {
                index = 0;
            }

            this.elem.style.transition = "left 0.5s linear";
            this.elem.style.left = `${-100 * index}%`;

            //更新
            this._currentIndex = index;
        }

        //mousedownされると実行されるメソッド
        mouseDown(e) {
            e.preventDefault();

            let event;

            if (e.type === "mousedown") {
                event = e;
            } else {
                event = e.changedTouches[0];
            }

            this.downX = event.clientX;

            //ドラッグ開始
            this.isDragging = true;
        }

        //mousemoveされると実行されるメソッド
        mouseMove(e) {

            //ドラッグしていれば
            if (this.isDragging) {
                e.preventDefault();

                let event;

                if (e.type === "mousemove") {
                    event = e;
                } else {
                    event = e.changedTouches[0];
                }

                this.moveX = event.clientX;

                this.differenceX = this.moveX - this.downX;

                //mousemoveされたときの#slideshow-containerの幅を取得
                const slidesWidth = this.elem.clientWidth;

                this.elem.style.transform = `translateX(${Slides.clamp(this.differenceX, this.differenceX - slidesWidth, this.differenceX + slidesWidth)}px)`;
            }
        }

        //mouseupされると実行されるメソッド
        mouseUp(e, nav) {

            //ドラッグしていれば
            if (this.isDragging) {

                //ドラッグ終了
                this.isDragging = false;

                /*
                if (this.differenceX == this.downX) {
                    return;
                }
                */

                //mouseupされたときの#slideshow-containerの幅
                const slidesWidth = this.elem.clientWidth;

                //右へslidesWidth / 4より多くドラッグしていれば移動する
                if (this.moveX > this.downX && Math.abs(this.differenceX) > slidesWidth / 4) {
                    this._currentIndex = this._currentIndex - 1;
                    console.log('→');
                }

                //左へslidesWidth / 4より多くドラッグしていれば移動する
                if (this.moveX < this.downX && Math.abs(this.differenceX) > slidesWidth / 4) {
                    this._currentIndex = this._currentIndex + 1;
                    console.log('←');
                }

                //スライドを移動
                this.move(this._currentIndex);

                //ナビゲーションを更新
                nav.update(this._currentIndex);

                this.elem.style.transition = "all 0.5s ease";
                this.elem.style.transform = '';

                //transition後に次に備えて削除
                setTimeout(() => {
                    this.elem.style.transition = '';
                }, 500);
            }
        }

        //スライドの移動範囲を制限するメソッド
        static clamp(number, min, max){ //numberをminからmaxまでの値で返す
            return Math.max(min, Math.min(number, max));
        }
    }

    //ナビゲーションに関するクラス
    class Nav {
        constructor() {

            //#navの参照を取得
            this.elem = document.getElementById("nav");

            //ナビボタンを取得
            this.btn = this.elem.getElementsByTagName("a");
        }

        //#navに画像の枚数だけボタンを挿入するメソッド
        set(imageLength) {
            for (let i = 0; i < imageLength; i++) {

                //ナビボタンを生成
                const navBtn = document.createElement("a");
                navBtn.classList.add('nav-btn');
                navBtn.setAttribute("href", "#");

                //ナビボタンを#navに挿入
                this.elem.appendChild(navBtn);
            }
        }

        //ナビゲーションをクリックできる状態にするメソッド
        setupListener(slides) {
            const navBtn = this.btn;

            //ナビボタンをクリックするとクリックしたナビボタンと同じ番号のスライドに切り替える
            for (let i = 0; i < navBtn.length; i++) {
                navBtn[i].addEventListener("click", e => {
                    e.preventDefault();

                    //クリックしたナビボタン
                    const target = e.target;

                    //クリックしたナビボタンの番号
                    const targetIndex = [].slice.call(navBtn).indexOf(target);
                    //https://lab.syncer.jp/Web/JavaScript/Snippet/54/
                    //https://lab.syncer.jp/Web/JavaScript/Snippet/53/

                    //クリックしたナビボタンと同じ番号のスライドに切り替える
                    slides.move(targetIndex);

                    //targetIndex番目のナビボタンをアクティブにする
                    this.update(targetIndex);
                });
            }
        }

        //現在表示されている画像と同じ番号のナビボタンをアクティブにするメソッド
        update(currentIndex) {
            const navBtn = this.btn;
            //console.log(navBtn);

            //一旦全てのナビボタンをinactiveにする
            for (let i = 0; i < navBtn.length; i++) {
                navBtn[i].classList.remove("active");
            }

            //currentIndex番目のナビボタンをactiveにする
            navBtn[currentIndex].classList.add("active");
        }
    }

    //ページャーに関するクラス
    class Pager {
        constructor() {
            this.elem = document.getElementById("pager");
            this.btn = this.elem.getElementsByTagName("a");
        }

        //ページャーをクリックできる状態にするメソッド
        setupListener(slides, nav) {
            const pagerBtn = this.btn;
            for (let i = 0; i < pagerBtn.length; i++) {
                pagerBtn[i].addEventListener("click", e => {
                    e.preventDefault();
                    const target = e.target;

                    //クリックしたページャーボタンが.pager-btn-prevであれば一つ前のスライドに切り替える
                    if (target.classList.contains("pager-btn-prev")) {
                        slides.currentIndex = slides.currentIndex - 1;

                    //.pager-btn-nextであれば一つ後のスライドに切り替える
                    } else {
                        slides.currentIndex = slides.currentIndex + 1;
                    }

                    slides.move(slides.currentIndex);
                    nav.update(slides.currentIndex);
                });
            }
        }
    }

    //タイマーに関するクラス
    class Timer {
        constructor() {
            this.timer = null;
        }
        start(slides, nav) {

            //5秒毎にスライドを切り替える
            this.timer = setInterval(() => {
                slides.currentIndex = slides.currentIndex + 1;
                slides.move(slides.currentIndex);
                nav.update(slides.currentIndex);
            }, 5000);
        }
        stop() {
            clearInterval(this.timer);
        }
    }

    /*
     * MAIN SCRIPTS
     */

    //インスタンス化
    const asset = new Asset();
    const slides = new Slides();
    const nav = new Nav();
    const pager = new Pager();
    const timer = new Timer();

    //全体を初期化する関数
    function init() {
        asset.createImage();
        const images = asset.images;
        const imageLength = asset.imageLength;

        slides.insertImages(images);
        slides.setImages(images);
        slides.move(slides.currentIndex);

        nav.set(imageLength);
        nav.update(slides.currentIndex);

        timer.start(slides, nav);

        setupListeners();
    }

    //スライドショーを操作できる状態にする関数
    function setupListeners() {
        nav.setupListener(slides);
        pager.setupListener(slides, nav);
        slides.setupListener(timer, nav);
    }

    //起動
    init();
})();

JavaScriptでは次のことを行っています。

画像の生成
画像を生成するためにAssetクラスのcreateImageメソッドでURLの数だけ画像を生成します。

画像の数だけナビボタンを生成
現時点ではナビゲーション内は空なのでNavクラスのsetメソッドでナビボタンを画像の数だけ生成します。

画像を挿入・配置
生成した画像をslideクラスのinsertImagesメソッドで#slideshow-container内に挿入します。更にsetImagesメソッドで挿入した画像を横並びに配置します。

スライドが移動するようにする
slideクラスのmoveメソッドでスライドが移動するようにします。moveメソッドはindexを受け取り、受け取ったindexはスライドの番号を示します。番号が0であれば先頭のスライドであり、1であれば次のスライドとなります。

受け取ったindexと-100%を掛けた値を#slideshow-containerleftプロパティに指定することでスライドを枠外へ移動させます。

最後のスライドに到達して尚スライドを進めた場合は先頭のスライドに戻るようにします。逆も同様です。

スライドの移動と同時にナビボタンの状態を更新する
スライドが移動するとNavクラスのupdateメソッドでそれまでアクティブな状態であったナビボタンをインアクティブな状態に変更し、切り替わったスライドと同じ番号のナビボタンをアクティブな状態にします。

ページ送りをクリックするとスライドが移動するようにする
PagerクラスのsetupListenerメソッドでページ送りボタン(.pager-btn-prev又は.pager-btn-next)をクリックするとスライドが移動するようにします。.pager-btn-prevがクリックされるとスライドは一つ前に戻り、.pager-btn-nextがクリックされるとスライドが一つ後に進みます。

ナビボタンをクリックするとスライドが移動するようにする
NavクラスのsetupListenerメソッドでナビボタンをクリックするとその番号と同じスライドに切り替わるようにします。同時にナビボタンの更新も行い、切り替わったスライドと同じ番号のナビボタンをアクティブな状態にします。

スライドをドラッグ&ドロップで移動可能にする
スライド上をドラッグ&ドロップすることでスライドの移動を可能にします。ドラッグ&ドロップはSlidesクラスのmouseDownmouseMovemouseUpメソッドで実装します。

  1. mouseDownメソッドでスライド上をマウスダウンしたときのx座標downXを取得し、ドラッグ開始を示すisDraggingをtrueに切り替えます。
  2. mouseMoveメソッドでドラッグ中の座標moveXを取得してdownXからmoveXまでの距離differenceXを取得後、clampメソッドで最小値と最大値を設定した値differenceX#slideshow-containertransformプロパティのtranslateXに指定することで、ドラッグでスライドを動かせるようにします。
  3. mouseUpメソッドでドラッグを終了させてスライドの4分の1の幅slidesWidth / 4より多くドラッグしていればスライドをその方向へ移動させます。ナビゲーションの更新も合わせて行います。

スライドが自動再生されるようにする
スライドを一定の間隔で自動で進むようにします。Timerクラスのstartメソッドで5秒毎にスライドを進めます。ナビゲーションの更新も同時に行います。

自動再生はスライド上をマウスオーバーするとstopメソッドにより停止されます。マウスアウトすると再びstartメソッドが実行されて自動再生が行われます。

さいごに

以上で【JavaScript】レスポンシブ+ドラッグ&ドロップでスライドの移動が可能なスライドショーの作り方を終わります。

参考文献