2018年8月8日水曜日

InteractionMessageActionの自作

MVVMのデータバインディング機構は「状態」を伝達するのに非常に便利です。一方で、一般的なグラフィカル・ユーザー・インターフェースでは「状態」だけでは伝えきれない動作もしばしばあります。

その代表的なものがウィンドウ遷移です。ViewModelが「ダイアログボックスを表示しなさい」という指示を出し、それを受け取ったViewがウィンドウを表示するという動作は、状態では表現できません。
ウィンドウも「表示状態」「非表示状態」の2種類の状態がある、と考えれば確かに状態で表現できそうな気もしますが、それはあまり自然な発想ではないですよね。

そのため、解決策として多くのMVVMライブラリにはそのような指示をViewModelからViewへ伝達する機能を備えています。
LivetではMessengerというものがそれに該当します。ViewModel側でInteractionMessageに伝えたい情報を載せてMessenger.Raiseすると、View側のInteractionMessageTriggerが発動し、その受け取ったメッセージをもとに様々な動作を行うことができます。

例えばメッセージボックスを表示する例です。
<Window x:Class="MessengerTest.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:MessengerTest.Views"
        xmlns:vm="clr-namespace:MessengerTest.ViewModels"
        Title="MainWindow" Height="350" Width="525">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

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

        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="Info">
            <l:InformationDialogInteractionMessageAction />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
    
    <Grid>
        
    </Grid>
</Window>
ダイアログボックスはInteractionMessageTriggerの部分です。キーを設定し(今回は"Info")、そのキーに対してメッセージを送ると中のInformationDialogInteractionMessageActionが発動します。

肝心のメッセージはこのように送ります。
public class MainWindowViewModel : ViewModel
{
    public void Initialize()
    {
        Messenger.Raise(new InformationMessage("InformationMessage raised.", "Information", MessageBoxImage.Information, "Info"));
    }
}
Messenger.Raiseに必要な情報を与えて呼び出すと、このタイミングでInformationDialogInteractionMessageActionが発動します。
ポイントは、InteractionMessageとInteractionMessageActionは対になっていて、パラメーターの箱(Message)とそれを受けた動作(Action)をうまく定義することで、ViewModelからViewにいろいろな動作を働きかけることができるということです。
LivetでどのようなInteractionMessageやInteractionMessageActionが用意されているかというのは、以前書いたこちらの記事を参考にしてください。

InteractionMessageActionの自作

さて、このようなInteractionMessageActionを自作するにはどうしたら良いのでしょう。
ScrollViewerを任意の場所にスクロールするInteractionMessageActionを例に取りながら説明していきます。

ScrollViewerにはスクロール位置を示すVerticalOffsetプロパティHorizontalOffsetプロパティはあるのですが、これらは読み取り専用でスクロール位置を変更するのには使えません。スクロール位置を変更するにはScrollToVerticalOffsetメソッドScrollToHorizontalOffsetメソッドを呼び出す必要があります。
メソッドの呼び出しはデータバインディングで行うことはできませんので、InteractionMessageActionに頼る必要があります。

まずはInteractionMessageを自作します。
public class ScrollInteractionMessage : InteractionMessage
{
    public ScrollInteractionMessage() : base()
    {
        Command = ScrollCommand.None;
        Offset = 0;
    }

    public ScrollInteractionMessage(string messageKey) : base(messageKey)
    {
        Command = ScrollCommand.None;
        Offset = 0;
    }

    public ScrollInteractionMessage(ScrollCommand command) : base()
    {
        Command = command;
        Offset = 0;
    }

    public ScrollInteractionMessage(ScrollCommand command, string messageKey) : base(messageKey)
    {
        Command = command;
        Offset = 0;
    }

    public ScrollInteractionMessage(ScrollCommand command, double offset) : base()
    {
        Command = command;
        Offset = offset;
    }

    public ScrollInteractionMessage(ScrollCommand command, double offset, string messageKey) : base(messageKey)
    {
        Command = command;
        Offset = offset;
    }

    public ScrollCommand Command { get; set; }

    public double Offset { get; set; }

