DataGridView のコンボボックス列で直接入力を許可する方法(サンプルあり)

📋 目次(クリックで展開)

DataGridView でコンボボックス列(DataGridViewComboBoxColumn)を使用する際、選択肢(ドロップダウンリスト)から値を選ばせるだけでなく、リストにない新しい値をユーザーがテキストとして直接入力できるようにしたい場合があります。

標準のコンボボックス列はリストからの選択のみを許可する状態(DropDownList 形式)になっているため、自由な入力を可能にするには編集時のコントロールやデータ検証時のイベントを制御する処理が必要です。

今回は、コンボボックス列で直接入力を可能にし、入力された値をクリアさせずにセルの選択値として維持する実装方法について解説します。日本語IMEによる入力や、新規追加行への入力にも対応した実装を紹介します。

コンボボックスで直接入力を許可する仕組み

DataGridView のコンボボックス列が編集状態になると、セルの内部には一時的に ComboBox コントロールが配置されます。

この編集用コンボボックスが表示される瞬間に発生するのが EditingControlShowing イベントです。このイベントハンドラ内で内部のコンボボックスを捕捉し、DropDownStyle プロパティを ComboBoxStyle.DropDown に変更することで、テキストの直接入力が可能になります。

ただし、自由に入力させただけでは、編集を終了しようとした瞬間に DataGridView の内部検証によって「選択肢にない不正な値」とみなされ、入力した文字が消去されてしまいます。そのため、値の検証が行われるタイミング(CellValidating)を捉え、入力された文字列を選択肢のリストへ動的に追加するとともに、コンボボックスの選択状態を再設定する処理を実装します。

注意点①:BindingListDataSource に使わない

直感的には BindingList<string>comboCol.DataSource に設定し、新規入力時に _categories.Add(newValue) すれば選択肢が増える、と考えたくなります。しかしこの設計には問題があります。

BindingList に項目を追加すると ListChanged イベントが発火し、バインドされているコンボボックスコントロール側でデータソースの再読み込みが走ります。この再読み込みにより選択インデックスが -1 にリセットされ、その後の SelectedIndex の設定がコントロール側の Items 件数と食い違うと ArgumentOutOfRangeException が発生します。

RaiseListChangedEvents = false で通知を抑制する方法も考えられますが、今度は Items への反映が行われなくなり、インデックス計算がずれて同様の例外を招きます。

根本的な解決策は、DataSource を使わず、コンボボックスの Items を直接操作することです。DataGridViewComboBoxColumn.Items に初期値を登録し、新規入力時は編集用コンボボックスの comboBox.Items.Add() で直接追加します。これにより ListChanged イベントは発火せず、インデックスのずれも起きません。

注意点②:IME確定のEnterでセル値が空になる

英数字の直接入力では問題なく動作しても、日本語IMEで文字を入力し、未確定状態でEnterを押して確定すると、セルの値が空になるという問題が発生します。

原因はIMEとDataGridViewのEnterキーの競合です。「あいう」を入力中にEnterを押すと、以下の順序でイベントが連鎖します。

  1. KeyDownKeys.Return)が発火
  2. DataGridView がEnterを「セル編集終了」と解釈して LeaveCellValidating を呼び出す
  3. その後、IMEが文字を確定して TextChanged を発火させる

つまり、LeaveCellValidating が走る時点では comboBox.Text がまだ**IME確定前の状態(空または未確定文字列)**になっており、正しい値を取得できません。

この問題への対策は2つあります。

対策A:KeyDown で IME 変換中の Enter を握りつぶす
Win32 API の ImmGetCompositionString を使って「現在 IME の未確定文字列が存在するか」を判定し、変換中であれば Enter キーの DataGridView への伝播を止めます。これによりセル編集終了のトリガーが抑止され、ユーザーが改めて Enter を押したとき(今度は未確定文字列がない状態)に正常に確定されます。

対策B:TextChanged で確定済み値を随時記録する
対策Aだけでは、IME確定後にマウスで別のセルをクリックした場合(Enterを押さずにフォーカスを移動)に対応できません。TextChanged イベントでIME変換中でないタイミングのみ _pendingInputValue に記録し、LeaveCellValidating ではそちらを参照することで、あらゆる操作パターンに対応します。

