※本ページには広告・PR が含まれます。

DataGridView で List<T> をソートする(SortableBindingList の実装)

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

DataGridViewDataTable をバインドしたときは列ヘッダーをクリックするだけで並べ替えができるのに、List<T>BindingList<T> をバインドするとヘッダーをクリックしても何も起きない。この非対称性に戸惑ったことはないでしょうか。

これは不具合ではなく、DataGridView の仕様どおりの動作です。原因はグリッド側ではなくバインドしたリスト側にあります。

この記事では、なぜソートされないのかを内部の仕組みから説明したうえで、ソートグリフ(ヘッダーの▲▼)の表示まで含めて動作する SortableBindingList<T> を実装します。あわせて、実装後に必ず遭遇する副作用と、その回避策も扱います。

実装イメージ

BindingList<T> を継承して4つのメンバーをオーバーライドすることで解決できます。

public class SortableBindingList<T> : BindingList<T>
{
protected override bool SupportsSortingCore => true;
protected override bool IsSortedCore => _isSorted;
protected override PropertyDescriptor SortPropertyCore => _sortProperty;
protected override ListSortDirection SortDirectionCore => _sortDirection;
// ApplySortCore / RemoveSortCore は後述
}

使い方は既存のリストを差し替えるだけです。

var products = productRepository.GetAll(); // List<Product>
bindingSource1.DataSource = new SortableBindingList<Product>(products);
dataGridView1.DataSource = bindingSource1;

以下、なぜこれで動くのかを順に見ていきます。

なぜ List<T> バインドではソートできないのか

DataGridView が見ているのは IBindingList.SupportsSorting

DataGridView はデータバインドされている状態では、自前で並べ替えを行いません。並べ替えの実処理はデータソース側に委譲されます。

ヘッダーがクリックされたとき、DataGridView はおおむね次の判断をしています。

  1. データソースは IBindingList を実装しているか
  2. その SupportsSorting プロパティが true を返すか
  3. 対象列の SortModeAutomatic

この3つが揃って初めて ApplySort が呼ばれます。List<T> はそもそも IBindingList を実装していないため、1の時点で脱落します。

BindingList<T>IBindingList を実装していますが、既定の SupportsSortingCorefalse を返します。つまり2で脱落します。

// BindingList<T> の既定実装(イメージ)
protected virtual bool SupportsSortingCore => false;
// ApplySortCore も既定では例外を投げる
protected virtual void ApplySortCore(
PropertyDescriptor prop, ListSortDirection direction)
{
throw new NotSupportedException();
}

BindingList<T> は「変更通知」の機能だけを提供する最小実装であり、並べ替え機能は派生クラスに委ねる設計になっています。ヘッダーをクリックしても無反応なのは、この既定値がそのまま効いているためです。

DataTable では動く理由

DataTable をバインドすると、実際に DataGridView が受け取るのは DataTable そのものではなく DataView です。DataViewIBindingListViewIBindingList の拡張インターフェイス)を実装しており、SupportsSortingtrue を返します。

データソース IBindingList SupportsSorting ヘッダークリック
List<T> 未実装 動かない
BindingList<T> 実装 false 動かない
DataTable / DataView 実装 true 動く
SortableBindingList<T>(本記事) 実装 true 動く

DataTable なら動くのに」という体感の正体は、DataView が並べ替えを実装しているかどうかの差でしかありません。

BindingSource を挟んでも解決しない

よくある誤解として、「BindingSource を経由すればソートできるようになるのでは」というものがあります。結論としては解決しません

BindingSourceIBindingListView を実装していますが、SupportsSorting の値は内部リストへ委譲されます。内部が List<T> や既定の BindingList<T> である限り false のままです。

bindingSource1.DataSource = new List<Product>();
// false が返る
Debug.WriteLine(((IBindingList)bindingSource1).SupportsSorting);
// 例外:InvalidOperationException
bindingSource1.Sort = "Name ASC";

BindingSource.Sort に文字列を設定して InvalidOperationException が出るのも同じ理由です。並べ替えを有効にするには、内部リストそのものを差し替える必要があります。

補足:プログラムからの Sort 呼び出しも失敗する

ヘッダークリックだけでなく、コードから呼んだ場合も同様です。

// データバインド中に SupportsSorting が false だと例外
dataGridView1.Sort(dataGridView1.Columns["Name"], ListSortDirection.Ascending);
// System.InvalidOperationException:
// 'DataGridView コントロールは、データ連結されている場合、並べ替えできません。'

なお DataGridView.Sort(IComparer) というオーバーロードも存在しますが、これは非バインドモード専用です。DataSource を設定している状態では使えません。この点は後述の代替案でも触れます。

