はじめに、オーナードローとはメニューアイテムなどの描画処理をWindows側ではなくプログラム側(オーナー)で行うことを言います。 プログラムで描画処理を記述することができるので、Windowsの画一的なデザインのメニューではなく、全くオリジナルの個性的なメニューを作ることができます。 また、メニューだけでなく、リストボックスなどでもオーナードローを行うことができます。

下図のスクリーンショトは自作のランチャーソフトSylpheenのメニュー項目なのですが、その左側はWindowsがデフォルトで描画したもの、右側がオーナードローで描画したものです。 背景や文字色、選択されている項目のハイライトカラーなどWindowsのものとは異なります。 また、アイコンが表示されていることからもわかるとおり、絵なども表示することができます。 オーナードローでは描画対象のGraphicsオブジェクトが渡されるので、このGraphicsに対して描画処理を施せばこのようなメニューを作ることができます。

0.jpg

Visual Basicでこれを行うにはAPIなどを駆使してやらなければならなかったのですが、VB.NETではその必要がなく、イベントハンドラで行うことができるのでかなり楽になったといえます。


§1 オーナードローを行うには

オーナードローを行うためには、まずOwnerDrawプロパティをTrueに設定します。 Falseにしておくと、オーナードローしない、つまり Windowsに描画を任せてしまうことになります。 次に、MeasureItem、DrawItemイベントに適切なイベントハンドラを割り当てます。 MeasureItemは描画する際に必要な項目の寸法を知るためのものです。 これは項目が表示される直前に呼び出されます。 DrawItem は実際に描画を行うためのものです。 当然のごとく描画が必要になった際に呼び出されます。 ひとまず簡単なサンプルを作ったのでそのソースを見てみましょう。

Public Class formMain
    Inherits System.Windows.Forms.Form

#Region " Windows フォーム デザイナで生成されたコード "
#End Region

    Dim menuMain As ContextMenu
    Dim menuItem1 As MenuItem
    Dim menuItem2 As MenuItem
    Dim menuItem3 As MenuItem
    Dim menuItem4 As MenuItem

    Private Sub formMain_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load

        menuMain = New ContextMenu()

        menuItem1 = New MenuItem("Item1")
        SetOwnerDrawProperties(menuItem1)

        menuItem2 = New MenuItem("Item2")
        SetOwnerDrawProperties(menuItem2)

        menuItem3 = New MenuItem("Item3")
        SetOwnerDrawProperties(menuItem3)

        menuItem4 = New MenuItem("Item4")
        SetOwnerDrawProperties(menuItem4)

        menuMain.MenuItems.Add(menuItem1)
        menuMain.MenuItems.Add(menuItem2)
        menuMain.MenuItems.Add(menuItem3)
        menuMain.MenuItems.Add(menuItem4)

        Me.ContextMenu = menuMain

    End Sub

    Private Sub SetOwnerDrawProperties(ByVal item As MenuItem)

        item.OwnerDraw = True
        AddHandler item.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler item.DrawItem, AddressOf MenuItem_DrawItem

    End Sub

    Private Sub MenuItem_MeasureItem(ByVal sender As System.Object, ByVal e As Windows.Forms.MeasureItemEventArgs)

        e.ItemWidth = 200
        e.ItemHeight = 15

    End Sub

    Private Sub MenuItem_DrawItem(ByVal sender As System.Object, ByVal e As Windows.Forms.DrawItemEventArgs)

        Dim item As MenuItem = CType(sender, MenuItem)

        e.DrawBackground()

        e.Graphics.DrawString(item.Text, Control.DefaultFont, SystemBrushes.ControlText, e.Bounds.X + e.Index * 5, e.Bounds.Y)

        e.DrawFocusRectangle()

    End Sub


End Class
実行結果

このサンプルでは何もコントロールを配置していないフォームformMainにソースコードでコンテキストメニューを作成、さらにメニューアイテムを追加しています。 さらにオーナードローに必要なソースコードも記述されています。

ソースコードを部分毎に説明していきます。 まず13〜36行目までの間では、コンテキストメニューmenuMain及び四つのメニューアイテム menuItem1〜4をを作成し、各々のメニューアイテムをコンテキストメニューのMenuItemsコレクションに追加します。 最後に mainMenu自体もフォームのコンテキストメニューとして登録します。 コンテキストメニューはコントロール上で右クリックした際に表示されるメニューのことです。 そのため、自動的に表示されるので特別表示のためのイベントハンドラは必要ありません。  SetOwnerDrawProperties()ではオーナードローに必要なプロパティの設定を行う処理を記述しています。