    protected override Freezable CreateInstanceCore()
    {
        return new ScrollInteractionMessage(Command, Offset, MessageKey);
    }
}
大部分がコンストラクタで見にくいですが、要するにCommand(どのようなスクロールをするか)とOffset(オフセットへの移動の場合の値)を持たせたInteractionMessageを作りました。
ここで使っているScrollCommandはこのような列挙型です。
public enum ScrollCommand
{
    None,
    Home,
    End,
    Top,
    Bottom,
    LeftEnd,
    RightEnd,
    VerticalOffset,
    HorizontalOffset,
}

さて、このInteractionMessageの実装で重要なのが、CreateInstanceCoreのオーバーライドです。これをやらずにドツボにはまったことが私はありました。
InteractionMessageはFreezableを継承しており、Messenger.Raise中でFreezeされます。そしてその後にコピーされてInteractionMessageActionに渡されるのですが、その際、CreateInstanceCoreを呼び出してコピーされます。
これをオーバーライドしておかないと、InteractionMessage.CreateInstanceCoreでコピー作業が行われます。それはすなわち、InteractionMessageActionに渡されるのがInteractionMessage型のインスタンスとなってしまい追加のパラメーターがすべて消えることを意味しています。
ハマりポイントですので、ぜひ回避するようにしましょう。

また、当然ですが、CreateInstanceCoreメソッドでは同じパラメーターのインスタンスを生成するためのものですので、継承時に追加したプロパティはしっかりコピーするようにしてください。今回ならばCommandとOffsetですね。

これを受けるScrollInteractionMessageActionはこんな感じに実装しました。
public class ScrollInteractionMessageAction : InteractionMessageAction<FrameworkElement>
{
    protected override void InvokeAction(InteractionMessage message)
    {
        if(AssociatedObject is ScrollViewer ctrl && message is ScrollInteractionMessage msg) {
            switch(msg.Command) {
                case ScrollCommand.Home:
                    ctrl.ScrollToHome();
                    break;
                case ScrollCommand.End:
                    ctrl.ScrollToEnd();
                    break;
                case ScrollCommand.Top:
                    ctrl.ScrollToTop();
                    break;
                case ScrollCommand.Bottom:
                    ctrl.ScrollToBottom();
                    break;
                case ScrollCommand.LeftEnd:
                    ctrl.ScrollToLeftEnd();
                    break;
                case ScrollCommand.RightEnd:
                    ctrl.ScrollToRightEnd();
                    break;
                case ScrollCommand.VerticalOffset:
                    ctrl.ScrollToVerticalOffset(msg.Offset);
                    break;
                case ScrollCommand.HorizontalOffset:
                    ctrl.ScrollToHorizontalOffset(msg.Offset);
                    break;
            }
        }
    }
}
C#7.0からは「変数 is 型 新しい変数」で「変数を新しい変数にキャストしつつ、キャストできたかどうかをboolで返す」という構文が導入されたので、型判定と変換を同時に行っています。便利なものです。
AssociatedObjectはこのActionがぶら下げられたコントロール、messageはMessenger.Raiseに渡されたメッセージです。受け取ったmessegeのCommandをもとに、呼び出すべきスクロールメソッドに制御を振り分けています。

さて、XAMLはこのようになります。
<ScrollViewer HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible">
    <Image Source="{Binding FileName}" />
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="Scroll">
            <a:ScrollInteractionMessageAction />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</ScrollViewer>
InteractionMessageTrigger(メッセージで発生するトリガー)にScrollInteractionMessageActionをぶら下げています。

ViewModelからはこのようにMessenger.Raiseを呼び出すだけです。
ScrollToLeft = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.LeftEnd, "Scroll")));
ScrollToRight = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.RightEnd, "Scroll")));
ScrollToTop = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.Top, "Scroll")));
ScrollToBottom = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.Bottom, "Scroll")));
コマンドのコールバックとしてMessenger.Raiseを呼び出しています。パラメーターにはコマンド名とメッセージキーだけです。これらのコマンドをボタンに結び付ければ完成です。
ボタンを押すだけで四隅へ移動できるソフトが出来上がりました。

