気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

jQuery クリックやマウスオーバーで画像を切り替える

f:id:AonaSuzutsuki:20200318172634j:plain

とあるサイトで欲しくなったシリーズ第二弾、画像をクリックしたりマウスオーバーすると別の画像に切り替わるっていうやつです。
前回の記事はこちらです。

なお、JavaScript/jQueryは専門外なので流行りや効率が必要ならいい感じに書き換えてください。

検証環境

項目 詳細
Google Chrome 80.0.3987.132, 64bit
Safari 13.0.5 (15608.5.11)
JQuery v3.4.1

これ以外の環境は検証はしてないし、各関数、メソッドが対応しているかどうかすらチェックしてません。
古いブラウザとか対応する予定なら細かいところのチェックが必要です。

やること

画像をクリックすると画像が順次切り替わり、最後まで切り替わると再び元に戻ってグルグル切り替わり続けます。
また、その機能を利用して画像をマウスオーバーで二段階に切り替わる機能も同時に実装します。

クリックで切り替わるやつはこんな感じ。
f:id:AonaSuzutsuki:20200318171324g:plain

マウスホバーはこんな感じ。
こっちもクリックでも切り替えが可能です。
f:id:AonaSuzutsuki:20200318171341g:plain

普通の写真の切り替えをGIFにすると画面がうるさすぎたので前回と異なり今回から急遽シンプルな画像に切り替えました。
サンプルもどちらもシンプルな画像に切り替えています。

サンプル

今回のサンプルはこちらです。
ChangeableImage

functionで書いたSafariでも動くやつ
ChangeableImageSafari

HTML

前回のマウスオーバーで画像を出すリンク同様、a要素と画像の組み合わせで実現します。
a要素でなくても構いませんが、マウスカーソルを変えたりスタイルの調整がめんどくさいのでこれを使用します。

まずはクリックで画像が切り替わるやつから。
毎回この書き方するの冗長するので以下Changeableとします。

<a href="javascript:void(0)" class="changeable-image" data-src="1.jpg,2.jpg,3.jpg"><img /></a><br />

class名はJavaScriptの初期化で指定するのでなんでもいいのですが、今回は .changeable-image にしました。
切り替える画像のリンクはdata-srcの中に,区切りで記述します。
パスはimg srcが対応するものであればURIでも可能です。

マウスオーバーで切り替わるほうも全く同じですが、class名を別のものにします。
こちらも同様にHoverableとします。

<a href="javascript:void(0)" class="hoverable-image" data-src="1.jpg,2.jpg"><img /></a>

こちらも画像のリンクをdata-srcに,区切りで記述しますが、マウスが外に出た際に1つ目の画像に戻す処理をしているので3つ以上置いてもあんまり意味がないかなと。
なお、Hoverableでもクリックで画像が切り替わるのでChangeableで登録する必要はありません。

JavaScriptの初期化は前回同様、特定のclass名を指定してあげることでそのclassを持つ要素だけに適用します。

$(function () {
    let changer = new ImageChanger();
    changer.registerHoverable(".hoverable-image");
    changer.registerChangeable(".changeable-image");
    changer.initImageSource();
    changer.preload();
});

ChangeableにしたいならImageChanger#registerChangeableメソッドを利用し、HoverableにしたいならImageChanger#registerHoverableメソッドを利用します。

登録後にImageChanger#initImageSourceメソッドにて初期化します。
ここで1つ目の画像を表示させるのでimgタグのsrc属性に画像を指定する必要はありません。

ImageChanger#preloadメソッドはdata-srcに指定した画像を全て非同期でプリロードしておきます。
これは必須ではありませんが、画像のサイズが大きいと切り替え時の描写が遅れます。

ちなみに、JavaScript切ってる環境を考慮する場合はa要素の中にあるimg要素にsrcを指定してあげればOK。
もちろん画像は切り替わらないけど。

以下HTML全文

<html>

<head>
    <meta charset="utf-8" />
    <title>ChangeableImage</title>
    <link rel="stylesheet" type="text/css" href="changeable.css" />
</head>

<body>
    <a href="javascript:void(0)" class="changeable-image" data-src="1.jpg,2.jpg,3.jpg"><img /></a><br />
    <a href="javascript:void(0)" class="hoverable-image" data-src="1.jpg,2.jpg"><img /></a>

    <script type="text/javascript" src="jquery.js"></script>
    <script type="text/javascript" src="ImageRepetitionArray.js"></script>
    <script type="text/javascript" src="ImageChanger.js"></script>
    <script type="text/javascript">
        $(function () {
            let changer = new ImageChanger();
            changer.registerHoverable(".hoverable-image");
            changer.registerChangeable(".changeable-image");
            changer.initImageSource();
            changer.preload();
        });
    </script>
</body>

</html>

CSS

