2006年01月25日

DataGridViewComboBoxColumnって長いよ

今回のお題はDataGridViewの、DataGridViewComboBoxColumnについて。

私がDataGridViewを好かない理由の一つに、この長ったらしい型名があります。DataGridViewがプリフィクスになっちゃっててもうウンザリ。名前空間分けるとかしてもうちょっと何とかならなかったもんですかね。

過去ちょっと名前を挙げただけなのに、わざわざDataGridViewを含むクエリで検索エンジンからいらっしゃる方もいるくらいには需要があるキーワードのようなんですが、まだまだ.NET2.0が出て日も浅く、なかなか資料が見当たりません。単にDataSourceにデータ突っ込むだけみたいなDataGrid代わりとしてはいいんですが、ちょっと踏み込むともうなにがなにやら。

中でも使いやすそうででも案外そうでもなさそうなのが、今回の話題、DataGridViewComboBoxColumnクラスです。

DataGridの頃からComboBoxカラムの要求は強かったことと思います。DataGridViewでついに待望のComboBox追加、なんですが、これがまあどうにもよく分からない。

文字列コレクション扱う分にはどうってことありません。なぜか選択中のCellからSelectedIndexが取得できない(可能なのかな? 見渡した感じ不可能っぽい)なんてよく分からない制限(後々考えると理由も分かるのですが)もありますが、まあ許容範囲内です。一応。

ですが、普通のComboBoxのノリで独自オブジェクトを突っ込んだ途端、深い沼にはまります。

今回はなんとなく操作画面を乗っけてみました。まるで効果的で「ない」使い方ですが。

簡単なコードで色々検証していきましょう。まず、骨組みです。VC#2005を立ち上げ、コンソールアプリケーションを新規作成。System.Windows.FormsとSystem.Drawingを参照設定で追加してコーディング開始です。敢えて統合開発環境を使うのはひとえにデバッガのためなので、エディタとコンパイラとSDK付属のCLR Debuggerでもいいんですけど。

using System;
using System.Drawing;
using System.Windows.Forms;

public class EntryPoint {
   [STAThread] public static void Main() {
      Form form = new Form();
      DataGridView view = new DataGridView();
      DataGridViewComboBoxColumn column
         = new DataGridViewComboBoxColumn();
      column.Items.AddRange( //paramsなのでobject[]作らないでOK
         new ComboItem(1, "first"), new ComboItem(2, "second"),
         new ComboItem(3, "third"), new ComboItem(4, "fourth"),
         new ComboItem(5, "fifth"), new ComboItem(6, "sixth"));
      view.Columns.Add(column);
      view.Dock = DockStyle.Fill;
      form.Controls.Add(view);
      form.Size = new Size(152, 124);
      Application.Run(form);
   }
}
public class ComboItem {
   public ComboItem(int id, string name) {
      this.id = id;
      this.name = name;
   }
   public string Name {
      get { return this.name; }
      set { this.name = value; }
   }
   public int Id {
      get { return this.id; }
      set { this.id = value; }
   }
   private string name;
   private int id;
   public override string ToString() {
      return string.Format("{0,2}:{1}", this.id, this.name);
   }
}

さて、まさに最低限のコードですが、コンパイルは通るように書いているので、さて実行。

起動画面

ヘッダのテキストすら書いてませんがちゃんと起動します。コンボボックスが見えるので、早速ドロップダウン。

ドロップダウン

各アイテムがToString()されたのが並んでますね。では選んでみましょう。

選択してみた

ん、問題ないようです。では次のセル……ってあれ?

エラー!

二番目のセルをクリックした途端にこの有様です。以後、この選択済みセルにマウスを乗せたりするたびに発生します。原因を追究するには例外調べるのが必要ですが、DataGridViewの場合は単純にtry-catchってわけにはいかないようで(そもそも一体どこをtry-catchしていいのか良く分からないですし)、DataGridView.DataErrorイベントで処理しろと言うことのようです。ではDataErrorに対する処理を書き加えましょう。

