sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

WPF でフォルダー選択のダイアログを選択・実装する

ユーザーにフォルダーのパスを選択してもらうダイアログは、WPF (C#) だと以下の選択肢が用意されていると思います。(よくある標準的なものに限ります)

  • System.Windows.Forms
  • Microsoft.WindowsAPICodePack.Dialogs
  • P/Invoke ネイティブ ライブラリーの直接呼び出し

Microsoft Office の Application.FileDialog は、割愛します。C# で利用するには制約の面がつらい。

WPF のアプリケーションを作成するとき、どのフォルダー選択方法を選ぶのかを考えると、それぞれに一長一短があるように思います。それぞれ、どのようなものか見ていきます。

System.Windows.Forms のダイアログ

f:id:shikaku_sh:20191007153810p:plain
フォルダーを選択する GUI

こんな感じのウィンドウでパスを選択することができます。WPF だとデフォルトだと DLL が参照に入っていないのですが、Windows.Forms の参照を追加すれば、さっと実装できるのが魅力だと思います。

f:id:shikaku_sh:20191007153921p:plain:w400
参照追加

反面、フォルダーパスにあたるテキストを(クリップボードなどに)持っていても、入力できるテキストボックスを持ちません。なので、必ず GUI のフォルダーをクリックしてフォルダー選択をすすめることになります。

private void Button_Click2(object sender, RoutedEventArgs e)
{
    var browser = new System.Windows.Forms.FolderBrowserDialog();

    browser.Description = "フォルダーを選択してください";

    if (browser.ShowDialog() == System.Windows.Forms.DialogResult.OK)
    {
        Path2Lable.Content = browser.SelectedPath;
    }
}

コードとしても、特別なにかわかりづらいということはないです。

Microsoft.WindowsAPICodePack.Dialogs のダイアログ

f:id:shikaku_sh:20191007154043p:plain:w400
パスを入力できるので便利!

こんな感じのウィンドウでパスを選択することができます。NuGet パッケージで次をインストールする必要があります。

  • WindowsAPICodePack-Core
  • WindowsAPICorePack-Shell

f:id:shikaku_sh:20191007154106p:plain:w400
デフォルトだと v1.1.1 がインストールされます

Windows API CodePack は、後述の P/Invoke を使ってネイティブな Win32 API を直接編集せずに .NET Framework でサポートされていない Windows7, Vista の機能を使えるようにするライブラリーです。

なんで、そのライブラリーの中にフォルダー選択ができるダイアログの機能が含まれているといった感じです。これももう 2010 年ごろの話なので、十分に成熟しており、情報も出尽くした感じです。

現在の WindowsAPICodePack は Microsoft が NuGet に公開しているのではなく、有志の方が公開をしている状態です。懸念するところはそこで、微妙な位置で安定してしまったライブラリーです。(ライセンスだけ Microsoft のものをカスタムライセンスとして設定してある)デファクトスタンダードといっても十分よい状態ですが、まったくバグ報告が無いわけでもなく、使用上の注意もある点などに留意しましょう。 1

private void Button_Click1(object sender, RoutedEventArgs e)
{
    var browser = new CommonOpenFileDialog();

    browser.Title = "フォルダーを選択してください";
    browser.IsFolderPicker = true;

    if (browser.ShowDialog() == CommonFileDialogResult.Ok)
    {
        Path1Lable.Content = browser.FileName;
    }
}

コードとしても、特別なにかわかりづらいということはないです。

P/Invoke ネイティブ ライブラリーのダイアログ

f:id:shikaku_sh:20191007154542p:plain:w400
WindowsAPICodePack にそっくり!

こんな感じのウィンドウでパスを選択することができます。Microsoft.WindowsAPICodePack.Dialogs のものとそっくりですね。

Windows OS の深いところにあるネイティブなライブラリーから、ピンポイントにフォルダー選択をするダイアログを呼び出します。このやり方は、自分で実装しないといけないので、ダイアログを呼び出すための「クラス」を設計する部分と、上記2つと同じ、クラスをインスタンス化する部分の2つをコーディングしないといけません。

ダイアログを呼び出すための「クラス」を設計する部分がこちら。

/// <summary>
/// FolderBrowserDialog クラスは、フォルダーを選択する機能を提供するクラスです。
/// <para>
/// <see cref="Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialog"/> クラスを利用したフォルダーの選択に近い機能を提供します。
/// </para>
/// </summary>
public class FolderBrowserDialog
{
    #region DllImports

    [DllImport("shell32.dll")]
    private static extern int SHILCreateFromPath([MarshalAs(UnmanagedType.LPWStr)] string pszPath, out IntPtr ppIdl, ref uint rgflnOut);

    [DllImport("shell32.dll")]
    private static extern int SHCreateShellItem(IntPtr pidlParent, IntPtr psfParent, IntPtr pidl, out IShellItem ppsi);

    #endregion

    #region Private Classes & Interfaces

    [ComImport]
    [Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
    private class FileOpenDialogInternal { }

    [ComImport]
    [Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    private interface IShellItem
    {
        void BindToHandler(); // 省略宣言
        void GetParent(); // 省略宣言
        void GetDisplayName([In] SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
        void GetAttributes();  // 省略宣言
        void Compare();  // 省略宣言
    }

    [ComImport]
    [Guid("42f85136-db7e-439c-85f1-e4075d135fc8")]
    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    private interface IFileOpenDialog
    {
        [PreserveSig]
        uint Show([In] IntPtr parent); // IModalWindow
        void SetFileTypes();  // 省略宣言
        void SetFileTypeIndex([In] uint iFileType);
        void GetFileTypeIndex(out uint piFileType);
        void Advise(); // 省略宣言
        void Unadvise();
        void SetOptions([In] _FILEOPENDIALOGOPTIONS fos);
        void GetOptions(out _FILEOPENDIALOGOPTIONS pfos);
        void SetDefaultFolder(IShellItem psi);
        void SetFolder(IShellItem psi);
        void GetFolder(out IShellItem ppsi);
        void GetCurrentSelection(out IShellItem ppsi);
        void SetFileName([In, MarshalAs(UnmanagedType.LPWStr)] string pszName);
        void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
        void SetTitle([In, MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
        void SetOkButtonLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszText);
        void SetFileNameLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
        void GetResult(out IShellItem ppsi);
        void AddPlace(IShellItem psi, int alignment);
        void SetDefaultExtension([In, MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
        void Close(int hr);
        void SetClientGuid();  // 省略宣言
        void ClearClientData();
        void SetFilter([MarshalAs(UnmanagedType.Interface)] IntPtr pFilter);
        void GetResults([MarshalAs(UnmanagedType.Interface)] out IntPtr ppenum); // 省略宣言
        void GetSelectedItems([MarshalAs(UnmanagedType.Interface)] out IntPtr ppsai); // 省略宣言
    }

    #endregion

    #region Fields

    private const uint ERROR_CANCELLED = 0x800704C7;

    #endregion

    #region Properties

    /// <summary>
    /// ユーザーによって選択されたフォルダーのパスを取得または設定します。
    /// </summary>
    public string SelectedPath { get; set; }

    /// <summary>
    /// ダイアログ上に表示されるタイトルのテキストを取得または設定します。
    /// </summary>
    public string Title { get; set; }

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="FolderBrowserDialog"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public FolderBrowserDialog() { }

    #endregion

    #region Events

    #endregion

    #region Public Methods

    public DialogResult ShowDialog()
    {
        return ShowDialog(IntPtr.Zero);
    }

    public DialogResult ShowDialog(Window owner)
    {
        if (owner == null)
        {
            throw new ArgumentNullException("指定したウィンドウは null です。オーナーを正しく設定できません。");
        }

        var handle = new WindowInteropHelper(owner).Handle;

        return ShowDialog(handle);
    }

    public DialogResult ShowDialog(IntPtr owner)
    {
        var dialog = new FileOpenDialogInternal() as IFileOpenDialog;

        try
        {
            IShellItem item;
            string selectedPath;

            dialog.SetOptions(_FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS | _FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM);

            if (!string.IsNullOrEmpty(SelectedPath))
            {
                IntPtr idl = IntPtr.Zero; // path の intptr
                uint attributes = 0;

                if (SHILCreateFromPath(SelectedPath, out idl, ref attributes) == 0)
                {
                    if (SHCreateShellItem(IntPtr.Zero, IntPtr.Zero, idl, out item) == 0)
                    {
                        dialog.SetFolder(item);
                    }

                    if (idl != IntPtr.Zero)
                    {
                        Marshal.FreeCoTaskMem(idl);
                    }
                }
            }

            if (!string.IsNullOrEmpty(Title))
            {
                dialog.SetTitle(Title);
            }

            var hr = dialog.Show(owner);

            // 選択のキャンセルまたは例外
            if (hr == ERROR_CANCELLED) return DialogResult.Cancel;
            if (hr != 0) return DialogResult.Abort;

            dialog.GetResult(out item);

            if (item != null)
            {
                item.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out selectedPath);
                SelectedPath = selectedPath;
            }
            else
            {
                return DialogResult.Abort;
            }

            return DialogResult.OK;
        }
        finally
        {
            Marshal.FinalReleaseComObject(dialog);
        }
    }

    #endregion
}
/// <summary>
/// <see cref="DialogResult"/> 列挙型は、ダイアログ ボックスの戻り値を示す識別子を表します。
/// </summary>
public enum DialogResult : int
{
    /// <summary>
    /// ダイアログ ボックスの戻り値は Nothing です。モーダル ダイアログ ボックスの実行が継続します。
    /// </summary>
    None = 0,
    /// <summary>
    /// ダイアログ ボックスの戻り値は OK です。
    /// </summary>
    OK = 1,
    /// <summary>
    /// ダイアログ ボックスの戻り値は Cancel です。
    /// </summary>
    Cancel = 2,
    /// <summary>
    /// ダイアログ ボックスの戻り値は Abort です。
    /// </summary>
    Abort = 3,
    /// <summary>
    /// ダイアログ ボックスの戻り値は Retry です。
    /// </summary>
    Retry = 4,
    /// <summary>
    /// ダイアログ ボックスの戻り値は Ignore です。
    /// </summary>
    Ignore = 5,
    /// <summary>
    /// ダイアログ ボックスの戻り値は Yes です。
    /// </summary>
    Yes = 6,
    /// <summary>
    /// ダイアログ ボックスの戻り値は No です。
    /// </summary>
    No = 7
}
/// <summary>
/// SIGDN クラスは、IShellItem::GetDisplayName および SHGetNameFromIDList を使用して取得するアイテムの表示名の形式を定義します。
/// </summary>
public enum SIGDN : uint
{
    SIGDN_DESKTOPABSOLUTEEDITING = 0x8004c000,
    SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000,
    SIGDN_FILESYSPATH = 0x80058000,
    SIGDN_NORMALDISPLAY = 0,
    SIGDN_PARENTRELATIVE = 0x80080001,
    SIGDN_PARENTRELATIVEEDITING = 0x80031001,
    SIGDN_PARENTRELATIVEFORADDRESSBAR = 0x8007c001,
    SIGDN_PARENTRELATIVEPARSING = 0x80018001,
    SIGDN_URL = 0x80068000
}

/// <summary>
/// <see cref="_FILEOPENDIALOGOPTIONS"/> 列挙型は、[開く] または [保存] ダイアログで使用できるオプションのセットを定義します。
/// </summary>
[Flags]
public enum _FILEOPENDIALOGOPTIONS : uint
{
    FOS_OVERWRITEPROMPT = 0x00000002,
    FOS_STRICTFILETYPES = 0x00000004,
    FOS_NOCHANGEDIR = 0x00000008,
    /// <summary>
    /// ファイルではなくフォルダを選択できる [開く] ダイアログボックスを表示します。
    /// </summary>
    FOS_PICKFOLDERS = 0x00000020,
    /// <summary>
    /// ファイルシステムのアイテムを返却します。
    /// </summary>
    FOS_FORCEFILESYSTEM = 0x00000040,
    FOS_ALLNONSTORAGEITEMS = 0x00000080,
    FOS_NOVALIDATE = 0x00000100,
    FOS_ALLOWMULTISELECT = 0x00000200,
    FOS_PATHMUSTEXIST = 0x00000800,
    FOS_FILEMUSTEXIST = 0x00001000,
    FOS_CREATEPROMPT = 0x00002000,
    FOS_SHAREAWARE = 0x00004000,
    FOS_NOREADONLYRETURN = 0x00008000,
    FOS_NOTESTFILECREATE = 0x00010000,
    FOS_HIDEMRUPLACES = 0x00020000,
    FOS_HIDEPINNEDPLACES = 0x00040000,
    FOS_NODEREFERENCELINKS = 0x00100000,
    FOS_DONTADDTORECENT = 0x02000000,
    FOS_FORCESHOWHIDDEN = 0x10000000,
    FOS_DEFAULTNOMINIMODE = 0x20000000,
    FOS_FORCEPREVIEWPANEON = 0x40000000,
    FOS_SUPPORTSTREAMABLEITEMS = 0x80000000
}

クラスを呼び出す部分がこちら。

private void Button_Click3(object sender, RoutedEventArgs e)
{
    var result = Dialogs.DialogResult.None;
    var browser = new Dialogs.FolderBrowserDialog();
    
    browser.Title = "フォルダーを選択してください";
    browser.SelectedPath = Path3Lable.Content.ToString();

    // ウィンドウが取得できるときは設定する
    var obj = sender as DependencyObject;

    if (obj != null)
    {
        var window = Window.GetWindow(obj);

        if (window != null) result = browser.ShowDialog(window);
    }
    else
    {
        result = browser.ShowDialog(IntPtr.Zero);
    }

    if (result == Dialogs.DialogResult.OK)
    {
        Path3Lable.Content = browser.SelectedPath;
    }
}

呼び出す部分のコードは、上記の2つと同じようにすることができます。ひと手間挟んでいる分、ついでに WPF にアジャストさせてウィンドウハンドルを掴みやすくしてあげるなど、カスタムらしいカスタムを盛り込むことが可能です。

返り値は System.Windows.Forms.DialogResult と一致するようですが、それだけのために System.Windows.Forms.dll を参照に追加したくないときは、自分で Enum を実装すればよいと思います。(コメントに互換性を書いておけばよいです)

もちろん上述2つでも、デザインパターンにおける Adapter パターンのようなことをしてやればいいのですが、そこまでのものでもないので、自分で実装するなら盛り込んでおくかなくらいの感じが悩ましい。

サンプル

f:id:shikaku_sh:20191007154748p:plain:w400
フォルダーを選択するサンプル

「Sample」に「FileOpenDialogSample」を追加しています。

参考


  1. README を見てみよう。