38〜44行目のSetOwnerDrawProperties()では、引数にとったMenuItemオブジェクトの OwnerDrawプロパティをTrueにし、MeasureItem・DrawItemイベントに適切なイベントハンドラ MenuItem_MeasureItem・MenuItem_DrawItemを追加しています。

46〜51行目のMenuItem_MeasureItem()では特定の項目の寸法を計算するためのイベントハンドラで、本来ならば表示すべきテキストの幅などから算出するべきなのですが、ここでは簡単のため幅・高さに一律200×15ピクセルを指定しています。

53〜63行目のMenuItem_DrawItem()では実際に特定の項目の描画を行うイベントハンドラです。 引数の DrawItemEventArgsにはGraphicsプロパティがあり、これに対して描画処理を施してやります。 まず、55行目でイベント送信元のメニューアイテムを取得します。 つぎに57行目で項目の背景を描画しています。 このメソッドはデフォルトの描画処理を行うものでオーナードローを行わない場合の背景と同じものが描画されます。 61行目も同様に前景を描画するメソッドです。 59行目でメニュー項目の文字を描画しています。 変化を持たせるために項目のインデックスが増えるにつれ横に5ピクセルずつずれるように描画しています。

59行目について、回りくどい方法で記述してしまいましたが、DrawItemEventArgsにはFontおよびForeColorプロパティがあるので(後で気づいた・・・)この行は次のように簡略化することができます。 「e.Graphics.DrawString(item.Text, e.Font, New SolidBrush(e.ForeColor), e.Bounds.X + e.Index * 5, e.Bounds.Y)」

実際にオーナードローがなされているか確認するにはフォーム上の任意の点で右クリックしてコンテキストメニューを表示させるだけです。

§2 ListBoxのオーナードロー

メニューアイテムでオーナードローをする方法はわかりました。 ただ、いまいちオーナードローっぽくないサンプルだったので、リストボックスの場合でもう一度やってみます。

リストボックスの場合はOwnerDrawプロパティではなくDrawModeプロパティをOwnerDrawFixedまたは OwnerDrawVariableに指定することでオーナードローを有効にします。 Fixedの方は、項目のサイズは変更できず、デフォルトの項目の大きさでしか描画できません。 対してVariableは項目の大きさを変更して描画することができます。

ここではOwnerDrawVariableを指定してリストボックスでオーナードローを行い、色指定用のリストメニューを作成してみることにします。 コーディングする前にフォーム上にlistBoxColorという名前でリストボックスを追加しておいて下さい。

Public Class formMain
    Inherits System.Windows.Forms.Form

#Region " Windows フォーム デザイナで生成されたコード "
#End Region

    Private Sub formMain_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load

        listBoxColor.DrawMode = DrawMode.OwnerDrawVariable

        AddHandler listBoxColor.MeasureItem, AddressOf ListBox_MeasureItem
        AddHandler listBoxColor.DrawItem, AddressOf ListBox_DrawItem

        listBoxColor.Items.Clear()

        listBoxColor.BeginUpdate()

        listBoxColor.Items.Add(Color.Red)
        listBoxColor.Items.Add(Color.Orange)
        listBoxColor.Items.Add(Color.Yellow)
        listBoxColor.Items.Add(Color.GreenYellow)
        listBoxColor.Items.Add(Color.Green)
        listBoxColor.Items.Add(Color.Blue)
        listBoxColor.Items.Add(Color.Purple)
        listBoxColor.Items.Add(Color.ForestGreen)
        listBoxColor.Items.Add(Color.MediumSlateBlue)
        listBoxColor.Items.Add(Color.SkyBlue)
        listBoxColor.Items.Add(Color.Cornsilk)

        listBoxColor.EndUpdate()

    End Sub

    Private Sub ListBox_MeasureItem(ByVal sender As System.Object, ByVal e As Windows.Forms.MeasureItemEventArgs)

        e.ItemWidth = 200
        e.ItemHeight = 20

    End Sub

    Private Sub ListBox_DrawItem(ByVal sender As System.Object, ByVal e As Windows.Forms.DrawItemEventArgs)

        ' 背景を白でクリア
        e.Graphics.FillRectangle(New SolidBrush(Color.White), e.Bounds)

        Dim listBox As ListBox = CType(sender, ListBox)
        Dim col As Color = CType(listBox.Items(e.Index), Color)
        Dim b As New SolidBrush(col)

        ' リストアイテムの色で■を描画
        e.Graphics.FillRectangle(b, New Rectangle(e.Bounds.X + 5, e.Bounds.Y + 5, 10, 10))

        Dim sizeText As SizeF = e.Graphics.MeasureString(col.ToString, e.Font)

        ' リストアイテムのテキストを描画
        e.Graphics.DrawString(col.ToString, e.Font, b, e.Bounds.X + 25, e.Bounds.Y + (20 - sizeText.Height) / 2)

        ' 項目が選択されている場合
        If e.State And DrawItemState.Selected Then

            Dim boundFocus As New Rectangle(e.Bounds.X + 1, e.Bounds.Y + 1, listBox.GetItemRectangle(e.Index).Width - 2, listBox.GetItemRectangle(e.Index).Height - 2)
            Dim colFocus As Color = Color.FromArgb(&H20, col.R, col.G, col.B) ' αチャンネルを使用
            Dim bFocus As New SolidBrush(colFocus)
            Dim p As New Pen(col)

            ' ハイライト表示
            e.Graphics.FillRectangle(bFocus, boundFocus)
            e.Graphics.DrawRectangle(p, boundFocus)

            bFocus.Dispose()
            p.Dispose()

        End If

        b.Dispose()

    End Sub