[STAThread] public static void Main() {
   // 中略
   view.DataError += delegate(object sender,
                              DataGridViewDataErrorEventArgs e) {
      e.Cancel = true;
   };
   Application.Run(form);
}

C#2.0ってことで早速匿名メソッドを使ってます。e.Cancel = true; はあまり意味がありません。ブレークポイント用のダミーみたいなものです。それではブレークポイントを設置して早速実行。同じ問題を発生させてみると、e.Contextには Formatting | Display という値が入っています。MSDNによるとこれらは以下の意味です。

DataGridViewDataErrorContexts.Formatting

データ ストアに送信されるデータ、またはデータ ストアから読み込まれるデータの書式を指定するときに、データ エラーが発生しました。この値は、セルの変更で、正しい書式設定ができなかったことを示します。新しいセル値を修正するか、セルの書式設定を変更する必要があります。

DataGridViewDataErrorContexts.Display

データ ソースによって値が設定されたセルを表示するときに、データ エラーが発生しました。この値は、データ ソースのデータをセルに表示できないか、データ ソースからの値をセル用に変換するマップが欠落していることを示します。

総合すると、セルに表示させるのに失敗してるっぽいですね。セルの値を設定した後、それを表示用の情報に変換できないって感じでしょうか? しかしe.Exceptionには意味がある情報が見当たりません。値が有効ではないって言われてもじゃあ何なら有効なのかと言う話です。

ここで、どこでエラーが発生しているのか、はもちろん(0, 0)のセル、つまり選択済みのセルです(e.base.ColumnIndex/RowIndexを見れば分かります)。ではこのセルはどんなもんなんでしょうか。senderからRows、items、[0]、Cells、items、[0]、と辿った所がちょうどセルに当たります。どうやらDataGridViewComboBoxCell型のインスタンスのようです。そのまんまですが。

ではそもそもこのDataGridViewComboBoxCellというのはどんなクラスなのか。ヘルプによると、DataGridViewCellから派生した、ComboBoxを抱え込むことのできるセルを表すということです。メンバを見ていくと、いくつか興味深いプロパティを目にします。例えばFormattedValueTypeとValueType。書式指定済みのオブジェクトの型と、実際にセルが表すオブジェクトの型、ですね。今はstringとobjectになっています。そういえばDataGridComboBoxColumnにもValueTypeがあり、これは設定可能なプロパティでした。早速設定してみましょう。

[STAThread] public static void Main() {
   // 中略
   column.ValueType = typeof(ComboItem);
   Application.Run(form);
}

ValueType変更済み

同じように例外を発生させてセルのValueTypeプロパティを見ると、きっちりComboItem型に変わっているのが分かります。さて、そんなことに気をとられていると意外な事実を見落とします。

InvalidCastException!

見ての通り、発生する例外が変わっているのです。今回は以前の曖昧なものと違ってそれなりにはっきりとした物言いです。StringからComboItemにキャストしようとして失敗したと。e.Contextすら変更になっています。Parsing | Commit だそうで、これらは次のようになってます。

DataGridViewDataErrorContexts.Parsing

新しいデータを解析するときに、データ エラーが発生しました。この値は、ユーザーが入力したか、基になるデータ ソースから読み込んだ新しいデータを DataGridView が解析できなかったことを示します。

DataGridViewDataErrorContexts.Commit

変更をデータ ストアにコミットするときに、データ エラーが発生しました。この値は、セルに入力されたデータを基になるデータ ソースにコミットできなかったことを示します。

ポイントは、StringからComboItemにキャストしようとした、と言う点です。つまり、セルはComboItemそのものとしてデータを持っていないと言うことになるのです。何故なら、ComboItemをセルが保持しているのならこのキャストはまったく無用ですから。値を要求されたらそのまま返すだけで済みます。しかし実際にはStringをComboItemにキャストしている。つまり、セルが持っている情報は飽くまで文字列であって、値を取得するにはこの文字列から元の値を再構築しなければならないということが推測できます。その裏づけとして、このセルのValueプロパティを確認するとしっかりnullになっています。上記のコンテキスト情報も、恐らくデータソースであるComboItem型のオブジェクトに変換できないってエラーを示しているのでしょう。

