Microsoft ASP.NET AJAXを使ってみようかと、つか、ASP.NETを始めたいと思ったのはAjaxが楽に扱えそうと思っているわけなんで、手始めにControl ToolkitにあるAutoCompleteExtenderを試してみた。所謂入力補完機能というか、Google Suggestに使われているアレです。このコントロールをそのまま使うのは、既にいろいろなサイトで紹介されているので簡単に扱えたが、入力中のボックスだけに補完されてもつまらん。Google Suggstでさえ、補完語句以外に検索結果件数も表示されているし、実際の入力フォームでは付随する情報も他の入力ボックス等に反映させたいときが多い。たとえば氏名を入力すると住所や電話番号も出てくるとか。

てなわけで、AutoCompleteExtenderの中身をちょっと弄って他の付随情報も扱えるようにしてみた。具体的には図1の様に郵便番号を3桁以上入力するとAutoCompleteが働いて、対応する住所まで引っ張ってきて反映(図2)させる様にする。ちなみにWeb Programmingはそこそこ長いが、ASP.NETは駆け出しなんで、どこかで間違っている可能性もあります。

附随情報も表示
図1.附随情報も表示

入力ボックスに反映
図2.入力ボックスに反映

 

で、いきなりデザイナでポトペタかと思いきや、先に補完候補を返すWebサービスクラスを作る。通常のAutoCompleteExtenderなら1次元の文字列配列を返すが、ここでは、郵便番号と住所を保持するクラスを定義して、そのオブジェクト配列を返すようにしている。別にわざわざクラスを書いて返さなくても、文字列の多次元配列でもいいのだが、いつものノリで書いてる。ちょっと長いがPostalComplete.cs(PostalComplete.asmx)

using System;
using System.Web;
using System.Collections.Generic;
using System.Web.Services;
using System.Web.Services.Protocols;
using System.Data;
using System.Data.Common;
using System.Text.RegularExpressions;
using System.Web.Script.Services;
 