SortableBindingList<T> を実装する

全体像

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
public class SortableBindingList<T> : BindingList<T>
{
private bool _isSorted;
private PropertyDescriptor _sortProperty;
private ListSortDirection _sortDirection = ListSortDirection.Ascending;
public SortableBindingList()
{
}
public SortableBindingList(IList<T> list)
: base(new List<T>(list))
{
}
// --- 並べ替えのサポートを宣言する ---
protected override bool SupportsSortingCore => true;
protected override bool IsSortedCore => _isSorted;
protected override PropertyDescriptor SortPropertyCore => _sortProperty;
protected override ListSortDirection SortDirectionCore => _sortDirection;
// --- 並べ替えの実処理 ---
protected override void ApplySortCore(
PropertyDescriptor prop, ListSortDirection direction)
{
if (!(Items is List<T> items))
{
return;
}
var sorted = direction == ListSortDirection.Ascending
? items.OrderBy(x => prop.GetValue(x), NullSafeComparer.Instance).ToList()
: items.OrderByDescending(x => prop.GetValue(x), NullSafeComparer.Instance).ToList();
items.Clear();
items.AddRange(sorted);
_isSorted = true;
_sortProperty = prop;
_sortDirection = direction;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
protected override void RemoveSortCore()
{
_isSorted = false;
_sortProperty = null;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
// --- null と非 IComparable を安全に扱う比較子 ---
private sealed class NullSafeComparer : IComparer<object>
{
public static readonly NullSafeComparer Instance = new NullSafeComparer();
public int Compare(object x, object y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
// null は常に先頭へ寄せる
if (x == null)
{
return -1;
}
if (y == null)
{
return 1;
}
if (x is IComparable comparable)
{
return comparable.CompareTo(y);
}
// 比較不能な型は文字列表現で代替する
return string.Compare(
x.ToString(), y.ToString(), StringComparison.CurrentCulture);
}
}
}

各メンバーの役割

実装の要点は、4つのプロパティがそれぞれ別の役割を持っていることです。ここを混同すると「ソートは効くのに矢印が出ない」といった中途半端な状態になります。

メンバー 役割 省略したときの症状
SupportsSortingCore 並べ替え可能であることの宣言 そもそもソートされない
IsSortedCore 現在ソート済みかどうか 昇順・降順のトグルが不安定になる
SortPropertyCore どの列でソート中か ソートグリフが表示されない
SortDirectionCore 昇順か降順か グリフの向きが正しくならない
ApplySortCore 実際の並べ替え処理 NotSupportedException
RemoveSortCore ソート解除 NotSupportedException(呼ばれた場合)

Web 上のサンプルには SupportsSortingCoreApplySortCore だけを実装したものが少なくありませんが、それだとヘッダーの▲▼が出ませんDataGridView はグリフの描画にあたって SortPropertyCoreSortDirectionCore を参照するためです。ユーザーから見て「今どの列で並んでいるか」が分からない UI になるので、この2つは必ず実装してください。

Items を List<T> として扱う理由

BindingList<T>Collection<T> を継承しており、Items プロパティで内部リストへ直接アクセスできます。ここを経由すると ListChanged イベントが発火しないため、並べ替え中の大量の通知を抑制できます。

// NG:1件ごとに ListChanged が飛び、グリッドが激しくちらつく
for (int i = 0; i < sorted.Count; i++)
{
this[i] = sorted[i];
}
// OK:Items 経由で入れ替え、最後に Reset を1回だけ通知
items.Clear();
items.AddRange(sorted);
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));

コンストラクタで base(new List<T>(list)) としているのは、Items が確実に List<T> になるようにするためです。引数のリストをそのまま渡すと、呼び出し元のコレクションと参照を共有してしまい、並べ替えが元のリストの順序まで書き換えてしまいます。

null と非 IComparable への対応

PropertyDescriptor.GetValue の戻り値は object です。既定の Comparer<object>.Default は次の2ケースで例外になります。

  • 値が null を含む参照型プロパティ(stringint? など)
  • IComparable を実装していない独自クラス型のプロパティ

実務データでは null は普通に混ざるため、比較子を差し替えておかないと特定のデータでだけ落ちるという厄介な不具合になります。上の NullSafeComparer はこれを吸収しています。

null を末尾に寄せたい場合は、Compare の戻り値の符号を反転させてください。

安定ソートである点

OrderBy安定ソートです。同じキーを持つ要素の相対順序が保たれるため、「まず部署でソートし、次に名前でソート」といった多段ソートをユーザー操作で実現できます。

