みなさんこんにちは!閃光部おばらです。

今回はCSSトランジション終了の検知についてです。
トランジション終了を検知するための方法とそれを使用したデモをご紹介します。


CSSトランジションのおさらい

まずは簡単なデモを用いておさらいをしましょう。

HTML

<div>を一つ用意します。
JavaScript から要素を取得するため、適当な id を付与します。

<div id="hoge"></div>

CSS

次はトランジションの設定です。

#hoge {
    -webkit-transition: all 5s ease-in-out;
       -moz-transition: all 5s ease-in-out;
        -ms-transition: all 5s ease-in-out;
         -o-transition: all 5s ease-in-out;
            transition: all 5s ease-in-out;
}

これにより JavaScript などで状態が変更された場合、自動でトランジションします。

JavaScript

getElementById() を用いて要素を取得し、style属性の left の値を変更します。

var hoge = document.getElementById("hoge");

hoge.offsetTop;// 強制的にリフローさせる
hoge.style.left = "300px";// 300px 水平方向に移動させる

ページ読み込み直後など、値を変更してもトランジションしない場合があります。
その場合、上記のように適当なDOMプロパティにアクセスし、
強制的にリフローさせるとよいでしょう。
もしくは、setTimeout() で処理を遅らせてもよいかもしれません。

とっても簡単ですね。
ここからは CSS Transition をもっと使いやすくするための方法を検討していきます。


トランジション終了を検知する方法

トランジション終了を検知するには以下のようにします。

hoge.addEventListener("webkitTransitionEnd", handleTransitionEnd, false);
hoge.addEventListener(   "MozTransitionEnd", handleTransitionEnd, false);
hoge.addEventListener(   "mozTransitionEnd", handleTransitionEnd, false);
hoge.addEventListener(    "msTransitionEnd", handleTransitionEnd, false);
hoge.addEventListener(     "oTransitionEnd", handleTransitionEnd, false);
hoge.addEventListener(      "transitionEnd", handleTransitionEnd, false);
hoge.addEventListener(      "transitionend", handleTransitionEnd, false);

function handleTransitionEnd() {
    alert("foobar");
}

※イベント名のパターンは念のため多めに書いてます(Moz.. と moz... など)

イベント名が環境によって異なる点が面倒ですね。
毎回これを書くのは大変なので、もっと簡単な方法を検討しましょう。


トランジションの検知をより簡単にしたい。

アプローチはいろいろあると思いますが、今回は Deferred を作ろう!#1 と Deferred を作ろう!#2 で紹介した Deferred を用い、
CSSTransitionEnd というクラスを作成していきます。

以下のように使用するものとします。

var hoge = document.getElementById("hoge");

new CSSTransitionEnd(hoge).done(function() {
    alert("hoge");
});

hoge.style.left = "10px";

実装

Deferred のコードは以下となります。
参考:Deferred を作ろう!#2

/**
 *  @return {Object.<function>}
 */
function Deferred() {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _queue = [],
        _data;
    
    /*-------------------------------------------
        PUBLIC
    -------------------------------------------*/
    /**
     *  @return {boolean}
     */
    function isResolved() {
        return !_queue;
    }
    /**
     *  @param  {*} data
     *  @return {undefined}
     */
    function resolve(data) {
        if (isResolved()) {
            return;
        }
        
        var arr = _queue,
            len = arr.length,
            i   = 0;
        
        _queue = null;
        _data  = data;
        for (; i < len; ++i) {
            arr[i](data);
        }
    }
    /**
     *  @param  {function} func
     *  @return {undefined}
     */
    function done(func) {
        _queue ? _queue.push(func) : func(_data);
    }
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return {
        isResolved : isResolved,
        resolve    : resolve,
        done       : done
    };
}

まずはコンストラクタ関数(にあたるもの)を作成します。
簡単のため prototype は用いず、モジュールパターンで実装します。
引数には監視対象のDOM要素を取ります。
また、コンストラクタ関数の内部で Deferred を生成し、それを最終的に return します。

/**
 *  @param  {Element} elm
 *  @return {Deferred}
 */
