はじめに
Windowsエクスプローラーに存在しないファイルをドロップする(Part1)の続きです。
What/How
この課題に対するソリューションは複数あり、やりたいことが複雑になるほど実装も複雑になります。複雑になるほど汎用ソリューションとして使えますが、保守性を考えると適切なレベルで妥協するほうがよいでしょう。
それぞれ内容が全く異なるため記事を分けます。簡単な順に
- ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→Part1
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→本記事
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→Part3
- ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→Part4
- エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
- いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→Windowsエクスプローラーへファイルドロップした際、ドロップ先のフォルダーを取得する
実装
FileGroupDescriptorW
Part1でFILEGROUPDESCRIPTOR
構造体について詳しく説明しました。この構造体はファイルのグループを表すため、複数ファイルに拡張することも容易です。最初の32ビットでファイル数を表す値を書き込み、それ以降ファイル数の数だけFILEDESCRIPTOR
構造体のバイナリ表現を書き込んでやります。
var fileGroupDescriptorBinary = new MemoryStream();
fileGroupDescriptorBinary.Write(BitConverter.GetBytes(files.Count()));
var fdSize = Marshal.SizeOf<FILEDESCRIPTOR>();
var fdArray = new byte[fdSize];
var fdPtr = Marshal.AllocHGlobal(fdSize);
try
{
foreach (var file in files)
{
var fileDescriptor = new FILEDESCRIPTORFactory().Create(
file.Name,
file.CreationTime, file.LastAccessTime, file.LastWriteTime,
file.Size);
Marshal.StructureToPtr(fileDescriptor, fdPtr, false);
Marshal.Copy(fdPtr, fdArray, 0, fdSize);
fileGroupDescriptorBinary.Write(fdArray);
}
}
finally
{
Marshal.FreeHGlobal(fdPtr);
}
fileGroupDescriptorBinary.Position = 0;
dataObject.SetData("FileGroupDescriptorW", fileGroupDescriptorBinary);
FileContents
FileGroupDescriptorは構造が複数ファイルに対応していましたが、FileContentsはMemoryStream
を指定するだけです。FileGroupDescriptorで複数指定したファイルにうち、Windowsエクスプローラーがどのファイルのデータを要求しているのか、どうしたらわかるのでしょう。シェル クリップボードの形式/CFSTR_FILECONTENTSには次のように説明があります。
ターゲットが IDataObject::GetData を呼び出してデータを抽出する場合、FORMATETC 構造体の lindex メンバーをファイルの FILEDESCRIPTOR 構造体の 0 から始まるインデックスに、付随するCFSTR_FILEDESCRIPTOR形式で設定することで、特定のファイルを指定します。
IDataObject::GetData
メソッド呼び出しの際にlindex
というデータが渡され、それがFileGroupDescriptorで指定したFILEDESCRIPTOR
構造体配列のうち、何番目のファイルのデータを要求しているのかを表しているとのことです。
FORMATETC
やlindex
といった見慣れないキーワードが出てきました。これらはWin32でドラッグ&ドロップを扱う際に出てくる用語です。.NETではこれらの値をラップしたり変換したりして扱いやすいマネージドの形式にしてくれています。
じゃあlindex
の値をどのようにラップしているかなんですが、実はこの情報は捨てられてしまっています1。捨てられている以上どうしようもないので、推測してファイルのデータを返してやる必要があります。シンプルに「FileGroupDescriptorで指定したFILEDESCRIPTOR
構造体配列の順にFileContentsは要求される」としましょう。あとはIDataObject::GetData
メソッドから返すデータを適宜切り替えてやるだけです。
これを実現するには独自のIDataObject
実装をしてやる必要があります。複数のMemoryStream
を保持してFileContentsが要求されるごとに返すMemoryStream
を切り替えていきます。実装の一部をピックアップします
public object GetData(string format, bool autoConvert)
{
if (format == "FileContents" &&
this.fileContents is not null &&
this.fileContents.MoveNext())
{
return fileContents.Current;
}
return this.dataObject.GetData(format, autoConvert);
}
fileContents
はIEnumerator<MemoryStream>
です。FileContentsが要求されるごとにMoveNext
して返すMemoryStream
を切り替えます。
WinFormsではDataObject
を継承して一部のメソッドをoverrideするだけで済みます。WPFではDataObject
がsealed
のため、継承して実装をoverrideすることができません。そのためデザインパターンのProxyパターンを使います。
解説
仮定としておいた「FileGroupDescriptorで指定したFILEDESCRIPTOR
構造体配列の順にFileContentsは要求される」ですが、これが正しい保証はありません。経験上こうならなかったことはないですが、どのドキュメントにも記載はありません。そのため「未定義動作が起きる可能性を許容する」ことが前提の実装です。定義済み動作のみの実装はPart3で解説しますが、Win32の世界により深く進んでいきます。
応用
ファイルだけでなく、フォルダーを作ったりその中にファイルを格納したりすることもできます。FILEDESCRIPTOR
構造体のdwFileAttributes
にFILE_ATTRIBUTE_DIRECTORY
(0x10)2を指定するとフォルダーが作成されます。さらに、そのフォルダーの中に格納するファイル・フォルダーはcFileName
に相対パスを記載してやります。
ソースコード全体
上記のコードそのままではなくさらにブラッシュアップしていますが、https://github.com/miswil/DropFiles/tree/master/DropMultipleFiles にあります。
所感
未定義動作なんて説明するんじゃないよ、というお叱りは受けそうですが、まだかろうじて.NETの範囲で済ませられる妥協点とご理解ください…