かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

MVVMでファイルを開くダイアログを使う(View完結とViewModel経由)

さて、今日はリクエストをもらいました。以下のをやる方法を考えてみようと思います。


実は、1つ前のエントリのT4 テンプレートは、このプログラムの部品を作るためのものでした。
とりあえず、今回作りたいものは以下のような動作をするものです。

  1. ボタンを押すとファイルを開くダイアログが出る
  2. ファイルを選択するとTextBoxに選択したファイル名が表示される

素直にやると以下のような手順になりそうです。

  1. ViewからCommandでViewModelに処理を通知
  2. ViewModelからInteractionRequestでViewに通知を上げる
  3. Viewで通知を受け取ってActionでダイアログを表示する
  4. ダイアログでファイルが選択されたら、コールバックを呼ぶ
  5. ViewModelで選択されたファイル名を受け取って自分のプロパティにセットする
  6. ViewModelからのPropertyChangedを受け取ってViewのTextBoxあたりに選択したファイル名が表示される。

とりあえず、ファイルを開くダイアログのためのActionが無いのでせっせと作ります。先ほど作った使い捨てのT4 テンプレートで単純なプロパティの設定とかは自動生成したコードを使ってるので、冗長な感じのコードになってますが以下のような感じで出来ました。

namespace DialogAction
{
    using System.Windows;
    using Microsoft.Practices.Prism.Interactivity.InteractionRequest;
    using Microsoft.Win32;
    using Okazuki.MVVM.PrismSupport.Interactivity;

    public class OpenFileDialogAction : DispatcherTriggerAction
    {
        private static readonly OpenFileDialogConfirmation NullObject = new OpenFileDialogConfirmation();

        #region OpenFileDialogのプロパティ(自動生成)
        public static readonly DependencyProperty MultiselectProperty =
            DependencyProperty.Register(
                "Multiselect",
                typeof(System.Boolean?),
                typeof(OpenFileDialogAction),
                new PropertyMetadata(null));
        // ... 中身は省略 ...
        #endregion

        protected override void InvokeAction(InteractionRequestedEventArgs e)
        {
            var n = e.Context as OpenFileDialogConfirmation ?? NullObject;
            var dlg = new OpenFileDialog();

            this.ApplyMessagePropertyeValues(n, dlg);

            var conf = e.Context as Confirmation;

            // キャンセル時
            if (dlg.ShowDialog() != true)
            {
                if (conf != null)
                {
                    conf.Confirmed = false;
                }
                e.Callback();
                return;
            }

            this.ApplyDialogPropertyValues(n, dlg);

            if (conf != null)
            {
                conf.Confirmed = true;
            }
            e.Callback();
        }

        private void ApplyMessagePropertyeValues(OpenFileDialogConfirmation n, OpenFileDialog dlg)
        {
            #region OpenFileDialogのプロパティのセット(自動生成)
            dlg.Multiselect = n.Multiselect ?? this.Multiselect ?? dlg.Multiselect;
            // ... 中身は省略 ...
            #endregion
        }

        private void ApplyDialogPropertyValues(OpenFileDialogConfirmation n, OpenFileDialog dlg)
        {
            #region OpenFileDialogからNotificationへのプロパティのコピー(自動生成)
            if (n != NullObject)
            {
                n.Multiselect = dlg.Multiselect;
            }
            // ... 中身は省略 ...
            #endregion
        }
    }
}

このActionに渡すためのNotificationを拡張したクラスは以下のような感じです。

namespace DialogAction
{
    using Microsoft.Practices.Prism.Interactivity.InteractionRequest;