End Class
実行結果

このプログラムを実行するとこのような感じになります。 右は同様のことをドロップダウンリストのコンボボックスで行ってみた例です。 多少のコード変更だけで作れます。

§3 MenuItemのオーナードロー

まず始めに、MenuItemクラスを用いてオーナードローを行う場合、OwnerDrawプロパティをTrueに設定し、MeasureItem及び DrawItemイベントに適切なイベントハンドラを指定します。 MeasureItemではオーナードローされる場合における項目毎の寸法を計算するもで、DrawItemでは実際にオーナードローを行います。 基本的にMeasureItemイベントハンドラでは、メニュー項目で指定されている文字列のサイズを計算し、それをもって項目のサイズとします。 また、オーナードローを使用するでは、Textプロパティに"-"を指定してもセパレータとしては扱われないので、この文字列が指定されているメニュー項目ではセパレータの描画をするコードを付け加えなければなりません。

次のコードでは、オーナードローされたコンテキストメニューを作成し、テキストボックスが右クリックされたときに表示させるコンテキストメニューを指定しています。 これを実行する場合は、テキストボックスを右クリックすればコンテキストメニューが表示されるはずです。

Option Strict On

Public Class Form1
    Inherits System.Windows.Forms.Form

#Region " Windows フォーム デザイナで生成されたコード "
#End Region

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load

        ' コンテキストメニューのインスタンスを作成
        Dim contextMenu As New ContextMenu()

        ' メニュー項目のインスタンスへの参照を一時的に保持するための変数
        Dim menuItem As MenuItem

        ' メニュー項目のインスタンスを作成し、コンテキストメニューに追加
        menuItem = New MenuItem("切り取り")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        menuItem = New MenuItem("コピー")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        menuItem = New MenuItem("張り付け")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        menuItem = New MenuItem("削除")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        menuItem = New MenuItem("-")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        menuItem = New MenuItem("すべて選択")
        menuItem.OwnerDraw = True
        AddHandler menuItem.MeasureItem, AddressOf MenuItem_MeasureItem
        AddHandler menuItem.DrawItem, AddressOf MenuItem_DrawItem
        contextMenu.MenuItems.Add(menuItem)


        ' テキストボックスを作成し、コンテキストメニューを指定。
        Dim textBox As New TextBox()

        textBox.Location = New Point(50, 20)
        textBox.Size = New Size(200, 25)
        textBox.ContextMenu = contextMenu

        Me.Controls.Add(textBox)

    End Sub


    ' メニュー項目のサイズを計算するためのイベントハンドラ
    Private Sub MenuItem_MeasureItem(ByVal sender As Object, ByVal e As MeasureItemEventArgs)

        ' メニュー項目を取得
        Dim menuItem As MenuItem = DirectCast(sender, MenuItem)

        ' メニュー項目に指定されている文字列のサイズを計算する
        ' フォントはシステムで指定されているメニューのフォントを使用
        Dim size As SizeF = e.Graphics.MeasureString(menuItem.Text, SystemInformation.MenuFont)

        ' セパレータの場合は、幅と高さを直接指定
        If menuItem.Text = "-" Then

            size.Width = 20
            size.Height = 5

        End If

        ' 取得できたサイズをメニュー項目のサイズとする
        e.ItemWidth = CInt(size.Width)
        e.ItemHeight = CInt(size.Height)

    End Sub


    ' メニュー項目のオーナードローを行うためのイベントハンドラ
    Private Sub MenuItem_DrawItem(ByVal sender As Object, ByVal e As DrawItemEventArgs)

        ' メニュー項目を取得
        Dim menuItem As MenuItem = DirectCast(sender, MenuItem)

        ' 背景をグラデーションで描画
        Dim gradPoint1 As New Point(e.Bounds.Left, e.Bounds.Y) 'グラデーションの始点
        Dim gradPoint2 As New Point(e.Bounds.Right, e.Bounds.Y) 'グラデーションの終点
        Dim gradBrush As New Drawing2D.LinearGradientBrush(gradPoint1, gradPoint2, Color.LightSkyBlue, Color.White)

        e.Graphics.FillRectangle(gradBrush, e.Bounds)

        gradBrush.Dispose()

        ' セパレータ以外の場合
        If menuItem.Text <> "-" Then

            ' 文字を描画
            e.Graphics.DrawString(menuItem.Text, SystemInformation.MenuFont, Brushes.DarkBlue, e.Bounds.X, e.Bounds.Y)

        Else

            ' セパレータを描画
            Dim point1 As New PointF(e.Bounds.Left + 5, e.Bounds.Y + e.Bounds.Height / 2.0F)
            Dim point2 As New PointF(e.Bounds.Right - 5, point1.Y)

            e.Graphics.DrawLine(Pens.DarkBlue, point1, point2)

        End If

        ' 選択されている項目の場合、半透明のボックスを描画して強調
        If (e.State And DrawItemState.Selected) = DrawItemState.Selected Then

            Dim b As New SolidBrush(Color.FromArgb(&H60, &H0, &HA0, &HFF))

            e.Graphics.FillRectangle(b, e.Bounds)

            b.Dispose()

        End If

    End Sub