今回のe.Exceptionには色々な例外情報がしっかり入っています。要注目なのはやはりStackTrace。具体的にどこで例外が起こっているのでしょうか。

場所 System.Windows.Forms.Formatter.ChangeType(Object value, Type type, IFormatProvider formatInfo)

場所 System.Windows.Forms.Formatter.ParseObjectInternal(Object value, Type targetType, Type sourceType, TypeConverter targetConverter, TypeConverter sourceConverter, IFormatProvider formatInfo, Object formattedNullValue)

場所 System.Windows.Forms.Formatter.ParseObject(Object value, Type targetType, Type sourceType, TypeConverter targetConverter, TypeConverter sourceConverter, IFormatProvider formatInfo, Object formattedNullValue, Object dataSourceNullValue)

場所 System.Windows.Forms.DataGridViewCell.ParseFormattedValueInternal(Type valueType, Object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter)

場所 System.Windows.Forms.DataGridViewComboBoxCell.ParseFormattedValue(Object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter)

場所 System.Windows.Forms.DataGridView.PushFormattedValue(DataGridViewCell& dataGridViewCurrentCell, Object formattedValue, Exception& exception)

見えないのは置いておくと、DataGridViewComboBoxCell.ParseFormattedValueメソッドがpublicで、派生クラスを通じてなら覗けそうな気がしますね。現状ではどうにも手の出しようがありませんが。ウォッチ式を使おうにも例外が投げられるだけですし。

ところでParseFormattedValueメソッドで気になるのが、2つのTypeConverterです。TypeConverterと言えば思い出されるのがPropertyGrid。これで表示させるオブジェクトのプロパティがTypeConverter属性を付けた型の場合、ユーザからの文字入力によってインスタンスを変更することができるようになります。Color構造体とかが分かりやすいですかね。例えば#FFFFFとかをユーザが入力すると、TypeConverter(の派生クラス)を使って#FFFFFという文字列から [Color {255, 255, 255}] なんて構造体を作成するって寸法です(もちろん解析は派生先で自前で実装です。Colorならそういうパーサが既にありそうな気もしますが)。なにやら今回の件と似ていますね。ComboItemにもTypeConverterを実装させてみたらどうでしょうか。

結論から言うと意味はないことはないが不完全、でした。理由は後述。

DataGridViewComboBoxCell.ParseFormattedValueをどうにかしてオーバーライドする手法は無いものか。いや、オーバーライドするのは簡単です。別にDataGridViewComboBoxCellはsealedではありませんから、派生クラスは作りたい放題、オーバーライドもご自由にってなもんです。では作った派生クラスをどうするか?

改めてDataGridViewComboBoxCellをヘルプで調べてみると、こんな文句が目に入ります。

DataGridViewComboBoxColumn は、この型のセルを保持するための特殊な列型です。既存の DataGridViewComboBoxCell を列に含まれる他のセルのモデルにするには、そのセルを列の CellTemplate プロパティに設定します。既定では、CellTemplate は新しい DataGridViewComboBoxCell に初期化されます。

継承時の注意

DataGridViewComboBoxCell から派生したクラスに新しいプロパティを追加する場合は、Clone メソッドをオーバーライドして、クローン処理時に新しいプロパティをコピーする必要があります。また、基本クラスの Clone メソッドも呼び出して、基本クラスのプロパティが新しいセルにコピーされるようにする必要があります。

要約すると、DataGridViewComboBoxColumn.CellTemplateにインスタンスを一つ突っ込んでおくとそのクローンが使われるようになるってことでしょうか。おお、これで派生クラスの問題は解決しそうです。早速仕込んでみましょう。

