VBAでユーザーフォームのHWNDを取得する(WindowFromAccessibleObject)

# 概要

VBAのユーザーフォームからHWND(ウィンドウハンドル)を取得する方法についてまとめた記事です。
HWNDを取得することで、他のWin32APIによりユーザーフォームの挙動を拡張することができるようになります。

## 動作確認環境

|項目|バージョン|
|:-|:-|
|Windows|11 Pro 23H2|
|VBA|7.1.1136|
|VBAホスト|Microsoft® Excel® for Microsoft 365 MSO (バージョン 2403 ビルド 16.0.17425.20176) 64 ビット/ 32 ビット|

## 記事内で使用するWin32API

|関数名|説明|
|:-|:-|
|[WindowFromAccessibleObject](https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nf-oleacc-windowfromaccessibleobject)|ユーザーフォームからHWNDを取得するメイン関数です。以前はこれだけで問題無かったはずですが、[動作確認環境](#動作確認環境)では意図したように動作しないため以下の関数を追加で使用します。|
|[GetClassNameW](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getclassnamew)|取得したHWNDが意図したものかどうか判定するために使用します。|
|[GetParent](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getparent)| 取得したHWNDが意図したものではなかった場合に階層を移動するのに使用します。|

# VBA のコード

```vb:ユーザーフォームのHWNDを取得する
'ユーザーフォームなど IAccessible を実装するオブジェクトから HWND を取得する。
'https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nf-oleacc-windowfromaccessibleobject
Private Declare PtrSafe _
    Function WindowFromAccessibleObject Lib "Oleacc.dll" ( _
        ByVal inIAccessible As Office.IAccessible, _
        ByRef phWnd As LongPtr _
    ) As Long 'HRESULT

'HWND のクラス名を取得する。
'https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getclassnamew
Private Declare PtrSafe _
    Function GetClassNameW Lib "User32.dll" ( _
        ByVal hWnd As LongPtr, _
        ByVal lpClassName As LongPtr, _
        ByVal nMaxCount As Long _
    ) As Long 'Used Text Length

'HWND の親ウィンドウを取得する。
'https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getparent
Private Declare PtrSafe _
    Function GetParent Lib "User32.dll" ( _
        ByVal hWnd As LongPtr _
    ) As LongPtr 'Parent HWND


Public Function GetUserFormHwndAsThunderDFrame( _
        ByVal inUserForm As Office.IAccessible _
    ) As LongPtr 'HWND
'ユーザーフォームから ThunderDFrame としての HWND を取得する。
'https://qiita.com/nukie_53/items/39c28d000d521329548b
'inUserForm :VBA の UserForm を指定する。 UserForm 内から呼び出す場合は Me を指定すればよい。
'           :Dim h As LongPtr
'           :h = GetUserFormHwndAsThunderDFrame(Me)
'return     :inUserForm の ThunderDFrame としての HWND を返す。意図したクラス名を取得できなかった場合はエラー。
240512
    Const ExpectClassName = "ThunderDFrame"
    If inUserForm Is Nothing Then Err.Raise 91
    
    'inUserForm から HWND を取得する。
    Dim hWnd1 As LongPtr
    Dim hr As Long 'HRESULT
    hr = WindowFromAccessibleObject(inUserForm, hWnd1)
    
    '成功した場合は S_OK 、失敗した場合はそれ以外の値となる。
    Const S_OK = 0
    If hr <> S_OK Then Err.Raise 5, , "ユーザーフォームのHWNDを取得できませんでした。"
    
    'HWND のクラス名を確認。
    
    '想定されるクラス名は長くても20文字程度。
    Dim classNameBuffer As String
    classNameBuffer = VBA.Strings.String(20, 0)
    
    'hWnd1 からクラス名を取得。
    Dim usedLen As Long
    usedLen = GetClassNameW(hWnd1, VBA.[_HiddenModule].StrPtr(classNameBuffer), VBA.Strings.Len(classNameBuffer))
    
    Dim className1 As String
    className1 = VBA.Strings.Left$(classNameBuffer, usedLen)
    
    If className1 = ExpectClassName Then
        '意図したクラス名なのでここで終了。
        Let GetUserFormHwndAsThunderDFrame = hWnd1
        Exit Function
    End If
    
    If className1 Like "F3 Server *" Then
        '365 環境？だと、"F3 Server eb8e0000"のようなクラス名になる。
        'この場合は、親の HWND をたどれば ThunderDFrame を取得できる。
        'Next
    Else
        Err.Raise 5, , "意図したクラス名のHWNDを取得できませんでした。" & vbLf & "HWND : " & hWnd1 & vbLf & "ClassName : " & className1
    End If
    
    '親の HWND を取得する。
    Dim hWnd2 As LongPtr
    hWnd2 = GetParent(hWnd1)
    
    'クラス名を確認。
    usedLen = GetClassNameW(hWnd2, VBA.[_HiddenModule].StrPtr(classNameBuffer), VBA.Strings.Len(classNameBuffer))
    
    Dim className2 As String
    className2 = VBA.Strings.Left$(classNameBuffer, usedLen)
    
    If className2 = ExpectClassName Then
        Let GetUserFormHwndAsThunderDFrame = hWnd2
        Exit Function
    End If
    
    Err.Raise 5, , "意図したクラス名のHWNDを取得できませんでした。" & vbLf & "HWND : " & hWnd2 & vbLf & "ClassName : " & className2
End Function
```

## コードの解説

### WindowFromAccessibleObject

```vb
    'inUserForm から HWND を取得する。
    Dim hWnd1 As LongPtr
    Dim hr As Long 'HRESULT
    hr = WindowFromAccessibleObject(inUserForm, hWnd1)
    
    '成功した場合は S_OK 、失敗した場合はそれ以外の値となる。
    Const S_OK = 0
    If hr <> S_OK Then Err.Raise 5, , "ユーザーフォームのHWNDを取得できませんでした。"
```

上記の箇所で、ユーザーフォーム自身を示す HWND を取得します。

https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nf-oleacc-windowfromaccessibleobject

```c:WindowFromAccessibleObject
HRESULT WindowFromAccessibleObject(
  [in]  IAccessible *unnamedParam1,
  [out] HWND        *phwnd
);
```

WindowFromAccessibleObjectは上記のように定義されています。

`IAccessible *unnamedParam1`は`IAccessible`へのポインタを渡すということになりますが、VBAの`IAccessible`はCOMオブジェクトであり、オブジェクト=参照型=変数にはポインタが入っている、となるため、ByValで`IAccessible`を渡します。

`HWND        *phwnd`はHWNDへのポインタを渡すということになりますが、HWNDはVBAではLongPtrという値型で表現するため、そのままByRefでLongPtr変数を渡してあげます。
結果はHRESULTとなるため、0(=S_OK)であれば成功、それ以外であれば失敗となります。

```vb
'ユーザーフォームなど IAccessible を実装するオブジェクトから HWND を取得する。
'https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nf-oleacc-windowfromaccessibleobject
Private Declare PtrSafe _
    Function WindowFromAccessibleObject Lib "Oleacc.dll" ( _
        ByVal inIAccessible As Office.IAccessible, _
        ByRef phWnd As LongPtr _
    ) As Long 'HRESULT
```

#### (参考)IAccessibleとは？

MSAA(Microsoft Active Accessibility)というアクセシビリティ関連技術で使われるインターフェイス(VBAにおける型のようなもの)です。

https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nn-oleacc-iaccessible

https://learn.microsoft.com/ja-jp/windows/win32/winauto/microsoft-active-accessibility

タイプライブラリ`Microsoft Office 16.0 Object Library`の非表示メンバーとして型の定義が公開されています。
後継技術であるUI Automationのタイプライブラリ`UIAutomationClient`でもIAccessibleの定義が公開されています(こちらでも非表示メンバーです)。

# (参考)ほかの方法

## FindWindow

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-findwindoww

FindWindowW関数を利用して、クラス名=`ThunderDFrame`、ウィンドウ名=フォームのCaptionとして検索するのも比較的メジャーな方法です。

この方法でも多くの場合は問題無く動作しますが、同名のユーザーフォームが複数存在する場合に意図しない挙動になりうるため注意が必要です。

同名のユーザーフォームが存在する状況としては、たまたま一致してしまう場合の他にMS Officeのアドインとしてフォームを表示している場合などが考えられます。

HWNDを取得する際に、一時的にフォームのCaptionを変更するなどの方法で対策はできますが、Captionを変更すると`SetWindowLong`などで設定した内容が初期化されるなどの問題もあるため注意が必要です。




# 参考ページ

## Microsoft 公式

https://learn.microsoft.com/ja-jp/windows/win32/api/oleacc/nf-oleacc-windowfromaccessibleobject

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getclassnamew

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-getparent

## その他

ここのコードを記載しているGitHub

https://github.com/imihito/imihito_Sandbox/blob/master/VBA_UserForm/UserFormHWND/M_UserForm.bas

自分のX

https://twitter.com/nukie_53/status/1787844389232124007

# 関連記事

https://qiita.com/nukie_53/items/6a88ce82565e238a3c71