    public class OpenFileDialogConfirmation : Confirmation
    {
        #region OpenFileDialogのプロパティ(自動生成)
        public System.Boolean? Multiselect { get; set; }
        public System.Boolean? ReadOnlyChecked { get; set; }
        public System.Boolean? ShowReadOnly { get; set; }
        public System.Boolean? AddExtension { get; set; }
        public System.Boolean? CheckFileExists { get; set; }
        public System.Boolean? CheckPathExists { get; set; }
        public System.String DefaultExt { get; set; }
        public System.Boolean? DereferenceLinks { get; set; }
        public System.String SafeFileName { get; set; }
        public System.String[] SafeFileNames { get; set; }
        public System.String FileName { get; set; }
        public System.String[] FileNames { get; set; }
        public System.String Filter { get; set; }
        public System.Int32? FilterIndex { get; set; }
        public System.String InitialDirectory { get; set; }
        public System.Boolean? RestoreDirectory { get; set; }
        public System.Boolean? ValidateNames { get; set; }
        public System.Collections.Generic.IList<Microsoft.Win32.FileDialogCustomPlace> CustomPlaces { get; set; }
        public System.Object Tag { get; set; }
        #endregion
    }
}

どちらも、OpenFileDialogのプロパティを全部持ってます。Actionの処理が実行されるとOpenFileDialogの各プロパティにOpenFileDialogConfirmationクラスに設定されている値が設定されます。もし、OpenFileDialogConfirmationの値がnullの場合はActionの同名のプロパティの値が設定されます。Actionのプロパティもnullの場合はOpenFileDialogのデフォルト値が使われます。

そして、ダイアログが開かれてキャンセルボタンが押されるとConfirmedプロパティにfalseが設定されコールバックが呼ばれます。ファイルが選択されると、OpenFileDialogのプロパティの値がActionとVMから渡されたOpenFileDialogConfirmationにコピーされます。その後、Confirmedプロパティがtureに設定されてコールバックが呼ばれます。

ちょっとしたポイント

今回は、ActionとOpenFileDialogConfirmationの両方にOpenFileDialogと同じ名前のプロパティを用意しています。こうすることで、ViewModelから明示的に値が渡された場合は、それが使われて、そうじゃない場合はActionにXAMLで指定した値が使われます。
また、ActionのTriggerは好きなものを使えるように作っているので、EventTriggerと組み合わせることで、ViewModelを経由せずにダイアログを出したり出来るようになります。OpenFileDialogで選んだ結果のファイルはActionのプロパティに保持されるため、それを他のコントロールのプロパティとバインドすることで、ViewModelとか使わないで今回目的としているような処理が作れるようになっています。


心配な所は、アクションが実行されるたびにDependencyPropertyに激しくアクセスしてるところです・・・。パフォーマンスは良くないはずです。気になる事あるかな?

使ってみよう

実装は、Prism + KinkumaFrameworkでやります。Prismのパワフルさを拝借しつつ、便利なクラスを用意していくことを目的としたMVVMフレームワークです。

ViewModelの作成

ということでまずは、ViewModel経由の方法でやってみようとおもいます。ViewModelには、FileNameプロパティとOpenFileCommandとOpenFileRequestの3つのプロパティを定義しています。
MainWindowViewModelを作成してMainWindowViewModel.ttのほうにCommandとFileNameのプロパティを定義します。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".generated.cs" #>
// <generated />
namespace DialogAction
{
	using System;

	[System.ComponentModel.TypeDescriptionProvider(typeof(MainWindowViewModelAssociatedMetadataTypeTypeDescriptionProvider))]
	public partial class MainWindowViewModel
	{
		<#
		// 引数なしのAlertCommandの定義
		Command("OpenFile");
		// ファイル名
		Property("string", "FileName");
		#>
		<# AssociatedMetadataTypeTypeDescriptionProvider("MainWindowViewModel"); #>
	}
		
}
<#@ include file="MVVMParts.ttinclude" #>

そして、MainWindowViewModel.csのほうにInteractionRequest型のプロパティを定義してコマンドの処理を書いていきます。

namespace DialogAction
{
    using System.ComponentModel.DataAnnotations;
    using Microsoft.Practices.Prism.Interactivity.InteractionRequest;
    using Okazuki.MVVM.PrismSupport.Utils;
    using Okazuki.MVVM.PrismSupport.ViewModels;