注意点③:新規追加行で入力値が表示されない

既存行では正しく値が保持されても、DataGridView の末尾にある新規追加行(* 行)にコンボボックスで値を入力すると、セルが空のまま表示されるという問題が起きます。

原因は既存行と新規行でのコミット処理の違いにあります。既存行の場合、CellValidating での SelectedIndex 設定が DataTable に即座に反映されます。一方、新規行の場合は CellValidating の後に DataGridView が DataTable.Rows.Add() を呼び出してデータを確定させますが、このタイミングで DataGridView が参照する編集コントロールの値はすでに空になっているため、空文字が DataTable に書き込まれてしまいます。

修正策は CellEndEdit イベントで DataTable の該当セルに直接書き込むことです。CellEndEdit は編集が完了してデータがコミットされた直後に発火します。ここで _pendingInputValue を参照してセルの値を上書きすることで、新規行でも正しく値が反映されます。

実装コード

以下は、DataTable をデータソースとしてバインドし、「Category」列のコンボボックスにおいて、既存の選択肢(「食品」「家電」など)の選択だけでなく、日本語を含む新しいカテゴリ名を直接入力して確定できるようにするコード例です。

Form1.cs

Form1.cs
public partial class Form1 : Form
{
private DataTable _productsTable = new DataTable();
// 選択肢は DataGridViewComboBoxColumn.Items で一元管理する
// BindingList を DataSource に設定すると ListChanged 発火時に
// 編集コントロールがリセットされ ArgumentOutOfRangeException の原因となるため使用しない
private readonly List<string> _categories = new List<string> { "食品", "家電", "雑貨" };
// IME確定Enter問題の対策:確定済みの入力値を別途保持する
// Leave や CellValidating が走るタイミングでは comboBox.Text が
// 空になっている場合があるため、TextChanged で随時記録しておく
private string _pendingInputValue = "";
// IME変換中かどうかを判定するための Win32 API
[DllImport("imm32.dll")]
private static extern IntPtr ImmGetContext(IntPtr hWnd);
[DllImport("imm32.dll")]
private static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC);
[DllImport("imm32.dll", CharSet = CharSet.Unicode)]
private static extern int ImmGetCompositionString(IntPtr hIMC, uint dwIndex, StringBuilder lpBuf, uint dwBufLen);
// GCS_COMPSTR:現在の未確定文字列を取得するフラグ
private const uint GCS_COMPSTR = 0x0008;
public Form1()
{
InitializeComponent();
InitializeDataGridView();
}
private void InitializeDataGridView()
{
// 1. メインのデータテーブルを準備
_productsTable.Columns.Add("Name", typeof(string));
_productsTable.Columns.Add("Category", typeof(string));
_productsTable.Rows.Add("ノートパソコン", "家電");
_productsTable.Rows.Add("ミネラルウォーター", "食品");
// 2. コンボボックス列を手動で作成して追加
var comboCol = new DataGridViewComboBoxColumn();
comboCol.DataPropertyName = "Category";
comboCol.HeaderText = "カテゴリ";
comboCol.Name = "CategoryCombo";
// DataSource ではなく Items に直接追加する
// DataSource (BindingList等) を使うと Add() 時に ListChanged が発火し、
// 編集コントロールの Items が再構築されて SelectedIndex がリセットされる
comboCol.Items.AddRange(_categories.ToArray());
// グリッドに列を追加してデータソースを設定
dataGridView1.Columns.Add(comboCol);
dataGridView1.DataSource = _productsTable;
dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
// 3. 必要なイベントを購読
dataGridView1.EditingControlShowing += DataGridView1_EditingControlShowing;
dataGridView1.CellValidating += DataGridView1_CellValidating;
dataGridView1.CellEndEdit += DataGridView1_CellEndEdit;
dataGridView1.DataError += DataGridView1_DataError;
}
/// <summary>
/// セルが編集状態になったときに発生するイベント
/// </summary>
private void DataGridView1_EditingControlShowing(object? sender, DataGridViewEditingControlShowingEventArgs e)
{
if (e.Control is ComboBox comboBox)
{
string columnName = dataGridView1.Columns[dataGridView1.CurrentCell.ColumnIndex].Name;
if (columnName == "CategoryCombo")
{
comboBox.DropDownStyle = ComboBoxStyle.DropDown;
// イベントの重複購読を防ぐため、一度解除してから再購読する
comboBox.Leave -= CategoryComboBox_Leave;
comboBox.TextChanged -= CategoryComboBox_TextChanged;
comboBox.KeyDown -= CategoryComboBox_KeyDown;
comboBox.Leave += CategoryComboBox_Leave;
comboBox.TextChanged += CategoryComboBox_TextChanged;
comboBox.KeyDown += CategoryComboBox_KeyDown;
// 編集開始時に既存のセル値で初期化する
_pendingInputValue = comboBox.Text;
}
else
{
comboBox.DropDownStyle = ComboBoxStyle.DropDownList;
comboBox.Leave -= CategoryComboBox_Leave;
comboBox.TextChanged -= CategoryComboBox_TextChanged;
comboBox.KeyDown -= CategoryComboBox_KeyDown;
}
}
}
/// <summary>
/// テキストが変化するたびに呼ばれる。
/// IME変換中でない(未確定文字列がない)タイミングのみ _pendingInputValue に記録する。
/// これにより、Leave や CellValidating が走った時点で comboBox.Text が空でも
/// 確定済みの正しい値を参照できる。
/// </summary>
private void CategoryComboBox_TextChanged(object? sender, EventArgs e)
{
if (sender is not ComboBox comboBox) return;
if (!IsImeComposing(comboBox.Handle))
{
_pendingInputValue = comboBox.Text;
}
}
/// <summary>
/// Enter キー押下時に IME 変換中であれば DataGridView へのキー伝播を止める。
/// これにより、IME確定のEnterがセル編集終了として扱われることを防ぐ。
/// ユーザーが IME 確定後に改めて Enter を押すと、正常にセル編集が終了する。
/// </summary>
private void CategoryComboBox_KeyDown(object? sender, KeyEventArgs e)
{
if (sender is not ComboBox comboBox) return;
if (e.KeyCode == Keys.Return && IsImeComposing(comboBox.Handle))
{
// IME変換中の Enter はセル移動に使わせない
e.Handled = true;
e.SuppressKeyPress = true;
}
}
/// <summary>
/// IME変換中かどうかを Win32 API で判定する。
/// ImmGetCompositionString で未確定文字列(GCS_COMPSTR)の長さが 0 より大きければ変換中。
/// </summary>
private static bool IsImeComposing(IntPtr hWnd)
{
IntPtr hIMC = ImmGetContext(hWnd);
if (hIMC == IntPtr.Zero) return false;
try
{
var sb = new StringBuilder(256);
int len = ImmGetCompositionString(hIMC, GCS_COMPSTR, sb, (uint)sb.Capacity);
return len > 0;
}
finally
{
ImmReleaseContext(hWnd, hIMC);
}
}
/// <summary>
/// 直接入力を許可したコンボボックスからフォーカスが離れる直前に発生するイベント。
/// CellValidating より前のタイミングで値をリストへ追加しておくことで、
/// DataGridView の内部検証(「選択肢にない値」エラー)を回避する。
/// comboBox.Text ではなく _pendingInputValue を参照する点がポイント。
/// </summary>
private void CategoryComboBox_Leave(object? sender, EventArgs e)
{
if (sender is not ComboBox comboBox) return;
// comboBox.Text ではなく _pendingInputValue を使用する
// IME確定直後に Leave が走った場合、comboBox.Text が空になることがある
string inputValue = _pendingInputValue;
if (string.IsNullOrEmpty(inputValue)) return;
if (!comboBox.Items.Contains(inputValue))
{
comboBox.Items.Add(inputValue);
if (!_categories.Contains(inputValue))
{
_categories.Add(inputValue);
// DataGridViewComboBoxColumn.Items にも追加して
// 次回以降の編集時にドロップダウンに表示されるようにする
var col = dataGridView1.Columns["CategoryCombo"] as DataGridViewComboBoxColumn;
col?.Items.Add(inputValue);
}
}
comboBox.SelectedItem = inputValue;
}
/// <summary>
/// セルの値が検証されるときに発生するイベント
/// </summary>
private void DataGridView1_CellValidating(object? sender, DataGridViewCellValidatingEventArgs e)
{
if (dataGridView1.Columns[e.ColumnIndex].Name == "CategoryCombo")
{
// e.FormattedValue は IME確定直後に空になることがあるため信頼しない
// Leave で記録済みの _pendingInputValue を優先して参照する
string newValue = !string.IsNullOrEmpty(_pendingInputValue)
? _pendingInputValue
: e.FormattedValue?.ToString() ?? "";
// Leave で未処理だった場合の保険として Items に追加する
if (!string.IsNullOrEmpty(newValue)
&& dataGridView1.EditingControl is ComboBox comboBox
&& !comboBox.Items.Contains(newValue))
{
comboBox.Items.Add(newValue);
if (!_categories.Contains(newValue))
{
_categories.Add(newValue);
var col = dataGridView1.Columns["CategoryCombo"] as DataGridViewComboBoxColumn;
col?.Items.Add(newValue);
}
}
// SelectedIndex を再設定して値を確定させる
if (dataGridView1.EditingControl is ComboBox cb)
{
int index = cb.Items.IndexOf(newValue);
if (index >= 0)
{
cb.SelectedIndex = index;
}
}
}
}
/// <summary>
/// セルの編集が完了し、値がコミットされた直後に発生するイベント。
/// 新規追加行(IsNewRow)の場合、CellValidating での SelectedIndex 設定が
/// DataTable.Rows.Add() のタイミングで空文字に上書きされてしまうため、
/// CellEndEdit で DataTable の該当セルに直接 _pendingInputValue を書き込む。
/// </summary>
private void DataGridView1_CellEndEdit(object? sender, DataGridViewCellEventArgs e)
{
if (dataGridView1.Columns[e.ColumnIndex].Name != "CategoryCombo") return;
if (string.IsNullOrEmpty(_pendingInputValue)) return;
// 新規行・既存行を問わず、DataTable の該当セルを _pendingInputValue で上書きする
// 新規行の場合は DataTable.Rows.Add() 後に空が書き込まれることへの対策
// 既存行の場合も、CellValidating での設定を確実に永続化するための保険となる
DataGridViewRow row = dataGridView1.Rows[e.RowIndex];
if (row.DataBoundItem is DataRowView drv)
{
string colName = ((DataGridViewComboBoxColumn)dataGridView1.Columns[e.ColumnIndex]).DataPropertyName;
if (drv[colName]?.ToString() != _pendingInputValue)
{
drv[colName] = _pendingInputValue;
}
}
}
/// <summary>
/// データエラーが発生したときに呼び出されるイベント
/// </summary>
private void DataGridView1_DataError(object? sender, DataGridViewDataErrorEventArgs e)
{
// 予期せぬ整合性エラーが発生した場合に、標準のエラーダイアログが表示されるのを防ぐ
if (dataGridView1.Columns[e.ColumnIndex].Name == "CategoryCombo")
{
e.ThrowException = false;
e.Cancel = false;
}
}
}

