外観をカスタマイズしたチェックボックスをASP.NET MVCで利用する

やりたいこと

問題の背景

デフォルトのチェックボックスはイケてない上に変更が面倒

HTMLフォームの中でも、チェックボックスは外観のカスタマイズに技巧的な手法を要します。

とはいえ適当にぐぐるだけでも 「CSSだけでチェックボックスをカスタマイズする方法」 の解説ページがいくつか見つかるので、外観を変えるだけならば特に問題なくできます。

ちなみに今回はこちらの記事を参考にしました。感謝!plustrick.com

外観を弄ったチェックボックスASP.NET MVC 5 とうまく連携できない

そこまではよかったのですが、 外観をカスタマイズしたチェックボックスASP.NET MVC 5 でそのまま使おうとすると、入力値がモデルにうまくバインドされず、丸一日ドはまりしました(具体的には、チェックボックスにチェックを入れても、Post 先のアクションメソッドでは入力値が常に false と見なされてしまう現象です)。

加えて、カスタマイズ版チェックボックスはデフォルトのビューヘルパーで生成できないため、一筋縄ではいきません。


まるでサウザー戦で北斗神拳が通用しないことに落胆するケンシロウのようなみじめな気持ちになりましたが、とりあえずなんとかなったのでここに書き留めておくことにします。

やりかた

利用環境

今回使用した開発環境はこちら。

プロジェクトは Visual C# > Web > ASP.NET Webアプリケーション を選択し、テンプレートには MVC を指定しました。

モデル

まず、ビュー (HTML) の入力値をバインドするための簡単なモデルを用意します。このモデルが、ビューとコントローラの仲立ちになってくれます*1

/Models/SampleModel.cs
namespace Sample151011.Models
{
    public class SampleModel
    {
        public bool CheckBox1 { get; set; }
        public bool CheckBox2 { get; set; }
    }
}

コントローラ

コントローラには2つのアクションメソッドを作ります。

このうち SampleAction メソッドは、チェックボックスとモデルがきちんとバインドされているかを確認するためのものです。

/Controllers/SampleController.cs
using Sample151011.Models;
using System.Web.Mvc;

namespace Sample151011.Controllers
{
    public class SampleController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        public ActionResult SampleAction(SampleModel model)
        {
            if (model == null) return new EmptyResult();

            return Content(
                string.Format("CheckBox1 : {0} / CheckBo2 : {1}", 
                              model.CheckBox1, 
                              model.CheckBox2));
        }
    }
}

ビュー

ビューまわりのコードを書く前に、チェックボックス off 時の画像と on 時の画像をそれぞれ /Content/img/ 配下に置いておきます。

/Content/img/off.png

f:id:tercel_s:20151011160549p:plain

/Content/img/on.png

f:id:tercel_s:20151011160614p:plain

CSSはほぼコピペです。しいて変更点を挙げるならば、セレクタの一部と画像のパスを変更していることくらいです。

.bg_checkbox span { /* !変更! li → span */
    position: relative;
    display: inline-block;
    margin: 0;
    padding: 0;
}
 
.bg_checkbox input {
    position: absolute;
    top: 0;
    opacity: 0;
    width: 100%;  
    height: 100%;
}
 
.bg_checkbox input[type="checkbox"] + label {
    display: block;
    background-image: url(/Content/img/off.png);
    background-size: 24px;
    background-position: left center;
    background-repeat: no-repeat;
    padding: 4px 0 0 28px;
}
 
.bg_checkbox input[type="checkbox"]:checked + label {
    background-image: url(/Content/img/on.png);
}

特製カスタムビューヘルパー

今回の目玉です。ヘルパークラスをコーディングする前に、NuGetでHtmlAgilityPackをインストールしておきましょう*2

このヘルパーは「モデルとバインドできるカスタムチェックボックス」の HTML を生成する CustomCheckboxFor メソッドを外部に公開します。

/Views/Helpers/SampleHelper.cs
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Html;

namespace Sample151011.Views.Helpers
{
    public static class SampleHelper
    {
        private static class TagNames
        {
            internal static readonly string Label = "label";
            internal static readonly string Span = "span";
        }

        private static class TypeNames
        {
            internal static readonly string CheckBox = "checkbox";
            internal static readonly string Hidden = "hidden";
        }

