ListViewを使用したことがある人なら一度は考えるこのテーマ
簡単な実装から少々面倒な方法まで、備忘録を兼ねて簡潔に書いていこうというお話。
目次
下準備
方法1. カラム幅を0にする
方法2. カラムを削除/復元する
方法3. カラム幅0+α
時間のない人はこちら
下準備
表記は.Net8用ですが、やっていること自体は.Net Frameworkでも問題なく実装できるはず。
まずは、今回実験に使うListViewの設定をさくっと。
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();
実行するとこうなる
ColumnWidthChangingはカラム幅が変更される場合に発生するイベント
CheckedChangedはチェック状態が変更される場合に発生するイベント、カラムの表示/非表示を切り替える際に使う
とりあえず今は作っただけ、触れると危険
準備完了、続いて実装方法を以下に記す
方法1. カラム幅を0にする
よく見かける方法、実装もお手軽
private void CheckBox_CheckedChanged(object? sender, EventArgs e)
=> listView.Columns[2].Width = checkBox.Checked ? 60 : 0;
チェックボックスにチェックがある場合は3列目(C2)を幅60
で表示
チェックがない場合は幅を0
にする処理、簡素化のため列や幅は決め打ちで記述中
幅を0にするだけだと幅の変更が可能なので、幅が変更されたときの処理も必要
private void ListView_ColumnWidthChanging(object? sender, ColumnWidthChangingEventArgs e)
{
if (e.ColumnIndex == 2)
{
e.NewWidth = 0;
e.Cancel = true;
}
}
3列目(ColumnIndexが2)の幅が変更されそうなとき、幅を0にして処理を終了する
これで最低限の目的は達成できるが、カーソルが下図のように変わるという問題が出る
これの解決方法は「3.カラム幅0+α」に記述
方法2. カラムを削除/復元する
非表示にするのならいっそのこと削除してしまおうという発想
削除した部分を復元するために元の情報を保存しておく必要がある
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()));
}
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については下記参照、知らなくても問題はない
メッセージを処理するためにHeaderControlを操作するための準備を行う
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クラスの説明
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でハンドルを割り当て
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
メッセージを使い、指定したポイントにあるヘッダー項目 (存在する場合) を判断する。
上サイト、戻り値の説明で失敗時に1が返るとあるがそれは誤りで、-1が返る
if (res >= 0 && info.iItem == 2)
res
はヒットテストの戻り値、ヒットした場合info.iItem
と一致する
info.iItem
はカーソルが乗っているカラムのインデックス。ヒットテストに成功している場合res
と同じ値となる。
なので、if (res == 2)
と書いても同じ動作になる。
if ((info.flags & HHT_ONDIVIDER) > 0 || (info.flags & HHT_ONDIVOPEN) > 0)
{
Cursor.Current = Cursors.Default;
return;
}
info.flagsの詳細は下記サイト参照
HHT_ONDIVIDER、HHT_ONDIVOPENは下図カーソルアイコン
左が
HHT_ONDIVIDER
、右がHHT_ONDIVOPEN
Cursors.Default
は通常の矢印カーソル
隠しているカラム上でカーソルが区切り線
アイコンに代わる場合、デフォルトカーソルに変更して終了という処理
デフォルトに置き換えているのは、左右のカラムからカーソルが来た場合の対処も兼ねているため。置き換えていないと、選択しているカラムがないのに区切り線カーソルになるという下図の様なことが起こる。
C1からC2に侵入、アイコンは区切り線なのに色の変わったカラムが無い
クラスの使用方法は、カラムが作成されたListViewを引数にインスタンス化するだけ。
invisibleの値を変化させると、カーソルアイコンが変化するかどうか切り替えられる
HeaderControlクラスで行っているのはカーソルアイコンの制御だけなので別途、ListView_ColumnWidthChanging
を処理して枠のサイズ変更を適宜無効にする必要がある
この処理は方法1と同様なので割愛
以下に埋め込んだマジックナンバーを定数として書き直したコード全体を記す
必要に応じて定数宣言を変数化すれば柔軟に非表示カラム処理が行えるはず。以上。
方法3 のソースコード全体
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; }
}