サンプルアプリケーション

DataGridView_ComboBoxColumn_Input.zip
※ サンプルアプリケーションは、.NET 10 をターゲットに作成しています。

留意すべき注意点と実装の背景

DataSource ではなく Items で選択肢を管理する

DataGridViewComboBoxColumnBindingList などを DataSource として設定すると、Add() のたびに ListChanged が発火し、編集コントロール内の Items が再構築されます。この再構築で SelectedIndex-1 にリセットされ、直後に範囲外のインデックスを設定しようとして ArgumentOutOfRangeException が発生します。

DataSource を使わず Items.AddRange() で初期値を登録し、新規入力時は comboBox.Items.Add() で直接追加する設計にすれば、この問題は根本から発生しません。

// ❌ DataSource 経由:Add() のたびに ListChanged が発火してリセットされる
comboCol.DataSource = _categories; // BindingList<string>
// ✅ Items 直接管理:ListChanged は発火しない
comboCol.Items.AddRange(_categories.ToArray());

② IME確定Enter対策:TextChanged + KeyDown の組み合わせ

日本語IMEで文字を変換中にEnterを押すと、DataGridView がそのEnterを「セル編集終了」と解釈し、IME確定より先に LeaveCellValidating を実行します。このタイミングでは comboBox.Text がまだ空のため、値が失われます。
KeyDown で変換中の Enter を抑止し(対策A)、TextChanged で確定済み値を _pendingInputValue に先行記録する(対策B)ことで対応します。

