はじめに
Windowsエクスプローラーに存在しないファイルをドロップする(Part2)の続きです。
What/How
この課題に対するソリューションは複数あり、やりたいことが複雑になるほど実装も複雑になります。複雑になるほど汎用ソリューションとして使えますが、保守性を考えると適切なレベルで妥協するほうがよいでしょう。
それぞれ内容が全く異なるため記事を分けます。簡単な順に
- ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→Part1
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→Part2
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→本記事
- ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→Part4
- エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
- いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→Windowsエクスプローラーへファイルドロップした際、ドロップ先のフォルダーを取得する
実装
Part2では未定義動作になる理由として、.NETがWin32 APIのパラメータを捨ててしまうためと説明しました。そこで今回はWin32 API対応コードを書いてパラメータを直接受け取れるようにします。
System.Runtime.InteropServices.ComTypes.IDataObject
System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースはWin32 APIにおけるデータ交換(ドラッグ&ドロップ、クリップボードコピー)操作に使われます。より詳しく説明すれば"COMインタフェース"と呼ばれるもので、プロセス間でデータを共有できているのはCOMの仕組みのおかげです12。WinFormsで使われるSystem.Windows.Forms.DataObjectやWPFのSystem.Windows.DataObjectはいずれもこのインタフェースを実装しているので、別プロセスで実行環境も異なるWindowsエクスプローラーとデータをやり取りできるようになっています。System.Windows.Forms.DataObjectやSystem.Windows.DataObjectではWin32のアンマネージドな領域をうまくラップしてマネージドコードから使いやすくしてくれますが、その副作用で一部のパラメータが捨てられてしまいます。そこで、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースを直接実装することで生のパラメータを受け取れるようにするのが今回の趣旨です。
とはいえ、本格的にSystem.Runtime.InteropServices.ComTypes.IDataObjectインタフェースを実装することはしません。せっかくSystem.Windows.Forms.DataObjectやSystem.Windows.DataObjectで既に実装されているのですから、ほとんどそれを再利用します。デザインパターンにおけるProxyです。
class MyDataObject : System.Runtime.InteropServices.ComTypes.IDataObject
{
private DataObject dataObject;
Part2ではWinFormsのほうは継承で新しいIDataObject実装を作ることができましたが、今回はそれはできずWPFと同じようにProxyパターンで実装する必要があります。System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースを実装しているメソッドがvirtualではなくoverrideできないためです。
ここでWPFでのみ注意が必要な点として、新しく実装するクラスはSystem.Windows.IDataObjectインタフェースを実装してはいけません。System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースのみ実装してください。
// NG!!!
class MyDataObject : System.Windows.IDataObject, System.Runtime.InteropServices.ComTypes.IDataObject
{
private DataObject dataObject;
これは、両方のインタフェースが実装されている場合には、System.Windows.IDataObjectインタフェースの実装を優先して呼び出すという.NETの仕様によるものです。ドキュメントなどに明記がないものではありますが、実際のDataObjectがそう動作しています。今後この動作はたぶん変わらないでしょう(変わると互換性が著しく失われるので)。
GetDataメソッドとGetDataHereメソッド
System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースのデータ取得メソッドを実装します。ここでFileContentsを取得しようとしているときには、どのファイルかを示すlindexパラメータを受け取れるようになります。
void IComDataObject.GetData(ref FORMATETC format, out STGMEDIUM medium)
{
if (DataFormats.GetDataFormat(format.cfFormat).Name == "FileContents" &&
this.fileContents is not null)
{
this.dataObject.SetData("FileContents", this.fileContents[format.lindex]);
}
((IComDataObject)this.dataObject).GetData(ref format, out medium);
}
void IComDataObject.GetDataHere(ref FORMATETC format, ref STGMEDIUM medium)
{
if (DataFormats.GetDataFormat(format.cfFormat).Name == "FileContents" &&
this.fileContents is not null)
{
this.dataObject.SetData("FileContents", this.fileContents[format.lindex]);
}
((IComDataObject)this.dataObject).GetDataHere(ref format, ref medium);
}
lindexの値によって、FileContentsに設定するMemoryStreamを切り替えてやります。あとは元のDataObject実装に丸投げしてやるだけです。
ドラッグ&ドロップの開始
System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースを実装したクラスが出来上がったら、そのオブジェクトを直接使ってドラッグ&ドロップを開始します。
var myData = new MyDataObject();
DragDrop.DoDragDrop((Border)sender, myData, DragDropEffects.Copy);
解説
ソースの説明は適宜していたので、さらに詳細な実装の話をします。System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースの扱いはWPF, WinFormsそれぞれに独特で、詳細を知らないと期待通りに動作しない場合があります。独自実装などを加える際にはどのインタフェースを実装しどうやって使うのかをよく注意してください。
WPFの場合
WPFでドラッグ&ドロップを開始するときにはDragDrop.DoDragDrop(DependencyObject dragSource, object data, DragDropEffects allowedEffects)staticメソッドを呼び出しますが、このメソッドは第二引数で渡されたdataがSystem.Windows.DataObjectではない場合に、System.Windows.DataObjectでラップする動作をします3。
var data = new DataObject(myData);
System.Windows.DataObjectはコンストラクタ第一引数で渡された引数がSystem.Windows.IDataObjectインタフェースを実装する場合には、そのインタフェース経由でSystem.Windows.IDataObjectインタフェースメソッド、およびSystem.Runtime.InteropServices.ComTypes.IDataObjectインタフェースメソッドへの呼び出しをディスパッチします。あるいは、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースを実装している場合にはそのインタフェース経由でSystem.Windows.IDataObjectインタフェースメソッド、およびSystem.Runtime.InteropServices.ComTypes.IDataObjectインタフェースメソッドへの呼び出しをディスパッチします4。インタフェースが一致しない部分はデータ変換を行い、また一部のメソッドはNotImplementedとして扱います。

これが、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースとSystem.Windows.IDataObjectインタフェースを同時に実装してはいけない理由です。System.Windows.IDataObjectインタフェースを実装している場合、そのインタフェース経由で処理がディスパッチされるため、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェース実装は使われなくなってしまいます。
WinFormsの場合
WinFormsでは逆の動きになります。ドラッグ&ドロップを開始するときに呼び出すControl::DoDragDropメソッドでは、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースはそのまま使われます5。そのためWinFormsではSystem.Runtime.InteropServices.ComTypes.IDataObjectインタフェースとSystem.Windows.Forms.IDataObjectインタフェースを同時に実装したオブジェクトでControl::DoDragDropメソッドを呼び出した場合、System.Runtime.InteropServices.ComTypes.IDataObjectインタフェースメソッドのみ使われSystem.Windows.Forms.IDataObjectインタフェースの実装は生かされません。
一方、System.Windows.Forms.DataObjectのコンストラクタに引数を渡した場合の動きはWPFと同様になります。各インタフェースメソッドから内部オブジェクトに処理をディスパッチします6。
ソースコード全体
上記のコードそのままではなくさらにブラッシュアップしていますが、https://github.com/miswil/DropFiles/tree/master/DropMultipleFilesCom にあります。
所感
だんだん怪しいにおいがし始めてきました。フレームワークの内部実装まで気にし始めるくらいなら、素直にWin32を直接使ったほうがきれいな気さえします。