ラベル XAML の投稿を表示しています。 すべての投稿を表示
ラベル XAML の投稿を表示しています。 すべての投稿を表示

2024年10月26日土曜日

Prism + DryIoc 入門③(ダイアログ編)

前回から少し時間が空きましたが、Prism+DryIoc入門の第3弾です。一応これで一区切りのつもりです。 

前回はPrism+DryIocでプロジェクトを立ち上げるお作法を紹介しました。これに加えてWPFの基礎知識がある皆さんはそれなりのソフトを作ることができると思います。ですが、ひとつ足りないことがありました。それはダイアログの表示です。今回はそのダイアログの表示の方法について説明していきます。 

ダイアログの実装

さて、通常、ダイアログを開くとなるとウィンドウを作ることになりますが、PrismではUserControlを作ることになります。ちょうどウィンドウ内でRegionを作るのと同じような感じですね。

順に説明していきます。

1. Viewの作成

「追加」→「新しい項目」からPrism UserControl (WPF)を追加します。

出来上がったXAMLファイルに前回の記事に書いたとおりのお作法でIgnorebleなどを設定します。

<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <Grid />
</UserControl>

2. VideModelの作成

ViewModelにはIDialogAwareを実装する必要があります。これはPrismにこれはダイアログですよと教えるためのものですね。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
    }

    public string Title => "Test Dialog";

    public event Action<IDialogResult>? RequestClose;

    public bool CanCloseDialog() => true;

    public void OnDialogClosed()
    {    
    }

    public void OnDialogOpened(IDialogParameters parameters)
    {
    }
}

タイトルやいくつかのメソッド、ダイアログを閉じる際に呼ぶイベントなどを実装します。特段説明をしなくても、その名前から何のためのものかはすぐにわかると思います。

3. Viewをダイアログとして登録

App.xaml.csを開き、RegisterTypesメソッド中にて作った型をダイアログとして登録します。これを登録することで、PrismがこのViewがダイアログであることを認識してくれます。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterDialog<TestDialog>("TestDialog");
    }
}

これも前回の記事までを読んだ人なら特に困ることは無いと思います。

4. ダイアログを表示

さて、ダイアログの表示も至って簡単です。ライブラリ無しではMVVMのポリシーを保ちつつViewModelから別ウィンドウを表示するのがとても面倒だったので、さすがPrismという感じですね。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => dialog.ShowDialog("TestDialog"));
    }

    public DelegateCommand ShowDialogCommand { get; }
}

適当なViewModelでIDialogServiceを受け取るようにすると、DryIoc経由でダイアログ関係の機能にアクセスできるようになります。ダイアログを表示するにはShowDialogメソッドを呼ぶだけです。引数はApp.xaml.csで設定した名前ですね。

このコマンドを呼び出すと、無事ダイアログが表示されました。

ダイアログを作りこむ

1. ウィンドウスタイル

さて、ダイアログが表示されましたが、よく見ると最大化/最小化ボタンなどが表示されていますね。Windows11のデザインではわかりにくいですが、ウィンドウもリサイズ可能なものになっています。さらにタスクバーを見るとダイアログが単体のアイコンを持っています。イケてないですね。


端的に、普通のウィンドウが表示されたと言えます。ダイアログはもっとダイアログっぽいスタイルでウィンドウを表示したいですね。

これに関しては、UserControlにprism:Dialog.WindowStyle添付プロパティを設定してやれば良いです。

<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <prism:Dialog.WindowStyle>
        <Style TargetType="Window">
            <Setter Property="prism:Dialog.WindowStartupLocation" Value="CenterOwner" />
            <Setter Property="ResizeMode" Value="NoResize"/>
            <Setter Property="ShowInTaskbar" Value="False"/>
            <Setter Property="SizeToContent" Value="WidthAndHeight"/>
        </Style>
    </prism:Dialog.WindowStyle>

    <Grid />
</UserControl>

Windows11のデザインではわかりにくいですが、ウィンドウ枠が細枠(サイズ変更不可)になっており、最小化/最大化のボタンもなくなっています。もちろんタスクバーにも表示はされていません。

2. データの受け渡しをする

さて、ダイアログを表示するときは何かしらのデータを引き渡したいし、ダイアログを閉じた時には入力されたデータを受け取りたいことがあります。安心してください、Prismではそのような機能もサポートしています。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => {
            var param = new DialogParameters() { { "Text", Text }, };

            dialog.ShowDialog("TestDialog", param, result => {
                if(result.Result == ButtonResult.OK)
                    Text = result.Parameters.GetValue<string>("Text");
            });
        });
    }

    public DelegateCommand ShowDialogCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

呼び出し側のViewModelはこんな感じです。ShowDialogの第2引数にDialogParametersを渡します。名前こそDialogParametersですが、単純なディクショナリ型ですので、任意のデータを入れ込むことができます。

第3引数はダイアログから制御が返ってきたときに呼び出されるデリゲートです。下のダイアログ側のViewModelを見てからのほうがわかりやすいと思いますので、ひとまずそちらを見てから解説します。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
        OkButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.OK, new DialogParameters() { { "Text", Text } }));
        CancelButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.Cancel));
    }

    public string Title => "Test Dialog";

    public void OnDialogOpened(IDialogParameters parameters)
    {
        Text = parameters.GetValue<string>("Text");
    }

    public void OnDialogClosed()
    {
    }

    public bool CanCloseDialog() => true;

    protected void RaiseRequestClose(ButtonResult btnres, IDialogParameters param)
    {
        RequestClose?.Invoke(new DialogResult(btnres, param));
    }
    protected void RaiseRequestClose(ButtonResult btnres)
    {
        RequestClose?.Invoke(new DialogResult(btnres));
    }
    public event Action<IDialogResult>? RequestClose;


    public DelegateCommand OkButtonCommand { get; }
    public DelegateCommand CancelButtonCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

OnDialogOpenedメソッドはIDialogAwareインターフェースのメンバーの一つですが、ダイアログが開いたときにPrismがこのメソッドを呼んでくれます。この引数が、呼び出し元がパラメーターとして指定したものになります。今回は呼び出し物が指定したTextを読み取って、Textプロパティに代入しています。 

OKボタン/Cancelボタンが押されたときにそれぞれRequestCloseイベントを発動させています。その引数にはDialogResultクラスで、どのボタンが押されたかとDialogParametersをコンストラクタに渡してあげることで、その値を呼び出し元に返すことができます。

そして、呼び出し元のコードでは、OKボタンが押されたことを受けて、ShowDialogメソッドの第3引数のデリゲートが呼ばれますので、そこでButtonResultやDialogParametersを読み取って、適宜何かしらの処理を追加してやれば良いです。

ちなみに、ShowDialogメソッドは同期的に呼ばれます。すなわち、第3引数のデリゲートが制御を返した後にShowDialogメソッドは制御を返します。じゃあなんでShowDialogメソッドの戻り値をIDialogResultにしてくれなかったんだ…。

番外編:標準ダイアログを表示する

さて、ダイアログの表示のしかたは分かったけど、MessageBoxやファイルを開くダイアログなどのWindows標準ダイアログを表示させるにはどうしたら良いでしょう?

結論から言うと、Prismではサポートしていません。今まで説明してきた通り、ダイアログを実装するための支援が充実しているから、MessageBoxやファイルを開くダイアログは自作してねということのようです。

確かに、最近そういったダイアログも独自実装されたアプリをしばしば見かけますが、個人的にはソウジャナイ感をどうしても感じてしまいます。そういう標準ダイアログは、アプリを作る側の省力化だけでなく、使う側としてもどのアプリを使っても同じデザインだからOSとして統一感があり使いやすいわけです。それをわざわざ「自分で実装しろ」というのはいかがなものか…。

というわけで、標準ダイアログを表示するためにはLivetを使いましょう。大丈夫、PrismとLivetも共存できます。

まずは NugetからLivet.Messagingを入れます。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel()
    {
        Messenger = new InteractionMessenger();

        ShowMessageBoxCommand = new DelegateCommand(() => Messenger.Raise(new InformationMessage("MessageBox Text", "Caption", System.Windows.MessageBoxImage.Information, "InformationMessageBox")));
    }

    public InteractionMessenger Messenger { get; }

    public DelegateCommand ShowMessageBoxCommand { get; }
}

その後、ViewModelにInteractionMessengerクラスのプロパティを一つ用意してあげます。Livet標準のViewModel基底型ならもとからこのプロパティを持っているのですが、当然ながらPrismには無いので後付けしてやる必要があります。

<UserControl x:Class="PrismTest.Views.ContentRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:ContentRegionViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             d:Height="350" d:Width="525" >

    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="InformationMessageBox">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
    
    <Grid>
        <Button Content="Show MessageBox" Command="{Binding ShowMessageBoxCommand}" Width="150" Height="40" />
    </Grid>