// 対策A:変換中のEnterを握りつぶす
if (e.KeyCode == Keys.Return && IsImeComposing(comboBox.Handle))
{
e.Handled = true;
e.SuppressKeyPress = true;
}
// 対策B:確定済み値を記録する
if (!IsImeComposing(comboBox.Handle))
{
_pendingInputValue = comboBox.Text;
}

③ 新規追加行への対応:CellEndEdit で DataTable に直接書き込む

新規行(* 行)にコンボボックスで値を入力した場合、CellValidating での SelectedIndex 設定が DataTable へのコミット処理で空文字に上書きされてしまいます。これは DataGridView が新規行をコミットする際、編集コントロールから値を再取得する時点ですでにコントロールが空になっているためです。

CellEndEdit はコミット完了直後に発火するため、ここで row.DataBoundItemDataRowView)を通じて DataTable のセルに _pendingInputValue を直接書き込むことで確実に値を反映させます。

private void DataGridView1_CellEndEdit(object? sender, DataGridViewCellEventArgs e)
{
if (dataGridView1.Columns[e.ColumnIndex].Name != "CategoryCombo") return;
if (string.IsNullOrEmpty(_pendingInputValue)) return;
DataGridViewRow row = dataGridView1.Rows[e.RowIndex];
if (row.DataBoundItem is DataRowView drv)
{
string colName = ((DataGridViewComboBoxColumn)dataGridView1.Columns[e.ColumnIndex]).DataPropertyName;
if (drv[colName]?.ToString() != _pendingInputValue)
{
drv[colName] = _pendingInputValue;
}
}
}

