レガシーは消えず

リタイアしたプログラマのコーヒータイム

エクセルVBAワークショップ(2)メニュー作成ツールAutoMenuを作る

前回アドインにはメニューが不可欠だというお話をしました。アドイン内のプロシージャを呼ぶためのトリガーとして、メニューがなければならないという話でした。

エクセルのメニューについてはExcel2007からリボンUIが導入されたので、それまで使われていたメニューバーはすっかり影をひそめてしまいました。メニューバーが使えなくなったのかというとそういうことはなく、現在のExcel2019でもまったく支障なく使うことができます。リボンUIのようなデザイン性はありませんが、ツリー表示ができるのでプログラム内の構造を直観的に表現することに優れています。

f:id:mz-80k:20210220211251j:plain:w300:left

メニューバーを追加すると[アドイン]という名前のリボンができます。このリボンの中に追加したメニューツリーが表示されます。


ワークショップの内容

メニューバーの基本については他の多くのサイトにサンプルが載っています。一般的にメニューはロジックを持ちません。どのサイトでも「メニューツリーの作り方」「クリック時に呼び出すプロシージャの登録方法」が説明の中心です。簡単なメニューであれば十数行で実現することができるでしょう。しかし複雑なメニューではどうでしょうか?プログラム行数はメニュー数に比例して増えてきます。工夫してもロジックがないので行数はそれほど少なくはなりません。しかも単純作業です。メニュー数が増えてもメニュー作りが苦にならないような方法はないものでしょうか?

ということで簡単にメニューを作成するツール(AutoMenu)を作ってみました。VBEでAutoMenuを参照登録すればコードを書かなくてもメニューを作ることができます。作りたいメニューの指図書をワークシートに作成しAutoMenuに渡すだけです。今回のワークショップではAutoMenuを実際に作ってみましょう。

1.AutoMenuの概要

AutoMenuを実行するとワークシートのメニュー指図書に従ってメニューが作成されます(AutoMenuはAuto_Openプロシージャで実行します)。作成されたメニューをクリックすると関連付けられたプロシージャが呼ばれます。

f:id:mz-80k:20210222114211j:plain:w400

2.メニュー指図書

メニュー指図書は作成するアドインのワークシートに作成します。メニューのツリー構造、クリック時に実行するプロシージャ、クリックのショートカットキーを登録します。

f:id:mz-80k:20210222094349j:plain:w400

  • A列(NodeMenu)親メニュー
  • B列(LeafMenu)子メニュー
  • C列(onAction)メニュークリックで呼び出すプロシージャ
  • D列(ShortCutKey)メニュークリックのショートカットキー

A列とB列でメニューの親子関係を表現します。子メニューがない場合は親メニューのみで子メニューは空にします。C列はメニューをクリックしたときのプロシージャです。イベントは子メニューに(子メニューがない場合は親メニューに)設定されます。D列はショートカットキーです(記法は"^"と"{}"を知っておけば十分でしょう。"^q"はCtrl+q、"{F10}"はF10の意味です)。またセルの上下罫線でメニューの区切り線を判断します。図の子メニューの上下罫線を削除すればメニューの区切りがなくなります。

3.AutoMenuを実行するまで

ここからは完成したAutoMenuがアドインフォルダ(C:\Users\<ユーザー>\AppData\Roaming\Microsoft\AddIns)に保存されているものとして話を進めます。AutoMenuの作成についてはそのあとで説明します。
テスト用に新しいプロジェクトを作成してください。プロジェクト名は説明の都合上"VBAワークショップ"とします。

(1)ワークシートにメニュー指図書をつくる

f:id:mz-80k:20210224111740j:plain:w200:left

アドインも通常のブックですからワークシートを持っているのですが、通常は非表示になっていて操作することはできません。ワークシートを表示させるにはVBEでThisWorkBookのisAddinプロパティをFalseにします。するとエクセルのウィンドウにワークシートが表示され、通常の操作が可能となります。

このワークシートに前項(2)のメニュー指図書を作っていきます。