End Class
実行結果

§3.1 デフォルト指定の描画

オーナードローをする場合でも、描画処理をデフォルトのシステムカラーなどで行うこともできます。 DrawItemEventArgsクラスには、背景やフォーカスを描画するメソッドや、前景・背景色、フォントなどのプロパティがあるため、これらを参照することができます。 次のコードは実際にそれを用いて描画した場合の例です。 MenuItem_DrawItem()メソッド以外はすべて前のコードと同じです。

' メニュー項目のオーナードローを行うためのイベントハンドラ
Private Sub MenuItem_DrawItem(ByVal sender As Object, ByVal e As DrawItemEventArgs)

    ' メニュー項目を取得
    Dim menuItem As MenuItem = DirectCast(sender, MenuItem)

    ' デフォルトの背景を描画
    e.DrawBackground()

    ' セパレータ以外の場合
    If menuItem.Text <> "-" Then

        ' デフォルトの前景色で文字を描画
        Dim b As New SolidBrush(e.ForeColor)

        e.Graphics.DrawString(menuItem.Text, e.Font, b, e.Bounds.X, e.Bounds.Y)

        b.Dispose()

    Else

        Dim point1 As New PointF(e.Bounds.Left + 5, e.Bounds.Y + e.Bounds.Height / 2.0F)
        Dim point2 As New PointF(e.Bounds.Right - 5, point1.Y)

        ' デフォルトの前景色でセパレータを描画
        Dim p As New Pen(e.ForeColor)

        e.Graphics.DrawLine(p, point1, point2)

        p.Dispose()

    End If

    ' デフォルトでのフォーカス描画
    e.DrawFocusRectangle()

End Sub
実行結果

ただ、この場合でもセパレータだけは自分で描画する必要があります。

§3.2 メニュー項目の状態の取得と描画

チェック状態や選択状態を取得するには、DrawItemEventArgs.Stateプロパティを参照します。 このメンバは列挙型の値で、メニュー項目に関する様々な状態が格納されます。 この状態にあわせてオーナードローする場合は、次のようにして状態ごとに描画処理を記述します。

If (e.State And DrawItemState.Checked) = DrawItemState.Checked Then

    ' チェックされている時の描画

ElseIf (e.State And DrawItemState.Grayed) = DrawItemState.Selected Then

    ' 選択されている時の描画

ElseIf (e.State And DrawItemState.Disabled) = DrawItemState.Disabled Then

    ' 無効な状態(EnabledにFalseが指定)されている時の描画

ElseIf (e.State And DrawItemState.None) = DrawItemState.None Then

    ' 特に状態が無い(平常時の)ときの描画

End If