using System.ComponentModel;
public class EntryPoint {
   [STAThread] public static void Main() {
      Form form = new Form();
      DataGridView view = new DataGridView();
      DataGridViewComboBoxColumn column
         = new DataGridViewComboBoxColumn();
      //Itemsを挿入する前にCellTemplateを設定しないと問題がある
      column.CellTemplate = new CustomComboBoxCell();
      column.ValueType = typeof(ComboItem);
      column.Items.AddRange( //paramsなのでobject[]作らないでOK
         new ComboItem(1, "first"), new ComboItem(2, "second"),
         new ComboItem(3, "third"), new ComboItem(4, "fourth"),
         new ComboItem(5, "fifth"), new ComboItem(6, "sixth"));
      view.Columns.Add(column);
      view.Dock = DockStyle.Fill;
      form.Controls.Add(view);
      form.Size = new Size(152, 124);
      Application.Run(form);
   }
}
public class CustomComboBoxCell
               : DataGridViewComboBoxCell {
   public override object ParseFormattedValue(
         object formattedValue,
         DataGridViewCellStyle cellStyle,
         TypeConverter formattedValueTypeConverter,
         TypeConverter valueTypeConverter) {
      return base.ParseFormattedValue(
               formattedValue, cellStyle,
               formattedValueTypeConverter,
               valueTypeConverter);
   }
}

早速ブレークポイントで値のチェックと参りましょう。formattedValueは表示されている文字列そのままが。2つのTypeConverterはnullのままです。で、base.ParseFormattedValueを呼び出すと例外が発生。まあ、真っ当なTypeConverterもなくObjectの振りをしたStringを適当な型(ComboItem)に変換しろと言われても困りますよね。ここの問題の解決法は、このメソッドの役割である「書式指定済みの値から元のオブジェクトを組み立てる」を考えればそのままですが、要はFormattedValueTypeからValueTypeへの変換を行えばいいわけです。今回の場合、StringからComboItemへの変換ですね。実装は以下のような感じですか。

ComboItemにTypeConverter属性を付けていた場合、実はこのメソッドをオーバーライドする必要はありません。ここの処理部分はTypeConverterがやってくれます。基底クラスのParseFormattedValueにて、(valueTypeConverter==nullの場合に)既定のTypeConverterとして呼び出されているものと想像されます。

public class CustomComboBoxCell
               : DataGridViewComboBoxCell {
   public override object ParseFormattedValue(
         object formattedValue,
         DataGridViewCellStyle cellStyle,
         TypeConverter formattedValueTypeConverter,
         TypeConverter valueTypeConverter) {
      string str = formattedValue as string;
      return ComboItem.FromFormatted(str);
   }
}
public class ComboItem {
   //省略
   public static ComboItem FromFormatted(string value) {
      if (str != null) {
         string[] strs = str.Split(':');
         int result;
         if (strs.Length == 2
                  && int.TryParse(strs[0], out result)) {
            return new ComboItem(result, strs[1]);
         }
      }
      return null;
   }
}

べたべたな解析ですね。さて、これでParseFormattedValueの例外は除きました。巧くいくはずです。では実行……

はい、やっぱりDataErrorイベントに引っかかりました。初めに出た例外と同じもののようです。なにせヒントが少ないものですからこの場合はどうすればいいんでしょうか。取りあえずDataGridViewComboBoxCellのメンバを見ていきながら考えましょう。

まず、Contextからみて、例外が起こっているのはString>ComboItemではなくComboItem>Stringの流れの中ではないか、と推測できます。単純にToString()を呼び出すだけでいけるようにはしているんですが、どうやらフレームーワークの中の人はそんな単純なやり方をやらないようですね。

ところで、DataErrorイベントのイベント引数であるDataGridViewDataErrorEventArgsクラスには、ThrowExceptionというプロパティがあります。これをtrueにしているとエラーの修復をあきらめて外部に投げてしまいます。これをtrueにしてみたら、意外な例外情報が手に入るかもしれません。ということで早速例外を発生させてみたところ。