List<T>.Sort は不安定ソートなので、この挙動は得られません。パフォーマンスを理由に List<T>.Sort へ置き換える場合は、安定性が失われる点を意識してください。

DataGridView に適用する

基本形

private SortableBindingList<Product> _products;
private void LoadData()
{
_products = new SortableBindingList<Product>(productRepository.GetAll());
bindingSource1.DataSource = _products;
dataGridView1.DataSource = bindingSource1;
}

これでヘッダークリックによる並べ替えが動作します。BindingSource は必須ではありませんが、フィルタや現在位置の管理を併用するなら挟んでおくほうが扱いやすくなります。

SortMode の確認

自動生成された列の SortMode は既定で Automatic ですが、デザイナで列を手動追加した場合や、DataGridViewImageColumn などでは NotSortable になっていることがあります。

foreach (DataGridViewColumn column in dataGridView1.Columns)
{
column.SortMode = DataGridViewColumnSortMode.Automatic;
}

Programmatic に設定されている列は、ヘッダーをクリックしても並べ替えは走りません(グリフの表示のみ可能)。「一部の列だけソートできない」という場合はここを疑ってください。

プログラムから並べ替える

初期表示時に既定のソート順を適用したいケースです。

dataGridView1.Sort(
dataGridView1.Columns[nameof(Product.Name)],
ListSortDirection.Ascending);

BindingSource 経由でも指定できます。

bindingSource1.Sort = "Name ASC";

どちらも最終的には ApplySortCore を呼び出します。

このアプローチの副作用

選択行とスクロール位置が失われる

ApplySortCore の末尾で発行している ListChangedType.Reset は、「リスト全体が変わったので作り直せ」という通知です。これを受けた DataGridView は行を再生成するため、選択状態・現在セル・スクロール位置がすべてリセットされます。

ユーザーが選択していた行をソート後も追い続けたい場合は、ソートの前後で状態を退避・復元します。

private void SortWithSelectionKept(string columnName, ListSortDirection direction)
{
// ソート前にキーを退避
var current = bindingSource1.CurrentOrDefault<Product>();
var keptId = current?.Id;
dataGridView1.Sort(dataGridView1.Columns[columnName], direction);
if (keptId == null)
{
return;
}
// ソート後にキーで探し直す
var index = _products
.Select((item, i) => new { item, i })
.FirstOrDefault(x => x.item.Id == keptId.Value)?.i ?? -1;
if (index >= 0)
{
bindingSource1.Position = index;
}
}

行インデックスを退避しても意味がない点に注意してください。並べ替えによって同じインデックスが別の行を指すようになるため、必ず主キーなどの識別子で復元します。

ソート直後の Current 参照で例外が出る

Reset 通知の直後は BindingSource.Position が一時的に -1 になることがあります。この状態で Current を参照すると IndexOutOfRangeException(インデックス -1 には値がありません)が発生します。

上のコードで CurrentOrDefault を使っているのはこのためです。実装と背景は次の記事にまとめています。

SelectionChangedCurrentCellChanged のハンドラを持っているフォームでは、ソートを実装した途端にこの例外が顕在化することがよくあります。ソート機能とセットで防御的アクセサを導入しておくのが安全です。

RemoveSortCore では元の順序に戻らない

上の実装の RemoveSortCore は、フラグを落として Reset を通知するだけです。ApplySortCoreItems を破壊的に並べ替えているため、バインド時の順序は既に失われています

「ソート解除で元の並びに戻す」機能が必要なら、初期状態のスナップショットを保持してください。

private readonly List<T> _originalOrder;
public SortableBindingList(IList<T> list) : base(new List<T>(list))
{
_originalOrder = new List<T>(list);
}
protected override void RemoveSortCore()
{
if (Items is List<T> items)
{
items.Clear();
items.AddRange(_originalOrder);
}
_isSorted = false;
_sortProperty = null;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}

ただし要素の追加・削除が行われると、このスナップショットは実データと乖離します。編集可能なグリッドで厳密に運用するなら、ソート用のキー列(表示しない連番列)を持たせて、そこでの昇順を「元の順序」と定義するほうが破綻しません。

なお DataGridView のヘッダークリックは昇順と降順のトグルのみで、ソート解除は呼ばれませんRemoveSortCore が必要になるのは、コードから明示的に解除する場合に限られます。

パフォーマンスの考え方

ボトルネックは PropertyDescriptor.GetValue

この実装で最も重いのは、比較のたびに呼ばれる prop.GetValue(x) です。内部でリフレクションが走るため、要素数が増えると計算量以上にコストが効いてきます。