function CSSTransitionEnd(elm) {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _deferred = new Deferred;
    
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return _deferred;
}

これに先ほどの トランジション終了の検知の処理を加えます。
そしてトランジション終了時に実行されるイベントハンドラ内で、Deferred オブジェクトの resolve() を呼びます。

/**
 *  @param  {Element} elm
 *  @return {Deferred}
 */
function CSSTransitionEnd(elm) {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _deferred = new Deferred;
    
    /*-------------------------------------------
        INIT
    -------------------------------------------*/
    elm.addEventListener("webkitTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(   "MozTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(   "mozTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(    "msTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(     "oTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(      "transitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(      "transitionend", _handleTransitionEnd, false);
    
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    function _handleTransitionEnd() {
        elm.removeEventListener("webkitTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(   "MozTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(   "mozTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(    "msTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(     "oTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(      "transitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(      "transitionend", _handleTransitionEnd, false);
        elm = null;
        setTimeout(_deferred.resolve, 100);// Firefox では Transition が完全に終了する前に発火する気がする
    }
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return _deferred;
}

上記のコードでは Deferred オブジェクトの resolve() の実行を setTimeout で 100ms 遅らせていますね。
これは Firefox で CSS Transition が完全に終了する前に 終了のイベントが発火している(気がする)ためです。
webkit のみの対応でよい場合、遅らせる必要はないようです。

これでほぼ完成ですが、何らかの原因により CSS Transition 終了のイベントがしなかった場合の対策も加えましょう。
transition-duration の値を取得し、タイマーでイベントハンドラを強制的に実行させます。
なお、タイマーはイベントが正常に発火した場合に妨げとならないよう、100ms程度遅らせるものとします。

/**
 *  @param  {Element} elm
 *  @return {Deferred}
 */
function CSSTransitionEnd(elm) {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _deferred = new Deferred,
        _css      = window.getComputedStyle(elm, null),
        _duration = _css.webkitTransitionDuration ||
                       _css.MozTransitionDuration ||
                        _css.msTransitionDuration ||
                         _css.oTransitionDuration ||
                          _css.transitionDuration || "0s",
        _msec     = parseFloat(_duration, 10) * 1000,
        _timerId;
    
    /*-------------------------------------------
        INIT
    -------------------------------------------*/
    _timerId = setTimeout(_handleTransitionEnd, _msec + 100);
    elm.addEventListener("webkitTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(   "MozTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(   "mozTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(    "msTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(     "oTransitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(      "transitionEnd", _handleTransitionEnd, false);
    elm.addEventListener(      "transitionend", _handleTransitionEnd, false);
    
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    function _handleTransitionEnd() {
        clearTimeout(_timerId);
        elm.removeEventListener("webkitTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(   "MozTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(   "mozTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(    "msTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(     "oTransitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(      "transitionEnd", _handleTransitionEnd, false);
        elm.removeEventListener(      "transitionend", _handleTransitionEnd, false);
        elm = _css = null;
        setTimeout(_deferred.resolve, 100);
    }
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return _deferred;
}

これで完成です!


デモ #1

以下にサンプルコードを用意しましたのでご覧ください。

ここでちょっとJavaScript のコードを覗いてみましょう。

var elm = document.getElementById("hoge");

new CSSTransitionEnd(elm).done(function() {
    new CSSTransitionEnd(elm).done(function() {
        new CSSTransitionEnd(elm).done(function() {
            new CSSTransitionEnd(elm).done(function() {
                new CSSTransitionEnd(elm).done(function() {
                    new CSSTransitionEnd(elm).done(function() {
                        new CSSTransitionEnd(elm).done(function() {
                            new CSSTransitionEnd(elm).done(function() {
                                new CSSTransitionEnd(elm).done(function() {
                                    new CSSTransitionEnd(elm).done(function() {
                                        new CSSTransitionEnd(elm).done(function() {
                                            elm.style.top = 300 + "px";
                                        });
                                        elm.style.left = 300 + "px";
                                    });
                                    elm.style.top = 250 + "px";
                                });
                                elm.style.left = 250 + "px";
                            });
                            elm.style.top = 200 + "px";
                        });
                        elm.style.left = 200 + "px";
                    });
                    elm.style.top = 150 + "px";
                });
                elm.style.left = 150 + "px";
            });
            elm.style.top = 100 + "px";
        });
        elm.style.left = 100 + "px";
    });
    elm.style.top = 50 + "px";
});
elm.style.left = 50 + "px";

ネストが深く、実行順と記述順が逆になっており、処理を追いにくくなっています。
これをもっとわかりやすく、例えば以下のように書けたらよいですね。

new Hoge("#hoge").
    moveTo( 50,   0).
    moveTo( 50,  50).
    moveTo(100,  50).
    moveTo(100, 100).
    moveTo(150, 100).
    moveTo(150, 150).
    moveTo(200, 150).
    moveTo(200, 200).
    moveTo(250, 200).
    moveTo(250, 250).
    moveTo(300, 250).
    moveTo(300, 300);

というわけでさらに改良します。


サンプル #2

まず、Deferred オブジェクトを返す関数をキューにどんどん追加していくというしくみを考えます。
ここでは TodoList のようなものとしておきましょう。

/**
 *  @return {Object.<function>}
 */
function TodoList() {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _queue      = [],
        _isProgress = false;
    
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    function _shift() {
        var func, deferred;
        
        if (!_queue.length) {
            _isProgress = false;
            return;
        }
        
        _isProgress = true;
        
        func = _queue.shift();
        if (typeof func !== "function") {
            _shift();
            return;
        }
        
        deferred = func();
        if (deferred && (typeof deferred.done === "function")) {
            deferred.done(_shift);
        } else {
            _shift();
        }
    }
    /*-------------------------------------------
        PUBLIC
    -------------------------------------------*/
    /**
     *  @param  {function:Deferred} func
     */
    function push(func) {
        _queue.push(func);
        if (!_isProgress) {
            _shift();
        }
    }
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return {
        push : push
    };
}

使い方は以下のようになります。

var todoList = new TodoList;

todoList.push(homework1);
todoList.push(homework2);

function homework1() {
    var deferred = new Deferred;
    
    // do something here
    
    return deferred;
}
function homework2() {
    var deferred = new Deferred;
    
    // do something here
    
    return deferred;
}

これを用いて 先ほど例に出した Hoge() を作成します。

/**
 *  @param  {string} cssSelector
 *  @return {Object.<function>}
 */
function Hoge(cssSelector) {
    /*-------------------------------------------
        PRIVATE
    -------------------------------------------*/
    var _elm      = document.querySelector(cssSelector),
        _sty      = _elm.style,
        _todoList = new TodoList;
    
    /*-------------------------------------------
        PUBLIC
    -------------------------------------------*/
    /**
     *  @param  {int} x
     *  @param  {int} y
     *  @return {Object.<function>}
     */
    function moveTo(x, y) {
        _todoList.push(task);
        
        function task() {
            var deferred = new CSSTransitionEnd(_elm);
            
            _elm.offsetTop;
            _sty.cssText += ";left:" + x + "px;top:" + y + "px;";
            return deferred;
        }
        return this;
    }
    /*-------------------------------------------
        EXPORT
    -------------------------------------------*/
    return {
        moveTo : moveTo
    };
}

以下にサンプルを用意しましたので、ご覧ください。

new Hoge("#hoge").
    moveTo( 50,   0).
    moveTo( 50,  50).
    moveTo(100,  50).
    moveTo(100, 100).
    moveTo(150, 100).
    moveTo(150, 150).
    moveTo(200, 150).
    moveTo(200, 200).
    moveTo(250, 200).
    moveTo(250, 250).
    moveTo(300, 250).
    moveTo(300, 300);

深いネストがなくなり、実行順と記述順が同じになったことで、非常にすっきりしました。


まとめ

今回はプロパティの変更で Transition を発動させましたが、クラス名の変更で発動させることもできますね。

その場合、上記サンプルの応用で、例えば以下のように描くことも可能でしょう。

new Hoge("#hoge").
    addClass("moveRight").
    addClass("moveDown").
    addClass("moveLeft").
    addClass("moveUp");

是非挑戦してみてください!

HTML5飯