既存行に対しても CellEndEdit は発火しますが、drv[colName] がすでに正しい値であれば書き込みをスキップするため、二重書き込みの副作用はありません。

LeaveCellValidating の二段構え

フォーカスが離れる順序は「LeaveCellValidating」です。Leave のタイミングで comboBox.Items に追加しておくことで、CellValidating 時の DataGridView 内部検証が「リストに存在する値」として通過します。CellValidating 側はあくまで保険として残します。

DataGridViewComboBoxColumn.Items への同期

comboBox.Items.Add() で追加した値は、あくまでその編集セッション中の編集コントロールにのみ反映されます。次回同じ列を編集したときのドロップダウンに新しい値を表示するには、DataGridViewComboBoxColumn.Items にも追加する必要があります。

⑥ イベントハンドラの重複購読を防ぐ

EditingControlShowing はセルを編集するたびに発火します。Leave / TextChanged / KeyDown を購読する場合は「外してから付け直す」パターンにより、常にハンドラが1件だけ登録された状態を保ちます。

⑦ 他のコンボボックス列への影響

DataGridView 内の編集用コントロールは同じインスタンスが使い回されるため、一つの列で変更した DropDownStyle が他の列にも引き継がれます。else 句で DropDownList に戻すとともに各ハンドラも解除します。

まとめ

今回は、DataGridView のコンボボックス列にユーザーが文字列を直接入力できるようにする方法について、日本語IMEへの対応や新規追加行への対応も含めて解説しました。

ポイント 対応策
直接入力の許可 EditingControlShowingDropDownStyleDropDown に変更
選択肢管理の方針 DataSource ではなく Items を直接操作(ListChanged 問題を根絶)
新規値の追加 LeavecomboBox.Items.Add()DataGridViewComboBoxColumn.Items にも同期
IME確定Enterの抑止 KeyDownImmGetCompositionString を使い変換中のEnterを握りつぶす
IME確定後の値保持 TextChanged で変換中でないタイミングのみ _pendingInputValue に記録
新規追加行の値反映 CellEndEditDataRowView を通じて DataTable に直接書き込む
イベントの重複防止 各ハンドラを外してから付け直す
他の列への影響防止 対象外の列では DropDownList と全ハンドラ解除を明示

DataGridView のコンボボックス列での直接入力は、BindingListListChanged、IMEとEnterキーの競合、新規行のコミットタイミングという3つの落とし穴を順に対処することで、既存行・新規行・日本語入力のいずれでも安定した動作を実現できます。コンボボックスの入力制御を調整する際の参考になりましたら幸いです。


💬 コメント