NodeMenu LeafMenu onAction ShortCutKey
親メニュー 子メニュー1 Module1.Sub1 ^q
子メニュー2 Module1.Sub2 {F10}
子メニュー3 Module1.Sub3

メニュー指図書が完成したら、isAddinプロパティをTrueに戻しておきます。

(2)AutoMenuを参照設定する

VBAで他のアドインのプロシージャや関数を利用するには参照設定が必要になります。VBEの[ツール]→[参照設定]で表示されるダイアログで[参照]をクリックします。

f:id:mz-80k:20210223113649j:plain:w250
f:id:mz-80k:20210215113518p:plain:w250
f:id:mz-80k:20210225195304j:plain:w250

ファイルの場所にアドインフォルダ(C:\Users\<ユーザー>\AppData\Roaming\Microsoft\AddIns)、ファイルの種類に「Microsoft Excel Files (*.xlsm,・・・)」を指定すると、ペインにAutoMenuが表示されるので選択して[開く]をクリックします。戻ったダイアログのリストにAutoMenuが表示されるのでチェックを確認して[OK]をクリックしてください。

(3)Auto_OpenプロシージャにAutoMenuを記述する

プロジェクトにモジュールを追加します。モジュール名は"Module1"のままでかまいません。"Module1"に以下のコードを記述します。

Const ProjectName = "VBAワークショップ.xlam!VBAワークショップ"
Const RootMenuCaption = "ルートメニュー"
Public myMenu As Collection

Sub Auto_Open()
   Set myMenu = AutoMenu.AutoMenu(RootMenuCaption, ThisWorkbook.Sheets(1), ProjectName)
End Sub
  • ProjectName:"VBAワークショップ.xlam!VBAワークショップ"はプロジェクト名の正式な書式です。プロジェクトを一意にするために、ブック名.拡張子+"!"+プロジェクト名と書きます。前回も触れましたがブック名とプロジェクト名は同じ方が便利です。上の例ではブック名もプロジェクト名も同じ"VBAワークショップ"です。

  • RootMenuCaption:作成するルートメニューのボタンキャプションなので文字列は自由です。

  • myMenu:AutoMenuの戻値を保存するコレクションです。AutoMenuが作成したメニューオブジェクトが作成した順に保存されます(1オリジン)。このコレクションを使ってメニューのプロパティが操作できます。例えばEnabledプロパティをFalseにすればクリック不可にできます。

  • AutoMenu.AutoMenu:AutoMenuプロジェクト内のAutoMenuプロシージャ

  • ThisWorkbook.Sheet(1):メニュー指図書のワークシート

(4)アドインを実行する

VBEの[実行]→[マクロの実行]は開発途中のデバッグ機能です。アドインを正式に実行するためには保存する必要があります。VBEの[ファイル]→[〇〇の上書き保存]を選択して表示されるダイアログで、ファイルの種類を"xlam"にすると保存場所がアドインフォルダ(C:\Users\<ユーザー>\AppData\Roaming\Microsoft\AddIns)に変更されるので、ファイル名をプロジェクト名と同じにして保存、一旦エクセルを終了します。
次にエクセルを起動して[ファイル]→[オプション]で表示されるダイアログの左ペインで[アドイン]、右ペインの下の管理リストが「Excelアドイン」であることを確認して[設定]をクリックします。表示されるダイアログに先ほど保存したファイルがあるのでチェックして[OK]をクリックします(このとき"AutoMenu"も一緒に表示されますが、前述した参照登録用なのでチェックは不要です)。
以上でエクセルの起動と同時にアドインのAuto_Openが実行されます。

f:id:mz-80k:20210223172135j:plain:w250

(5)メニューの動作を確認する

メニュー指図書では「子メニュー1」をクリックすると「Module1.Sub1」を実行するようになっています。VBEでModule1 に Sub1 を作って動作を確かめてみましょう。

Sub Sub1()
   MsgBox "Hello World !!", , ThisWorkbook.Name
End Sub

変更をコンパイル・保存して一度エクセルを終了します。その後エクセルを再起動して「子メニュー1」をクリックすると"Hello World !!"が表示されるはずです。