この方法によってチェック状態の判別などはができますが、メニュー項目の横にチェックマークなどは入らないので、これも独自に描画しなければなりません。 独自に描画するのがめんどくさい場合は、ControlPaintクラスのDrawMenuGlyph()メソッドを使用することができます。 ただ、このメソッドではチェックマークは描画されますが、白い背景も同時に描画されてしまうので、デザインを重視する場合はやはり独自の描画をするべきかと思います。

淡色表示・チェック項目の表示
' メニュー項目のオーナードローを行うためのイベントハンドラ
Private Sub MenuItem_DrawItem(ByVal sender As Object, ByVal e As DrawItemEventArgs)

    ' メニュー項目を取得
    Dim menuItem As MenuItem = DirectCast(sender, MenuItem)

    ' 背景をグラデーションで描画
    Dim gradPoint1 As New Point(e.Bounds.Left, e.Bounds.Y) 'グラデーションの始点
    Dim gradPoint2 As New Point(e.Bounds.Right, e.Bounds.Y) 'グラデーションの終点
    Dim gradBrush As New Drawing2D.LinearGradientBrush(gradPoint1, gradPoint2, Color.LightSkyBlue, Color.White)

    e.Graphics.FillRectangle(gradBrush, e.Bounds)

    gradBrush.Dispose()

    ' セパレータ以外の場合
    If menuItem.Text <> "-" Then

        ' 文字を描画
        If (e.State And DrawItemState.Disabled) = DrawItemState.Disabled Then

            ' 無効状態の時
            e.Graphics.DrawString(menuItem.Text, SystemInformation.MenuFont, Brushes.Gray, e.Bounds.X + 16, e.Bounds.Y)

        Else

            ' 通常状態の時
            e.Graphics.DrawString(menuItem.Text, SystemInformation.MenuFont, Brushes.DarkBlue, e.Bounds.X + 16, e.Bounds.Y)

        End If

        ' チェックされているとき
        If (e.State And DrawItemState.Checked) = DrawItemState.Checked Then

            ' チェックマークの描画範囲
            Dim r As New Rectangle(e.Bounds.Left + 2, e.Bounds.Top + 2, 12, 12)

            ' チェックマークを描画
            ControlPaint.DrawMenuGlyph(e.Graphics, r, MenuGlyph.Checkmark)

        End If

    Else

        ' セパレータを描画
        Dim point1 As New PointF(e.Bounds.Left + 5, e.Bounds.Y + e.Bounds.Height / 2.0F)
        Dim point2 As New PointF(e.Bounds.Right - 5, point1.Y)

        e.Graphics.DrawLine(Pens.DarkBlue, point1, point2)

    End If

    ' 選択されている項目の場合、半透明のボックスを描画して強調
    If (e.State And DrawItemState.Selected) = DrawItemState.Selected Then

        Dim b As New SolidBrush(Color.FromArgb(&H60, &H0, &HA0, &HFF))

        e.Graphics.FillRectangle(b, e.Bounds)

        b.Dispose()

    End If

End Sub
実行結果

このコードでは「コピー」の項目のCheckedプロパティをTrue、「貼り付け」EnabledプロパティをFalseに指定してオーナードローしたものです。 淡色表示やチェックマークの表示はMenuItem_DrawItem()メソッドで行いますが、チェックマークを挿入するために MenuItem_MeasureItem()でのサイズ計算の式を多少変更しています。 具体的には、文字を横にずらすために幅を少し広めに取っています。

§4 タスクトレイ上のコンテキストメニューに関するバグ

(注)これは.NET Framework version 1.0上での結果です。 .NET Framework version 1.1では改善されているかもしれませんが、まだ実験していません。

結論から先に言うと、オーナードローを利用したコンテキストメニューをNotifyIconクラスのインスタンスに対して指定した場合、正しく描画されません。 まず、次のようにコードを変えてタスクトレイ上にアイコンを表示させます。

Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load

    '
    ' メニュー項目を作成するコードは前述のものと同じ。
    '


    ' 通知アイコンを作成し、コンテキストメニューを指定。
    Dim notifyIcon As New NotifyIcon()

    notifyIcon.Visible = True
    notifyIcon.Icon = Me.Icon
    notifyIcon.ContextMenu = contextMenu
    notifyIcon.Text = "MenuItemOwnerDraw"

End Sub

そして、これをコンパイル・実行したあと、タスクトレイに現れたアイコンを右クリックすると次のようになってしまいます。

実行結果

これは意図的にこのようにしたのではなく、本当にこのようになってしまいます。 つまり、タスクトレイ上のアイコンからはオーナードローしたメニューを正しく表示することができないのです。 これはどうも.NET Framework自体のバグのようなので、これを回避する唯一の手段は、オーナードローを使用しないことしかありません。