[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[ScriptService]
public class PostalComplete : System.Web.Services.WebService
{
    public PostalComplete()
    {
    }
    public class ZipAddress
    {
        private string _zip;
        private string _address;
        public string zip
        {
            get { return this._zip; }
        }
        public string address
        {
            get { return this._address; }
        }
        public ZipAddress(string zip, string address)
        {
            this._zip = zip;
            this._address = address;
        }
    }
    [WebMethod]
    public ZipAddress[] GetPostalCompleteList(String prefixText, int count)
    {
        string _prefixText = prefixText.Trim().Replace("-", "");
        Regex reg = new Regex("^[0-9]{3,7}$");
        if (!reg.IsMatch(_prefixText)) return null;
        DbConnection con = DbProviderFactories.GetFactory(DbProviderFactories.GetFactoryClasses().Rows[0]).CreateConnection();
        con.ConnectionString = System.Configuration.ConfigurationManager.ConnectionStrings[0].ConnectionString;
        con.Open();
        DbCommand cmd = con.CreateCommand();
        cmd.CommandText = "SELECT * FROM tb_postal_code WHERE zip_code LIKE :C1 ORDER BY zip_code";
        DbParameter dpC1 = cmd.CreateParameter();
        dpC1.DbType = DbType.String;
        dpC1.Value = _prefixText + "%";
        dpC1.ParameterName = "C1";
        cmd.Parameters.Add(dpC1);
        DbDataReader dr = cmd.ExecuteReader();
        List<ZipAddress> list = new List<ZipAddress>();
        int c = 1;
        while (dr.Read())
        {
            list.Add(new ZipAddress((string)dr["zip_code"], (string)dr["city_name"] + (string)dr["local_name"]));
            if (++c > count) break;
        }
        con.Close();
        return list.ToArray();
    }
}

フォームレイアウト
図3.フォームレイアウト

AutoCompleteExtenderプロパティ
図4.AutoCompleteExtenderプロパティ

テキストボックスプロパティ
図5.テキストボックスプロパティ

入力された文字列(prefixText)を元にデータベースに問い合わせ、その結果の必要行分(count)をオブジェクト配列にして返している。ちなみにtb_postal_codeテーブルのzip_codeが郵便番号、city_nameが市区町村名、local_nameが町域名になってます。今回都道府県名はなし。SQLはLIMITクエリかCURSORを使ったほうが、DB→ASP.NET間の転送が少なくて済むのでしょうけど、どちらもDB依存度が高そうで...ここら辺りはデータプロバイダの方で面倒見てほしいなぁ。

ようやくデザイナでレイアウト。と言っても住所を反映させるテキストボックスが多い程度で、他はよくあるサンプルと同じ(図3)。郵便番号のボックスIDがZip、住所はAddressとしています。関連付ける方法も標準と同じだが、AutoCompleteExtenderでややこしいのが、その設定プロパティがAutoCompleteExtenderのプロパティ(図4)よりターゲットとなるテキストボックスのプロパティ(図5)の方が多い、つかこっちにAutoCompleteExtender関連の設定が出てくる。

さて、残るはAutoCompleteExtender本体と言うべきAutoCompleteBehavior.jsの改造。改造と言ってもAutoCompleteExtenderを置いた時点でAutoCompleteBehavior.jsが読み込まれているようになっているから、そのあとに関連するメソッドを上書きで置き換える。置き換えるメソッドは非同期通信の結果(PostalComplete/GetPostalCompleteListの返り値)を受け取るコールバックの_updateメソッドとテキストボックスに選択値を入れる_setTextメソッド。_setTextメソッドは引数に文字列だけを受け取るようになっているので、エレメントオブジェクトを受け取るように修正するのだが、当然呼び出し側もすべて修正する必要が出てくる。_onListMouseDownと_onKeyDownメソッドがそれにあたります。予想以上に結構増えた...orz。以下に置き換えるメソッドのスクリプトを挙げるが、加える場所はaspxファイルの下の方、AutoCompleteBehavior.jsを読み込む位置より後になっていなくてはならない。

<script type="text/javascript">
AjaxControlToolkit.AutoCompleteBehavior.prototype._update = function(prefixText, completionItems, cacheResults) {
    if (cacheResults && this.get_enableCaching()) {
        if (!this._cache) {
            this._cache = {};
        }
        this._cache[prefixText] = completionItems;
    }
 
    this._completionListElement.innerHTML = '';
    this._selectIndex = -1;
    if (completionItems && completionItems.length) {
        for (var i = 0; i < completionItems.length; i++) {
            var itemElement = document.createElement('div');
 
            itemElement.innerHTML=completionItems[i].zip+'<br />'+completionItems[i].address; //修正
            itemElement.__item = completionItems[i]; //修正
 
            var itemElementStyle = itemElement.style;
            itemElementStyle.padding = '1px';
            itemElementStyle.textAlign = 'left';
            itemElementStyle.textOverflow = 'ellipsis';
            itemElementStyle.backgroundColor = this._textBackground;
            itemElementStyle.color = this._textColor;
            itemElementStyle.fontSize = '9pt'; //追加
 
            this._completionListElement.appendChild(itemElement);
        }
        var elementBounds = CommonToolkitScripts.getBounds(this.get_element());        
        this._completionListElement.style.width = '200px'; //修正
        this._popupBehavior.show();
    }
    else {
        this._popupBehavior.hide();
    }
}
AjaxControlToolkit.AutoCompleteBehavior.prototype._setText= function(ev) {
    this._timer.set_enabled(false);
    this._currentPrefix = ev.__item.zip; //修正
    var element = this.get_element();
    var control = element.control;
    // todo: should check for 'derives from' too and should somehow manually cause TB to raise property changed event
    if (control && control.set_text) {
        control.set_text(ev.__item.zip); //修正
    }
    else {
        element.value = ev.__item.zip; //修正
    }
    $get('Address').value  = ev.__item.address; //追加
    this._hideCompletionList();
}
AjaxControlToolkit.AutoCompleteBehavior.prototype._onListMouseDown= function(ev) {
    if (ev.target !== this._completionListElement) {
        this._setText(ev.target); //修正
    }
}
AjaxControlToolkit.AutoCompleteBehavior.prototype._onKeyDown= function(ev) {
    var k = ev.keyCode ? ev.keyCode : ev.rawEvent.keyCode;
    if (k === Sys.UI.Key.esc) {
        this._hideCompletionList();
        ev.preventDefault();
    }
    else if (k === Sys.UI.Key.up) {
        if (this._selectIndex > 0) {
            this._selectIndex--;
            this._highlightItem(this._completionListElement.childNodes[this._selectIndex]);
            ev.preventDefault();
        }
    }
    else if (k === Sys.UI.Key.down) {
        if (this._selectIndex < (this._completionListElement.childNodes.length - 1)) {
            this._selectIndex++;
            this._highlightItem(this._completionListElement.childNodes[this._selectIndex]);
            ev.preventDefault();
        }
    }
    else if (k === Sys.UI.Key.enter) {
        if (this._selectIndex !== -1) {
            this._setText(this._completionListElement.childNodes[this._selectIndex]); //修正
            ev.preventDefault();
        }
    }
    else if (k === Sys.UI.Key.tab) {
        if (this._selectIndex !== -1) {
            this._setText(this._completionListElement.childNodes[this._selectIndex]); //修正
        }
    }
    else {
        this._timer.set_enabled(true);
    }
}
</script>

長々となってしまいました。郵便番号で補完候補10件だけだとあまり価値がないし、ポップアップされる候補ウィンドーはある程度の大きさでそれ以上はスクロールさせるとか、いろいろツッコミどころはありますが、あくまで手始めのサンプルと言うことで。つーより、やっぱりカスタムコントロール化してJavaScriptを書くのを極力避ける様にする必要があると思う。

Author: rakko