StackTrace発見

巧い具合にStackTraceが出ています。Application.Runよりも前から始まるというなかなか長いリストですが、最終的にはDataGridViewComboBoxCell.GetFormattedValueで発生しているのが分かります。名前からも推察できますが、先ほどオーバーライドしたParseFormattedValueメソッドとちょうど対称となるメソッドのようです。おお、Context情報となにやら一致してますね。怪しいです。幸いと言うか当然と言うか、これもvirtualメソッド、早速オーバーライドして挙動を確かめてみましょう。

public class CustomComboBoxCell
               : DataGridViewComboBoxCell {
   protected override object GetFormattedValue(
         object value, int rowIndex,
         ref DataGridViewCellStyle cellStyle,
         TypeConverter valueTypeConverter,
         TypeConverter formattedValueTypeConverter,
         DataGridViewDataErrorContexts context) {
      Console.WriteLine("{0} {1}", value, value == null);
      object retval = base.GetFormattedValue(
         value, rowIndex, ref cellStyle,
         valueTypeConverter, formattedValueTypeConverter,
         context);
      Console.WriteLine("{0} {1}", retval, retval == null);
      return retval;
   }
   //以下略
}

今回はブレークポイントが使いにくいところですね。フォームがアクティブになるタイミングで発生してしまうので無限ループです。そこでConsole.WriteLineを使って、valueの値を監視してみることにします。すると、ちょうど例外が投げられる直前、初めてvalueにnull以外が与えられるのが分かります。そして、非nullであるvalue(実体は当然のことながらComboItemです)を基底クラスのGetFormattedValueに渡したところで例外が発生するのも簡単に確認できます。

どうやらこれが駄目なようですね。ここを簡単に書き換えてしまえばそれでできそうです。

public class CustomComboBoxCell
               : DataGridViewComboBoxCell {
   protected override object GetFormattedValue(
         object value, int rowIndex,
         ref DataGridViewCellStyle cellStyle,
         TypeConverter valueTypeConverter,
         TypeConverter formattedValueTypeConverter,
         DataGridViewDataErrorContexts context) {
      ComboItem item = value as ComboItem;
      return (value == null) ? "" : item.ToString();
   }
   //以下略
}

ということで、ようやく完成です。長い道のりでした。

謎なのですが、TypeConverter属性をつけてもこっち方向、つまりStringへの変換はTypeConverterは一切関知しないようなのです。valueTypeConverterに明示的にTypeConverterを指定して基底クラスのGetFormattedValueを呼び出してもやはり例外でした。というかこの場面ではConvertToが呼び出されていません。MSILをざっと見た感じSystem.Windows.FormsのFormatter.FormatObjectInternalまで行ってそこでConvertToが実行されるようなんですけどね。途中で阻害されるのかな。ここをクリアすればCellTemplateを使う必要が無くなって便利になるんですけど。

さて、ここで原点に返って、初めに出た「値が有効でない」と、その後出てきた「値が有効でない」は同じなのでしょうか? 後で出てきた方は、ValueTypeの設定とそれによる文字列からValueTypeへの型変換の失敗を解決した後で出てきた問題です。つまり、最大の問題はValueTypeの設定の有無ということになります。

何故ValueTypeを設定しなかったときは文字列からの型変換が失敗しなかったのか。実はValueTypeがnullの場合、objectを返せばいいので、デフォルトでは文字列そのままを返すのです。そりゃ型変換の失敗も何もありませんな。

この場合、やはりGetFormattedValueのオーバーライドが必要ですが、返すのは文字列ですからvalueがnullでないときに.ToString()を呼ぶだけで十分です。

つまり、ValueTypeがnullだと、Rows[0].Cells[0].Value.GetType()とやるとstringが返ってくるわけです。……これはこれでありかも。

以上、完成品を置いておきましょう。

using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;

