ドラッグドロップ時にイメージを表示する

# はじめに
[Windowsエクスプローラーに存在しないファイルをドロップする（Part4）](https://qiita.com/miswil/items/39dfdffde6df0d2a384c)の続きですが、この記事単体でも読めます。Windowsエクスプローラーではドラッグ中に↓こういうイメージが表示されますが、それを自前のデスクトップアプリケーションでも表示する方法です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247638/3531c1ff-3d22-29d1-a778-f02a0c5784ad.png)

# 実装
## WinForms
WinFormsでは実は組み込みの機能提供があります。`Control.DoDragDrop`を呼ぶ際に`Bitmap`を渡してやることで簡単にドラッグ中のイメージ表示ができるのです。
```csharp
dwEffect = control.DoDragDrop(data, allowedEffects, dragImage, cursorOffset, useDefaultDragImage);
```
ドラッグ中のテキスト表示には`DragEnter`などのイベントハンドラーにテキストを設定してやります。
```csharp
private void Form1_DragEnter(object sender, DragEventArgs e)
{
    e.Effect = DragDropEffects.Link;
    e.DropImageType = DropImageType.Link;
    e.Message = "この文字列が表示される";
```
WinForms歴の長い人でも知らない人がいるのはないでしょうか。なんと、この機能が追加されたのは.NET7以降[^1]。LTSという意味では.NET8以降です。2022年になってWinFormsはまだ進化する。しかもこういうあってもなくても微妙に困らないところを機能追加する。ちょっと感動しています。
しかしどこか処理が甘いのか、エクスプローラー上でドラッグしたときにイメージが消えずに残る現象が起きがちです。どうもWinFormsアプリ内でのドラッグドロップを念頭においたような経緯がありそう[^2]で、あまりエクスプローラーのことは考えてないのかもしれません。
そこも解消したものが欲しければ、自分で直してPL投げるか、WPF版と同じやり方をします。
## WPF
WPFといいつつ、COM実装なので言語・フレームワーク依らず汎用ソリューションです。やり方を知っているかどうかだけなのですが、どうにもそのやり方がまとまっていない。そしてやるのもまあまあ面倒くさい。そのためにこの記事にまとめているわけですが。情報源は以下のリンクです。
* [Shell Data Object](https://learn.microsoft.com/en-us/windows/win32/shell/dataobject)
* [Shell Clipboard Formats#DragWindow](https://learn.microsoft.com/en-us/windows/win32/shell/clipboard#dragwindow)
### `IDataObject`インタフェース
まずCOMの[`IDataObject`](https://learn.microsoft.com/en-us/windows/win32/api/objidl/nn-objidl-idataobject)インタフェースをを実装してやります。[Part4](https://qiita.com/miswil/items/39dfdffde6df0d2a384c)では渡したいデータだけを`GetData`か`GetDataHere`で渡すように実装していましたが、ドラッグイメージを表示するには`SetData`で動的に受け取って`GetData`で返せるようにする必要があります。
[`SetData`](https://learn.microsoft.com/en-us/windows/win32/api/objidl/nf-objidl-idataobject-setdata)メソッドはそれほど難しくはありません。受け取ったデータを、重複チェックののち内部に保持するだけです。実装するうえで注意が必要なのは[`GetData`](https://learn.microsoft.com/en-us/windows/win32/api/objidl/nf-objidl-idataobject-getdata)および[`GetDataHere`](https://learn.microsoft.com/en-us/windows/win32/api/objidl/nf-objidl-idataobject-getdatahere)の実装です。各メソッドの説明を見てもらえればわかる通り、`GetData`の`out`変数または`GetDataHere`の`ref`変数で返した`STGMEDIUM`は受け取った側で解放してしまいます。そのため返す`STGMEDIUM`は`SetData`で受け取ったデータをディープコピーする必要があります。解放の詳細は[`ReleaseStgMedium`関数](https://learn.microsoft.com/en-us/windows/win32/api/ole2/nf-ole2-releasestgmedium)もご覧ください。ディープコピーできると思しき[`CopyStgMedium`関数](https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms775093(v=vs.85))というものもあるのですが、これは詳細な仕様がよくわからないうえに、実装されているライブラリが他と異なったりして怪しいので今回はあえて避けました。また、`GetDataHere`の場合には渡された`STGMEDIUM`に直接書き込んでやる必要があり、メモリの再取得などは許されていない点にも注意が必要です。
### `IDragSourceHelper2`インタフェース
[`IDragSourceHelper2`インタフェース](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl/nn-shobjidl-idragsourcehelper2)を定義します。
```csharp:IDragSourceHelper2.cs
[ComImport]
[Guid("83E07D0D-0C5F-4163-BF1A-60B274051E40")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IDragSourceHelper2
{
    void InitializeFromBitmap(
        [In][MarshalAs(UnmanagedType.Struct)] ref ShDragImage dragImage,
        [In][MarshalAs(UnmanagedType.Interface)] IComDataObject dataObject);
    void InitializeFromWindow(
        [In] IntPtr hwnd,
        [In] ref Win32Point pt,
        [In][MarshalAs(UnmanagedType.Interface)] IComDataObject dataObject);
    void SetFlags(
        [In][MarshalAs(UnmanagedType.I4)] DSH_FLAGS dwFlags);
}
```
見ての通りCOMインタフェースです。`InitializeFromWindow`という気になるメソッドがありますが、まさにこれでドラッグ時のイメージを設定してやります。内部的には`IDataObject`の`SetData`などが呼ばれるためあらかじめ実装しておく必要があったわけです。Helperの名に恥じない便利メソッドです。
インタフェースだけでは役に立ちません。実装が必要ですが、次のクラスを宣言してやればOKです。
```csharp:DragDropHelper.cs
[ComImport]
[Guid("4657278A-411B-11d2-839A-00C04FD918D0")]
public class DragDropHelper { }
```
中身が空っぽですが構いません。実際の実装は`ComImport`で持ってきます。なので無理やりキャストして使います。
```csharp
var helper = (IDragSourceHelper2)new DragDropHelper();
helper.InitializeFromBitmap(ref dragImage, dataObject);
```
### `IDropTargetHelper`インタフェース
[`IDropTargetHelper`インタフェース](https://learn.microsoft.com/en-us/windows/win32/api/oleidl/nn-oleidl-idroptarget)はドラッグイメージを表示する側で使います。
```csharp:IDropTargetHelper.cs
[ComVisible(true)]
[ComImport]
[Guid("4657278B-411B-11D2-839A-00C04FD918D0")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IDropTargetHelper
{
    void DragEnter(
        [In] IntPtr hwndTarget,
        [In][MarshalAs(UnmanagedType.Interface)] IComDataObject dataObject,
        [In] ref Win32Point pt,
        [In][MarshalAs(UnmanagedType.I4)] DragDropEffects effect);
    void DragLeave();
    void DragOver(
        [In] ref Win32Point pt,
        [In][MarshalAs(UnmanagedType.I4)] DragDropEffects effect);
    void Drop(
        [In, MarshalAs(UnmanagedType.Interface)] IComDataObject dataObject,
        [In] ref Win32Point pt,
        [In][MarshalAs(UnmanagedType.I4)] DragDropEffects effect);
    void Show(
        [In][MarshalAs(UnmanagedType.Bool)] bool show);
}
```
各メソッドを呼ぶことでドラッグイメージが表示されます。インタフェース実装は例によってDragDropHelperです。
```csharp:DragDropHelper.cs
[ComImport]
[Guid("4657278A-411B-11d2-839A-00C04FD918D0")]
public class DragDropHelper { }
```
```csharp
var helper = (IDropTargetHelper)new DragDropHelper();
helper.DragEnter(handle, comData, ref dPoint, e.Effects);
```
ちなみに、このインタフェースはドラッグイメージを受け取るだけの場合でも使えます。エクスプローラーにドロップはしないけど、エクスプローラーからのドロップはされる、という場合でもこのインタフェースを使うことで、アプリケーション側でドラッグイメージを表示することができます。
### `DROPDESCRIPTION`構造体
[`DROPDESCRIPTION`構造体](https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropdescription)はドラッグイメージと同時に表示するテキストを表します。
```csharp:DROPDESCRIPION.cs
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct DROPDESCRIPTION
{
    [MarshalAs(UnmanagedType.I4)]
    public DropImageType types;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string szMessage;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string? szInsert;
}
```
この構造体のメモリ表現を"DropDescription"というフォーマットで`SetData`してやることでテキストを表示させる準備ができます。
### DragWindowフォーマット
`DROPDESCRIPTION`構造体だけではテキストを表示させることはできません。[Shell Clipboard Formats#DragWindow](https://learn.microsoft.com/en-us/windows/win32/shell/clipboard#dragwindow)の説明にある通り、このフォーマットで取得できるHWNDに対してDDWM_UPDATEWINDOWメッセージを送ってやる必要があります。
```csharp
NativeMethods.PostMessage(hwnd, NativeMethods.DDWM_UPDATEWINDOW, IntPtr.Zero, IntPtr.Zero);
```
HWNDとかメッセージとか、マネージドコードではあまり意識しないところが出てきましたが、範囲を超えるので説明は割愛します。参考資料だけ載せます。
* [What Is a Window?#Window Handles](https://learn.microsoft.com/en-us/windows/win32/learnwin32/what-is-a-window-#window-handles)
* [Window Messages](https://learn.microsoft.com/en-us/windows/win32/learnwin32/window-messages)
# ソースコード全体
実装に必要なエッセンスを解説しましたが、あとは実装を見たほうが早いと思います。https://github.com/miswil/DropFiles/tree/master/DropMultipleFilesComAsyncDragImage にあります。
# 所感
WinFormsとWPFの足並みの揃わなさはなんなんでしょう。そりゃ中身が全然違うんだから簡単じゃないのはわかりますが、機能仕様レベルではなるべく同じにしてほしい…
今回の実装で一番大変だったのがCOM`IDataObject`の実装ですが、いいリファレンス実装はないものでしょうか。なるべく公式に近いほうで。とにかくドキュメントになってない仕様が多々あって調べるのも容易じゃない。


[^1]:https://devblogs.microsoft.com/dotnet/winforms-enhancements-in-dotnet-7/
[^2]:https://github.com/dotnet/winforms/pull/6576