【C#】 ListViewでOwnerDrawのすゝめ

OwnerDrawを使いこなす為の入り口になるかもしれない話。
# 目次
[1. 下準備](#下準備)
[2. 注意事項](#注意事項)
[3. 描画順序と範囲](#順序と範囲)
[4. 背景描画](#背景描画)
[5. 文字描画](#文字描画)

## 下準備
至極まっとうな5行4列のListView、OwnerDrawを行うのでもちろん`OwnerDraw = true`が必要
~~~C#
public partial class Form1 : Form
{
    ListView listView;
    public Form1()
    {
        InitializeComponent();
        listView = new ListView() { View = View.Details, OwnerDraw = true, GridLines = true,　
                                    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.DrawColumnHeader += ListView_DrawColumnHeader;
        listView.DrawItem += ListView_DrawItem;
        listView.DrawSubItem += ListView_DrawSubItem;
        Controls.Add(listView);
    }
    private void ListView_DrawColumnHeader(object? sender, DrawListViewColumnHeaderEventArgs e)
    => e.DrawDefault = true;
    private void ListView_DrawItem(object? sender, DrawListViewItemEventArgs e)
    => e.DrawDefault = true;
    private void ListView_DrawSubItem(object? sender, DrawListViewSubItemEventArgs e)
    => e.DrawDefault = true;
}
~~~
生まれたての状態、描画はシステム任せの`e.DrawDefault = true`
![listview05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/7738c6d9-c55f-f2d1-887e-75118b897ba6.png)
実行すると何の変哲もないListViewの完成

## 注意事項
まったく知識のない状態でいきなり注意点を書くのもどうかと思うが、頭の片隅に置いておくと助かることもあるので一応書いておく、[読み飛ばしても問題はない](#順序と範囲)

:::note warn
ListViewの`FullRowSelect`プロパティが`True`の時、かつDrawItem部分で背景描画の処理を行っている場合、初回のマウスホバー時にSubItemが背景色で塗りつぶされる現象が起こる。

マウスポインターが行上に移動すると、詳細ビューで行ごとに1回、DrawSubItemイベントを伴わずにDrawItemイベントが発生し、DrawSubItemイベントハンドラーで描画されたものがカスタム背景によって塗りつぶされるという既知のバグの為である
:::
詳細は以下

https://learn.microsoft.com/ja-jp/dotnet/api/system.windows.forms.listview.drawitem?view=netframework-4.8#remarks

通常DrawItemで背景を、DrawSubItemで文字などを描画するのでFullRowSelectを使用する場合は対策が必要となる。

:::note info
いろいろ試してみた結果、ListView.HideSelection プロパティがTrueの時、
マウスポインターが行上に移動するだけでDrawItemが呼ばれるが、DrawSubItemは呼ばれない。
一方、HideSelectionがFalseの時、DrawListViewSubItemEventArgsのe.Stateの内容は正しくない値になるが、描画が必要な時はe.Stateに0以外の値が入るので、e.Stateが0の時を無視すれば思った通りの動作を行う。気がする
HideSelectionの規定値はNET Framework 4.8以降はFalse。
:::
![listview06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/260c18bf-ba56-5a12-f8cd-84a13070fce6.png)
起動後、Item0から順番にカーソルを通過させている途中の図、GridLineが塗りつぶされているのが分かる。このような現象に陥った時に、上記のバグのことが頭の片隅にあると多少解決が早まるかもしれない。

## 順序と範囲
`DrawColumnHeader`はカラムの数だけ呼ばれる。
`DrawItem`と`DrawSubItem`は、`DrawItem`が呼ばれ、その後、属するSubItem数分`DrawSubItem`が呼ばれる。
今回利用する5行4列のListViewの場合
~~~ruby
DrawColumnHeader index : 0
DrawColumnHeader index : 1
DrawColumnHeader index : 2
DrawColumnHeader index : 3
DrawItem index : 0
DrawSubItem ItemIndex : 0 ColumnIndex : 0
DrawSubItem ItemIndex : 0 ColumnIndex : 1
DrawSubItem ItemIndex : 0 ColumnIndex : 2
DrawSubItem ItemIndex : 0 ColumnIndex : 3
DrawItem index : 1
DrawSubItem ItemIndex : 1 ColumnIndex : 0
DrawSubItem ItemIndex : 1 ColumnIndex : 1
DrawSubItem ItemIndex : 1 ColumnIndex : 2
DrawSubItem ItemIndex : 1 ColumnIndex : 3
# 以下略
~~~
上記の順序で描画が為される、GridLineはDrawSubItemの後に引かれる。
起動時のログなので`DrawColumnHeader`が呼ばれているが、必ずしもDrawItemの前に呼ばれるわけではない。が、DrawItemの後にはDrawSubItemが必ず呼ばれる。[注意事項](#注意事項)のバグを除いてだが。

今回は`DrawColumnHeader`には触れず、花形の`DrawItem`と`DrawSubItem`について書く。

描画するListViewItemの情報は`DrawListViewItemEventArgs`、`DrawListViewSubItemEventArgs`により提供される

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

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

DrawItemの`DrawListViewItemEventArgs`.`Bounds`には描画する`ListViewItemのサイズと位置`をが記されている。
例として2行目の`Bounds`は、`{X=0,Y=43,Width=240,Height=19}`このような値。
それに従い2行目だけに枠線を引いてみると下図のようなことになる
![listview07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/0b364645-4588-e19d-c2df-0868987d32b9.png)
一部GridLineと被る部分がある。枠線が1ピクセル幅なので、1ピクセルずつずれたような矩形となる。

DrawSubItemの`DrawListViewSubItemEventArgs`.`Bounds`には描画する`ListViewSubItemのサイズと位置`をが記されている。
例として2行1列目の`Bounds`は、`{X=0,Y=43,Width=60,Height=19}`このような値である。
枠線を引くと下図のようになる
![listview08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/efecbdff-3de8-5a66-3384-870dfd1229dc.png)
2行2列目の`Bounds`は、`{X=60,Y=43,Width=60,Height=19}`で
![listview09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/5d9e17c2-85fc-9902-b6a8-0728b03039fe.png)
なんと両端がGridLineの餌食に。

Boundsは上記のような感じ、当然サイズと位置が入っているだけなのでそれに従う必要は全くない。DrawItemですべてを描画してもいいし、DrawSubItemの好きな位置ですべてを描いてもいい。が、通常はDrawItemで背景、DrawSubItemで文字を描くと収まりがいい、自分もそうしている。

## 背景描画
ここからが本題、まずはDrawItem内で背景を描いてみる。
`DrawListViewItemEventArgs`.`Graphics`に対して好きに描ける。

https://learn.microsoft.com/ja-jp/dotnet/api/system.drawing.graphics?view=dotnet-plat-ext-8.0

矩形、多角形、楕円、円弧、画像もテキストもなんでもOK。
コントロールを描画する場合は`Renderer`を利用する。フォーカス枠が描きたい場合は`ControlPaint.DrawFocusRectangle`なんてのもある。もちろんフォーカス枠をDrawRectangleで描いても良い。
```C#:DrawItem と DrawSubItem 変更
private void ListView_DrawItem(object? sender, DrawListViewItemEventArgs e)
{
    var location = new Point(e.Bounds.X + 4, e.Bounds.Y + 2);
    // チェックボックスを描画
    CheckBoxRenderer.DrawCheckBox(e.Graphics, location, CheckBoxState.CheckedNormal);
    e.Graphics.FillRectangle(Brushes.GreenYellow, 20, e.Bounds.Y + 4, 10, 10);
    e.Graphics.DrawRectangle(Pens.Red, 34, e.Bounds.Y + 5, 8, 8);
    // 背景なので横に長くてもいいし、何ならBoundsをはみ出しても問題はない
    e.Graphics.FillEllipse(Brushes.SkyBlue, 46, e.Bounds.Y + 2, 200, 15);
}
private void ListView_DrawSubItem(object? sender, DrawListViewSubItemEventArgs e)
{ /* DrawSubItemでは今は何も行わない */ }
```
適当に図形を配置した上記コードの実行結果が下図
![listview10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/fa90b765-37e0-7761-f7a5-6ef571f9f110.png)
`e.Graphics`をキャンバスに好きに描けることが確認できる。
ちなみに、上図のチェックボックスはあくまでも画像なのでクリックしても変化は起こらない。

## 文字描画
基本的には背景と同様、`e.Graphics`に好きに描くといい。

文字を描く方法は何通りかある。
`e.DrawText`を使用すると、位置や色が指定できない。
`e.Graphics.DrawString`を使用すると、それなりに描ける。
`TextRenderer.DrawText`を使用すると、ListViewと同様の描画ができる。

```C#:DrawSubItemで文字を描く
private void ListView_DrawSubItem(object? sender, DrawListViewSubItemEventArgs e)
{
    if (e.ItemIndex == 0)
        e.DrawText();
    else if (e.ItemIndex == 1)
        e.Graphics.DrawString(e.SubItem.Text, Font, Brushes.DarkRed, e.Bounds.Location);
    else if (e.ItemIndex == 2)
        TextRenderer.DrawText(e.Graphics, e.SubItem.Text, Font, e.Bounds.Location, Color.Crimson);
}
```
![listview11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688857/786d1631-6962-623d-17d7-3dbb445a3644.png)
上から順に、`e.DrawText`、`e.Graphics.DrawString`、`TextRenderer.DrawText`
`e.Graphics.DrawString`と`TextRenderer.DrawText`の違いは数字を描くと表れる。

さらに、背景の上に掛かれるので描画位置は背景を考慮する必要がある。上図は全く考慮せずに描画したので重なりあい大変なことになっている。
チェックボックスが表示されているならその分ずらす必要があるし、カラム幅が文字の全長よりも狭い場合はその処理も必要。こだわりだすときりがない。

ここで唐突に、知っておくと何かの助けになる事柄を一つ
画像でもテキストでも、描画してみて何か違うなと思ったら、別の方法をとると解決することがある。例えばSmallImageListの画像を表示する場合、`e.Graphics.DrawImage`で描くと上手くいかないことでも、`ListView.SmallImageList.Draw`を使うとListViewと同様の画像が描画できる。

あてもなく綴ってきたが、以上がOwnerDrawの基礎の基礎であり全てだと思う。
アイデア次第で無限の可能性が広がるOwnerDrawの世界。
ここでの話がその入り口にたどり着く手掛かりにでもなれば幸いに思う。