public class EntryPoint {
   [STAThread] public static void Main() {
      Form form = new Form();
      DataGridView view = new DataGridView();
      DataGridViewComboBoxColumn column
         = new DataGridViewComboBoxColumn();
      column.CellTemplate = new CustomComboBoxCell();
      column.ValueType = typeof(ComboItem);
      column.Items.AddRange(
         new ComboItem(1, "first"), new ComboItem(2, "second"),
         new ComboItem(3, "third"), new ComboItem(4, "fourth"),
         new ComboItem(5, "fifth"), new ComboItem(6, "sixth"));
      view.Columns.Add(column);
      view.Dock = DockStyle.Fill;
      form.Controls.Add(view);
      form.Size = new Size(152, 124);
      Application.Run(form);
   }
}
public class ComboItem {
   public ComboItem(int id, string name) {
      this.id = id;
      this.name = name;
   }
   public string Name {
      get { return this.name; }
      set { this.name = value; }
   }
   public int Id {
      get { return this.id; }
      set { this.id = value; }
   }
   private string name;
   private int id;
   public override string ToString() {
      return string.Format("{0,2}:{1}", this.id, this.name);
   }
   public static ComboItem FromFormatted(string value) {
      if (str != null) {
         string[] strs = str.Split(':');
         int result;
         if (strs.Length == 2
                  && int.TryParse(strs[0], out result)) {
            return new ComboItem(result, strs[1]);
         }
      }
      return null;
   }
}
public class CustomComboBoxCell
               : DataGridViewComboBoxCell {
   public override object ParseFormattedValue(
         object formattedValue,
         DataGridViewCellStyle cellStyle,
         TypeConverter formattedValueTypeConverter,
         TypeConverter valueTypeConverter) {
      string str = formattedValue as string;
      return ComboItem.FromFormatted(str);
   }
   protected override object GetFormattedValue(
         object value, int rowIndex,
         ref DataGridViewCellStyle cellStyle,
         TypeConverter valueTypeConverter,
         TypeConverter formattedValueTypeConverter,
         DataGridViewDataErrorContexts context) {
      ComboItem item = value as ComboItem;
      return (value == null) ? "" : item.ToString();
   }
}
posted by Hongliang at 10:19| Comment(2) | TrackBack(0) | C# | このブログの読者になる | 更新情報をチェックする
この記事へのコメント
こんばんは。
古い記事に反応ですみません。
ちょうどこの問題(e.ContextはParsing | Commit)に今ぶちあたったので、非常に参考になりました。

VC#2008での状況なのと後、DataGridView初心者なので、何か手違いがあるかもしれませんが、

DataGridViewComboBoxColumnの
・DisplayMemberにオブジェクトの文字列表現を返すプロパティを設定
・ValueMemberにそのオブジェクトのインスタンス自身を返すプロパティを設定
してやるだけで、エラーは発生しなくなりました。
まだ、検証が十分ではありませんが、このやり方でOKなら、TypeConverterやオーバーライドはいらないかもしれません。
Posted by よねけん at 2008年05月10日 03:28
4年も前のことなのでいまさらなんですが、統合環境からやってみたんですが、DataGridViewComboBoxColumnのAutoCompleteがtrueのときにエラーが出るみたいです。これをfalseにしたらエラーが出なくなりました。
Posted by ごーまんさん at 2010年10月02日 19:11
コメントを書く
お名前:

メールアドレス:

ホームページアドレス:

コメント:

認証コード: [必須入力]


※画像の中の文字を半角で入力してください。

この記事へのトラックバック

ここ(hongliang.seesaa.net)で公開しているものについて、利用は自由に行って頂いて構いません。改変、再頒布もお好きになさって下さい。利用に対しこちらが何かを要求することはありません。

ただし、公開するものを使用、または参考したことによって何らかの損害等が生じた場合でも、私はいかなる責任も負いません。

あ、こんなのに使ったってコメントを頂ければ嬉しいです。

×

この広告は1年以上新しい記事の投稿がないブログに表示されております。