目次
- 前提条件
- はじめに
- コントロールの背景知識
- プロジェクト再利用のための計画
- データバインドされたプロパティ
- CSSと他の視覚プロパティ
- Renderメソッド
- コントロールによるHTML出力
- デザイン時サポート
- Webカスタムコントロールの利用
- まとめ
- 著者紹介
前提条件
- C#、Visual Studio .Net、およびASP.NETアプリケーション作成に詳しいこと
- ADO.NETおよびデータバインディングの基本を理解していること
- HTMLおよびカスケーディングスタイルシート(CSS)の基本を理解していること
はじめに
ASP.NETページで利用できるようなサーバーコントロールを設計したいと思ったことはありませんか? 私があるページを手がけていたとき、CheckBoxList
のようなコントロールが必要になりました。一連のチェックボックスをグループにまとめ、それぞれのグループにカテゴリ見出しを付ける必要がありました。このコントロールに配置すべきアイテムとアイテムカテゴリはデータベースに格納されていました。しかし、標準のCheckBoxList
コントロールでは、チェックボックスをカテゴリ別に表示することができません。
もちろん、CheckBoxList
コントロールをうまく使いながらこうした問題の解決を試みる方法は数多くあります。簡単に思いつく方法は2つあります。1つは、カテゴリまたはグループごとに個別のCheckBoxList
コントロールをASPXページに追加し、カテゴリ見出しはハードコーディングするという方法です。もう1つは、分離コードファイルのPage_Load
メソッドを使用して、実行時にCheckBoxList
コントロールをプログラム的にページに追加するという方法です。
私のプロジェクトでは、チェックボックスとして表示されたアイテムのなかからユーザーが任意の数のアイテムを選択できるフォームを作成する必要がありました。ここでは仮に、カテゴリは自動車メーカー、アイテムは車種を表すものとして話を進めます。アイテムとカテゴリはデータベースに格納されていたので、新たなメーカーや車種の追加のほか、既存エントリの変更や削除も行うことができました。
「カテゴリ名とCheckBoxList
コントロールをWebフォーム上にハードコーディングする」という選択肢はすぐに除外しました。メーカーの追加、削除、名前変更が起こるたびにASPXページとC#の分離コードファイルの更新を行うのは非現実的だったからです。
また、実行時にコントロールをフォームに追加するという選択肢も賢明とは言えません。プログラミングによってWebコントロールをプレースホルダや他のコンテナコントロールに追加するのはとても容易です。しかし問題は、ユーザーの指定した値を読み取るためには、ポストバックの状態やチェックボックスの表示と非表示に関わらず、ページを読み込むたびにコントロールの「リビルド」(プログラムによるコントロールの再作成)を行わなければならないという点です。これではサーバーに相当な負荷がかかってしまいます。
私が本当に欲しかったのは、フォームにドラッグアンドドロップしたり、いくつかのプロパティを設定したり、データバインディングコードを追加したりでき、デザイン時にきちんと形ができているコントロールでした。標準のWebコントロールにはぴったり来るものがなさそうだったので、自分の手でカスタムサーバーコントロールを作ることにし、これを「CategorizedCheckBoxList
」と名付けました。コントロールを一から作り上げると思うと気力が萎えてしまうかもしれませんが、実際にはそれほど難しいものではないことがわかりました。
コントロールの背景知識
Webカスタムコントロールは、いくつかの点でユーザーコントロールよりも優れています。まず、ユーザーコントロールは、それを利用するプロジェクトごとにASCXやクラスファイルをコピーしなければならないので、再利用に適していません。また、Visual Studio .NETでデザイナサポートが用意されていないという点でも不利です。ユーザーコントロールをWebフォームに配置してみれば、私の言いたいことがわかるはずです。ユーザーコントロールは汎用的なグレーボックスで表示され、ユーザーコントロールのプロパティ値を示すヒントはIDEから一切提供されません。ユーザーコントロールの主な利点と言えば、作成が容易なので迅速なアプリケーション開発ができるということくらいです。
一方、Webカスタムコントロールには大きな魅力があります。サーバーコントロールにはASCXファイルが存在しないので、非常に簡単に再利用できます。DLLへのプロジェクト参照を設定しさえすれば、すぐに動作させることができます。WebカスタムコントロールはVisual Studio .NETツールボックスに追加でき、こうすることによって再利用は極めて容易になります。さらに、グローバルアセンブリキャッシュ(GAC)にWebカスタムコントロールをインストールすることも可能です。なお、GACの詳細については、本稿の範囲を超えるのでこれ以上は触れません。ともかく、私がWebカスタムコントロールを気に入っている最大の理由は、申し分のないカスタマイズが可能で、デザイン時サポートがあるということです。
Webカスタムコントロールを作成するにあたっての最大の障壁は、コントロール用のHTMLを記述しなければならないという点でしょう。Visual Studio .NETにはWebカスタムコントロールのためのビジュアルデザイナがないので、ツールボックスのアイテムを自作のコントロールにドラッグアンドドロップすることができません。しかし、C#プログラムを習得できるくらいなら、HTMLを覚えるのも苦ではないでしょう。
プロジェクト再利用のための計画
これから作成するコントロールを簡単に再利用できるようにするために、新しいWebコントロールライブラリのプロジェクトを用意することにしましょう。
Visual Studio .NETによって、「WebCustomeControl1.cs」というクラスが作成されます。この名前を「CategorizedCheckBoxList.cs」に変更します(なお、クラスファイル内の記述もWebCustomControl1
をCategorizedCheckBoxList
に置き換えるようにしてください)。
データバインドプロパティ
今回作成するコントロールにはデータソースをバインドする必要があります。問題を簡単にするために、このコントロールではADO.NETのDataTable
をデータソースとして使用することにしました。DataTable
型を選んだのは、コントロールにはデータやデータの取得方法に関する情報をあまり持たせたくなかったからです。今回のコントロールが必要とするデータは、DataTable
、カテゴリ名として使われるフィールド名、チェックボックスの値、それに各チェックボックスのラベルとなるテキストだけです。それぞれに関するデータバインドプロパティを次に示します。
- DataTable
- DataCategoryColumn
- DataValueColumn
- DataTextColumn
System.Data.DataTable
型。データソースとして利用されるテーブルを表します。このテーブルには、カテゴリ名の列、チェックボックス値の列、および各チェックボックスのラベルの列を必ず含めなければなりません。今回のコントロールは、カテゴリを第一キー、ラベルを第二キーとしてこのテーブルを自動的にソートします。DataTable
のテーブル内で、アイテムの所属カテゴリ名を格納している列の名前を表します。DataTable
のテーブル内で、チェックボックスの値を格納している列の名前を表します。DataValueColumn
とDataTextColumn
に同じ列名を指定できます。DataValueColumn
には任意の型のデータを格納できますが、文字データを使用する場合、カンマを含めることはできません。DataTable
のテーブル内で、チェックボックスの隣に表示されるラベルを格納している列の名前を表します。 また、ページを読み込むときにチェックマークを入れるべきチェックボックスを指定する手段も必要になります。この機能を用意しておくと、フォームのデフォルト値を設定したり、以前に保存しておいた値をユーザーが編集するようなフォームを実現したりできます。そのためのプロパティを、ArrayList
オブジェクトを使って用意することにしました。
- Selections
ArrayList
オブジェクト。選択状態(チェックマークがオンの状態)としてマークすべき値のリストです。このプロパティを利用して、選択状態の値を指定し、ポストバック後に選択状態の値のリストを取得することができます。CSSとその他の視覚的プロパティ
CSSクラスセレクタのための追加プロパティを公開することで、CategorizedCheckBoxList
コントロールの視覚的スタイルをASPXページから制御することができます。
- TableCssClass
- RowCssClass
- CategoryCssClass
- CheckBoxCssClass
- TextCssClass
さらに次のようなプロパティを追加しました。
- TableWidth
- CellPadding
- CellSpacing
- Columns
- SharedTable
True
の場合は、すべてのチェックボックスを1つの同じテーブルに表示します。False
の場合は、一連のチェックボックスをカテゴリごとに独立したテーブルに表示します。このプロパティを用意したのは、チェックボックスのラベルの長さがカテゴリごとに大きく異なる場合に、ページの見た目が悪くなってしまうからです。Renderメソッド
Webカスタムコントロールを扱うときには、Render
メソッドが大活躍します。簡単に言うと、Render
メソッドは、Webブラウザ上にコントロールを表示するためのHTMLを出力します。ちょうどクラシックASPのResponse.Write
を使うのと同じです。ただし、Render
メソッドでは、ホストASPXページから自動的に渡されるHtmlTextWriter
オブジェクトを利用する点が異なります。
細かい処理はすべてVisual Studio .Netが面倒を見てくれるので、このWebカスタムコントロールをページ上で使用するための特別なコーディングをする必要はありません。ページにWebカスタムコントロールを配置するだけで、互いにどのようにやりとりをすればよいかをページとコントロールが理解してくれるのです。
では、今回のWebカスタムコントロールのRender
メソッドを詳しく見てみましょう。
/// <summary> /// Writes out the HTML needed to render this control. /// </summary> /// <param name="output">The HTML text writer the we will utilize. /// This is passed to the control automatically, /// by the host ASPX page.</param> protected override void Render(HtmlTextWriter output) { try { // No need for ViewState this.EnableViewState = false; // Make sure that the htmlFieldName is set. GetHtmlFieldName(); // Should the control be visible? if(this.Visible == true) { // Yes. Render the html. BuildCategorizedCheckBoxList(output); } } catch(Exception ex) { // Something bad happened. // Let's tell the user what that was. output.Write("Error building CategorizedCheckBoxList:<br>"); output.Write(ex.Message); } }
ご覧のとおり、最初にコントロールのViewState
を無効にします。これにより、Webブラウザに送られるHTMLの負荷が軽減されます。次に、ReadPostBack
というメソッドを呼び出します。このメソッドにより、フォームが発行された場合に、どのチェックボックスが選択されていたかを把握します。最後に、コントロールを可視化する場合にはBuildCategorizedCheckBoxList
メソッドを呼び出して、コントロールを表示するためのHTMLを出力させます。
ReadPostBack
メソッドは、チェックボックスフィールドに割り当てている名前を取得し、Request.Form
コレクションに同じフィールド値がないか探します。コントロールを含むWebフォームがポストバックされると、ASP.NETはフォーム上のすべてのフィールドを含むNameValueCollection
の形でそのForm
オブジェクトを公開します。フィールドがない場合はそのForm
オブジェクトはnull
になり、フィールドがある場合はif
文内のコードが実行されます。
/// <summary> /// Retrieves a list of the checkbox values that were /// selected (checked), if the form was submitted. /// This is kind of a poor-man's view state implementation. /// But unlike view state, /// it doesn't add anything to the page weight. /// </summary> protected void ReadPostBack() { // See what field name we are assigning to the checkboxes GetHtmlFieldName(); // Were any checkboxes checked? if(HttpContext.Current.Request.Form[htmlFieldName] != null) { // Since we assigned the same field name to all of // the checkboxes, ASP.NET will give us // a comma-delimited list of the selections. // First, conver the list to a string array. string [] Input = HttpContext.Current .Request.Form[htmlFieldName].Split(','); // Then, iterate through the array and add // each value to our ArrayList. for(int i = 0; i < Input.Length; i++) { selections.Add(Input[i]); } } }
詳しくは後述しますが、チェックボックスフィールドのHTMLを作成すると、各フィールドに同じ名前が割り当てられていることがわかるでしょう。ASP.NETがRequest.Form
コレクションにフィールドを格納するときには、複数の値を持つフィールドをカンマ区切りリストにまとめます。あとはこのカンマ区切りリストを文字列の配列に変換し、それぞれの値を選択状態変数(つまりArrayList
)に追加するだけです。
ReadPostBack
メソッドは、パブリックプロパティであるSelection
にアクセスするときにも呼び出されます。この呼び出しが必要なのは、ページ読み込み時に発生するイベントのシーケンス上の理由からです。これにより、ホストページ側は、どのチェックボックスが選択されていたかをコントロールの描画に先立って把握できるのです。
次に示すのは、GetHtmlFieldName
メソッドです。
/// <summary> /// Returns the unique field name /// that we will assogn to the checkboxes, later. /// </summary> protected void GetHtmlFieldName() { // Pickup the ID assigned to the control // in the consuming ASPX page htmlFieldName = this.ID; }
GetHtmlFieldName
メソッドは、利用側のASPXページによってコントロールに割り当てられたIDの値をhtmlFieldName
というプロテクト変数に割り当てます。なぜこのIDをわざわざ取り上げるのか不思議に思われるかもしれません。ここでは、チェックボックスフィールドの作成時に使う名前を自分の都合に合わせてハードコーディングすることを避けたかったので、このIDを利用しています。
フィールド名が確実に一意であれば、複数のCategorizedCheckBoxList
コントロールを同じWebフォーム上に配置し、どのチェックボックスが選択されているのかを各コントロールに適切に判断させることもできます。Visual Studio .NETは、プログラマが1つのページ上の複数のコントロールに同一IDを割り当てないようにしてくれます(あるいは少なくともその手助けをしてくれます)。ですから、このIDプロパティは私たちの目的にかなったものだと言えるでしょう。
BuildCategorizedCheckBoxList
は、少々複雑なメソッドです。このメソッドは、出力ストリームに直接書き込みを行うためにRender
メソッドからHtmlTextWriter
オブジェクトを取得します。このオブジェクトは、コントロール用HTMLを出力するために必要となる面倒な作業をすべて行ってくれます。それでは、このメソッドの処理内容をいくつかのセクションに分けて見ていきましょう。
まず、DataTable
にデータが入っているか否かの確認から始めます。データが入っていなければ、キーワードreturn
によってこのメソッドから抜け出します。
/// <summary> /// Outputs the HTML for this control. /// </summary> /// <param name="output"></param> protected void BuildCategorizedCheckBoxList(HtmlTextWriter output) { // Do we have any data? if(dataTable == null || dataTable.Rows.Count < 1) { // There is no data, so there's nothing to render. return; }
次に、チェックボックスのカテゴリ、値、ラベルを示す列を探します。これらの列が見つかれば、ローカル変数にインデックスを割り当てます。この値は後で使用します。テーブルの列を名前で参照するのは、インデックス番号で参照するよりもずっと時間がかかるので、このコードではインデックスで参照することにしました。
初期化時に、これらの列のインデックス値をわざと-1に設定します。すべてそうしておく必要があります。というのは、DataRaw
において列のインデックス番号が-1の列の値を取得しようすると、.NETフレームワークが例外を発行してくれるからです。
// First retrieve the column indexes of // the specified columns. Later, we'll get // the values that we need using these indexes. // This is faster than referencing a column by name. int CategoryColumnIndex = -1; int TextColumnIndex = -1; int ValueColumnIndex = -1; for(int i = 0; i < dataTable.Columns.Count; i++) { if(dataTextColumn == dataTable.Columns[i].ColumnName) { TextColumnIndex = i; } if(dataValueColumn == dataTable.Columns[i].ColumnName) { ValueColumnIndex = i; } if(dataCategoryColumn == dataTable.Columns[i].ColumnName) { CategoryColumnIndex = i; } }
次に、すべてのカテゴリとそのチェックボックスを同じテーブル上に表示するかどうかを判断します。同じテーブルに表示する場合は、次のようにテーブルの開始タグを書き出します。
/**********************************/ /* Build the html to display of the items */ /**********************************/ // If the consuming page wants one single, shared table, // write the opening tag, now. if(this.sharedTable == true) { output.Write(GetTableTag()); }
GetTableTag
は、テーブルの開始タグ用HTMLを生成するヘルパーメソッドです。より正確に言えば、このメソッドは、tableTag
というプライベート変数の値がもしnull
であれば値を割り当てたうえで、その値を返すものです。話を簡単にするため、GetTableTag
の詳細についてここでは詳しく触れません。ただ、文字列は不変のデータ型なので、あとで何度も文字列に追加していくようなときは必ずStringBuilder
オブジェクトを使うということを覚えておけばいいでしょう。
話を元に戻しましょう。続いて、DataTable
に含まれるカテゴリのリストを取得する必要があります。そこでまず、このDataTable
からDataView
を生成し、カテゴリ列に基づいてソートを行います。LastCategory
というローカル変数を用意し、これを使ってDataView
の各列を参照しながら新たなカテゴリ名が現れていないか繰り返しチェックしていきます。新しいカテゴリが現れるたびに、そのカテゴリをCategories
というArrayList
に追加します。
// Create a string for the "previous" category string LastCategory = string.Empty; // Sort the data by category DataView Category = dataTable.DefaultView; Category.Sort = this.dataCategoryColumn; // Assemble a distinct list of the categories // found in the data ArrayList Categories = new ArrayList(); for(int i = 0; i < Category.Count; i++) { if(LastCategory != Category[i][CategoryColumnIndex].ToString()) { Categories.Add(Category[i][CategoryColumnIndex].ToString()); LastCategory = Category[i][CategoryColumnIndex].ToString(); } }
これでようやくカテゴリのリストができあがったので、このリストに対してループ処理を行い、各カテゴリ(および対応するチェックボックス)のHTMLを出力します。これを行うために、DataView
を作成し、そのビューの列数を対象カテゴリの列数以内に制限するRowFilter
を設定します。
// Loop through the categories for(int i = 0; i < Categories.Count; i++) { // Get the rows for this category only DataView CategoryItems = new DataView(dataTable); CategoryItems.RowFilter = String.Format("{0}=\'{1}\'", this.dataCategoryColumn, Categories[i].ToString().Replace("\'","\'\'")); CategoryItems.Sort = this.dataTextColumn;
カテゴリごとに別々のHTMLテーブルを作成する場合は、この時点でテーブルの開始タグを出力します。
// If the consuming page wants a separate table // for each category, write the opening tabel tag // for the current category, now. if(this.sharedTable == false) { output.Write(GetTableTag()); }
今度は、現在処理しているカテゴリ用のHTMLを出力します。ヘルパーメソッドであるOutputCategoryRow
を使用して、必要なHTMLを出力します。
// Add the category heading to the html OutputCategoryRow(output, (string)Categories[i]);
この段階になると、話が少し複雑になってきます。現在処理しているカテゴリに属するアイテム数や、チェックボックスの表示に使用する列数はわかっています。しかし、すべてのアイテムの表示に必要な列数が明確になっていません。
そこで、カテゴリ内の全アイテム数を表示列数で割ることによって、全アイテムの表示に必要な列数を求めます。余りが出た場合には、求めた列数に1を加えます。
// Calculate the total number of rows based on // the item count and the number of columns totalItems = CategoryItems.Count; totalRows = totalItems / columns; // If there was anything left-over as a result of // the division, we need to add another row if(totalItems % columns > 0) { totalRows++; }
次に、列を処理するループに入ります。現在処理中のアイテムのインデックス番号を保持するためのカウンタを用意します。このカウンタの値は0から始まります。各アイテムのHTMLを書き出すたびに、最後のアイテムでないかチェックします(CurrentItemIndex
の値が「全アイテム数-1」に等しければ、最後のアイテムの列を作成し終えたことになります)。
現在のカテゴリにおける最後のアイテムのHTML出力が終わったら、CurrentItemIndex
を-1に設定します。CurrentItemIndex
の値が-1なのに表示すべき列がまだ残っている場合には、チェックボックスとその隣のテキストの両方に対して空のテーブルセル用HTMLを出力します。
// Create an integer to hold the index number // for the current item int CurrentItemIndex = 0; // Now loop through the rows for(int Row = 0; Row < totalRows; Row++) { // Determine the starting index for this row. // This is the same calculation that we would perform // to handle paging in a grid. int Start = (Row * columns); // Create an integer to hold the index number // for the current item int CurrentItemIndex = Start; // Start the row output.Write("\t"); output.Write("<tr class=\""); output.Write(this.rowCssClass); output.Write("\">"); output.Write("\n"); // Column loop for(int Col = 0; Col < columns; Col++) { // Make sure that we haven't hit a blank entry. if(CurrentItemIndex == -1) { // Add an empty cell (two, actually) AddEmptyCells(output); } else { // Now add the checkbox and text OutputCheckBox(output, htmlFieldName, CategoryItems[CurrentItemIndex] [TextColumnIndex].ToString(), CategoryItems[CurrentItemIndex] [ValueColumnIndex].ToString(), IsChecked(CategoryItems[CurrentItemIndex] [ValueColumnIndex].ToString()) ); // If we have more data left, // increment the current index counter. if(CurrentItemIndex < (totalItems - 1) && CurrentItemIndex != -1) { // increment the current index CurrentItemIndex++; } else { // We're at the end of the items // in the data table. Set the value of // the current index to -1, // which our rendering code // ignores (creates empty table cells) CurrentItemIndex = -1; } } // Add a line break output.Write("\n"); }
この時点で列内のすべての列の処理が終わっているので、列の終わりに必要な処理を行います。
// End the row output.Write("\t"); output.Write("</tr>\n"); }
このループは、すべての列に対する処理が終了するまで繰り返されます。最後に、カテゴリごとに別々のテーブルを使う場合には、現在のカテゴリテーブルを次のようにして終了させます。
// Table tag if(this.sharedTable == false) { output.Write("</table>\n"); } }
このループは、すべてのカテゴリの処理が終了するまで繰り返されます。共有テーブルを使用する場合は、ループの最後でテーブルの終了タグを出力します。
// Finish the table if(this.sharedTable == true) { output.Write("</table>\n"); } /**********************************/ }
これまで説明していなかったヘルパーメソッドのOutputCheckbox
とIsChecked
について、ここで述べておきましょう。OutputCheckbox
は、チェックボックスフィールドを含むテーブルセルと、その隣のラベルを含むテーブルセルのためのHTMLを出力します。ここでは、ラベルが2番目の行に重ならないようにするために、チェックボックスとそのラベルを別々のセルに配置しました。これにより、すべての要素をきれいに配置することができます。
IsChecked
メソッドは、チェックボックスに「チェック」を付けるか付けないかを決めるために用いられます。ここで使っているSelections
という変数はArrayList
オブジェクトなので、その実装は容易です。
/// <summary> /// Looks for a match between the current value and /// the list of selected values. /// </summary> /// <param name="currentValue">The value that we want to look for. /// </param> /// <returns>True if the current value is contained in /// the selected list. Otherwise, false.</returns> protected bool IsChecked(string currentValue) { // If we have selections, continue if(selections != null && selections.Count > 0) { // Can we find the current value? if(selections.IndexOf(currentValue) > -1) { // Yes, so this item should be marked // as selected (checked) return true; } else { // No, so this item should not be selected return false; } } else { // Nothing at all was selected, so return false return false; } }