【C#】 ListViewで行の高さを変更する

ListViewの一行の高さを変更する方法は色々あるので、備忘録を兼ねて纏めてみるという話。
ViewがDetailsの場合を想定して話してます。

実行環境は.Net8ですが、やっていること自体は.Net Frameworkでも問題なく実装できるはず。多分。
# 目次
[方法1. SmallImageListを使用する](#方法1-smallimagelistを使用する)
[方法2. StateImageListを利用する](#方法2-stateimagelistを利用する)
[方法3. Fontを利用する](#方法3-fontを利用する)
[方法4. WM_MEASUREITEMメッセージを利用する](#方法4-wm_measureitemメッセージを利用する)
[方法5. WM_MEASUREITEMメッセージを利用する改](#方法5-wm_measureitemメッセージを利用する改)
[時間のない人はこちら](#方法5のソースコード全体)

## 方法1. SmallImageListを使用する
行の高さを変更する方法を検索すると、大抵この方法がヒットする。
最も簡潔に書ける実装方法
```C#
listView.SmallImageList = new ImageList() { ImageSize = new Size(1, 30) };
```
のように書くだけで、行の高さが30ピクセルに変更できる。
当然のことながらSmallImageListは本来の目的では使用できなくなる。
## 方法2. StateImageListを利用する
方法1とほぼ同じことをStateImageListを利用して行うことができる。
```C#
listView.StateImageList = new ImageList() { ImageSize = new Size(1, 30) };
```
コードもほぼ同じで、結果も同じ。
ただしこちらの方法の場合、チェックボックスが利用できなくなる。
（`listView.CheckBoxes=true`の場合でも、チェックボックスが表示されない）
しかし、チェックボックスを使用しない場合は気兼ねなく使え、さらに`SmallImageList`と併用できる。

```C#:併用した場合
listView.StateImageList = new ImageList() { ImageSize = new Size(1, 50) };
listView.SmallImageList = new ImageList() { ImageSize = new Size(79, 30) };
listView.SmallImageList.Images.Add(new Bitmap("logo.png"));
listView.Items.Add(new ListViewItem("test"));                  // イメージ無し
listView.Items.Add(new ListViewItem("test") { ImageIndex = 0});// イメージ有り
```
![listview12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/954f3060-43c1-558c-1ab9-0cdb36e5c171.png)
ロゴが大きかったので行の高さは`50`にしている。
SmallImageListの表示位置を調節する場合は、OwnerDrawを行う必要がある（と思う）。
## 方法3. Fontを利用する
フォントサイズの変更でも行の高さが変わる。
```C#
listView.Font = new Font(listView.Font.FontFamily, 30);
```
![listview13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/94c37563-726d-ebe5-bbb8-add504128c5f.png)
ついでにカラムの高さも変わる。色がついている部分がカラム。
サイズを`30`と指定しても行の高さが`30ピクセル`となるわけではない。文字のサイズが`30em`になる。

カラムの高さは変わってほしくないので小細工を行う
```C#
// カラムのハンドラを取得して
var handle = SendMessage(listView.Handle, 0x101F/*LVM_GETHEADER*/, IntPtr.Zero, IntPtr.Zero);
// カラムのフォントはサイズが元のままの9em
var font = new Font(listView.Font.FontFamily, listView.Font.Size);
// ListViewはフォントサイズ20em
listView.Font = new Font(listView.Font.FontFamily, 20);
// カラムのフォントを再設定
SendMessage(handle, 0x0030/*WM_SETFONT*/, font, 0);
```
![listview14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/7e604f5f-5382-a98c-e398-6a7f90183b13.png)
カラムの高さは元に戻ったが、なぜかフォントがおかしなことに。
## 問題点
以上の方法だと、どれも高さを広げることはできても、既定のサイズ（環境にもよるが大体18ピクセル、.Net Frameworkの場合は15ピクセル）以下に縮めることができない。
さらに`ImageList`を使用する場合、ImageSizeの最大値は`256`ピクセルという制限もある。
## 方法4. WM_MEASUREITEMメッセージを利用する
規定のサイズよりも高さを小さくしたい、逆に256ピクセルよりも大きな行を作りたい衝動に駆られた場合どうするのか。
それが今回のお話の肝となる`WM_MEASUREITEM`メッセージを利用する方法である。
 WM_MEASUREITEMメッセージについては下記参照。

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

このメッセージで送られる`MEASUREITEMSTRUCT`構造体の`itemHeight`の値を変更すれば目的の結果が得られる。
MEASUREITEMSTRUCT構造体については下記参照

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/ns-winuser-measureitemstruct

問題はどうすれば`WM_MEASUREITEM`メッセージが発生するかという点。

WM_MEASUREITEMメッセージの説明文には
>コントロールまたはメニューの`作成時`に、リストビューコントロールの所有者ウィンドウに送信されます。

さらに、注釈には
>システムは 、WM_INITDIALOG メッセージを送信する前に、OWNERDRAWFIXED スタイルで作成されたウィンドウに WM_MEASUREITEM メッセージを送信します

とある、つまり作成時に`OWNERDRAWFIXED`スタイルを持ったListViewを作る必要があるというわけだが、どのみち`WM_MEASUREITEM`メッセージの処理のために、ListViewをサブクラス化するのでちょうどいい。以上を踏まえてListViewを継承したListViewExを作る。
```C#
partial class ListViewEx : ListView
{
    // 行の高さ
    int rowHeight = 10;
    
    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);
        
        if(WM_MEASUREITEM == m.Msg)
        {
            // 詳しくは後述
            Marshal.WriteInt32(m.LParam + (sizeof(uint) * 4), rowHeight);
            // 処理したのでTrueを設定
            m.Result = 1;
        }
        else if(WM_DRAWITEM == m.Msg)
        { /* 描画処理が必要 */ }
    }

    protected override CreateParams CreateParams
    {
        get
        {
            var cp = base.CreateParams;
            cp.Style |= LVS_OWNERDRAWFIXED;
            return cp;
        }
    }
    
    const int LVS_OWNERDRAWFIXED = 0x0400;
    const int WM_REFLECT = 0x2000;
    const int WM_MEASUREITEM = 0x002C + WM_REFLECT;
    const int WM_DRAWITEM = 0x002B + WM_REFLECT;
}
```
少し説明を加えると
```C#
Marshal.WriteInt32(m.LParam + (sizeof(uint) * 4), rowHeight);
```
この部分、`WM_MEASUREITEM`メッセージの`LParam`には`MEASUREITEMSTRUCT`構造体へのポインタが入っている。rowHeightは行の高さ、今回は10ピクセルに挑戦。
`MEASUREITEMSTRUCT`構造体は下記サイト参照。

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/ns-winuser-measureitemstruct

構造体の中身は、目的の`itemHeight`に至るまでuint型が4つ並んでいるので、`itemHeight`のアドレスに対してMarshal.WriteInt32で値を直接書き換えるというのが先のコード。
もちろん構造体を定義して書き換えてもいい、下記がその方法
```C#:構造体を使用する場合のコード、当該箇所のみ。
if(WM_MEASUREITEM == m.Msg)
{
    var measure = Marshal.PtrToStructure<MEASUREITEMSTRUCT>(m.LParam);
    measure.itemHeight = rowHeight;
    Marshal.StructureToPtr<MEASUREITEMSTRUCT>(measure, m.LParam, false);
    m.Result = 1;
}
// 中略
[StructLayout(LayoutKind.Sequential)]
struct MEASUREITEMSTRUCT
{
    public int CtlType;
    public int CtlID;
    public int itemID;
    public int itemWidth;
    public int itemHeight;
    public IntPtr itemData;
}
```
続いて`CreateParams`プロパティについて
`LVS_OWNERDRAWFIXED`と`CreateParams`は下記サイト参照、スタイルに`LVS_OWNERDRAWFIXED`を追加している。

https://learn.microsoft.com/ja-jp/windows/win32/controls/list-view-window-styles

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

`LVS_OWNERDRAWFIXED`の説明によると、もれなく`WM_DRAWITEM `メッセージを処理する必要があると書かれている。今回はとりあえず行の高さが変更できることを確認したいだけなので、何も処理を描いていない。何も処理していないので文字や諸々のものは表示されない。

![listview15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/5d352a7f-3059-c671-58c1-ba47be9f6fa3.png)
実行すると見事に行の高さが10ピクセルのListViewが完成した。めでたしめでたし。

## 方法5. WM_MEASUREITEMメッセージを利用する改
ここからが本題。おそらく世界初の方法（適当調べ）

方法4の場合、行の高さの変更はできたが、`WM_DRAWITEM`を処理する必要が出てきた。
どうにかして行の高さだけを変更し、WM_DRAWITEMはシステムに任せる方法はないだろうか。もちろん方法はある。

以下に、この美味しいとこ取りの概略を記す。
1. ウィンドウスタイルにLVS_OWNERDRAWFIXEDを追加する
2. `WM_MEASUREITEM`メッセージを送信させる
3. ウィンドウスタイルからLVS_OWNERDRAWFIXEDを削除する

問題は、`WM_MEASUREITEM`メッセージをどうすれば送信してもらえるかという点だが、話は簡単。このメッセージ、システムがコントロールの寸法を知りたいときに送ってくるので、ListViewの寸法を変えてやればいい。つまりListViewのサイズを変更すれば送られてくるというわけだ。

加えて言うと、ListViewの`OwnerDraw=true`と`LVS_OWNERDRAWFIXED`は別物なので、OwnerDraw利用時でもウィンドウスタイルに`LVS_OWNERDRAWFIXED`がない場合は`WM_MEASUREITEM`メッセージは発生しない。

上記を実現するためのコードは下記
```C# 
// ListViewのサイズを保存しておく
var size = listView.Size;
// 現在のウィンドウスタイルを取得しLVS_OWNERDRAWFIXEDを追加
var style = GetWindowLong(listView.Handle, GWL_STYLE);
style |= LVS_OWNERDRAWFIXED;
// スタイルの適用
SetWindowLong(listView.Handle, GWL_STYLE, style);
// WM_MEASUREITEMを送信させるためにListViewのサイズを少し変更
listView.Size = new Size(size.Width, size.Height + 1);
// ウィンドウスタイルからLVS_OWNERDRAWFIXEDを削除
style ^= LVS_OWNERDRAWFIXED;
// スタイルの適用
SetWindowLong(listView.Handle, GWL_STYLE, style);
// 変更した分を元に戻す、ここではWM_MEASUREITEMが発生しない
listView.Size = size;

const int
    GWL_STYLE = -16,
    LVS_OWNERDRAWFIXED = 0x0400;

[DllImport("user32.dll")]
static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
```
コードの詳細はコメントにある通り。
`WM_MEASUREITEM`を発生させるためにListViewのサイズを変更するのがポイント。
変更した分はすぐに元に戻すので見た目の変化は何もない。
`LVS_OWNERDRAWFIXED`もすぐに削除しているので以降の描画はシステムが行ってくれる。

以上で任意のタイミングで行の高さを好きに変更することができるはず。

実行すると
![listview16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/ac68dfef-96f9-3a09-6439-4cda969f5e76.png)
描画も行われており大満足。

以下に方法5のソース全体を記しておく

## 方法5のソースコード全体
```C#
public partial class Form1 : Form
{
    ListViewEx listView;
    public Form1()
    {
        InitializeComponent();
        listView = new ListViewEx()
        {
            View = View.Details,
            GridLines = true,
            Location = new(12, 12),
            Size = new(450, 350)
        };
        listView.Columns.Add(new ColumnHeader() { Text = "test01" });
        listView.Items.Add(new ListViewItem("test"));
        listView.Items.Add(new ListViewItem("test"));
        Controls.Add(listView);

        // サイズ変更、実行場所はどこでもいい
        listView.RowHeightChange(10);
    }
    // ボタンで実行してもいい
    //private void button1_Click(object sender, EventArgs e)
    //=> listView.RowHeightChange(30);
}
partial class ListViewEx : ListView
{
    int rowHeight;

    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);
        if (WM_MEASUREITEM == m.Msg)
        {
            Marshal.WriteInt32(m.LParam + (sizeof(uint) * 4), rowHeight);
            m.Result = 1;
        }
    }
    public void RowHeightChange(int height)
    {
        rowHeight = height;

        var size = Size;
        var style = GetWindowLong(Handle, GWL_STYLE);
        style |= LVS_OWNERDRAWFIXED;
        SetWindowLong(Handle, GWL_STYLE, style);
        Size = new Size(size.Width, size.Height + 1);
        style ^= LVS_OWNERDRAWFIXED;
        SetWindowLong(Handle, GWL_STYLE, style);
        Size = size;
    }

    const int
        GWL_STYLE = -16,
        LVS_OWNERDRAWFIXED = 0x0400,
        WM_MEASUREITEM = 0x002C + 0x2000;

    [DllImport("user32.dll")]
    static extern int GetWindowLong(IntPtr hWnd, int nIndex);
    [DllImport("user32.dll")]
    static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
}
```
行の高さ変更部分をメソッド化してあるので、好きな場所で`RowHeightChange(目的の高さ)`を実行すると、意図した結果が得られる。現在設定されている高さを返す予定なら、プロパティ化の方が良かった気もするが、項目（Items）が空の時の、高さの初期値の取得方法が思いつかないので、とりあえず今回の話はここまで、お粗末様。

## 追記
```C#
listView.SmallImageList = new ImageList() { ImageSize = new Size(1, 30) };
```
上記のようにSmallImageList等で`30`と指定した場合、`ListViewItem.Bounds.Height`は`31`となるが、
`WM_MEASUREITEM`で`30`と指定した場合は、`ListViewItem.Bounds.Height`は`30`となる。
数値のズレが気になる場合はrowHeightに+1した値を設定すればいい。

## 追記その2
プロパティでの実装の詳細は下記を参照してください。

https://qiita.com/HALXene/items/1babf0c4f3515af02418

[目次に戻る](#目次)