【C#】 ListViewのカラム（列）を非表示にする方法

ListViewを使用したことがある人なら一度は考えるこのテーマ
簡単な実装から少々面倒な方法まで、備忘録を兼ねて簡潔に書いていこうというお話。

## 目次
[下準備](#下準備)
[方法1. カラム幅を0にする](#方法1-カラム幅を0にする)
[方法2. カラムを削除/復元する](#方法2-カラムを削除復元する)
[方法3. カラム幅0+α](#方法3-カラム幅0α)
[時間のない人はこちら](#方法3-のソースコード全体)

## 下準備
表記は.Net8用ですが、やっていること自体は.Net Frameworkでも問題なく実装できるはず。
まずは、今回実験に使うListViewの設定をさくっと。
~~~C#:4列5行のListViiew作成
ListView listView;
CheckBox checkBox;
public Form1()
{
    InitializeComponent();
    listView = new ListView() { View = View.Details, Location = new(12, 12), Size = new(350, 150) };
    listView.Columns.AddRange(Enumerable.Range(0, 4).Select(i => new ColumnHeader() { Text = $"C {i}" }).ToArray());
    listView.Items.AddRange(Enumerable.Range(0,5).Select(i => new ListViewItem($"Item {i}")).ToArray());
    foreach (ListViewItem item in listView.Items)
        item.SubItems.AddRange(Enumerable.Range(0, 3).Select(i => new ListViewItem.ListViewSubItem(item, $"sub {i}")).ToArray());
    listView.ColumnWidthChanging += ListView_ColumnWidthChanging;
    checkBox = new CheckBox() { Text = "表示/非表示", Location = new(12, 162),Checked = true };
    checkBox.CheckedChanged += CheckBox_CheckedChanged;
    Controls.Add(listView);
    Controls.Add(checkBox);
}
private void ListView_ColumnWidthChanging(object? sender, ColumnWidthChangingEventArgs e)
=> throw new NotImplementedException();
private void CheckBox_CheckedChanged(object? sender, EventArgs e)
=> throw new NotImplementedException();
~~~
実行するとこうなる
![listview01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/d3809a8d-6e03-faa7-ed39-7b7a21bcaea8.png)
ColumnWidthChangingはカラム幅が変更される場合に発生するイベント

https://learn.microsoft.com/ja-jp/dotnet/api/system.windows.forms.listview.columnwidthchanging?view=windowsdesktop-8.0

CheckedChangedはチェック状態が変更される場合に発生するイベント、カラムの表示/非表示を切り替える際に使う
とりあえず今は作っただけ、触れると危険

準備完了、続いて実装方法を以下に記す
## 方法1. カラム幅を0にする
よく見かける方法、実装もお手軽
~~~diff_C:CheckBox_CheckedChanged 変更
private void CheckBox_CheckedChanged(object? sender, EventArgs e)
    => listView.Columns[2].Width = checkBox.Checked ? 60 : 0;
~~~
チェックボックスにチェックがある場合は3列目(C2)を`幅60`で表示
チェックがない場合は`幅を0`にする処理、簡素化のため列や幅は決め打ちで記述中

幅を0にするだけだと幅の変更が可能なので、幅が変更されたときの処理も必要
~~~C#:ListView_ColumnWidthChanging 変更
private void ListView_ColumnWidthChanging(object? sender, ColumnWidthChangingEventArgs e)
{
    if (e.ColumnIndex == 2)
    {
        e.NewWidth = 0;
        e.Cancel = true;
    }
}
~~~
3列目（ColumnIndexが2）の幅が変更されそうなとき、幅を0にして処理を終了する
これで最低限の目的は達成できるが、カーソルが下図のように変わるという問題が出る
![listview02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/3690e15d-10f3-2052-64c3-f148bd242634.png)
これの解決方法は「[3.カラム幅0+α](#方法3-カラム幅0α)」に記述
## 方法2. カラムを削除/復元する
非表示にするのならいっそのこと削除してしまおうという発想
削除した部分を復元するために元の情報を保存しておく必要がある
~~~diff_d:色付きが追加箇所
ListView listView;
CheckBox checkBox;
+List<ColumnHeader> columns = [];
+List<ListViewItem> items = [];
public Form1()
{
    InitializeComponent();
    listView = new ListView() { View = View.Details, Location = new(12, 12), Size = new(350, 150) };
    listView.Columns.AddRange(Enumerable.Range(0, 4).Select(i => new ColumnHeader() { Text = $"C {i}" }).ToArray());
    listView.Items.AddRange(Enumerable.Range(0,5).Select(i => new ListViewItem($"Item {i}")).ToArray());
    foreach (ListViewItem item in listView.Items)
        item.SubItems.AddRange(Enumerable.Range(0, 3).Select(i => new ListViewItem.ListViewSubItem(item, $"sub {i}")).ToArray());
    listView.ColumnWidthChanging += ListView_ColumnWidthChanging;
    checkBox = new CheckBox() { Text = "表示/非表示", Location = new(12, 162),Checked = true };
    checkBox.CheckedChanged += CheckBox_CheckedChanged;
    Controls.Add(listView);
    Controls.Add(checkBox);
    // コピーの作成、Itemsはディープコピーが必要
+   columns.AddRange(listView.Columns.Cast<ColumnHeader>().ToArray());
+   listView.Items.Cast<ListViewItem>().ToList().ForEach(item => items.Add((ListViewItem)item.Clone()));
}
~~~
~~~C#:CheckBox_CheckedChanged 変更
private void CheckBox_CheckedChanged(object? sender, EventArgs e)
{
    if (checkBox.Checked)
    {
        listView.Columns.Clear();
        listView.Items.Clear();
        listView.Columns.AddRange(columns.ToArray());
        listView.Items.AddRange(items.Select(item => (ListViewItem)item.Clone()).ToArray());
    }
    else
    {
        listView.Columns.RemoveAt(2);
        listView.Items.Cast<ListViewItem>().ToList().ForEach(item => item.SubItems.RemoveAt(2));
    }
}
~~~
カラムを表示する場合は予め保存しておいたカラムとアイテムで復元
カラムを削除する際は、Itemsより先にColumnsから削除を行う

一応これでも目的のことができるが、非常に無駄が多い

## 方法3. カラム幅0+α
ここからが本題
「1.カラム幅を0にする」＋カーソルアイコンを変化させない方法

アイコンが変わる原因となる`WM_SETCURSOR`メッセージをフックする
手順は、ListViewの`HeaderControl`をサブクラス化してWM_SETCURSORをフック
カーソルアイコンが変化する場合には処理を止める、ただこれだけ。
HeaderControlについては下記参照、知らなくても問題はない

https://learn.microsoft.com/ja-jp/windows/win32/controls/header-controls


メッセージを処理するためにHeaderControlを操作するための準備を行う
~~~c#
class HeaderControl : NativeWindow
{
    ListView owner;
    public bool invisible = false;

    public HeaderControl(ListView owner)
    {
        this.owner = owner;
        var handle = SendMessage(owner.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);
        this.AssignHandle(handle);
    }
    protected override void WndProc(ref Message m)
    {
        if (invisible && m.Msg == WM_SETCURSOR)
        {
            var info = new HDHITTESTINFO() { pt = owner.PointToClient(Cursor.Position) };
            var res = SendMessage(this.Handle, HDM_HITTEST, IntPtr.Zero, info);
            // info.iItemはヒットしたカラムのインデックス
            if (res >= 0 && info.iItem == 2)
            {
                // 区切り線アイコンの場合は無視
                if ((info.flags & HHT_ONDIVIDER) > 0 || (info.flags & HHT_ONDIVOPEN) > 0)
                {
                    Cursor.Current = Cursors.Default;
                    return;
                }
            }
        }
        base.WndProc(ref m);
    }
    const int
    HDM_HITTEST = 0x1206,
    WM_SETCURSOR = 0x0020,
    LVM_GETHEADER = 0x101F,
    HHT_ONDIVIDER = 0x0004,     // 項目間の区切り線を表すフラグ
    HHT_ONDIVOPEN = 0x0008;     // 幅0の項目の区切り線を表すフラグ
    [DllImport("user32.dll", EntryPoint = "SendMessage", CharSet = CharSet.Auto)]
    static extern int SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
    [DllImport("user32.dll", EntryPoint = "SendMessage", CharSet = CharSet.Auto)]
    static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, [In, Out] HDHITTESTINFO lParam);
    [StructLayout(LayoutKind.Sequential)]
    class HDHITTESTINFO { public Point pt; public int flags; public int iItem; }
}
~~~
準備は以上、あとはこのクラスを使用してアイコンを制御するだけ、お粗末様。
[コード全体はページ最後に記載](#第三の方法の全コード) ← 押してみな、飛ぶぞ

上記HeaderControlクラスの説明
```C#:コンストラクタ部分
    public HeaderControl(ListView owner)
    {
        this.owner = owner;
        var handle = SendMessage(owner.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);
        this.AssignHandle(handle);
    }
```
ListViewに対して`LVM_GETHEADER`メッセージを送り、ヘッダコントロールのハンドルを取得、AssignHandleでハンドルを割り当て
```C#:ウィンドウプロシージャ
    protected override void WndProc(ref Message m)
    {
        if (invisible && m.Msg == WM_SETCURSOR)
        {
            var info = new HDHITTESTINFO() { pt = owner.PointToClient(Cursor.Position) };
            var res = SendMessage(this.Handle, HDM_HITTEST, IntPtr.Zero, info);
            // info.iItemはヒットしたカラムのインデックス
            if (res >= 0 && info.iItem == 2)
            {
                // 区切り線アイコンの場合は無視
                if ((info.flags & HHT_ONDIVIDER) > 0 || (info.flags & HHT_ONDIVOPEN) > 0)
                {
                    Cursor.Current = Cursors.Default;
                    return;
                }
            }
        }
        base.WndProc(ref m);
    }
```
`invisible`は、Trueの場合隠す、Falseなら表示のフラグ、表示するのなら処理は不要。
`WM_SETCURSOR`メッセージの場合、`HDM_HITTEST`メッセージを使い、指定したポイントにあるヘッダー項目 (存在する場合) を判断する。

https://learn.microsoft.com/ja-jp/windows/win32/controls/hdm-hittest

上サイト、戻り値の説明で失敗時に1が返るとあるがそれは誤りで、-1が返る
```C#:隠したいカラムの判定
if (res >= 0 && info.iItem == 2)
```
`res`はヒットテストの戻り値、ヒットした場合`info.iItem`と一致する
`info.iItem`はカーソルが乗っているカラムのインデックス。ヒットテストに成功している場合`res`と同じ値となる。
なので、`if (res == 2)`と書いても同じ動作になる。

~~~C#:カーソル判定
if ((info.flags & HHT_ONDIVIDER) > 0 || (info.flags & HHT_ONDIVOPEN) > 0)
{
    Cursor.Current = Cursors.Default;
    return;
}
~~~
info.flagsの詳細は下記サイト参照

https://learn.microsoft.com/ja-jp/windows/win32/api/commctrl/ns-commctrl-hdhittestinfo

HHT_ONDIVIDER、HHT_ONDIVOPENは下図カーソルアイコン
![listview03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/feaa47a8-d02c-eb57-a378-d1eade7e2e5f.png)　左が`HHT_ONDIVIDER`、右が`HHT_ONDIVOPEN`
`Cursors.Default`は通常の矢印カーソル
隠しているカラム上でカーソルが`区切り線`アイコンに代わる場合、デフォルトカーソルに変更して終了という処理

デフォルトに置き換えているのは、左右のカラムからカーソルが来た場合の対処も兼ねているため。置き換えていないと、選択しているカラムがないのに区切り線カーソルになるという下図の様なことが起こる。
![listview04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/9c9e44d2-972a-a001-6dd2-59e4010c0a1c.png)
C1からC2に侵入、アイコンは区切り線なのに色の変わったカラムが無い

クラスの使用方法は、カラムが作成されたListViewを引数にインスタンス化するだけ。

invisibleの値を変化させると、カーソルアイコンが変化するかどうか切り替えられる
HeaderControlクラスで行っているのはカーソルアイコンの制御だけなので別途、`ListView_ColumnWidthChanging`を処理して枠のサイズ変更を適宜無効にする必要がある
この処理は[方法1](#方法1-カラム幅を0にする)と同様なので割愛

以下に埋め込んだマジックナンバーを定数として書き直したコード全体を記す
必要に応じて定数宣言を変数化すれば柔軟に非表示カラム処理が行えるはず。以上。

## 方法3 のソースコード全体
~~~C#
ListView listView;
CheckBox checkBox;
HeaderControl header;
// 非表示にするカラムインデックス
const int hiddenColumn = 2;
public Form1()
{
    InitializeComponent();
    listView = new ListView() { View = View.Details, Location = new(12, 12), Size = new(350, 150) };
    listView.Columns.AddRange(Enumerable.Range(0, 4).Select(i => new ColumnHeader() { Text = $"C {i}" }).ToArray());
    listView.Items.AddRange(Enumerable.Range(0, 5).Select(i => new ListViewItem($"Item {i}")).ToArray());
    foreach (ListViewItem item in listView.Items)
        item.SubItems.AddRange(Enumerable.Range(0, 3).Select(i => new ListViewItem.ListViewSubItem(item, $"sub {i}")).ToArray());
    listView.ColumnWidthChanging += ListView_ColumnWidthChanging;
    checkBox = new CheckBox() { Text = "表示/非表示", Location = new(12, 162), Checked = true };
    checkBox.CheckedChanged += CheckBox_CheckedChanged;

    Controls.Add(listView);
    Controls.Add(checkBox);

    // ヘッダコントロールのインスタンス化
    header = new HeaderControl(listView);
}

private void ListView_ColumnWidthChanging(object? sender, ColumnWidthChangingEventArgs e)
{
    if (e.ColumnIndex == hiddenColumn)
    {
        e.NewWidth = 0;
        e.Cancel = true;
    }
}

private void CheckBox_CheckedChanged(object? sender, EventArgs e)
{
    // 表示状態の切り替え
    header.invisible = !checkBox.Checked;
    listView.Columns[hiddenColumn].Width = checkBox.Checked ? 60 : 0;
}
class HeaderControl : NativeWindow
{
    ListView owner;
    public bool invisible = false;

    public HeaderControl(ListView owner)
    {
        this.owner = owner;
        var handle = SendMessage(owner.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);
        this.AssignHandle(handle);
    }
    protected override void WndProc(ref Message m)
    {
        if (invisible && m.Msg == WM_SETCURSOR)
        {
            var info = new HDHITTESTINFO() { pt = owner.PointToClient(Cursor.Position) };
            var res = SendMessage(this.Handle, HDM_HITTEST, IntPtr.Zero, info);
            if (res >= 0 && info.iItem == hiddenColumn)
            {
                // 区切り線アイコンの場合は無視
                if ((info.flags & HHT_ONDIVIDER) > 0 || (info.flags & HHT_ONDIVOPEN) > 0)
                {
                    Cursor.Current = Cursors.Default;
                    return;
                }
            }
        }

        base.WndProc(ref m);
    }
    const int
    HDM_HITTEST = 0x1206,
    WM_SETCURSOR = 0x0020,
    LVM_GETHEADER = 0x101F,
    HHT_ONDIVIDER = 0x0004,
    HHT_ONDIVOPEN = 0x0008;
    [DllImport("user32.dll", EntryPoint = "SendMessage", CharSet = CharSet.Auto)]
    static extern int SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
    [DllImport("user32.dll", EntryPoint = "SendMessage", CharSet = CharSet.Auto)]
    static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, [In, Out] HDHITTESTINFO lParam);
    [StructLayout(LayoutKind.Sequential)]
    class HDHITTESTINFO { public Point pt; public int flags; public int iItem; }
}
~~~
[目次に戻る](#目次)