OwnerDrawを使いこなす為の入り口になるかもしれない話。
目次
1. 下準備
2. 注意事項
3. 描画順序と範囲
4. 背景描画
5. 文字描画
下準備
至極まっとうな5行4列のListView、OwnerDrawを行うのでもちろんOwnerDraw = true
が必要
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
実行すると何の変哲もないListViewの完成
注意事項
まったく知識のない状態でいきなり注意点を書くのもどうかと思うが、頭の片隅に置いておくと助かることもあるので一応書いておく、読み飛ばしても問題はない
ListViewのFullRowSelect
プロパティがTrue
の時、かつDrawItem部分で背景描画の処理を行っている場合、初回のマウスホバー時にSubItemが背景色で塗りつぶされる現象が起こる。
マウスポインターが行上に移動すると、詳細ビューで行ごとに1回、DrawSubItemイベントを伴わずにDrawItemイベントが発生し、DrawSubItemイベントハンドラーで描画されたものがカスタム背景によって塗りつぶされるという既知のバグの為である
詳細は以下
通常DrawItemで背景を、DrawSubItemで文字などを描画するのでFullRowSelectを使用する場合は対策が必要となる。
いろいろ試してみた結果、ListView.HideSelection プロパティがTrueの時、
マウスポインターが行上に移動するだけでDrawItemが呼ばれるが、DrawSubItemは呼ばれない。
一方、HideSelectionがFalseの時、DrawListViewSubItemEventArgsのe.Stateの内容は正しくない値になるが、描画が必要な時はe.Stateに0以外の値が入るので、e.Stateが0の時を無視すれば思った通りの動作を行う。気がする
HideSelectionの規定値はNET Framework 4.8以降はFalse。
起動後、Item0から順番にカーソルを通過させている途中の図、GridLineが塗りつぶされているのが分かる。このような現象に陥った時に、上記のバグのことが頭の片隅にあると多少解決が早まるかもしれない。
順序と範囲
DrawColumnHeader
はカラムの数だけ呼ばれる。
DrawItem
とDrawSubItem
は、DrawItem
が呼ばれ、その後、属するSubItem数分DrawSubItem
が呼ばれる。
今回利用する5行4列のListViewの場合
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
により提供される
DrawItemのDrawListViewItemEventArgs
.Bounds
には描画するListViewItemのサイズと位置
をが記されている。
例として2行目のBounds
は、{X=0,Y=43,Width=240,Height=19}
このような値。
それに従い2行目だけに枠線を引いてみると下図のようなことになる
一部GridLineと被る部分がある。枠線が1ピクセル幅なので、1ピクセルずつずれたような矩形となる。
DrawSubItemのDrawListViewSubItemEventArgs
.Bounds
には描画するListViewSubItemのサイズと位置
をが記されている。
例として2行1列目のBounds
は、{X=0,Y=43,Width=60,Height=19}
このような値である。
枠線を引くと下図のようになる
2行2列目のBounds
は、{X=60,Y=43,Width=60,Height=19}
で
なんと両端がGridLineの餌食に。
Boundsは上記のような感じ、当然サイズと位置が入っているだけなのでそれに従う必要は全くない。DrawItemですべてを描画してもいいし、DrawSubItemの好きな位置ですべてを描いてもいい。が、通常はDrawItemで背景、DrawSubItemで文字を描くと収まりがいい、自分もそうしている。
背景描画
ここからが本題、まずはDrawItem内で背景を描いてみる。
DrawListViewItemEventArgs
.Graphics
に対して好きに描ける。
矩形、多角形、楕円、円弧、画像もテキストもなんでもOK。
コントロールを描画する場合はRenderer
を利用する。フォーカス枠が描きたい場合はControlPaint.DrawFocusRectangle
なんてのもある。もちろんフォーカス枠をDrawRectangleで描いても良い。
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では今は何も行わない */ }
適当に図形を配置した上記コードの実行結果が下図
e.Graphics
をキャンバスに好きに描けることが確認できる。
ちなみに、上図のチェックボックスはあくまでも画像なのでクリックしても変化は起こらない。
文字描画
基本的には背景と同様、e.Graphics
に好きに描くといい。
文字を描く方法は何通りかある。
e.DrawText
を使用すると、位置や色が指定できない。
e.Graphics.DrawString
を使用すると、それなりに描ける。
TextRenderer.DrawText
を使用すると、ListViewと同様の描画ができる。
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);
}
上から順に、e.DrawText
、e.Graphics.DrawString
、TextRenderer.DrawText
e.Graphics.DrawString
とTextRenderer.DrawText
の違いは数字を描くと表れる。
さらに、背景の上に掛かれるので描画位置は背景を考慮する必要がある。上図は全く考慮せずに描画したので重なりあい大変なことになっている。
チェックボックスが表示されているならその分ずらす必要があるし、カラム幅が文字の全長よりも狭い場合はその処理も必要。こだわりだすときりがない。
ここで唐突に、知っておくと何かの助けになる事柄を一つ
画像でもテキストでも、描画してみて何か違うなと思ったら、別の方法をとると解決することがある。例えばSmallImageListの画像を表示する場合、e.Graphics.DrawImage
で描くと上手くいかないことでも、ListView.SmallImageList.Draw
を使うとListViewと同様の画像が描画できる。
あてもなく綴ってきたが、以上がOwnerDrawの基礎の基礎であり全てだと思う。
アイデア次第で無限の可能性が広がるOwnerDrawの世界。
ここでの話がその入り口にたどり着く手掛かりにでもなれば幸いに思う。