参考までにViewとViewModelの全容を載せておきます。
<Window x:Class="MessengerTest.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:MessengerTest.Views"
        xmlns:a="clr-namespace:MessengerTest.Views.Actions"
        xmlns:vm="clr-namespace:MessengerTest.ViewModels"
        Title="MainWindow" Height="350" Width="525">

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

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

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

    <Grid>
        <DockPanel>
            <Grid DockPanel.Dock="Top">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="1*" />
                </Grid.ColumnDefinitions>
                <Button Grid.Column="0" Content="Left" Command="{Binding ScrollToLeft}" Margin="2" />
                <Button Grid.Column="1" Content="Top" Command="{Binding ScrollToTop}" Margin="2" />
                <Button Grid.Column="2" Content="Bottom" Command="{Binding ScrollToBottom}" Margin="2" />
                <Button Grid.Column="3" Content="Right" Command="{Binding ScrollToRight}" Margin="2" />
            </Grid>
            <Grid DockPanel.Dock="Bottom">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="1*" />
                    <ColumnDefinition Width="100" />
                </Grid.ColumnDefinitions>
                <TextBlock Grid.Column="0" Text="Path: " VerticalAlignment="Center" Margin="2" />
                <TextBox Grid.Column="1" Text="{Binding FileName}" VerticalAlignment="Center" Margin="2" />
                <Button Grid.Column="2" Content="Browse..." VerticalAlignment="Center" Margin="2">
                    <i:Interaction.Triggers>
                        <i:EventTrigger EventName="Click">
                            <l:OpenFileDialogInteractionMessageAction>
                                <l:DirectInteractionMessage CallbackMethodTarget="{Binding}" CallbackMethodName="FileSelected">
                                    <l:OpeningFileSelectionMessage Filter="Image file (*.jpg;*.png;*.gif;*.bmp)|*.jpg;*.png;*.gif;*.bmp" MultiSelect="False" />
                                </l:DirectInteractionMessage>
                            </l:OpenFileDialogInteractionMessageAction>
                        </i:EventTrigger>
                    </i:Interaction.Triggers>
                </Button>
            </Grid>
            <ScrollViewer HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible">
                <Image Source="{Binding FileName}" />
                <i:Interaction.Triggers>
                    <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="Scroll">
                        <a:ScrollInteractionMessageAction />
                    </l:InteractionMessageTrigger>
                </i:Interaction.Triggers>
            </ScrollViewer>
        </DockPanel>
    </Grid>
</Window>
public class MainWindowViewModel : ViewModel
{
    public MainWindowViewModel()
    {
        ScrollToLeft = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.LeftEnd, "Scroll")));
        ScrollToRight = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.RightEnd, "Scroll")));
        ScrollToTop = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.Top, "Scroll")));
        ScrollToBottom = new ViewModelCommand(() => Messenger.Raise(new ScrollInteractionMessage(ScrollCommand.Bottom, "Scroll")));
    }

    public void Initialize()
    {
    }

    #region FileName変更通知プロパティ
    private string _FileName;

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

    public ViewModelCommand ScrollToLeft { get; }
    public ViewModelCommand ScrollToRight { get; }
    public ViewModelCommand ScrollToTop { get; }
    public ViewModelCommand ScrollToBottom { get; }

    public void FileSelected(OpeningFileSelectionMessage msg)
    {
        if(msg.Response != null && msg.Response.Length == 1) {
            FileName = msg.Response.Single();
        }
    }
}
試してみたい方はこれとScrollInteractionMessageAction/ScrollInteractionMessageの2つのクラスで概ね動作させられるはずです。

おまけ

Livetを使っていると気づくかもしれませんが、WindowActionMessageをMessenger.Raiseしたときにうまく動きません。DirectInteractionMessage経由(XAMLで全部記述)ならばうまくいくのですが。
ですので、ViewModel側からウィンドウ操作をしたい場合は、WindowActionごとにInteractionMessageTriggerを用意して、キーで呼び分ける必要がでてきます。

なぜこんなことになっているのかというと、ただのバグです。
WindowActionMessageのCreateInstanceCoreのオーバーライドにてWindowActionのコピーを忘れています。ですので、Messenger.RaiseでどんなWindowActionを設定してもここの時点で闇に葬られるわけですね。
DirectInteractionMessageだとCreateInstanceCoreは呼ばれない(元のインスタンスのまま渡る)ので、設定した値がそのまま生かされます。

ま、もうLivetは事実上開発が止まっていますので、自分で修正してビルドして使うなりなんなりしてやるしかないですね。はい。

0 件のコメント:

コメントを投稿