Knockout.jsで新しいコンテンツが現在のコンテンツの上からスライドして入ってくるトランジションを作る

本記事ではタイトルに示すようにKnockout.jsで新しいコンテンツが現在のコンテンツの上からスライドして入ってくるトランジションの実装方法を説明する。文章だけではわかりにくいので以下に成果物のGIFを示す。

本記事で説明するトランジションの例

Knockout.jsでは、templateバインディングを用いると、ある種類のデータを表示するためのテンプレートを定義しておき、JS上でデータを与えると自動的にそのテンプレートを用いて描画するということができる。新しいデータを設定すると、明示的に変更を反映するような操作を行わずとも表示も更新される。

templateバインディングを拡張すれば、描画の前後にDOMの操作を行う新たなバインディングを作成することができる。これを利用して、

  1. 描画する直前に元々の要素をクローンする
  2. 元の要素を画面外右側へ移動する
  3. 元の要素を新しいデータで更新する
  4. 元の要素を通常の場所までトランジション付きで移動させる
  5. 移動が完了したら、クローンした要素を消す

という手順で目標が達成できる。

完全なコードを以下に示す。なお、トランジションの時間もバインディングの際のプロパティで指定できるようにしてみた。

HTML:

<!DOCTYPE html>  
<html>  
  <head>
    <title>knockout slidetransition</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container" data-bind="slideTemplate: { name: 'contentTemplate', data: currentData, duration: 500 }">
    </div>

    <script type="text/html" id="contentTemplate">
      <h1 data-bind="text: title"></h1>
      <p data-bind="text: content"></p>
      <button data-bind="click: switchData">Switch Data</button>
    </script>

    <script src="https://code.jquery.com/jquery-2.2.2.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.0/knockout-min.js"></script>
    <script src="main.js"></script>
  </body>
</html>

JS:

var data1 = {  
    title: ko.observable("This is Data1"),
    content: "Lorem ipsum dolor sit amet, ....",
    switchData: function() {
        viewModel.currentData(data2);
    }
};

var data2 = {  
    title: ko.observable("This is Data2"),
    content: "吾輩は猫である。名前はまだ無い。・・・。",
    switchData: function() {
        viewModel.currentData(data1);
    }
};

var viewModel = {  
    currentData: ko.observable(data1)
};

ko.bindingHandlers.slideTemplate = {  
    init: function(elem, valueAccessor, allBindings, viewModel, bindingContext) {
        return ko.bindingHandlers.template.init.apply(this, arguments);
    },
    update: function(elem, valueAccessor, allBindings, viewModel, bindingContext) {
        var value = ko.unwrap(valueAccessor());
        var $elem = $(elem);

        // 1. トランジションの間、変化前のコンテンツを表示させ続けるために元の要素をクローンする。クローンされた要素にはバインディングは適用されない。position: absoluteが指定された要素はz-indexが同じ場合、後に記述されているものがより上に重なる。このため、クローンを元の要素の直前に配置することで、新しいコンテンツがクローンされた要素の上に重なる。
        var $clone = $(elem).clone().insertBefore($elem);
        var duration = value.duration || 500;

        // 2. 一旦トランジションを無効にして画面外右側の見切れた位置に元要素を移動する。
        $elem.css('transition-property', "none")
            .css('left', "100%")
            .css('transition-duration', duration + "ms");

        // 3. 元要素を新しいデータで更新する。本来のtemplateバインディングのupdate関数を呼ぶ。
        ko.bindingHandlers.template.update.apply(this, arguments);

        // Firefoxではここでディレイを入れないとトランジションがかからない場合があるので1フレーム分遅らせる。
        setTimeout(function() {

            // 4. トランジションを有効化し本来の場所まで移動する
            $elem.css('transition-property', "left")
                .css('left', "0%");

            // 5. トランジションが終了したタイミングで要素のクローンを削除する。
            setTimeout(function() {
                $clone.remove();
            }, duration);
        }, 33);
    }
};

ko.applyBindings(viewModel);

CSS:

.container {
    position: absolute;
    width: 100%;
    height: 100%;
    padding: 1.5em;
    box-sizing: border-box;

    background: white;
    transition-timing-function: ease;
}

JSFiddleでのデモも作成している。

コメントとして書いたように、CSSの変更を頻繁に行うせいか、Firefoxの場合ではディレイを入れないとトランジションが起こらない場合があった。もう少しきれいに解決したいが思いつかなかった。

ところで、今回説明したようなトランジションをどのように行うか以前からずっと悩んでいた。トランジションの間は古いデータと新しいデータを表示なければならないところが問題だ。afterRenderプロパティを利用してDOMを操作してみたり、あるいは古いデータを保持しておくためのobservableを追加したりと試行錯誤してみたがしっくりこなかった。ふと古いデータに関してはDOMをそのままクローンしておけばいいのではと思いつき、試してみたところ上手くいったのでまとめた。以上が本記事を書くに至った次第である。