WPF DataGrid 行番号を自動更新する方法|添付プロパティで追加・削除・ソート対応+区切り行で番号リセット

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) =&gt;
            {
                UpdateRowHeader(args.Row);
            };

            handlers.ItemsChangedHandler = (sender, args) =&gt;
            {
                RefreshRowHeaders(dg);
            };

            handlers.SortingHandler = (sender, args) =&gt;
            {
                // ソート直後はインデックスが確定していない場合があるので遅延更新
                dg.Dispatcher.BeginInvoke(
                    new Action(() =&gt; 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(() =&gt; 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 &lt; 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 &amp;&amp; info.IsSeparator)
    {
        row.Header = string.Empty;
        return;
    }

    row.Header = (row.GetIndex() + 1).ToString();
}

区切り行を「それっぽく」見せる RowStyle

区切り行は入力・編集対象にしないことが多いので、選択やクリックを無効化し、薄い背景・高さ調整などを当てると運用が安定します。

&lt;DataGrid.RowStyle&gt;
  &lt;Style TargetType="DataGridRow"&gt;
    &lt;Style.Triggers&gt;
      &lt;DataTrigger Binding="{Binding IsSeparator}" Value="True"&gt;
        &lt;Setter Property="IsHitTestVisible" Value="False"/&gt;
        &lt;Setter Property="Height" Value="12"/&gt;
        &lt;Setter Property="Background" Value="#FFEFEFEF"/&gt;
      &lt;/DataTrigger&gt;
    &lt;/Style.Triggers&gt;
  &lt;/Style&gt;
&lt;/DataGrid.RowStyle&gt;

「区切り行は完全に空白にしたい」という場合は、CellStyle 側で文字色を透明にする、Template を差し替えるなども可能です。ただし、UI を作り込みすぎると保守コストが上がるため、まずは “クリック不可+薄い帯” 程度に留めるのが現場では扱いやすいです。

区切り行で No2 を 1 から振り直す:行ヘッダーではなく「番号列」を持つ

行ヘッダーの行番号(GetIndex)は「表示順の連番」なので、追加・削除・ソートに強い一方、区切りごとのリセットのようなビジネスルールを乗せると途端に複雑になります。

そのため、No2 のような番号は 行ヘッダーで頑張らず、データ側に「表示用番号プロパティ(例:DisplayNo2)」を持たせて列にバインドするのが実務的です。

XAML:No2 を列としてバインドする

&lt;DataGridTextColumn Header="No2" Binding="{Binding DisplayNo2}" Width="60" /&gt;

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&lt;Info&gt; Infos { get; private set; }

    public MainViewModel()
    {
        Infos = new ObservableCollection&lt;Info&gt;();
        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 をバインド)区切り行でリセット、非表示条件、フィルタ連動など “業務ルールの番号”ソート/フィルタ後の再計算トリガーを用意する必要がある
AlternationIndexXAML だけで簡易的な連番表示+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 を作っていきましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次