CSSは特に必要ではないです。
サンプルでは赤枠がChangeableで、赤の破線がHoverableにしています。
また、サンプルの画像がでかすぎるのでwidth 50%に調整しています。

.changeable-image {
    border: 2px solid #ff0000;
    display: block;
    text-decoration: none;
    width: 50%;
}

.hoverable-image {
    border: 2px dashed #ff0000;
    display: block;
    text-decoration: none;
    width: 50%;
}

.changeable-image img,
.hoverable-image img {
    width: 100%;
}

JavaScript

そして本題のJavaScriptです。
ボケーっと抽象化してたらだいぶ行数が増えてしまったけど、一発書きよりは見やすいかなと・・・。

ImageRepetitionArray

まずImageRepetitionArrayクラスから。
これはImageRepetitionArray#addメソッドで画像を追加します。
そしてImageRepetitionArray#getメソッドにて追加した画像リンクを実行する度にグルグル回る形で取得できるようにしています。
要するに必要な画像分だけaddしてあとはgetしていれば何回でも順番にリンクが得られるものです。

ImageRepetitionArray#resetは強制的に一番初めの画像に戻すもので、Hoverableのmouseleaveに利用します。
後回しになりましたが、コンストラクでは一番初めImageRepetitionArray#getで取得できる画像番号(index)を指定します。
例えば、「imgのsrc属性に1枚目を指定したからクリックしたら2枚目表示して欲しい」という場合はコンストラクタで1を指定すればgetした際に2枚目のリンクが得られます。
なお、ここで使用するindexは0オリジンです。

ImageRepetitionArray.js*

class ImageRepetitionArray {

    images = [];
    index = 0;

    constructor(defaultIndex = 0) {
        this.index = defaultIndex;
    }

    add(item) {
        this.images.push(item);
    }

    get() {
        if (this.images.length - 1 <= this.index) {
            let image = this.images[this.index];
            this.index = 0;
            return image;
        }
        else {
            let image = this.images[this.index];
            this.index += 1;
            return image;
        }
    }

    reset() {
        this.index = 0;
    }
}

ImageChanger

次にImageChangerクラスです。
まず、イベント登録の部分がImageChanger#registerHoverableメソッドImageChanger#registerChangeableメソッドです。
いずれも名前の通りHoverableとChangeableのクラスを指定します。
このタイミングで指定されたクラスに個別の識別子を与えるためにdata-uuid属性を追加し、ImageChanger._getUuidメソッドにてUUIDを追加します。
これは後で使います。

registerHoverable = (className) => {
    this.hoverableClassName = className;
    let self = this;

    let hoverableImage = $(className);
    hoverableImage.each(function (index, element) {
        $(element).attr("data-uuid", self._getUuid());
        if (self.availableMouseEvent()) {
            element.addEventListener("touchstart", () => self._changeImage($(this)), false);
        }
        else {
            element.addEventListener("mouseenter", () => self._changeImage($(this)), false);
            element.addEventListener("mouseleave", () => self._leaveHoverableImage($(this)), false);
            element.addEventListener("click", () => self._changeImage($(this)), false);
        }
    });
}

registerChangeable = (className) => {
    this.changeableClassName = className;
    let self = this;

    let changeableElem = $(className);
    changeableElem.each(function (index, element) {
        $(element).attr("data-uuid", self._getUuid());
        if (self.availableMouseEvent())
            element.addEventListener("touchstart", () => self._changeImage($(this)), false);
        else
            element.addEventListener("click", () => self._changeImage($(this)), false);
    });
}

...

_getUuid = () => {
    let uuid = "", i, random;
    for (i = 0; i < 32; i++) {
        random = Math.random() * 16 | 0;

        if (i == 8 || i == 12 || i == 16 || i == 20) {
            uuid += "-"
        }
        uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
    }
    return uuid;
}


順番が前後しますが、ImageChanger#initImageSourceメソッドでは内部で使用するImageRepetitionArrayオブジェクトのマップを生成し、ついでにimgタグのsrcにindex 0の画像をセットします。
ここのマップにて要素をそれぞれ個別に特定するためにUUIDを利用しています。
C#みたいにGetHashCodeメソッドがあればいいんですが、ないのでしょうがない...。

initImageSource =  async () => {
    this._initImageSource(this.hoverableClassName);
    this._initImageSource(this.changeableClassName);
}

_initImageSource = async (className) => {
    let hoverableElement = $(className);
    let self = this;

    hoverableElement.each(function (index, elem) {
        let element = $(elem);
        let sourceId = element.attr("data-uuid");
        let imageArray = new ImageRepetitionArray();

        let images = element.data("src").split(",");
        images.forEach((image) => {
            imageArray.add(image);
        });

        self.imageIndexMap[sourceId] = imageArray;

        self._changeImage(element);
    });
}