次はAutoMenuから返されたコレクション(myMenu)を使って、メニューボタンのプロパティを変更してみましょう。

Sub Sub2()
   With myMenu.Item(3)
      If .State = msoButtonUp Then
         .State = msoButtonDown
      Else
         .State = msoButtonUp
      End If
   End With
End Sub
  • myMenu.Item(3):myMenuにはAutoMenuで作られたメニューオブジェクトが作られた順に保存されます。「親メニュー」「子メニュー1」「子メニュー2」「子メニュー3」の順です。OptionBaseを0に指定しても、なぜか1オリジンとなるのでmyMenu(3)は「子メニュー2」です。

  • State:ボタンの状態(msoButtonUpとmsoButtonDown)を設定(取得)できます。リボンUIの導入前は3D表現だったのですが、現在はチェックマークの有無に変更されています。

  • Enabled:ここでは取り上げませんでしたがボタン押下の可否も設定(取得)できます。

4.AutoMenuの作成

最後にAutoMenuの作成です。本来は最初にAutoMenuを作成しないと他の作業ができないのですが、説明の都合で順番が最後になってしまいました。

新規にVBAプロジェクトを作成して、プロジェクト名を"AutoMenu"としてください。つぎにモジュールを追加します。名前は"Module1"のままでかまいません。


まずメインのAutoMenu関数です。他から呼ぶことができるようにpublicで宣言します。

Option Explicit
Option Base 0

Public Function AutoMenu(RootCaption As String, Sheet As Worksheet, ProjectName As String) As Collection
   '見出し辞書を作成
   Dim Header As Object
   Set Header = CreateObject("Scripting.Dictionary")
   If HeaderInitialize(Header, Sheet) = False Then
      Exit Function
   End If
   'ワークシートメニューバーを取得
   Dim WorksheetMenuBar As Object
   Set WorksheetMenuBar = Application.CommandBars("Worksheet Menu Bar")
   'ルートメニューがなければ作成
   Dim Root As Object
   Set Root = addRoot(WorksheetMenuBar, RootCaption)
   'スクリプトを読んでルートメニューにツリーを作成
   Set AutoMenu = addTree(Header, Root, Sheet, ProjectName)
End Function
  • RootCaption:エクセルのリボンに表示されるルートメニューの文字列

  • Sheet:メニュー指図書のあるワークシート

  • ProjectName:呼び出し元のプロジェクト名(ブック名+"."+拡張子+"!"+プロジェクト名)


次にAutoMenu関数から呼ばれる関数です。

  • HeaderInitialize関数:見出し辞書をHeaderに作成。戻り値は見出しのチェック結果(OK=True,NG=False)
Private Function HeaderInitialize(Header As Object, Sheet As Worksheet) As Boolean
   Dim Col As Integer
   Dim Caption As String
   For Col = 1 To 4
      Select Case Col
         Case 1: Caption = "NodeMenu"
         Case 2: Caption = "LeafMenu"
         Case 3: Caption = "onAction"
         Case 4: Caption = "ShortCutKey"
      End Select
      If Sheet.Cells(1, Col).Text <> Caption Then
         MsgBox "メニュー指図書 見出しエラー! " & "(行,列)=(1," & Col & ")" & _
                vbCrLf & vbCrLf & Caption, , Sheet.Name
         HeaderInitialize = False
         Exit Function
      End If
      Header.Add Caption, Col
   Next
   HeaderInitialize = True
End Function
  • addRoot関数:ルートメニューを検索し同じものがなければ追加します
Private Function addRoot(WorksheetMenuBar As Object, RootCaption As String) As Object
   Dim Root As Object
   For Each Root In WorksheetMenuBar.Controls
      If Root.Caption = RootCaption Then
         Set addRoot = Root
         Exit Function
      End If
   Next
   Set addRoot = WorksheetMenuBar.Controls.Add(Type:=msoControlPopup, Temporary:=True)
   addRoot.Caption = RootCaption
End Function
  • addTree関数:メニュー指図書を読みながらメニューツリーを新規作成します