</UserControl>

ViewではInteractionMessageTriggerを使い、ViewModelからのメッセージを受け取ってやれば良いです。今回はOKボタンのみのMessageBoxを表示させていますが、その他の種類のMessageBoxだったりファイルを開くダイアログだったりは、対応する***Message型/***InteractionMessageAction型に差し替えれば表示することができます。ここらへんはLivetの使い方ですので、だいぶ昔に書いたこちらの記事を見ていただければ良いかと思います。

Prismの入門記事でPrismではなく別のライブラリを使えというのも変な話ですが、ライブラリによって当然得手不得手というものはありますから、それによって組み合わせが発生するのはやむを得ないことと思います。

***

以上で、3回にわたって連載(?)してきたPrism+DryIocの入門編ですが、ひとまず今回で終わりにしたいと思います。また何か思いついたテーマがあれば随時記事にします。

2024年8月23日金曜日

Prism + DryIoc 入門②(お作法編)

どんなフレームワークにも決まったお作法というか、こう使ってほしいという想定があります。逆に言えば、それをマスターすることがそのフレームワークを自由自在に使いこなせるようになる近道というわけです。今回はそのようなPrismのお作法を紹介していきます。

1. プロジェクトテンプレート

Prismを使ったWPFアプリを作る場合は、プロジェクトテンプレートを使いましょう。Visual Studio 2022の場合は、メニューの「拡張機能」→「拡張機能の管理」からPrismと検索するとPrism Template Packが出てきてインストールすることができます。

インストールをすると、プロジェクトの新規作成からPrismを選択できるようになります。今回は「Prism Blank App (WPF)」を選択します。

そうするといつも通りプロジェクトの名前と保存名が聞かれるので適当に入力します。

ここで「作成」を押すと、PrismのDIコンテナを選択するダイアログが出てきますので、「DryIoc」を選択します。

これにて無事Prism+DryIocのプロジェクトが出来上がりました。

最後にお好みで、.NETのバージョンを最新に上げたり、Nullableを有効化したり、Nugetからパッケージを最新にしたりしておきましょう。ビルドすると空のWPFアプリが立ち上がるはずです。

ちなみに、Prismはバージョン8まではMITライセンスですが、バージョン9からはPrism Community License / Prism Commercial Licenseという独自の商用ライセンスに切り替わっているようです。バージョンアップには要注意です。

2. 領域(Region)の作成と配置

ウィンドウにUI要素(コントロールなど)を配置するとき、MainWindowに直接配置することもできますが、もう少し小さいパーツ単位でUI要素を作って配置したいときがあると思います。そうしないとMainWindowに全部入りになってしまいますもんね。そんな時にはRegionと呼ばれるものを作ると良いです。

ちなみに、Regionの実体はただのUserControlです。 新しい項目の追加から、Prismテンプレートの"Prism UserControl (WPF)"を選択します。

今回は、MainWindowの左半分と右半分のRegionということで、LeftRegionとRightRegionの2つを追加しました。UserControlを新規作成すると、対応するViewModelも自動的に作ってくれます。

MainWindow.xamlでは次のようにContentControlにてRegionを配置します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>

        <ContentControl Grid.Column="0" prism:RegionManager.RegionName="LeftRegion" />
        <ContentControl Grid.Column="1" prism:RegionManager.RegionName="RightRegion" />
    </Grid>
</Window>

これだけでは実は不十分で、Region名と実際のUserControl型を紐づけなければなりません。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));
    }
}

MainWindowViewModelのコンストラクタにIRegionManager型の引数を設定します。こうするとDIコンテナが良い感じにIRegionManagerの実体を割り振ってくれるので、ここでRegisterViewWithRegionメソッドを呼び出してRegion名と実際のUserControl型を結び付けておきます。

これでこのようにRegionが所定の場所に割り付けられるようになりました。

ちなみに上の画像に"Left Region"と"Right Region"と表示しているのは、空っぽだと見た目でよくわからないので、そういうコードを追加したからです。

<UserControl x:Class="PrismTest.Views.LeftRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Left Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>
<UserControl x:Class="PrismTest.Views.RightRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Right Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>

3. デザイン時のデータ

実装を進めているとあることに気が付きます。ViewのXAMLを編集する際、ViewModelのプロパティのサジェストがVisual Studioで働かないのです。これはViewModelLocator.AutoWireViewModelにてViewModelが実行時に動的に紐づけられるからで、XAMLにはどこにもViewModelの型を明記していないからです。それはさすがにサジェストが働かなくて当然ですね。

このようなときに便利なのがデザイン時のデータという機能です。

一番大元の要素に以下のような呪文を追加します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:vm="clr-namespace:PrismTest.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance Type=vm:MainWindowViewModel}"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <!-- 中略 -->
</Window>

5から9行目が呪文部分です。これを入力することによって、Visual Studioでのデザイン時のみDataContextをMainWindowViewModelとみなすことができ、プロパティなどのサジェストが働くようになります。

4. インターフェースとクラスのマッピング

最後にインターフェースとクラスのマッピングです。前回の記事でも説明した通り、DIを使用した実装では、各クラス間の依存性を抑えるため、コンストラクタでインターフェースを受け取るようにし、DIコンテナがそれに対応するクラスをインスタンス化して渡してくれます。

それを実現するためには、インターフェースとクラスの対応付けをあらかじめ設定しておく必要があります。その設定は、App.xaml.cs内で記述することになります。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

RegisterTypesメソッドをオーバーライドして、そのなかでRegisterメソッドを呼び出すことでインターフェースとクラスのマッピングをすることができます。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager, IMainWindowModel model)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));

        Title = model.Title;
    }
}

マッピングしてさえいれば、コンストラクタに引数を追加することで、DIコンテナにて対応するインスタンスを作ってくれます。

なお、グローバルなデータ、設定値などはRegisterSingletonメソッドでDIコンテナに登録することによって、同じインターフェースには唯一のインスタンス(実体が同じもの)が返されるようになります。

***

以上で必要最低限のお作法は紹介できたはずです。 ここから自分の作りたいアプリをどんどん実装していきましょう。

2024年8月14日水曜日

Prism + DryIoc 入門①(DI編)

このブログを普段から読んでくださっている方(どれくらいいるかわかりませんが…)なら、私はMVVMフレームワークとしてLivetを使っていることはよくご存じと思います。ですが、ついにPrismにも手を出してみようと思い、最近Prismを使い始めましたので、その内容をブログに残しておこうと思います。

今回はなぜPrismを使う必要があるのか、DI (Dependency Injection)の対応という観点から説明をしていきたいと思います。

DI (Dependency Injection)とは

DI (Dependency Injection)とは日本語で「依存性の注入」とか言われたりするそうです。ここで言う依存性とは、クラス間の依存性のことです。クラスA内でクラスBを使用していれば、クラスAはクラスBに依存しているということになります。DIでは、それをクラスA内部にクラスBを使った実装を直接書くのではなく、もう少し抽象化して実装して、クラスBを使うということを後から外部から注入できるようにしようとものです。

なぜそんなことをするかというと、ひとえに単体テストをしやすくするためです。クラスA内でクラスBとクラスCとクラスDを使用し…となっていると、クラスAのテストをしたいのにクラスB, C, Dすべて用意してテストしなければならなくなってきます。クラスBがエラーを起こした時の処理をテストしたいけど、クラスBにエラーを起こさせるのが難しいとかいうことがあるかもしれません。もしくはクラスCはサーバーと通信していて、いろいろなパターンを自動でテストしているとサーバーに負担がかかるとか通信で時間がかかるとかがあるかもしれません。ただ単にクラスA内のコードが正しく動くかを試験したいだけなのに、それではテストがとてもしにくいですよね。

そこで、クラスAの実装時にはクラスB, C, Dに対応したインターフェースのみをコンストラクタで受け取って使い、クラスB, C, Dそのものには触れないようにします。そして、実行時にコンストラクタに本物のクラスB, C, Dのインスタンスを与えてあげるという実装のしかたをすれば、テストのときはダミーのクラスB, C, Dを渡してあげることで本物のクラスB, C, Dには依存しないテストをすることができるようになります。これが「Dependency Injection」という考え方です。

LivetでMVVMを実装した場合(DI無し)

さて、LivetはDIをサポートしていませんが、LivetでMVVMなソフトを作った場合どのようになって何が問題になるでしょうか。以下の画像のようにテキストボックスに入力した文字を大文字に変換して表示するソフトを例に考えてみましょう。

これをLivetで実装した場合、おおむね以下のような感じになるかと思います。

<Window x:Class="LivetTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:LivetTest.Views"
        xmlns:vm="clr-namespace:LivetTest.ViewModels"
        Title="Livet Test" Width="525" Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        
        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : ViewModel
{
    readonly MainWindowModel model;

    public MainWindowViewModel()
    {
        model = new MainWindowModel();

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        { 
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        { 
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public class MainWindowModel : NotificationObject
{
    public MainWindowModel()
    {
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                OutputText = InputText.ToUpper();
                break;
        }
    }
}

少し長いですが、重要なのはMainWindow.xaml内で

<Window.DataContext>
    <vm:MainWindowViewModel />
</Window.DataContext>

と名指しでMainWindowViewModelをインスタンス化しているところ、およびMainWindowViewModel.cs内で

readonly MainWindowModel model;

public MainWindowViewModel()
{
    model = new MainWindowModel();

    // 中略
}

と名指しでMainWindowModelをインスタンス化しているところです。MainWindow.xamlをテストするにはMainWindowViewModelの本物が必要で、MainWindowViewModelのテストをするにはMainWindowModelの本物が必要になります。これでは単体テストと呼べず、ただの結合テストになってしまいますね。

PrismでMVVMを実装した場合(DryIoc)

Prismは標準でDIをサポートしていますが、Prismで直接DIコンテナが実装されているわけではなく、別のライブラリを参照する形となっています。WPFではUnityDryIocが使えるようですが、ここではDryIocを使います。DryIocを選んだ理由としては、こちらのほうが速度が速く、かつ、UnityはC#がよく使われているゲームライブラリと名前が同じで検索汚染がひどく、目的の情報にたどり着きにくいからです。

さて、先ほどのLivetと同様にPrism+DryIocで実装すると以下のようになります。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="Prism Test" Height="350" Width="525" >

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;

    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public interface IMainWindowModel : INotifyPropertyChanged
{
    string InputText { get; set; }

    string OutputText { get; }
}
    public class MainWindowModel : BindableBase, IMainWindowModel
    {
        public MainWindowModel()
        {
            PropertyChanged += This_PropertyChanged;
        }

        public string InputText
        {
            get => _InputText;
            set
            {
                if(_InputText == value)
                    return;
                _InputText = value;
                RaisePropertyChanged();
            }
        }
        private string _InputText;

        public string OutputText
        {
            get => _OutputText;
            private set
            {
                if(_OutputText == value)
                    return;
                _OutputText = value;
                RaisePropertyChanged();
            }
        }
        private string _OutputText;

        private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            switch(e.PropertyName) {
                case nameof(InputText):
                    OutputText = InputText.ToUpper();
                    break;
            }
        }
    }
public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

こんな感じになります。注目していただきたいところは、

prism:ViewModelLocator.AutoWireViewModel="True"

と書いてあるところと、

readonly IMainWindowModel model;

public MainWindowViewModel(IMainWindowModel model)
{
    this.model = model;

    // 中略
}

と書いてあるところです。

Viewでは、Windowに添付プロパティとしてViewModelLocator.AutoWireViewModelを追加することができます。これをTrueにすると、Windowのクラス名の末尾に「ViewModel」を付けたクラスをそのViewに対応したViewModelとして認識し、自動的にDataContextプロパティにそれをインスタンス化してくっつけてくれるようになります。敢えて名指しでViewModelを指定しなくて良くなるわけです。

ViewModelでは、直接内部でModelをインスタンス化する代わりに、コンストラクタの引数でIMainWindowModelというインターフェースを受け取っています。IMainWindowModelインターフェースはその名の通りMainWindowModelのpublicなプロパティのみを抜き出したインターフェースですが、こうすることによって、本番ではMainWindowModelのインスタンスをコンストラクタでに渡してあげれば良いですし、単体テストをするときはIMainWindowModelを実装した適当なダミーのクラスを渡してあげればテストができるようになります。こうすることで、ViewModelを動かす際に対応するModelが必須ではなくなり(=依存性が弱まり)、テストの自由度が上がるわけです。

ちなみに、実際にMainWindowModelをインスタンス化するところを我々が自分で実装することは無く、DIコンテナが良い感じにインスタンス化してくれます。その際に、MainWindowModelのコンストラクタに必要なインスタンスを作って渡してくれるのですが、あらかじめ「IMainWindowModelの本番実装はMainWindowModelだから、IMainWindowModelが出てきたらMainWindowModelをインスタンス化して渡してあげてね」と教えてあげなければなりません。そのことは、App.xaml.cs内に記述するようになっています。

まとめ

ここまで、Prismを使うにあたって必要なDIの基本的な考え方を説明してきました。今までは実装全体でしかテストできなかったものが、パーツごとに分けてテストができるようになるというのはとても良いですね。

ただ、その「依存性を減らす」という目的と裏表ですが、DIはコードの見通しが悪くなるのが欠点です。上の例だとIMainWindowModelの本番実装が何かというのはVisual StudioのF12キーだけで追いかけるのは難しく、App.xaml.csの中身を能動的に見に行かなければならなくなります。自分で実装したのならそういうマナーはわかっていますが、世の中に転がっている任意の流儀で実装されたDIのコードを追いかけるのは至難の業です…。

ちなみに、今回の記事で「LivetはDIに対応していない」と何度も書きましたが、LivetがPrismに対して劣っているという意図はありません。次回の記事くらいで出てくるかもしれませんが、LivetはできるけどPrismができないことというのもあります。ですので、実際のアプリ開発においては、PrismとLivetの二者択一ではなく、PrismとLivetの併用で作っていくことになると思います。その辺の話もまたそのうち記事にできればなと思っています。

2024年4月6日土曜日

WPFでFrameworkElementを直接継承したコントロールを作成する

WPFでコントロールを自作することはさほど多くありません。標準コントロールのほかExtended WPF Toolkitなどのライブラリが充実していることに加え、テンプレートやスタイル、その他もろもろの強力な機能により自作コントロールが無くてもかなりの表現ができてしまうからです。
それでももし何かコントロールが必要になったらカスタムコントロールでいくつかのコントロールを集めたコントロールを作ることができます。これでだいたい事足りてしまうのです。

それでも、もっと原始的なコントロールを作りたいことがあったらどうすれば良いでしょう。そんなことは普通は無いと思っていたのですが、WPF縦書きライブラリを作る過程で必要になってしまったので、そのようなときの手段を今回はまとめておきたいと思います。

 

WPFのレイアウトプロセス

まず最初に理解しなければならないのは、WPFにおけるコントロールの配置の仕組みです。多くの人は、Grid / StackPanel / Canvasの3種類のパネルで、コントロールに与えるパラメーターは同じなのにコントロールのサイズなどの配置のされ方が全く違うことを経験したことがあるでしょう。

Grid

<Grid ShowGridLines="True">
    <Grid.RowDefinitions>
        <RowDefinition Height="1*" />
        <RowDefinition Height="1*" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="1*" />
    </Grid.ColumnDefinitions>

    <TextBlock Grid.Row="0" Grid.Column="0" Text="Red" Foreground="White" Background="Red" />
    <TextBlock Grid.Row="0" Grid.Column="1" Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Grid.Row="1" Grid.Column="0" Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Grid.Row="1" Grid.Column="1" Text="Green" Foreground="White" Background="Green" />
</Grid>

StackPanel

<StackPanel Orientation="Vertical">
    <TextBlock Text="Red" Foreground="White" Background="Red" />
    <TextBlock Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Text="Green" Foreground="White" Background="Green" />
</StackPanel>

Canvas

<Canvas>
    <TextBlock Canvas.Left="50" Canvas.Top="50" Text="Red" Foreground="White" Background="Red" />
    <TextBlock Canvas.Left="100" Canvas.Top="50" Text="Yellow" Foreground="Black" Background="Yellow" />
    <TextBlock Canvas.Left="50" Canvas.Top="100" Text="Red" Foreground="White" Background="Blue" />
    <TextBlock Canvas.Left="100" Canvas.Top="100" Text="Green" Foreground="White" Background="Green" />
</Canvas>

それぞれのパネルに4つのTextBlockを配置してみました。一部パネル内のどこに配置するかの添付プロパティは追加していますが、それ以外はどのパネルに対しても同じパラメーターでTextBlockを配置しています。ですが、Gridの場合は右/下方向に引き伸ばされ、StackPanelは右方向のみに引き伸ばされ、Canvasは一切引き伸ばされていません。TextBlockからはパネルのどの位置に配置するのかしか指定しておらず、寸法はパネルの種類によって自動で決まるのです。それ以外にもHorizontalAlignmentプロパティVerticalAlignmentプロパティも配置に影響するのはご存じのとおりです。
これは一体どうやって実現されているのでしょうか。

MeasureOverrideとArrangeOverride

WPFのレイアウトは、Measure(測量)とArrange(配置)という2つのプロセスを経て決定されます。

1. Meausre

親要素が子要素の配置を考えるうえで、子要素に必要な大きさを申告してもらうための手続きです。 

親要素はMeasureが必要になった時に子要素のMeasureメソッドを呼びます。そうすると子要素はDesiredSizeプロパティを更新します。FrameworkElementを継承したクラスを実装するうえでは、MeasureOverrideメソッドをオーバーライドすることでその手続きを実装します。

protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize);

availableSizeは親要素が提供可能なサイズですので、これをもとに必要サイズを計算し、その値を返します。

2. Arrange

親要素はDesiredSizeをもとに各子要素の配置を決定し、子要素に通知する手続きです。

親要素は子要素の配置が決まると、子要素のArrangeメソッドを呼びます。そうすると子要素はRenderSizeプロパティを更新します。FrameworkElementを継承したクラスを実装するうえでは、ArrangeOverrideメソッドをオーバーライドすることでその手続きを実装します。

protected virtual System.Windows.Size ArrangeOverride(System.Windows.Size finalSize);

finalSizeは実際に自分に割り当てられた大きさですので、これをもとに自身の配置を制御します。返却値は実際のサイズになりますが、まあ、普通は割り当てられたサイズそのままになると思いますので、そのような場合はこのメソッドを敢えてオーバーライドする必要はありません。FrameworkElementがこの返却値をもとにRenderSizeプロパティを更新してくれます。

子要素の申告でレイアウト変更を行う

ここまで説明してきたのは、すべて親要素起点のレイアウト変更です。例えばウィンドウサイズの変更による配置変更などですね。ただ、コントロールを実装するうえでは、自身のサイズが変わるなどして親要素にレイアウト変更を依頼しなければならないことが出てきます。例えば表示する文字列が変わった、文字サイズが変わったなどですね。そのような場合はどうすれば良いのでしょうか。

一つの方法としては、InvalidateMeasureメソッドを呼ぶことです。これにより現在のMeasureの結果を無効なものとし、再度レイアウトプロセスを実行することを促せます。
ただしMSDNの説明文にも書いてある通り、このメソッドの頻繁な呼び出しはパフォーマンスに大きな影響を与えるため、可能な限り呼び出しは避けるべきと書かれております。もっと良い方法がほかにあるのでしょうか。

前述したとおり、レイアウト変更が必要になるケースとして、文字列の変更や文字サイズの変更などが考えられます。それらは、一般にコントロールの依存関係プロパティ(Dependency Property)として外に公開されていることが多いです。そこに、そのプロパティがMeasureに影響する項目かどうかを設定する機能があるのです。

public bool AffectsMeasure
{
    get { return (bool)GetValue(AffectsMeasureProperty); }
    set { SetValue(AffectsMeasureProperty, value); }
}
public static readonly DependencyProperty AffectsMeasureProperty =
    DependencyProperty.Register(nameof(AffectsMeasure), typeof(bool), typeof(FrameworkElementDerivedClass),
    new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsMeasure));

依存関係プロパティを実装するときはだいたいこんな形になると思います。
このFrameworkPropertyMetadataに渡している引数に注目です。FrameworkPropertyMetadataOptions.AffectsMeasureというのを渡しています。こうすることで、このプロパティが変化したときに自動的にMeasureプロセスが実施されるようになります。
他にもAffectsArrange、AffectsParentMeasure、AffectsParentArrangeがあり、名前の通りです。

 

描画処理

レイアウトに対応できるようになったところで、次は描画処理です。
これもいろいろな方法があるようですが、一番シンプルでかつ上のレイアウト変更と親和性の高い方法を紹介します。

OnRenderメソッド

FrameworkElementにはOnRenderメソッドがあり、これをオーバーライドするのが最もシンプルです。

protected virtual void OnRender(System.Windows.Media.DrawingContext drawingContext);

このメソッドでDrawingContextが渡されます。これがいわゆるWin32で言うところのデバイスコンテキストハンドルみたいなもので、これに対して描画操作をすることで画面に描画することができます。

DrawingContextが持っているメソッドのうちDrawから始まるものを見ればわかりますが、直線、四角形、楕円、ジオメトリ、画像、文字列、グリフなど一通りの描画メソッドを持っています。
また、Pushから始まるものを見ると、クリッピング、Opacity(透明性)、Transform(図形変換)などもサポートしています。Popメソッドもあある通りスタック構造をしており、PushしてからPopされるまでの間それらの処理が適用されるようです。

再描画指示

さて、上述のレイアウトプロセス(Measure→Arrange)の後に自動的にOnRenderが呼ばれるのは言うまでも無いですが、それ以外のときも再描画したいシチュエーションはあります。
これもレイアウトプロセスと同じで、InvalidateVisualメソッドを呼び出して再描画させることができますが、同様にパフォーマンスに影響を与えるので基本は呼ぶべきではないものです。
描画に影響を与えるプロパティにFrameworkPropertyMetadataOptions.AffectsRenderオプションを渡してやれば良いでしょう。


プロパティ値の継承

WPFは依存関係プロパティの値の継承機能があります。一番身近なのはDataContextで、親要素で設定したDataContextが子要素のDataContextにアクセスすることで触れるのはよく知られていると思います。これはDataContextだけでなく、例えばフォントサイズなども親要素で設定したら子要素にも伝搬します。これを「プロパティ値の継承(Property value inheritance)」と言います。

そのような依存関係プロパティを実装する際は、FrameworkPropertyMetadataのコンストラクタにFrameworkPropertyMetadataOptions.Inheritsを渡せば良いです。こうすることで、親要素で設定されたものが子要素に伝搬してきます。

ちなみに、例えばフォントファミリーやフォントサイズなどの文字列関係のプロパティは、TextElementクラスなんかが使えるようです。このクラスに文字列関係の依存関係プロパティのフィールドがありますので、それを使えば簡単にフォント関係のプロパティ値の継承ができます。

public FontFamily? FontFamily
{
    get { return (FontFamily?)GetValue(FontFamilyProperty); }
    set { SetValue(FontFamilyProperty, value); }
}
public static readonly DependencyProperty FontFamilyProperty = TextElement.FontFamilyProperty.AddOwner(typeof(TategakiText));

この依存関係プロパティはちゃんと上述のAffects***などのオプションもちゃんと実装されているようで、これだけでレイアウトやレンダリングも問題なしです。

 

まとめ

さて、多分滅多に使うことのない、WPFのレイアウトシステムと描画処理に関する実装の仕方をまとめました。滅多に使うことが無いからかちゃんとまとまって解説してくれている資料があまり無いんですよね。この点からも.NETのソースコードが公開されるようになったのは、それを見ることである程度どんな感じか追えるのでとても助かります。

最後に、こうやって作られたのがWPF縦書きライブラリ Tategakiです。こっちもよろしくね!

2021年12月11日土曜日

WPFのMenuを動的に生成する(もちろんMVVM)

今の時代メニューなんて流行らないんですかね。まあそういう私もRibbon UIでソフトを作ることが多いのであまりメニューを使うことも無いのですが、たまに使った際に動的なMenuを生成しようとしてググったところあまり良い記事が無かったのでここでまとめておきます。

動的なメニュー

こういうやつです。

今回はNIC(Network Interface Card)を列挙してメニュー項目として登録しています。当然NICはパソコンによって搭載しているものが違うため、ソフトウェアを実行するまで名前はわかりません。よって、ソフト実行中に(=動的に)メニュー項目を追加する必要が出てくるのです。

もちろんNICじゃなくても構いません。COMポートでも良いし、「最近使ったファイル」でも良いです。そういうところで動的なメニューが欲しくなることはしばしばあります。 

ItemsSource

さて、もはやこれが答えですが、MenuItemクラスにはItemsSourceプロパティがあります。勘のいいひとは気づくと思いますが、このプロパティに子アイテムのObservableCollectionをバインディングしてあげれば終わりです。基本的な作戦はこれになります。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" />
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs;
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<string> NICs { get; }

}
public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<string>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add($"{capture.Interface.FriendlyName} ({capture.Description})");
	}

	readonly ReactiveCollection<string> _NICs;
	public ReadOnlyReactiveCollection<string> NICs { get; }
}

通知可能コレクションは私はReactivePropertyのReactiveCollection派なのでこんなコードになっていますが、そこは好みに合わせてなんでもいいです。ちなみにMVVMインフラはLivetを使っていて、NICの列挙にはSharpPcapを使っています。

一応コードの解説をしておくと、Windowロード時にViewModelのInitializeメソッドが呼ばれるため、そこでModelにNIC名の取得を指示しています。ModelではNICs通知可能コレクションにNIC名を追加し、ViewModelを介してMenuItemのItemsSourceにバインディングされます。これだけでもう最初に示した画像のような動的なメニューは完成です。

MVVM化

さて、stringのコレクションをItemsSourceにバインドすることで動的にメニューを生成することができましたが、すでに気づいている人もいるかもしれませんが、このままではそれらのメニューを押した時のイベントに対応することができません。さすがにそれではメニューとして使い物にならないでしょう。ほかにも、動的に生成したメニューを無効化したりチェックマークを入れたりしたくなることもあるかと思います。

そこで、MenuItem一つにつき一つのViewModelを持たせる方法で実装をします。MenuItemに限らず、MVVMではMainWindowViewModelで書ききれないもの、個数が変化する要素などがその典型ですが、別のViewModelを作ってバインディングしてしまうのがベストプラクティスです。

public class NICMenuItemViewModel : ViewModel
{
	NICMenuItemModel model;

	public NICMenuItemViewModel(NICMenuItemModel model)
	{
		this.model = model;

		MenuHeader = model.MenuHeader;
		NICName = model.NICName;
		
		MenuCommand = new ReactiveCommand();
		MenuCommand.Subscribe(() => model.Invoke());
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public ReactiveCommand MenuCommand { get; }
}
public class NICMenuItemModel
{
	Action<string> menuclicked;

	public NICMenuItemModel(string header, string name, Action<string> menuclicked)
	{
		MenuHeader = header;
		NICName = name;
		this.menuclicked = menuclicked;
	}

	public string MenuHeader { get; }
	public string NICName { get; }

	public void Invoke()
	{
		menuclicked?.Invoke(NICName);
	}
}

まずこれがMenuItemに対応するViewModelとModelです。MenuItemに表示されるテキストを表すMenuHeaderプロパティと、その項目に対応するNICNameプロパティを用意しています。通常は通知可能プロパティにすべきですが、不変で困らないのでそこはサボっています。

ViewModelにはさらにMenuCommandというプロパティを用意しており、メニューがクリックされたときはこのコマンドが発動するようにしています。それが押されるとModelのInvokeメソッドが呼ばれ、Model生成時にコンストラクタで渡していたActionが発動するようにしています。この辺りはEventで実装するなどアレンジは自由かと思います。

public class MainWindowModel
{
	#region Singleton

	static MainWindowModel? instance = null;
	public static MainWindowModel GetInstance()
	{
		if(instance == null)
			instance = new MainWindowModel();
		return instance;
	}

	#endregion

	MainWindowModel()
	{
		_NICs = new ReactiveCollection<NICMenuItemModel>();
		NICs = _NICs.ToReadOnlyReactiveCollection();
	}

	public void LoadNICList()
	{
		foreach (var capture in LibPcapLiveDeviceList.Instance.OrderBy(p => p.Interface.FriendlyName))
			_NICs.Add(new NICMenuItemModel($"{capture.Interface.FriendlyName} ({capture.Description})", capture.Name, name => OpenNIC(name)));
	}

	public void OpenNIC(string name)
	{
		//throw new NotImplementedException();
	}

	readonly ReactiveCollection<NICMenuItemModel> _NICs;
	public ReadOnlyReactiveCollection<NICMenuItemModel> NICs { get; }
}
public class MainWindowViewModel : ViewModel
{
	MainWindowModel model;
	public MainWindowViewModel()
	{
		model = MainWindowModel.GetInstance();
		NICs = model.NICs.ToReadOnlyReactiveCollection(p => new NICMenuItemViewModel(p));
	}

	public void Initialize()
	{
		model.LoadNICList();
	}

	public ReadOnlyReactiveCollection<NICMenuItemViewModel> NICs { get; }
}

MainWindowModelではNICロード時にNICMenuItemModelインスタンスを生成しています。MainWindowViewModelではModelのNICsプロパティの中身をModelからViewModelに変換してViewModelのNICsとして保持しています。この辺りはMVVMのお決まりパターンです。

最後にXAMLです。

<DockPanel>
	<Menu DockPanel.Dock="Top">
		<MenuItem Header="File" >
			<MenuItem Header="Open NIC" ItemsSource="{Binding NICs}" >
				<MenuItem.ItemContainerStyle>
					<Style TargetType="MenuItem">
						<Setter Property="Header" Value="{Binding MenuHeader}" />
						<Setter Property="Command" Value="{Binding MenuCommand}" />
					</Style>
				</MenuItem.ItemContainerStyle>
			</MenuItem>
			<MenuItem Header="Close NIC" />
		</MenuItem>
	</Menu>
	<Grid />
</DockPanel>

NICsプロパティをItemsSourceにバインディングしただけではメニューの表示項目はNICMenuItemViewModel.ToString()の実行結果になってしまいます。そこで、ItemContainerStyleを使い、各子要素のHeaderプロパティをViewModelのMenuHeaderプロパティ、CommandプロパティをViewModelのMenuCommandプロパティにバインディングしています。

これで意図したとおり動的に生成したメニューをクリックしたらいろいろ伝わって最終的にMainWindowModelのOpenNICメソッドが呼ばれるようになります。ここで煮るなり焼くなり好きにすれば良いでしょう。

まとめ

今回はMVVMを保ちながらWPFのMenuを動的に生成する方法を見てきました。なんだかついでにMVVMの実装実例を紹介する記事にもなってしまいましたね。

MVVMでは数が減ったり増えたりするものにはItemsSourceに要素用のViewModelを作ってバインディング、これさえ覚えておけばメニュー以外でもいろいろと応用できそうです。

2021年3月14日日曜日

SliderのAutoToolTipPlacementをカスタマイズする

SliderのAutoToolTip

WPFのSliderにはAutoToolTipPlacementというプロパティがあり、これにNone以外の値を設定することで、Sliderを動かしている最中に自動的につまみ(Thumb)に連動するツールチップを表示させ、値をリアルタイムに確認できるようになります。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" />

非常に手軽で強力ですね。

しかし、これが案外WPFらしからぬ機能なのです。というのもWPFはXAMLの力でUIがかなり柔軟に書けるのが特徴なはずなのですが、このツールチップのテンプレートはおろか、数値のフォーマットすら指定できません。申し訳程度にできるのは、AutoToolTipPrecisionプロパティを使って小数点以下の表示桁数を指定する程度です。

<Slider VerticalAlignment="Center" Minimum="0" Maximum="10" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="2" />

そのため、例えばDateTimeのような、単純な数値以外を扱うSliderを作っただけで詰んでしまいます。

【MainWindow.xaml】

<Window
    x:Class="SliderTest.Views.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
    xmlns:v="clr-namespace:SliderTest.Views"
    xmlns:vc="clr-namespace:SliderTest.Views.Converters"
    xmlns:vm="clr-namespace:SliderTest.ViewModels"
    Title="MainWindow"
    Width="525"
    Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Window.Resources>
        <vc:DateTimeTickConverter x:Key="DateTimeTickConverter" />
    </Window.Resources>

    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodName="Initialize" MethodTarget="{Binding}" />
        </i:EventTrigger>

        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>
    </i:Interaction.Triggers>

    <Grid >
        <Slider VerticalAlignment="Center" AutoToolTipPlacement="TopLeft" AutoToolTipPrecision="0"
                Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
                Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
                Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" />
    </Grid>
</Window>

【DateTimeTickConverter.cs】

[ValueConversion(typeof(DateTime), typeof(long))]
public class DateTimeTickConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is DateTime val)
            return val.Ticks;
        else
            return DependencyProperty.UnsetValue;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(value is double val)
            return new DateTime((long)val);
        else
            return DependencyProperty.UnsetValue;
    }
}

【MainWindowViewModel.cs】

public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        var now = DateTime.Now;
        Minimum = now;
        Value = now;
        Maximum = now + TimeSpan.FromDays(1);
    }


    public DateTime Minimum
    {
        get { return _Minimum; }
        set { 
            if(_Minimum == value)
                return;
            _Minimum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Minimum;


    public DateTime Maximum
    {
        get { return _Maximum; }
        set { 
            if(_Maximum == value)
                return;
            _Maximum = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Maximum;


    public DateTime Value
    {
        get { return _Value; }
        set { 
            if(_Value == value)
                return;
            _Value = value;
            RaisePropertyChanged();
        }
    }
    private DateTime _Value;
}

何の変哲もない、ただ単にDateTimeをConverter経由でSliderにバインディングするだけのプログラムです。

なのに、このようにツールチップに表示される値はDateTimeのTicksの値になってしまい、アプリユーザーにとっては無意味な数の羅列となってしまうのです。変換しているので当然なのですが、それはもちろんDateTimeがそのままではSliderのMinimum/Maximum/Valueにバインディングできないからです。

通常のWPF脳ならば、BindingのStringFormatみたいなのは無いかなとか、DataTemplateでToolTipをカスタマイズできないかなとか考えるわけです。が、どんなに探してもSliderにあるこの自動ツールチップに設定できるパラメーターは「ツールチップの表示位置」と「数値の小数点以下の桁数」のみなのです。困った困った。

先人の知恵

さて、本件ちょっとググると、まあ10年以上前にこの解決策を提示してくれている人はすぐに見つかるわけです。

Modifying the auto tooltip of a Slider 

比較的シンプルなコードで作っています。Sliderクラスを継承したFormattedSliderクラスを作り、そこにAutoToolTipFormatというプロパティを作る作戦ですね。

ただし、少しコードを読んでいくとビビります。というのも、Sliderクラス内部のprivateフィールドである「_autoToolTip」をリフレクションで無理矢理取り出し、それを改造することで数値のフォーマット化を実現しているのです。 うーん、さすがに無理矢理すぎる気が。現実的にはあまり問題無いのかもしれませんが、例えば.NETの内部実装のリファクタリングで_autoToolTipという名前が変えられただけで正常に動かないソフトになってしまいます。

それが許せる人はこれを使えば良いかもしれませんが、個人的にはちょっと禁忌に触れた黒魔術感があって嫌だなー…。

Behaviorによる実装

さて、やっと本題に入ります。私は黒魔術ではなく正攻法で攻めていきます。

上のようにSliderクラスを継承した新しいクラスを作っても良いのですが、WPFの場合は強力なカスタマイズ機能によりほとんどそのような手順は不要です。既存のクラスに添付して振る舞いを変えさせたければBehaviorでしょう。

【SliderThumbToolTipBehavior.cs】

public class SliderThumbToolTipBehavior : Behavior<Slider>
{
    ToolTip? tooltip;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.AddHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.AddHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.AddHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.RemoveHandler(Thumb.DragStartedEvent, (DragStartedEventHandler)Thumb_DragStarted);
        AssociatedObject.RemoveHandler(Thumb.DragDeltaEvent, (DragDeltaEventHandler)Thumb_DragDelta);
        AssociatedObject.RemoveHandler(Thumb.DragCompletedEvent, (DragCompletedEventHandler)Thumb_DragCompleted);
    }

    void Thumb_DragStarted(object sender, DragStartedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb) {
            if(tooltip == null) {
                tooltip = new ToolTip();
                tooltip.Placement = PlacementMode.Custom;
                tooltip.PlacementTarget = thumb;
                tooltip.CustomPopupPlacementCallback = ToolTip_CustomPopupPlacementCallback;
            }

            thumb.ToolTip = tooltip;
            tooltip.Content = ToolTipTemplate.LoadContent();
            tooltip.IsOpen = true;
            TooltipReposition();
        }
    }

    void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
    {
        TooltipReposition();
    }

    void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
    {
        if(Placement != AutoToolTipPlacement.None && e.OriginalSource is Thumb thumb && tooltip != null) {
            tooltip.IsOpen = false;
        }
    }

    void TooltipReposition()
    {
        if(tooltip != null) {
            double temp;
            if(AssociatedObject.Orientation == Orientation.Horizontal) {
                temp = tooltip.HorizontalOffset;
                tooltip.HorizontalOffset += 0.125;
                tooltip.HorizontalOffset = temp;
            } else {
                temp = tooltip.VerticalOffset;
                tooltip.VerticalOffset += 0.125;
                tooltip.VerticalOffset = temp;
            }
        }
    }

    CustomPopupPlacement[] ToolTip_CustomPopupPlacementCallback(Size popupSize, Size targetSize, Point offset)
    {
        CustomPopupPlacement? ret = null;

        switch(Placement) {
            case AutoToolTipPlacement.TopLeft:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, -popupSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(-popupSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
            case AutoToolTipPlacement.BottomRight:
                if(AssociatedObject.Orientation == Orientation.Horizontal)
                    ret = new CustomPopupPlacement(new Point((targetSize.Width - popupSize.Width) * 0.5, targetSize.Height), PopupPrimaryAxis.Horizontal);
                else
                    ret = new CustomPopupPlacement(new Point(targetSize.Width, (targetSize.Height - popupSize.Height) * 0.5), PopupPrimaryAxis.Vertical);
                break;
        }

        if(ret != null)
            return new CustomPopupPlacement[] { ret.Value };
        else
            return Array.Empty<CustomPopupPlacement>();
    }



    public AutoToolTipPlacement Placement
    {
        get { return (AutoToolTipPlacement)GetValue(PlacementProperty); }
        set { SetValue(PlacementProperty, value); }
    }
    public static readonly DependencyProperty PlacementProperty =
        DependencyProperty.Register(nameof(Placement), typeof(AutoToolTipPlacement), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(AutoToolTipPlacement)));


    public DataTemplate ToolTipTemplate
    {
        get { return (DataTemplate)GetValue(ToolTipTemplateProperty); }
        set { SetValue(ToolTipTemplateProperty, value); }
    }
    public static readonly DependencyProperty ToolTipTemplateProperty =
        DependencyProperty.Register(nameof(ToolTipTemplate), typeof(DataTemplate), typeof(SliderThumbToolTipBehavior), new PropertyMetadata(default(DataTemplate)));
}

【MainWindow.xaml】(抜粋)

<Slider VerticalAlignment="Center" 
        Minimum="{Binding Minimum, Converter={StaticResource DateTimeTickConverter}}"
        Maximum="{Binding Maximum, Converter={StaticResource DateTimeTickConverter}}"
        Value="{Binding Value, Converter={StaticResource DateTimeTickConverter}}" >
    <i:Interaction.Behaviors>
        <vb:SliderThumbToolTipBehavior Placement="TopLeft" >
            <vb:SliderThumbToolTipBehavior.ToolTipTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Value, StringFormat=HH:mm:ss.fff}" />
                </DataTemplate>
            </vb:SliderThumbToolTipBehavior.ToolTipTemplate>
        </vb:SliderThumbToolTipBehavior>
    </i:Interaction.Behaviors>
</Slider>

まず、OnAttatchedで3つの添付イベントをリッスンし、つまみを握ったときにツールチップ表示、動かしたときにツールチップ位置変更、離したときにツールチップ非表示を行います。ミソは、このイベントのe.OriginalSourceにつまみ(Thumbクラス)のインスタンスが入っていると言うことですね。

このビヘイビア自体には2つの依存関係プロパティを用意しています。1つ目はPlacementで、これはSlider本家のAutoToolTipPlacementと同じ役目なので説明はいらないでしょう。2つ目はToolTipTemplateで、ツールチップの中身に表示するものを定義するテンプレートです。これを用意することにより、「値のフォーマット化」にとらわれず自由自在に表示内容を設定できるようにしています。

ツールチップの位置を変える方法ですが、残念ながら直接的に再計算を要求させる方法(ToolTip.CustomPopupPlacementCallbackを呼ばせる方法)は無いようです。ですので、不本意ながら「HorizontalOffset/VerticalOffsetを変更して戻す」という手段を用いることで再計算させています。2度再計算させちゃうので効率悪いんですがね。

XAMLではSlider本家のAutoToolTipPlacementは使わず、ビヘイビアのほうのPlacementを設定します。あとは、ToolTipTemplateにツールチップの内容を設定するだけです。

これで見事ツールチップに意図した通りのものを表示できるようになりました。めでたしめでたし。

2020年6月24日水曜日

WPFでChromium埋め込みブラウザを使用する [CefSharp]

自分のソフトの中でブラウザを埋め込みたいことってありますよね。
WPFではWebBrowserコントロールが用意されていますが、とにかく使いにくい上に、IE7ベースで動作するという非常に残念なものです。IE11/Edgeに切り替えることもできなくはないようですが…レジストリをいじることになるのでアプリケーションとして使うというよりか、システムとして設定を変更することになってしまいます。
どうせならGoogle Chromeなどが代表的なChromiumブラウザ系統の組み込みブラウザを使いたいですよね。最近、IE系統に対応せずにChrome対応を謳うサイトも増えてきたことですし。まあ私はFirefox派なんですが。

そこで今回紹介するのがCefSharpというライブラリです。
Chromium Embedded Framework (CEF)というChromiumブラウザの表示部分のみを抜き出したのフレームワークがあり、これをC#からアクセスできるようになったものがCefSharpです。

導入

導入は至って簡単です。Nugetから入れることができます。
今回はWPFで使用するので、CefSharp.WPFを入れましょう。そうするとついでにCefSharp.Commonも入ります。
注意点ですが、Targetをx86かx64に指定してやる必要があります。Any CPUだと動きません。

使用

これも簡単です。CefSharp.Wpf.ChromiumWebBrowserをXAML上で定義してあげるだけです。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <Grid>
        <cef:ChromiumWebBrowser Address="https://www.google.co.jp" />        
    </Grid>
</Window>
めでたくブラウザが表示されます。

Navigate

任意のURLのWebページに遷移したいことがありますよね。
標準のWebBrowserコントロールは厄介で、Sourceプロパティが依存関係プロパティではありません。そのため、バインディングをすることができず、ビヘイビアを使うなどして操作しないといけません。超面倒でした。
CefSharpはそこのところはちゃんと考えてあり、Addressプロパティにバインディングすることができます。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <DockPanel>
        <TextBox DockPanel.Dock="Top" x:Name="Address" Text="https://www.google.co.jp"/>
        <cef:ChromiumWebBrowser Address="{Binding ElementName=Address, Path=Text, Mode=TwoWay}" />        
    </DockPanel>
</Window>
TextBoxのTextをTwoWayでバインディングすることで、いわゆるブラウザのアドレスバーのような形で使用することができます。

スクリプトを実行

せっかく自分のプログラム上でブラウザを動かしているのですから、何かしらブラウザ上でアクションを起こしたいですよね。
そんな人の為にExecuteScriptAsyncという拡張メソッドが用意されています。実行するにはChromiumWebBrowserのインスタンスが必要ですので、MVVMを維持しながら作るならばビヘイビア等を活用して作る必要がありますが、今回は簡単のために、ボタンを押したらコードビハインドでスクリプトを実行するプログラムを作ってみます。
<Window x:Class="WebBrowserTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WebBrowserTest"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        mc:Ignorable="d"
        Title="MainWindow" Height="1080" Width="1920">
    <DockPanel>
        <TextBox DockPanel.Dock="Top" x:Name="Address" Text="http://orteil.dashnet.org/cookieclicker/"/>
        <Button DockPanel.Dock="Bottom" Content="Earn a million cookies" Click="Button_Click" />
        <cef:ChromiumWebBrowser x:Name="browser" Address="{Binding ElementName=Address, Path=Text, Mode=TwoWay}" />        
    </DockPanel>
</Window>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        browser.ExecuteScriptAsync("Game.Earn(1000000)");
    }
}
こんなコードを用意すると、ボタンを押すだけでクッキーが100万個生産されます。
True NeverclickなんていうAchievementを解除してしまいました。

スクリプトを実行してその値をC#側で受け取りたいこともあると思います。
そういうときは、EvaluateScriptAsync拡張メソッドを使います。
private async void Button_Click(object sender, RoutedEventArgs e)
{
    var response = await browser.EvaluateScriptAsync("document.getElementById('cookies').innerText");

    if(response.Success && response.Result != null)
        MessageBox.Show(response.Result.ToString());
}
非同期メソッドであることに注意してください。
上のメソッドを実行すると、このように'cookies'というIDを持った要素のinnerTextプロパティの値を返してきます。
注意点として、返却値は配列と文字列はOKみたいですが、それ以外のオブジェクトはダメなようです。微妙に使いにくいですが…。


てなところです。
私自身も使い始めたばかりなので全然触れていない部分もありますが、まあ、少なくとも標準のWebBrowserコントロールよりかは使いやすそうです。
こういうの使って何とかC#のステージに引きずり下ろすより、とっととJavascriptをしっかり勉強してブラウザのアドオンとか作ったほうがいいのかなあ。

2017年11月18日土曜日

MVVMのサンプルプログラム - TwitterViewer

前回の記事でMVVMとは何ぞやという説明をしましたが、今回はMVVMで実装する例としてTwitterのサンプルプログラムでも作ってみようかと思います。MVVMライブラリはLivetを使っています。

具体的なイメージを最初に持ってもらうために、完成形を最初に示しておきます。


短縮URLやRTの展開などはしていませんが、なかなかそれっぽいクライアントに見えるかと思います。

1. メインウィンドウのView

メインウィンドウのXAMLはこんな感じです。

<Window x:Class="TwitterViewer.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:TwitterViewer.Views"
        xmlns:vm="clr-namespace:TwitterViewer.ViewModels"
        Title="MainWindow" Height="1000" Width="500">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
    
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

        <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください-->

    </i:Interaction.Triggers>
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <Button Grid.Row="0" Content="Load" >
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click" >
                    <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="LoadButtonClicked" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>
        <ListBox Grid.Row="1" ItemsSource="{Binding Statuses}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.CanContentScroll="False" >
            <ListBox.ItemTemplate>
                <DataTemplate DataType="{x:Type vm:TwitterStatusViewModel}">
                    <DockPanel HorizontalAlignment="Stretch">
                        <DockPanel.Background>
                            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1" >
                                <GradientStop Color="White" Offset="0" />
                                <GradientStop Color="#FFF0F0F0" Offset="1" />
                            </LinearGradientBrush>
                        </DockPanel.Background>
                        <Image DockPanel.Dock="Left" Source="{Binding IconSource}" Width="50" VerticalAlignment="Top" />
                        <DockPanel DockPanel.Dock="Top">
                            <StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
                                <TextBlock Text="@" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding ScreenName}" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding UserName}" Foreground="Blue" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" />
                            </StackPanel>
                            <TextBlock DockPanel.Dock="Right" Text="{Binding CreatedAt}" Foreground="DarkGray" FontSize="12" HorizontalAlignment="Right"/>
                        </DockPanel>
                        <TextBlock DockPanel.Dock="Bottom">
                            via 
                            <Hyperlink ToolTip="{Binding ViaLink}" >
                                <TextBlock Text="{Binding ViaName}" />
                                <i:Interaction.Triggers>
                                    <i:EventTrigger EventName="Click">
                                        <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="ViaLinkClicked" />
                                    </i:EventTrigger>
                                </i:Interaction.Triggers>
                            </Hyperlink>
                        </TextBlock>
                        <TextBlock Text="{Binding Text}" FontSize="14" TextWrapping="Wrap" Margin="0,5,0,5" />
                    </DockPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch"></Setter>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
    </Grid>
</Window>

このクライアントはツイートを画面に表示する以外に、読み込みボタンとviaクライアントのクリックという2つの操作がユーザーでできるようになっています。
これはLivetのメソッド直接バインディング機能を使って、クリック時にViewModelのメソッドを呼び出すようにしています。

表示はListBoxのカスタマイズで行っています。
個々の要素(ツイート)の表示部分をテンプレートとしてListBox.ItemTemplateに渡しています。
若干複雑に見えるかもしれませんが、上の画像を見ながら追っていけば特段難しくないかと思います。

2. メインウィンドウのViewModel

メインウィンドウのViewModelはこんな感じです。

public class MainWindowViewModel : ViewModel
{
    Model model;

    public MainWindowViewModel()
    {
        model = Model.GetInstance();

        Statuses = model.Statuses.ToSyncedSynchronizationContextCollection(p => new TwitterStatusViewModel(p), System.Threading.SynchronizationContext.Current);
    }

    public void Initialize()
    {
    }

    public async void LoadButtonClicked()
    {
        await model.LoadHomeTimeline();
    }

    #region Statuses変更通知プロパティ

    public SynchronizationContextCollection<TwitterStatusViewModel> Statuses
    {
        get { return _Statuses; }
        set
        {
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SynchronizationContextCollection<TwitterStatusViewModel> _Statuses;

    #endregion
}

Loadボタンが押されたときにそれを受け取るメソッドと、ツイートの表示内容を示すコレクションを用意しています。

SynchronizationContextCollectionはStatefulModelというライブラリのものです。「特定のコンテキストでコレクションの変更通知を発行するスレッドセーフなコレクション」で、WPFの制約である「単一スレッドでしかUIを触ることができない」を吸収してくれる便利なコレクションです。

ちなみに、Statusesプロパティはmodel.Statuses.ToSyncedSynchronizationContextCollectionメソッドで生成しています。ここでmodel.Statusesという変更通知コレクション(詳しくはModelの項を見てください)のイベントを拾えるようリスナーを登録しています。ViewModel側のStausesではModel側のイベントを拾って自身もそれに同期し変更通知イベントを発生させますが、その際にUIスレッドに転送してイベントを発生させてくれます。まさにMVVMのためのコレクション機構ですね。

3. メインウィンドウのModel

Modelはこちらです。

public class Model : NotificationObject
{
    #region Singleton

    static Model instance;

    public static Model GetInstance()
    {
        if(instance == null)
            instance = new Model();
        return instance;
    }

    #endregion

    TwitterContext twitterContext;

    private Model()
    {
        Statuses = new SortedObservableCollection<TwitterStatus, DateTime>(s => s.CreatedAt, true);

        SingleUserAuthorizer auth = new SingleUserAuthorizer()
        {
            CredentialStore = new SingleUserInMemoryCredentialStore()
            {
                ConsumerKey = "****",
                ConsumerSecret = "****",
                AccessToken = "****",
                AccessTokenSecret = "****",
            }
        };
        twitterContext = new TwitterContext(auth);
    }

    public async Task LoadHomeTimeline()
    {
        await Task.Run(() => {
            foreach(var status in twitterContext.Status.Where(p => p.Type == StatusType.Home && p.Count == 100)) {
                if(!Statuses.Any(p => p.StatusID == status.StatusID))
                    Statuses.Add(new TwitterStatus(status));
            }
        });
    }
    
    #region Statuses変更通知プロパティ

    public SortedObservableCollection<TwitterStatus, DateTime> Statuses
    {
        get { return _Statuses; }
        set
        { 
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SortedObservableCollection<TwitterStatus, DateTime> _Statuses;

    #endregion        
}

TwitterにアクセスするのにはLinq2Twitterを使っています。
SortedObservableCollectionはStatefulModelの提供するクラスの1つです。タイムラインは常に時間順でソートされるべきですので、そうなるようにSortedなコレクションを使っています。ViewModel側でこれをウォッチしてUIのスレッドで変更通知をViewに伝えるのです。

ちなみに、このModelはシングルトンパターンを取っています。
ここはちょっとしたこだわりですが、ViewModelがModelを作るわけではないので、その気持ちを込めてこういう構造にしています。


4. ツイートのViewModel

さて、ここまで来てメインウィンドウに対応するViewModel、Model以外にもTwitterStatusViewModelとTwitterStatusというクラスがあることに気づいたことでしょう。個々のツイートに対応するViewがありますから、それに対応するViewModelを作るのは自然かと思います。

public class TwitterStatusViewModel : ViewModel
{
    TwitterStatus Source;

    public TwitterStatusViewModel(TwitterStatus source)
    {
        Source = source;

        Text = Source.Text;
        IconSource = Source.IconSource;
        ScreenName = Source.ScreenName;
        UserName = Source.UserName;
        CreatedAt = Source.CreatedAt;
        ViaName= Source.ViaName;
        ViaLink = Source.ViaLink;
    }

    #region Text変更通知プロパティ

    public string Text
    {
        get { return _Text; }
        set
        {
            if(_Text == value)
                return;
            _Text = value;
            RaisePropertyChanged(nameof(Text));
        }
    }
    private string _Text;

    #endregion

    #region IconSource変更通知プロパティ
    private string _IconSource;

    public string IconSource
    {
        get
        { return _IconSource; }
        set
        {
            if(_IconSource == value)
                return;
            _IconSource = value;
            RaisePropertyChanged(nameof(IconSource));
        }
    }
    #endregion

    #region ScreenName変更通知プロパティ

    public string ScreenName
    {
        get { return _ScreenName; }
        set
        {
            if(_ScreenName == value)
                return;
            _ScreenName = value;
            RaisePropertyChanged(nameof(ScreenName));
        }
    }
    private string _ScreenName;

    #endregion

    #region UserName変更通知プロパティ

    public string UserName
    {
        get { return _UserName; }
        set
        {
            if(_UserName == value)
                return;
            _UserName = value;
            RaisePropertyChanged(nameof(UserName));
        }
    }
    private string _UserName;

    #endregion
    
    #region CreatedAt変更通知プロパティ

    public DateTime CreatedAt
    {
        get { return _CreatedAt; }
        set
        { 
            if(_CreatedAt == value)
                return;
            _CreatedAt = value;
            RaisePropertyChanged(nameof(CreatedAt));
        }
    }
    private DateTime _CreatedAt;

    #endregion
    
    #region ViaName変更通知プロパティ

    public string ViaName
    {
        get { return _ViaName; }
        set
        {
            if(_ViaName == value)
                return;
            _ViaName = value;
            RaisePropertyChanged(nameof(ViaName));
        }
    }
    private string _ViaName;

    #endregion

    #region ViaLink変更通知プロパティ

    public string ViaLink
    {
        get { return _ViaLink; }
        set
        {
            if(_ViaLink == value)
                return;
            _ViaLink = value;
            RaisePropertyChanged(nameof(ViaLink));
        }
    }
    private string _ViaLink;

    #endregion

    public void ViaLinkClicked()
    {
        Source.OpenViaLink();
    }
}


やたら長いですが、大部分が変更通知プロパティです。
反射的に変更通知ができるように作ってしまいましたが、ツイートは一度つぶやくと後から変更できないので、Immutableに作っても良かってもよかったかもしれません。

今回は面倒なので実装しませんでしたが、例えば、ツイート時刻の相対表記(何秒前のツイートかを表示するもの)だったら定期的に表示内容を変えなければならないので、そういうものはModelの変更通知を監視し、変更されたら自身を更新するように作り替えねばなりません。

ちなみに、一番最後にはviaのリンクをクリックされたときに呼ばれるメソッドがありますが、これはそのままModelに横流ししています。

5. ツイートのModel

最後にツイートのModelです。

public class TwitterStatus : NotificationObject
{
    static Regex regViaUrl = new Regex("href=\"([^\"]+)\"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    static Regex regViaName = new Regex(">([^<]+)<", RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public TwitterStatus(Status Source)
    {
        Text = Source.Text;
        StatusID = Source.StatusID;
        IconSource = Source.User.ProfileImageUrlHttps;
        ScreenName = Source.User.ScreenNameResponse;
        UserName = Source.User.Name;
        CreatedAt = Source.CreatedAt.ToLocalTime();

        Match m = regViaName.Match(Source.Source);
        ViaName = m.Success ? m.Groups[1].Value : Source.Source;

        m = regViaUrl.Match(Source.Source);
        ViaLink = m.Success ? m.Groups[1].Value : string.Empty;
    }

    public string Text { get; }

    public ulong StatusID { get; }

    public string IconSource { get; }

    public string ScreenName { get; }

    public string UserName { get; }

    public DateTime CreatedAt { get; }

    public string ViaName { get; }

    public string ViaLink { get; }

    public void OpenViaLink()
    {
        try {
            System.Diagnostics.Process.Start(ViaLink);
        }
        catch(Win32Exception) {
            throw;
        }
        catch(FileNotFoundException) {
            throw;
        }
    }
}

基本的にViewModelに対応させていますが、viaのURLとクライアント名を分離するためのロジックをコンストラクタにちょろっと入れました。

また、viaのリンクを開くロジックもここに書いています。まあ、Process.Startを呼ぶだけなのですが。
ですが、何かしらの理由で開けないことがあるかと思います。例えばTwitterの仕様変更でURLが含まれなくなったり、WindowsにURLを開くアプリケーションが関連付けられていなかったり。そういう場合はProcess.Startメソッドが例外を吐いてきますが、その処理はModel側でやるべきです(今回はめんどいんで結局再スローしちゃっています)。 「URLが開けませんでした」みたいなエラーメッセージは改めてModel側からイベントを発生させてそれをViewModel経由で表示させるべきですね。ViewModelはあくまでもUIの制約を吸収するためだけの層です。URLが開けなかったというのはUIの制約じゃないですよね。ということは、それをViewModelに押し付けるのは間違っています。

まとめ

今回は初めて連載っぽい記事にしましたがいかがでしたでしょうか。
ここまできて気づいた方も多いかと思いますが、ViewModelとModelは同じようなプロパティやメソッドがたくさん登場してきます。当然です。なぜならば、ViewModelはViewの制約を吸収するための層なので、制約がない部分は素通りさせるべきなのです。なので、素通りする部分にとっては一層余計に挟んでいるような状態になってしまい、同じようなコードが生まれてしまうのです。

プログラマーと言えばコピペコードを嫌う生物ですから、このようなスタイルに懐疑の念を抱く人は多いかと思います。知ったうえで「俺はこっちのほうがいい」と思ったのなら、MVVMになっていないスタイルでプログラムを書いてもいいでしょう。ただし、非同期プログラミングで泣いても私は知りません。

少なくとも、ここまで読んでくださった方たちはMVVMが何かというのはわかってきたかと思います。

MVVMは問題領域の切り分け方です。UIの制約はViewModelに押し付けて、後はすべてModelが引き受ける、それだけです。なので、おそらくはWPFでMVVMを着崩していくよりかは、MVVMの形をキープしながら様々な便利なライブラリなどのアクセサリを身に着けていくのが一番スマートなやり方なのではないかなと私は思います。