mouseenterとclickで使用するImageChanger#_changeImageメソッドでは画像を順次切り替える処理を行っています。
引数から得られた要素、すなわちイベント登録したリンクの要素からUUIDを取得し、マップから該当するUUIDのImageRepetitionArrayオブジェクトを取得し、設定された画像リンクを取得します。
あとはその画像リンクをリンクの要素直下にあるimg要素のsrc属性に指定してあげれば切り替え完了です。

_changeImage = (elem) => {
    let sourceElement = elem;
    let sourceId = sourceElement.attr("data-uuid");
    let imgElem = sourceElement.children("img");

    if (sourceId in this.imageIndexMap) {
        let imageArray = this.imageIndexMap[sourceId];

        let src = imageArray.get();
        $(imgElem).attr("src", src);
    }
}


mouseleaveで使用するImageChanger#_leaveHoverableImageメソッドでも同様に画像を切り替えますが、取得する前にImageRepetitionArray#resetメソッドを呼び出して強制的にindex 0に戻します。
これにより必ず初めの画像に戻る処理ができあがります。
この影響で3枚以上設定してもクリックでは切り替わりはしますが、あんまり意味はないかなと。

_leaveHoverableImage = (sourceElement) => {
    let sourceId = sourceElement.attr("data-uuid");
    let imgElem = sourceElement.children("img");

    if (sourceId in this.imageIndexMap) {
        let imageArray = this.imageIndexMap[sourceId];
        imageArray.reset();

        let image = imageArray.get();
        $(imgElem).attr("src", image);
    }
}

あとはImageChanger#preloadメソッドですが、前回同様画像を予めダウンロードしておきます。
数が多いと不要な通信が増加するため必要に応じて呼び出すか、メソッド内容を修正してください。

○追記
スマホでもっさりする問題とHoverableで正しく動かない問題を修正しました。
タッチイベントのある端末のみtouchstartイベントに登録し、そのほかはClickやmouseenterで対応します
なのでスマホでのHoverableはChangeableになります。
参考: タップイベントが実装されているのかを調べる方法(2つ)

以下JS全文
ImageChanger.js

class ImageChanger {

    constructor() {
        this.hoverableClassName = '';
        this.changeableClassName = '';
        this.imageIndexMap = {};
    }

    registerHoverable = (className) => {
        this.hoverableClassName = className;
        let self = this;

        let hoverableImage = $(className);
        hoverableImage.each(function (index, element) {
            $(element).attr("data-uuid", self._getUuid());
            if (self.availableMouseEvent()) {
                element.addEventListener("touchstart", () => self._changeImage($(this)), false);
            }
            else {
                element.addEventListener("mouseenter", () => self._changeImage($(this)), false);
                element.addEventListener("mouseleave", () => self._leaveHoverableImage($(this)), false);
                element.addEventListener("click", () => self._changeImage($(this)), false);
            }
        });
    }

    registerChangeable = (className) => {
        this.changeableClassName = className;
        let self = this;

        let changeableElem = $(className);
        changeableElem.each(function (index, element) {
            $(element).attr("data-uuid", self._getUuid());
            if (self.availableMouseEvent())
                element.addEventListener("touchstart", () => self._changeImage($(this)), false);
            else
                element.addEventListener("click", () => self._changeImage($(this)), false);
        });
    }

    initImageSource =  async () => {
        this._initImageSource(this.hoverableClassName);
        this._initImageSource(this.changeableClassName);
    }

    preload = async () => {
        this._preload(this.hoverableClassName);
        this._preload(this.changeableClassName);
    }

    _initImageSource = async (className) => {
        let hoverableElement = $(className);
        let self = this;

        hoverableElement.each(function (index, elem) {
            let element = $(elem);
            let sourceId = element.attr("data-uuid");
            let imageArray = new ImageRepetitionArray();

            let images = element.data("src").split(",");
            images.forEach((image) => {
                imageArray.add(image);
            });

            self.imageIndexMap[sourceId] = imageArray;

            self._changeImage(element);
        });
    }

    _preload = async (className) => {
        let elem = $(className);
        elem.each(function (index, element) {
            let images = $(element).data("src").split(",");
            images.forEach((src) => {
                let image = new Image();
                image.src = src;
            });
        });
    }

    availableMouseEvent = () => {
        let iframe = document.createElement('iframe');
        document.body.appendChild(iframe);
        let hasTapEvent = ('ontouchstart' in iframe.contentWindow);
        iframe.remove();

        return hasTapEvent;
    }

    _getUuid = () => {
        let uuid = "", i, random;
        for (i = 0; i < 32; i++) {
            random = Math.random() * 16 | 0;

            if (i == 8 || i == 12 || i == 16 || i == 20) {
                uuid += "-"
            }
            uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
        }
        return uuid;
    }

