WPF の DataGrid で行番号を付けたいのに、追加・削除・ソートで番号がずれて困っていませんか?この記事では、添付プロパティで「行ヘッダーに常に正しい行番号を表示する」実装を解説し、さらに区切り行(空行)では番号を出さない/区切りのたびに別番号を1から振り直す方法まで具体例付きでまとめます。
WPF の DataGrid は、業務アプリの一覧画面で非常に便利なコントロールです。一方で、「行番号(連番)」の表示は標準機能として用意されていないため、開発側で仕組みを作る必要があります。
そして実際の現場では、ただ番号を出すだけで終わらず、次のような要件が重なって一気に難しくなりがちです。
- 別ウィンドウで入力したデータが DataGrid に行として追加される
- 行が追加されたら自動で行番号を振りたい
- 削除・並べ替え(ソート)後も、番号がずれずに常に正しい番号にしたい
- 区切り行(空行のような行)を挿入できるようにしたいが、その行には行番号を表示したくない
- 区切り行を挟んだ場合、別の列(例:No2)の番号は 1 からカウントし直したい
WPF DataGrid の行番号がずれる「根本原因」
行番号がずれる最大の原因は、DataGrid が内部的に 仮想化(Virtualization) を行うことが多い点です。仮想化が有効だと、画面に見えている行(DataGridRow)だけが生成・再利用されます。
つまり、
- ItemsSource(データ) は「追加・削除・並べ替え」を受けて変化する
- DataGridRow(見た目の行) は「表示されるタイミング」で生成/再利用される
この2つのタイミングがズレるため、「一度だけ番号を埋める」実装はほぼ確実に破綻します。
| ありがちな実装 | 破綻する理由 |
|---|---|
| 追加時に「最後の番号+1」を入れる | 削除・ソートで順番が変わると、再計算できずにずれる |
| 初期表示時に全行へ番号を入れる | 後から追加・削除された行に追随しない |
| DataGridRow を保持して番号を更新する | 仮想化で行コンテナが再利用され、参照が別行を指すようになる |
結論:行番号は「表示順のインデックス」を行ヘッダーに出すのが最も堅牢
堅牢にするコツは、行番号をデータに固定値として持たせるのではなく、いま DataGrid に表示されている順番(= インデックス)を使うことです。
DataGrid には左端に「行ヘッダー(RowHeader)」があります。ここへ DataGridRow.GetIndex() を使って番号を表示すると、追加・削除・ソート後でも「表示順に一致する番号」を作れます。
更新のカギになるイベント
| イベント | いつ呼ばれるか | やること |
|---|---|---|
| LoadingRow | 行(DataGridRow)が生成/再利用されるタイミング | その行のヘッダーに番号をセットする(仮想化に強い) |
| ItemContainerGenerator.ItemsChanged | 追加・削除・並べ替えなどでコンテナ構造が変わるタイミング | 表示中の行ヘッダーをまとめて振り直す |
| Sorting | ユーザーが列ヘッダークリックでソートしたとき | ソート適用後に番号を振り直す(遅延更新が安定) |
実装:添付プロパティで「行番号表示の振る舞い」を DataGrid に追加する
ここからが実装本体です。添付プロパティを使うと、XAML 側に 1 行足すだけで DataGrid に行番号機能を後付けできます。
実装方針はシンプルです。
local:DataGridBehavior.DisplayRowNumber="True"が指定されたらイベントをフックするLoadingRowでその行のGetIndex() + 1を行ヘッダーに表示する(ユーザー向けは 1 始まり)- 追加・削除・ソート等が起きたら、表示中の行ヘッダーを再計算する
XAML 側(使い方)
<DataGrid
x:Name="dg1"
ItemsSource="{Binding Infos}"
HeadersVisibility="All"
RowHeaderWidth="50"
local:DataGridBehavior.DisplayRowNumber="True">
<!-- 通常の列定義 -->
C# 側(DataGridBehavior)
添付プロパティは「有効化したときにイベント購読」「無効化したときにイベント解除」を確実に行うのがコツです。解除できるように、イベントハンドラは DataGrid ごとに保持します。
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Threading;
public static class DataGridBehavior
{
public static readonly DependencyProperty DisplayRowNumberProperty =
DependencyProperty.RegisterAttached(
"DisplayRowNumber",
typeof(bool),
typeof(DataGridBehavior),
new PropertyMetadata(false, OnDisplayRowNumberChanged));
public static void SetDisplayRowNumber(DependencyObject element, bool value)
{
element.SetValue(DisplayRowNumberProperty, value);
}
public static bool GetDisplayRowNumber(DependencyObject element)
{
return (bool)element.GetValue(DisplayRowNumberProperty);
}
// イベント解除に必要なハンドラを保持
private class RowNumberHandlers
{
public DataGridRowEventHandler LoadingRowHandler;
public ItemsChangedEventHandler ItemsChangedHandler;
public DataGridSortingEventHandler SortingHandler;
}
private static readonly DependencyProperty RowNumberHandlersProperty =
DependencyProperty.RegisterAttached(
"RowNumberHandlers",
typeof(RowNumberHandlers),
typeof(DataGridBehavior),
new PropertyMetadata(null));
private static void SetRowNumberHandlers(DependencyObject element, RowNumberHandlers value)
{
element.SetValue(RowNumberHandlersProperty, value);
}
private static RowNumberHandlers GetRowNumberHandlers(DependencyObject element)
{
return (RowNumberHandlers)element.GetValue(RowNumberHandlersProperty);
}
private static void OnDisplayRowNumberChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var dg = d as DataGrid;
if (dg == null) return;
var enabled = (bool)e.NewValue;
if (enabled)
{
var handlers = new RowNumberHandlers();
handlers.LoadingRowHandler = (sender, args) =>
{
UpdateRowHeader(args.Row);
};
handlers.ItemsChangedHandler = (sender, args) =>
{
RefreshRowHeaders(dg);
};
handlers.SortingHandler = (sender, args) =>
{
// ソート直後はインデックスが確定していない場合があるので遅延更新
dg.Dispatcher.BeginInvoke(
new Action(() => RefreshRowHeaders(dg)),
DispatcherPriority.Background);
};
SetRowNumberHandlers(dg, handlers);
dg.LoadingRow += handlers.LoadingRowHandler;
dg.ItemContainerGenerator.ItemsChanged += handlers.ItemsChangedHandler;
dg.Sorting += handlers.SortingHandler;
// 初回表示にも反映
dg.Dispatcher.BeginInvoke(
new Action(() => RefreshRowHeaders(dg)),
DispatcherPriority.Loaded);
}
else
{
var handlers = GetRowNumberHandlers(dg);
if (handlers == null) return;
dg.LoadingRow -= handlers.LoadingRowHandler;
dg.ItemContainerGenerator.ItemsChanged -= handlers.ItemsChangedHandler;
dg.Sorting -= handlers.SortingHandler;
SetRowNumberHandlers(dg, null);
}
}
private static void UpdateRowHeader(DataGridRow row)
{
// 「新規行」用のプレースホルダーには番号を出さないことが多い
if (row.Item == CollectionView.NewItemPlaceholder)
{
row.Header = string.Empty;
return;
}
// 1始まり表示
row.Header = (row.GetIndex() + 1).ToString();
}
private static void RefreshRowHeaders(DataGrid dg)
{
// 仮想化の都合で、ここで取得できるのは「表示中の行コンテナ」が中心です。
// ただし LoadingRow でも更新するため、スクロールで後から出る行も常に正しい番号になります。
for (int i = 0; i < dg.Items.Count; i++)
{
var row = dg.ItemContainerGenerator.ContainerFromIndex(i) as DataGridRow;
if (row != null)
{
UpdateRowHeader(row);
}
}
}
}
この方式で安定して解決できること
上の添付プロパティを付けるだけで、少なくとも次の問題はまとめて解決できます。
| 操作 | 期待どおりになる理由 |
|---|---|
| 行の追加 | 新しい行は LoadingRow で必ず番号が付く |
| 行の削除 | ItemsChanged と Refresh で表示中の行番号が振り直される |
| ソート(並べ替え) | GetIndex() は「表示順」を返すため、ソート後の順番に一致する |
区切り行を挿入する:その行だけ行番号を表示しない
次に「区切り行(空行のようなもの)を挿入できる」要件です。DataGrid は “アイテム1件=行1件” の表形式なので、厳密に「列が存在しない行」を混在させることはできません。
実務で壊れにくい落としどころは、区切り行も1件のアイテムとして持ち、見た目と操作を区切り行っぽく寄せることです。典型的には IsSeparator フラグを持たせます。
モデル例:区切りフラグを持つクラス
using System.ComponentModel;
public class Info : INotifyPropertyChanged
{
private bool _isSeparator;
public bool IsSeparator
{
get { return _isSeparator; }
set
{
if (_isSeparator == value) return;
_isSeparator = value;
OnPropertyChanged(nameof(IsSeparator));
}
}
// 例:通常行のデータ
public string Name { get; set; }
public string Memo { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string propertyName)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
}
行番号を非表示にする(LoadingRow の条件分岐)
先ほどの UpdateRowHeader に条件を足します。区切り行なら空文字にするだけで OK です。
private static void UpdateRowHeader(DataGridRow row)
{
if (row.Item == CollectionView.NewItemPlaceholder)
{
row.Header = string.Empty;
return;
}
// 区切り行なら番号を出さない
var info = row.Item as Info;
if (info != null && info.IsSeparator)
{
row.Header = string.Empty;
return;
}
row.Header = (row.GetIndex() + 1).ToString();
}
区切り行を「それっぽく」見せる RowStyle
区切り行は入力・編集対象にしないことが多いので、選択やクリックを無効化し、薄い背景・高さ調整などを当てると運用が安定します。
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSeparator}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
<Setter Property="Height" Value="12"/>
<Setter Property="Background" Value="#FFEFEFEF"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
「区切り行は完全に空白にしたい」という場合は、CellStyle 側で文字色を透明にする、Template を差し替えるなども可能です。ただし、UI を作り込みすぎると保守コストが上がるため、まずは “クリック不可+薄い帯” 程度に留めるのが現場では扱いやすいです。
区切り行で No2 を 1 から振り直す:行ヘッダーではなく「番号列」を持つ
行ヘッダーの行番号(GetIndex)は「表示順の連番」なので、追加・削除・ソートに強い一方、区切りごとのリセットのようなビジネスルールを乗せると途端に複雑になります。
そのため、No2 のような番号は 行ヘッダーで頑張らず、データ側に「表示用番号プロパティ(例:DisplayNo2)」を持たせて列にバインドするのが実務的です。
XAML:No2 を列としてバインドする
<DataGridTextColumn Header="No2" Binding="{Binding DisplayNo2}" Width="60" />
ViewModel:表示順に基づいて DisplayNo2 を再計算する
ここで重要なのが、「ObservableCollection の並び」ではなく「画面(CollectionView)の並び」で番号を振ることです。DataGrid のソートやフィルタは ICollectionView 側で行われるため、表示順のソースは View になります。
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Windows.Data;
public class Info : INotifyPropertyChanged
{
private bool _isSeparator;
private int? _displayNo2;
public bool IsSeparator
{
get { return _isSeparator; }
set
{
if (_isSeparator == value) return;
_isSeparator = value;
OnPropertyChanged(nameof(IsSeparator));
}
}
public int? DisplayNo2
{
get { return _displayNo2; }
set
{
if (_displayNo2 == value) return;
_displayNo2 = value;
OnPropertyChanged(nameof(DisplayNo2));
}
}
public string Name { get; set; }
public string Memo { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string name)
{
var h = PropertyChanged;
if (h != null) h(this, new PropertyChangedEventArgs(name));
}
}
public class MainViewModel
{
public ObservableCollection<Info> Infos { get; private set; }
public MainViewModel()
{
Infos = new ObservableCollection<Info>();
Infos.CollectionChanged += Infos_CollectionChanged;
}
private void Infos_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
RecalculateNo2();
}
public void RecalculateNo2()
{
var view = CollectionViewSource.GetDefaultView(Infos);
int counter = 0;
foreach (var obj in view)
{
var item = obj as Info;
if (item == null) continue;
if (item.IsSeparator)
{
counter = 0;
item.DisplayNo2 = null;
continue;
}
counter++;
item.DisplayNo2 = counter;
}
}
}
ソート後にも No2 を振り直すには
DataGrid の列ヘッダークリックによるソートは、CollectionView の並びを変えるだけで ObservableCollection.CollectionChanged は発火しません。そのため、No2 は “追加・削除では更新されるが、ソートでは更新されない” 状態になりがちです。
対策としては、現場で選ばれやすい順に次の通りです。
- 最短で安定:DataGrid.Sorting イベントで
RecalculateNo2()を呼ぶ(Dispatcher で遅延) - MVVM を強く保つ:EventToCommand(Microsoft.Xaml.Behaviors.Wpf 等)で Sorting をコマンド化して ViewModel で再計算
- ソート自体を ViewModel 主導:SortDescriptions を ViewModel で変更し、その直後に再計算
依存を増やしたくない場合の「まず動く」例は次の通りです。
// View 側(Window / UserControl)の例
private void DataGrid_Sorting(object sender, DataGridSortingEventArgs e)
{
var vm = this.DataContext as MainViewModel;
if (vm == null) return;
// ソート適用後に番号を振り直す
Dispatcher.BeginInvoke(new Action(vm.RecalculateNo2), DispatcherPriority.Background);
}
方式の比較:行ヘッダー連番と、番号列(表示用プロパティ)
要件が増えるほど「どこに番号を持たせるか」が効いてきます。選び方の目安を表にまとめます。
| 方式 | 得意なこと | 注意点 |
|---|---|---|
| 行ヘッダー(GetIndex) | 追加・削除・ソートに追随する “表示順の連番” | 区切りごとのリセットなど複雑ルールは書きづらい |
| 番号列(DisplayNo2 をバインド) | 区切り行でリセット、非表示条件、フィルタ連動など “業務ルールの番号” | ソート/フィルタ後の再計算トリガーを用意する必要がある |
| AlternationIndex | XAML だけで簡易的な連番表示 | +1 変換が必要になりやすく、拡張は結局コードが必要になりがち |
「列の無い行」を DataGrid で表現する現実解
「区切り行は列が存在しない」という UI を理想としても、DataGrid は表形式コントロールのため、行ごとに列構造を変える設計は不得意です。実務では次のいずれかに落とすと破綻しにくいです。
| 選択肢 | 見た目 | 実装難度 | 現場での扱いやすさ |
|---|---|---|---|
| 区切り行アイテム(IsSeparator)+スタイル | 空行/帯として自然に見せられる | 低 | 高(保存・復元も単純) |
| GroupStyle(グループ見出し)を区切りとして使う | 見出し行として自然 | 中 | 条件次第(区切り=グループのとき強い) |
| RowDetails を区切り表示に使う | 折りたたみ等も可能 | 中〜高 | 要件次第(UI 仕様が複雑なら有効) |
また、SQLite などに永続化する場合も、区切り行を “IsSeparator=1 のレコード” として保存できるため、仕様変更やバグ調査がしやすくなります(「区切りがどこにあったか」を再現しやすい)。
よくある落とし穴とチェックポイント
- 行ヘッダーが表示されない:
HeadersVisibility="All"またはHeadersVisibility="Row"を指定する - 番号が 0 から始まる:
GetIndex() + 1にする - ソート直後に番号が一瞬ズレる:Sorting では Dispatcher で遅延更新する
- 大量データで重い:全行更新にこだわらず LoadingRow で確実に更新する(表示分だけ更新される)
- No2 がソートで更新されない:Sorting 後に ViewModel の再計算メソッドを呼ぶ
- 区切り行を操作できてしまう:RowStyle で
IsHitTestVisible="False"や ReadOnly、選択不可などを検討する
まとめ
WPF DataGrid の行番号は、DataGridRow.GetIndex() を使って行ヘッダーへ表示するのが、追加・削除・ソートに強く、実装もシンプルです。添付プロパティ化しておけば、画面側は local:DataGridBehavior.DisplayRowNumber="True" を付けるだけで横展開できます。
一方で、区切り行を挟んだ “業務ルールの番号”(No2 のリセットなど)は、行ヘッダーではなく 表示用プロパティ(DisplayNo2)をデータ側に持ち、ICollectionView の順番で再計算するのが現実的です。要件が増えても破綻しにくい構成で、保守しやすい DataGrid を作っていきましょう。

コメント