technoshop

杉並区和泉のソフトウェアハウス、株式会社テラソフトの技術ブログです。主に.NET MVCとKnockoutJSの情報をまとめます。

.Net MVC4 で KnockoutJS ~【応用編1】BootstrapモーダルをKnockoutから呼び出す(カスタムバインディング)

こんにちは。ザクです。今回から「応用編」に突入します。タイトル通り、Bootstrapをより便利に使うためにKnockout独特の利用方法を解説していきます。

先に前回基礎編4で出した演習の答え合わせをしておきましょうか。

// Calculate GPA
self.gpa = ko.computed(function () {
    var point = (self.gradeA() * 4) + (self.gradeB() * 3) + (self.gradeC() * 2);
    return (point / self.totalcredits()).toFixed(1);
});

式のまんまですね。グレードごとの単位数の出力は前回できたので、それを使ってポイントのトータルを出します。各グレードの単位数にポイントを掛け算するだけですね。全グレードで合計したら、ポイントの合計が出ます。これを単位数で割って出た数字がGPAとなります。割り算で小数点が出ますので、GPAの表記通り小数点第一で丸めます。(toFixed(1)を使いました)できましたか?

KnockoutでBootstrapのモーダルってどう出す?

「普通にHTMLにモーダルのdivを用意して、data-toggleで呼び出せばいいんじゃないの?」と思った人はあまいです。Knockoutでモーダル画面を使う時、モーダルをKnockoutのビューモデルとバインドさせたい場合がほとんだと思います。Bootstrapの説明通りではバインドできません。JQueryでゴリゴリやってできるのかもしれませんが、とてつもなく面倒くさそうです。もっとKnockoutらしくやる方法はないのでしょうか?あるんですねぇ。


knockout.js - Knockoutjs bind model to template created with ko.renderTemplate - Stack Overflow

Knockoutの開発をやっていると度々お世話になるサイトstackoverflowのこれを応用してみます。RP Niemeyerさんの回答のコードに少しだけ手を加えます。(まんまコピーでも大丈夫かも)ko.applyBindingsの下に追加します。(ちなみにRP Niemeyerさんは、このブログでも度々登場しているknockmeout.netの執筆者です)

ko.applyBindings(new SchoolViewModel());

// Custom Binding for BS Modal
ko.bindingHandlers.modal = {
    init: function (element, valueAccessor, allBindings, vm, context) {
            var modal = valueAccessor();
            //init the modal and make sure that we clear the observable no matter how the modal is closed
            $(element).modal({ show: false, backdrop: 'static' }).on("hidden.bs.modal", function () {
                if (ko.isWriteableObservable(modal)) {
                    modal(null);
                }
            });

            //template's name field can accept a function to return the name dynamically
            var templateName = function () {
                var value = modal();
                return value.template;
            };

            //a computed to wrap the current modal data
            var templateData = ko.computed(function () {
                var value = modal();
                return value;
            });

            //apply the template binding to this element
            return ko.applyBindingsToNode(element, { template: { 'if': modal, name: templateName, data: templateData } }, context);
        },
    update: function(element, valueAccessor) {
        var data = ko.utils.unwrapObservable(valueAccessor());
        //show or hide the modal depending on whether the associated data is populated
        $(element).modal(data ? "show" : "hide");
    }
};

ko.bindingHandlersというのが出てきました。これはKnockoutのカスタムバインディングを作るときに使います。

Knockout : Creating custom bindings

中でinitとupdateを定義して使います。このコードではupdateのほうがわかりやすいので先に説明します。updateはこのバインディングを呼び出しているノード(element)にバインドされているデータ(valueAccessor)を監視して、変更があれば実行されます。ここでは単に、データがあったらJQueryでモーダルを表示(show)し、無ければ非表示(hide)にしています。