        private static readonly TagBuilder DummyLabel = new TagBuilder(TagNames.Label)
        {
            InnerHtml = ""
        };

        private static readonly string DummyLabelOuterHtml = DummyLabel.ToString(TagRenderMode.Normal);
        private static readonly string Type = "type";

        // ここはCSSクラス名に合わせて書き換えてくださいな...
        private const string CustomCssClassName = "bg_checkbox";

        private static string GetInputTagOuterHtml(this HtmlAgilityPack.HtmlDocument doc, 
                                                string typeName)
        {
            var query = from node in doc.DocumentNode.ChildNodes
                        where string.Compare(node.GetAttributeValue(Type, string.Empty), 
                                             typeName, true) == 0
                        select node;
            return query.Any() ? query.First().OuterHtml : string.Empty;
        }

        public static IHtmlString CustomCheckboxFor<TModel>(this HtmlHelper<TModel> helper,
            Expression<Func<TModel, bool>> expression,
            string customizeCssClassName = CustomCssClassName)
        {
            var doc = new HtmlAgilityPack.HtmlDocument();
            doc.LoadHtml(helper.CheckBoxFor(expression).ToHtmlString());
            
            var ret = new TagBuilder(TagNames.Span)
            {
                InnerHtml = new TagBuilder(TagNames.Span)
                {
                    InnerHtml = doc.GetInputTagOuterHtml(TypeNames.CheckBox) + DummyLabelOuterHtml
                }.ToString(TagRenderMode.Normal) + doc.GetInputTagOuterHtml(TypeNames.Hidden)
            };

            if (!string.IsNullOrWhiteSpace(customizeCssClassName))
                ret.AddCssClass(customizeCssClassName);
            return MvcHtmlString.Create(ret.ToString(TagRenderMode.Normal));
        }
    }
}

cshtml

最後に、SampleコントローラのIndexアクションに対応する cshtml を以下のように書きます。

/Views/Sample/Index.cshtml
@using Sample151011.Views.Helpers;
@model Sample151011.Models.SampleModel

<h2>Sample</h2>
<div class="row">
    <div class="col col-xs-12">
        @using (Html.BeginForm("SampleAction", "Sample", FormMethod.Post))
        {
            @Html.CustomCheckboxFor(model => model.CheckBox1)
            @Html.CustomCheckboxFor(model => model.CheckBox2)

            <button class="btn btn-link" type="submit" name="Submit">えいっ。</button>
        }
    </div>
</div>

実行結果

実際に上記のコードを IIS で動かしてみました。チェックボックスがに画像に置き換わっています。

f:id:tercel_s:20151011163642p:plain

この状態で「えいっ」ボタンを押すと、きちんと SampleAction メソッドで入力値が取得できていることが確認できました。やったー。

ブラウザ上でも、CheckBox1 は False (チェック off)、CheckBox2 は True (チェック on)であることが表示されています。

f:id:tercel_s:20151011163650p:plain

めでたし、めでたし。

おまけ: ヘルパークラスは何をしたのか

cshtmlファイルに @Html.CustomCheckboxFor(model => model.CheckBox1) と書いたとき、ヘルパークラスは以下の HTML を生成します。

<span class="bg_checkbox">
    <span>
        <input data-val="true" 
               data-val-required="CheckBox1 フィールドが必要です。" 
               id="CheckBox1" 
               name="CheckBox1" 
               type="checkbox" 
               value="true" />
        <label>&#xFEFF;</label>
    </span>
    <input name="CheckBox1"
           type="hidden"
           value="false" />
</span>

<input type="checkbox"> 要素と、同名の<input type="hidden">要素を出力します。これは、チェックボックスにチェックをしていない場合でも、「チェックが false である」という情報を確実にサーバへ送信するための手法だそうです(と、『ASP.NET MVC5実践プログラミング』に書いてありました)。

ちなみに&#xFEFF;とは、「文字幅ゼロのスペース」です。

「デフォルトでついてくるラベルは邪魔だけど、消すとまともに動かないので、せめて見えない文字でごまかしてしまおう」という苦肉の策です(素直に<label />と書くと、こんどはチェックボックスの表示が崩れてしまうことがあるのです)。


いじょ。

*1:あれ? モデルってそもそもビューとコントローラを結ぶものだっけ?

*2:本当は外部ライブラリ非依存で書きたかったのですが、、、

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.