自分の環境で影響を確認するには、次のように計測してください。

var sw = Stopwatch.StartNew();
dataGridView1.Sort(dataGridView1.Columns["Name"], ListSortDirection.Ascending);
sw.Stop();
Debug.WriteLine($"sort: {sw.ElapsedMilliseconds} ms");

体感として問題になりやすいのは、ソート処理そのものより Reset 後の行再生成です。数千行を超えたあたりから、並べ替えよりも描画のほうが支配的になります。まず切り分けてから最適化対象を決めてください。

改善の方向性

施策 効果 コスト
SuspendLayout / ResumeLayout で囲む 再描画の抑制
DoubleBuffered を有効化 ちらつき低減
プロパティアクセスを式ツリーでコンパイル リフレクション排除
Comparison<T> を外部から注入 型安全+高速
仮想モードへ移行 大量行の描画回避

型ごとに専用の比較を渡せるようにしておくと、汎用性を保ちながら高速化できます。

public Comparison<T> CustomComparison { get; set; }
protected override void ApplySortCore(
PropertyDescriptor prop, ListSortDirection direction)
{
if (!(Items is List<T> items))
{
return;
}
List<T> sorted;
if (CustomComparison != null)
{
sorted = new List<T>(items);
sorted.Sort(CustomComparison);
if (direction == ListSortDirection.Descending)
{
sorted.Reverse();
}
}
else
{
sorted = direction == ListSortDirection.Ascending
? items.OrderBy(x => prop.GetValue(x), NullSafeComparer.Instance).ToList()
: items.OrderByDescending(x => prop.GetValue(x), NullSafeComparer.Instance).ToList();
}
items.Clear();
items.AddRange(sorted);
_isSorted = true;
_sortProperty = prop;
_sortDirection = direction;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}

行数がさらに増える場合は、そもそもクライアント側で全件を持つ設計を見直し、SQL の ORDER BY に寄せるか仮想モードを検討する段階です。

代替案の比較

SortableBindingList<T> が常に最適解とは限りません。要件によっては別の選択肢のほうが総コストは低くなります。

方式 実装コスト 双方向バインド フィルタ 向いているケース
SortableBindingList<T> × オブジェクトのまま扱いたい標準的なケース
DataTable に切り替える 表形式データが中心。型安全性は諦める
LINQ でソートして再バインド 読み取り専用の一覧表示
BindingListView 系ライブラリ ソートとフィルタを両方求める場合
非バインドモード+Sort(IComparer) × × 特殊な並べ替えロジックが必要な場合

DataTable への切り替え

DataTable なら DataView.SortDataView.RowFilter が最初から使えます。「ソートもフィルタも欲しい」という要件であれば、SortableBindingList<T> を自作するよりコストが低いことは珍しくありません。

両者の性質の違いは次の記事で比較しています。

LINQ でソートして再バインド

最も単純な方法です。

private void DataGridView1_ColumnHeaderMouseClick(
object sender, DataGridViewCellMouseEventArgs e)
{
_ascending = !_ascending;
var sorted = _ascending
? _products.OrderBy(p => p.Name).ToList()
: _products.OrderByDescending(p => p.Name).ToList();
bindingSource1.DataSource = new BindingList<Product>(sorted);
}

実装は楽ですが、ソートグリフが表示されない、列ごとの分岐を自前で書く必要がある、DataSource の差し替えで状態が完全にリセットされる、といった難点があります。読み取り専用の一覧なら十分に実用的です。

フィルタも必要なら IBindingListView

SortableBindingList<T>IBindingList の範囲での実装なので、BindingSource.Filter は使えません。フィルタまで求めるなら IBindingListView を実装するか、同等機能を持つライブラリの導入を検討してください。自作する場合の工数は、ソート単独の数倍になります。

まとめ

  • DataGridView はバインド時、並べ替えをデータソースに委譲する。動くかどうかは IBindingList.SupportsSorting 次第
  • BindingList<T> の既定値は falseBindingSource を挟んでも解決しない
  • SupportsSortingCore / IsSortedCore / SortPropertyCore / SortDirectionCore / ApplySortCore の5つを実装する。後者2つを省くとソートグリフが出ない
  • PropertyDescriptor.GetValue の戻り値は objectnull と非 IComparable に備えた比較子が必須
  • ListChangedType.Reset により選択行・スクロール位置は失われる。復元は行インデックスではなく主キーで行う
  • ソート直後は Position-1 になり得るため、Current の直接参照は避ける
  • ソートとフィルタの両方が必要なら、DataTableIBindingListView 実装を先に検討したほうが安い

関連記事


💬 コメント