    _changeImage = (elem) => {
        let sourceElement = elem;
        let sourceId = sourceElement.attr("data-uuid");
        let imgElem = sourceElement.children("img");

        if (sourceId in this.imageIndexMap) {
            let imageArray = this.imageIndexMap[sourceId];

            let src = imageArray.get();
            $(imgElem).attr("src", src);
        }
    }

    _leaveHoverableImage = (sourceElement) => {
        let sourceId = sourceElement.attr("data-uuid");
        let imgElem = sourceElement.children("img");

        if (sourceId in this.imageIndexMap) {
            let imageArray = this.imageIndexMap[sourceId];
            imageArray.reset();

            let image = imageArray.get();
            $(imgElem).attr("src", image);
        }
    }
}

JavaScript (Safari)

classに書き換えた後に確認したのでてっきり動くかと思っていましたが、Safariで全く動かなかったのでfunctionに書き直したやつです。
Safariのみならず、これなら大抵のブラウザで動くかと思うので実際に使う場合はこっちのがいいかも。
やってることはclass構文のと一緒なので説明はそちらで。

ImageRepetitionArraySafari.js

function ImageRepetitionArray(defaultIndex = 0) {
    let images = [];
    let index = defaultIndex;
    
    this.add = (item) => {
        images.push(item);
    };
    this.get = () => {
        if (images.length - 1 <= index) {
            let image = images[index];
            index = 0;
            return image;
        }
        else {
            let image = images[index];
            index += 1;
            return image;
        }
    };
    this.reset = () => {
        index = 0;
    };
}

ImageChangerSafari.js

function ImageChanger() {
    this.registerHoverable = (className) => {
        hoverableClassName = className;
        let hoverableImage = $(className);
        hoverableImage.each(function (index, element) {
            $(element).attr("data-uuid", getUuid());
            if (availableMouseEvent()) {
                element.addEventListener("touchstart", () => changeImage($(this)), false);
            }
            else {
                element.addEventListener("mouseenter", () => changeImage($(this)), false);
                element.addEventListener("mouseleave", () => leaveHoverableImage($(this)), false);
                element.addEventListener("click", () => changeImage($(this)), false);
            }
        });
    };
    this.registerChangeable = (className) => {
        changeableClassName = className;
        let changeableElem = $(className);
        changeableElem.each(function (index, element) {
            $(element).attr("data-uuid", getUuid());
            if (availableMouseEvent())
                element.addEventListener("touchstart", () => changeImage($(this)), false);
            else
                element.addEventListener("click", () => changeImage($(this)), false);
        });
    };
    this.initImageSource = async () => {
        initImageSource(hoverableClassName);
        initImageSource(changeableClassName);
    };
    this.preload = async function () {
        preload(hoverableClassName);
        preload(changeableClassName);
    };

    let hoverableClassName = '';
    let changeableClassName = '';
    let imageIndexMap = {};

    let initImageSource = async (className) => {
        let hoverableElement = $(className);
        hoverableElement.each(function (index, elem) {
            let element = $(elem);
            let sourceId = element.attr("data-uuid");
            let imageArray = new ImageRepetitionArray();
            let images = element.data("src").split(",");
            images.forEach((image) => {
                imageArray.add(image);
            });
            imageIndexMap[sourceId] = imageArray;
            changeImage(element);
        });
    };
    
    let preload = async (className) => {
        let elem = $(className);
        elem.each(function (index, element) {
            let images = $(element).data("src").split(",");
            images.forEach((src) => {
                let image = new Image();
                image.src = src;
            });
        });
    };

    let availableMouseEvent = () => {
        let iframe = document.createElement('iframe');
        document.body.appendChild(iframe);
        let hasTapEvent = ('ontouchstart' in iframe.contentWindow);
        iframe.remove();

        return hasTapEvent;
    }

    let getUuid = () => {
        let uuid = "", i, random;
        for (i = 0; i < 32; i++) {
            random = Math.random() * 16 | 0;
            if (i == 8 || i == 12 || i == 16 || i == 20) {
                uuid += "-";
            }
            uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
        }
        return uuid;
    };

    let changeImage = (elem) => {
        let sourceElement = elem;
        let sourceId = sourceElement.attr("data-uuid");
        let imgElem = sourceElement.children("img");
        if (sourceId in imageIndexMap) {
            let imageArray = imageIndexMap[sourceId];
            let src = imageArray.get();
            $(imgElem).attr("src", src);
        }
    };

    let leaveHoverableImage = (sourceElement) => {
        let sourceId = sourceElement.attr("data-uuid");
        let imgElem = sourceElement.children("img");
        if (sourceId in imageIndexMap) {
            let imageArray = imageIndexMap[sourceId];
            imageArray.reset();
            let image = imageArray.get();
            $(imgElem).attr("src", image);
        }
    };
}