initは名前の通りイニシャライズです。このバインディングが呼ばれた時に実行されます。最初の部分はコメントに書いてあるとおり、モーダルがhiddenになっていたらバインドされているObservable(コード内の変数名はmodal)をnull(空)にしています。ただ、これだけだと、何やりたいのかちょっとわからんですよね。ここで何をしたいのかは、今回の実装の全容が明らかになったらわかるようになると思います。initの残りの部分はわかりやすいです。バインドされたデータから、テンプレート名とデータそのものを取り出して、templateバインディングしています。

「テンプレート(template)」は似たようなビューを中身を変えて何度も表示しなければならない時、ひな形を作ってそこにビューモデル内の任意のモデルを流し込んで表示させるKnockoutの便利機能です。Knockoutのウリの一つになっていますよね。

カスタムバインディングでテンプレートにバインドする

各生徒の成績は数字で出せたものの、何の授業を取っているのかわからないので、取っている授業の一覧をモーダルで表示してみます。モーダルはカスタムバインディングから呼び出されるテンプレートを用意します。

テンプレートを作成する前に、先ほど追加したモーダルカスタムバインディングをビューから呼び出すイベントを先に決めます。成績表のCreditsの数字をリンクにして、クリックできるようにしてみましょうか。

<table class="table table-bordered">
    <tr>
        <th rowspan="2">
            Last Name
        </th>
        <th rowspan="2">
            First Name
        </th>
        <th class="text-center" rowspan="2">
            Credits
        </th>
        <th class="text-center" colspan="3">
            Grade
        </th>
        <th class="text-center" rowspan="2">
            GPA
        </th>
    </tr>
    <tr>
        <th class="text-center">A</th>
        <th class="text-center">B</th>
        <th class="text-center">C</th>
    </tr>
    <!-- ko foreach: Students -->
    <tr>
        <td><span data-bind="text: LastName"></span></td>
        <td><span data-bind="text: FirstMidName"></span></td>
        <td class="text-center"><a href="#" data-bind="click: $root.currentModal, text: totalcredits"></a></td>
        <td class="text-center"><span data-bind="text: gradeA"></span></td>
        <td class="text-center"><span data-bind="text: gradeB"></span></td>
        <td class="text-center"><span data-bind="text: gradeC"></span></td>
        <td class="text-center"><span data-bind="text: gpa"></span></td>
    </tr>
    <!-- /ko -->
</table>

<div class="modal fade" data-bind="modal: currentModal">
</div>

今あるビューに少し手を加えました。spanの中でtotalcreditを表示していたのをリンクに変えてバインドしました。また、clickバインディングを追加してクリックイベントを起こしました。ここでは、このリンクがクリックされたらビューモデルのトップ(ルートレベル)にあるcurrentModalというObservableにクリックした要素にあるStudentオブジェクトを渡します。currentModalに値が入ることで、上のコードの下の方にあるdata-bind="modal: currentModal"があるdiv要素がko.bindingHandlers.modalを実行します。ここがモーダルカスタムバインディングになっています。モーダルカスタムバインディングではcurrentModalにデータが入っているかどうかを監視してモーダル表示をオン・オフしているわけです。Knockoutの機能を活かしたBootstrapのトグル実装になっていますね。initの最初の部分は、Closeボタンを押すなどBootstrapの方法でモーダルを非表示にする場合、とりあえずcurrentModalの中身を空にするためのコードだったというわけです。(currentModalにnullを代入してもモーダルは閉じます)