    [MetadataType(typeof(Metadata))]
    public partial class MainWindowViewModel : ViewModelBase
    {
        // ファイルを開くリクエストをViewに通知するためのInteractionRequest
        [AutoInit]
        public InteractionRequest<OpenFileDialogConfirmation> OpenFileRequest { get; private set; }

        // コマンドの処理
        partial void OpenFileExecute()
        {
            this.OpenFileRequest.Raise(
                // タイトルだけ指定してファイルを開く要求をViewに通知する
                new OpenFileDialogConfirmation
                {
                    Title = "ViewModel経由で開く"
                },
                conf =>
                {
                    // キャンセルされたら未選択をFileNameに設定
                    if (!conf.Confirmed)
                    {
                        this.FileName = "未選択";
                        return;
                    }

                    // ファイルが選択されてたら名前をFileNameに設定
                    this.FileName = conf.FileName;
                });
        }

        class Metadata
        {
        }
    }
}

以上でViewModelは完成です。処理は単純でViewにファイル開いてとお願いして結果を自分のプロパティに入れているだけです。

Viewの作成

まずは、DataContextにViewModelを設定します。

<Window.DataContext>
    <!-- ViewModelを設定 -->
    <l:MainWindowViewModel />
</Window.DataContext>

次にTextBoxとButtonを置いてTextBoxをViewModelのFileNameにバインドしてButtonにOpenFileCommandをバインドします。

<TextBox Text="{Binding Path=FileName}" HorizontalContentAlignment="Left" />
<Button Content="..." Command="{Binding Path=OpenFileCommand}" Grid.Column="1" />

そして、ViewModelからの通知を受け取るようにInteractionRequestTriggerとOpenFileDialogActionを設定します。

<i:Interaction.Triggers>
    <my:InteractionRequestTrigger SourceObject="{Binding Path=OpenFileRequest}">
        <l:OpenFileDialogAction />
    </my:InteractionRequestTrigger>
</i:Interaction.Triggers>

以上でViewModel経由は完成です。動作確認は、Viewのみで同じ処理を実現する方法をやってからにするので少しお預けです。

Viewのみで同じ処理をする

さて、先ほどViewModelとViewの連携を駆使して(たぶんMVVMでVとVMが連携する方式は一通り使ってる?)ファイルを開いてファイル名をテキストボックスに表示する処理を完成させました。今回作った部品を使ってXAMLだけで同じ処理をしてみたいと思います。
まずは、ButtonにEventTriggerをしかけてClickでActionを実行するようにします。実行するためのActionはもちろんOpenFileDialogです。そして、TextBoxのTextプロパティとOpenFileDialogのFileNameプロパティをバインドしてやります。XAMLにすると以下のような感じです。OneWayToSourceでActionからTextBoxに値を流し込んでいます。

<Button Content="..." Grid.Column="1">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <l:OpenFileDialogAction Title="View完結" FileName="{Binding ElementName=textBoxFileName, Path=Text, Mode=OneWayToSource}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>
<TextBox Name="textBoxFileName" />

実行してみよう

では、プログラムを実行してみます。実行直後は以下のような感じです。

まずは、上の方のViewで完結のほうのボタンを押してみます。ダイアログのタイトルがXAMLで設定していたものになっていることがわかると思います。

ファイルを選択してOKを押すと、選択したファイル名がTextBoxに設定されています。

次に、VM経由の方のボタンを押してみます。

ダイアログのタイトルがViewModel経由で設定したものになっていることがわかると思います。もちろんファイルを選択したら、選択したファイルがTextBoxに表示されます。

以上で今回はおしまいです!!

サンプルプログラムのダウンロード

サンプルプログラムは以下からダウンロードできます。

因みに、これのSaveFileDialogとフォルダを開くダイアログのバージョンも作成してKinkumaFrameworkに含める予定です。