Private Function addTree(Header As Object, Root As Object, Sheet As Worksheet, ProjectName As String) As Collection
   Set addTree = New Collection
   Dim myNode As Object   '親メニュー用ワーク
   Dim myLeaf As Object   '子メニュー用ワーク
   Dim Row As Integer
   Row = 2
   Do While True
      Dim NodeCaption As String   '親メニューのキャプション
      Dim LeafCaption As String   '子メニューのキャプション
      Dim onAction As String      'クリック時の処理
      Dim ShortCutKey As String   'ショートカットキー
      '指図書1行分を取得
      With Sheet
         NodeCaption = .Cells(Row, Header.Item("NodeMenu"))
         LeafCaption = .Cells(Row, Header.Item("LeafMenu"))
         onAction = .Cells(Row, Header.Item("onAction"))
         If onAction <> "" Then
            onAction = ProjectName & "." & onAction
         End If
         ShortCutKey = .Cells(Row, Header.Item("ShortCutKey"))
      End With
      'メニュー作成
      Select Case True
         '親メニューのみ
         Case NodeCaption <> "" And LeafCaption = ""
            Set myNode = addButton(Root, NodeCaption, onAction, ShortCutKey, isBeginGroup(Sheet, Row, 1))
            addTree.Add myNode
         '親子両方あるとき
         Case NodeCaption <> "" And LeafCaption <> ""
            Set myNode = addPopup(Root, NodeCaption, isBeginGroup(Sheet, Row, 1))
            addTree.Add myNode
            Set myLeaf = addButton(myNode, LeafCaption, onAction, ShortCutKey, isBeginGroup(Sheet, Row, 2))
            addTree.Add myLeaf
         '子メニューのみ
         Case NodeCaption = "" And LeafCaption <> ""
            Set myLeaf = addButton(myNode, LeafCaption, onAction, ShortCutKey, isBeginGroup(Sheet, Row, 2))
            addTree.Add myLeaf
         '親子両方ないときは終了
         Case Else
            Exit Do
      End Select
      Row = Row + 1
   Loop
End Function


最後にaddTree関数からコールする関数です。

  • isBeginGroup関数:区切り線の判断(上罫線があればTrue)
Private Function isBeginGroup(Sheet As Worksheet, Row As Integer, Col As Integer) As Boolean
   With Sheet
      .Activate
      .Cells(Row, Col).Select
      isBeginGroup = (Selection.Borders(xlEdgeTop).LineStyle <> xlLineStyleNone)
   End With
End Function
  • addPopup関数:ポップアップメニューを追加する
Private Function addPopup(Parent As Object, Caption As String, BeginGroup As Boolean) As Object
   Set addPopup = Parent.Controls.Add(Type:=msoControlPopup, Temporary:=True)
   With addPopup
      .Caption = Caption
      .BeginGroup = BeginGroup
   End With
End Function
  • addButton関数:ボタンメニューを追加する
Private Function addButton(Parent As Object, Caption As String, onAction As String, ShortCutKey As String, BeginGroup As Boolean) As Object
   Set addButton = Parent.Controls.Add(Type:=msoControlButton, Temporary:=True)
   With addButton
      .Caption = Caption
      .onAction = onAction
      If ShortCutKey <> "" Then
         Application.OnKey ShortCutKey, onAction
      End If
      .BeginGroup = BeginGroup
   End With
End Function


コードが完成したらアドインとして保存しておきます。それからこの記事の最初「1.AutoMenuの概要」に戻って読んでいただくと分かりやすいと思います。

5.最後に

こうしたツールのようなプログラムは単体では動作できないので開発時のデバッグ環境に苦労します。なので最初はプロジェクト内でツール本体とそれを使うデバッグ用プログラムを一緒に開発し、あとでバラして仕上げたりします。今回は(バラしたあとの)完成したものを順序良く説明しようとして肝心の本体の説明が一番最後になってしまいました。

とても読みにくい状態でリリースしてしまいましたが、最後までお読みいただきありがとうございました。