currentModalはまだビューモデルに存在しないので、Observableとして追加します。ビューモデルの直下、var self = this;の辺りでしょうか。これでクリックイベントの後、値を入れる入れ物ができました。(同時にバインディングの実行にもなります)

    function SchoolViewModel() {
        var self = this;

        // current Modal object to be saved
        self.currentModal = ko.observable();

生徒の受講一覧のテンプレートを作ります。Bootstrap3の表記法に従って書きます。とりあえず場所はモーダルカスタムバインディングのdivの真下でいいでしょう。

<div class="modal fade" data-bind="modal: currentModal">
</div>

<script id="enrollment" type="text/html">
<div class="modal-dialog">
    <div class="modal-content">
    <div class="modal-header">
        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
        <h4 class="modal-title">Class Enrolled</h4>
    </div>
    <div class="modal-body">  
        <table class="table table-bordered table-condensed">
            <thead>
                <tr>
                    <th>Title</th>
                    <th>Credits</th>
                    <th>Grade</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td>Chemistry</td>
                    <td>3</td>
                    <td>A</td>
                </tr>
            </tbody>
        </table>  
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    </div>
    </div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</script>

こんな感じになります。モーダルのテンプレートは数が増えてきたら別ファイルにしてRazorの@Html.Partialで呼んできてもいいですね。スッキリ整理できます。

scriptタグの中がKnockoutのテンプレートになります。使い方は簡単で、idでテンプレート名を指定するだけです。普通はビューの中でko templateを使ってバインドしますが、今回はカスタムバインディングのコードの中から呼び出してます。template:となっている所がそうでした。先のコードでは、currentModal(コードの中ではmodal)にオブジェクトが入っていれば、initでセットしたテンプレート名とオブジェクトを使ってテンプレートにバインドしています。

currentModalに入ったオブジェクトからテンプレート名(template)を取り出してどのテンプレートを使うかを特定するので、ビューモデルのどこかにテンプレート名を格納する変数を用意する必要があります。どこに入れたらいいでしょうか?モーダルカスタムバインディングから取り出すには、クリックした時currentModalに渡されるモデルの中に入っている必要がありますね。Studentモデルの中です。

// Student Model
function Student(student) {
    var self = this;
    self.template = "enrollment";

こちらもselfの後に加えました。値はテンプレートのidに必ず合わせます。これで単位(Credits)のリンクをクリックしたら、モーダルが表示されるようになっているはずです。クリックしてみましょう。

f:id:technoshop:20141007175133j:plain

中身はモックのままですが、モーダルカスタムバインディングでBootstrapのモーダル画面を表示することができました。モーダル画面の中ではちゃんとクリックした生徒のオブジェクトも取得できています。これを使ってモーダルの中の表示を作ってやります。とりあえず、Title、Credits、Gradeのテーブルを完成させます。

<script id="enrollment" type="text/html">
<div class="modal-dialog">
    <div class="modal-content">
    <div class="modal-header">
        <h4 class="modal-title">Class Enrolled (<span data-bind="text: LastName"></span>, <span data-bind="text: FirstMidName"></span>)</h4>
    </div>
    <div class="modal-body">  
        <table class="table table-bordered table-condensed">
            <thead>
                <tr>
                    <th>Title</th>
                    <th>Credits</th>
                    <th>Grade</th>
                </tr>
            </thead>
            <tbody>
                <!-- ko foreach: Enrollments -->
                <tr>
                    <td><span data-bind="text: Title"></span></td>
                    <td><span data-bind="text: Credits"></span></td>
                    <td><span data-bind="text: Grade"></span></td>
                </tr>
                <!-- /ko -->
            </tbody>
        </table>  
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
    </div>
    </div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</script>

生徒が持っているEnrollmentsの中身をループで表示します。ko foreachを使いtbodyの中を挟みました。ヘッダーのタイトルには、どの生徒かわかるように生徒の名前もつけました。リロードして、モーダルを表示させます。

授業のリストはちゃんと表示されましたが、Gradeが数字のままです。GradeはMVC4ではEnumだったのでintで送られてくるんでした。表示はアルファベットに変えたいですね。

【演習】Gradeのアウトプットを調整する

Gradeの数字をアルファベットに変換するのは、ビューのdata-bindの中でゴニョゴニョやってもできそうですが、カスタムバインディングを使ってビューでdata-bind="grade"となっていたら数字からアルファベットに変換して表示するようにしてみましょうか。これも演習にしてみます。ko.bindingHandlers.gradeを自分で書いてみてください。こことか参考になります。

10 Knockout Binding Handlers I Don't Want To Live Without - Tech.pro


f:id:technoshop:20141008111643j:plain

こんな感じに表示することができました!カスタムバインディングはko.computedとならぶKnockout開発のキモになるので、しっかりマスターしてください。