MVVMとNavigation


MVVMを謳うポエムです

ダイアログやナビゲーションはViewの領域?

MVVMにおいてダイアログやナビゲーションはViewの領域だからViewModelは意識しない。それが原則だと思ってきましたが、それは誤解なのではないかと最近は思い直しています。ダイアログやナビゲーションはViewModel同士の相互作用として記述されるべきなのではないかと。

例えばモーダルなダイアログを表示する場合ですが、ユーザーがそこで入力した内容や操作はダイアログのViewModelに保存され、呼び元のViewModelに返るのが自然だと思うのです。同様にナビゲーションにしても、例えばウィザード形式なら、各ページで入力した内容を最後に取りまとめないといけない。それはデータの領域ですからViewModelの仕事です。
もちろん最終的にはメッセンジャーなりを使ってViewへの通知を発行することにはなると思いますが、それはフレームワークの仕事であって、ViewModelの仕事ではないのが理想です。

もしかしたら、今更な話かもしれない、ナビゲーションを題材に実装した記録です。
※MVVMフレームワークとしてMicrosoft.Toolkit.Mvvm 7.0.1を前提としたサンプルコードを記載しています。

基本的なアイデア

ナビゲーションを管轄する上位VMと、各ページを管理する下位VMに機能を分割します。上位VMはナビゲーションと、すべての下位VMをとりまとめる以上の仕事はしません。下位VMは個別ページのロジックに注力します。これによりMVVMによる疎結合の利点を残しつつVM同士の相互作用を記述していきます。

上位VM

汎用的に使えるようにabstractで作っておきます。
まずViewがBindするpublicプロパティが2つあります。

  • 現在のページ(に対応する下位VM)を表すobject Current
  • ナビゲーションの「次へ」「前へ」操作に対応するICommand PrevCommand/NextCommand

他に使うプロパティとして次の2つももっておきます。

  • 下位VMを取りまとめるList<object> Histories
  • 現在の表示ページ番号を表すint CurrentIndex
NavigableViewModelBase.cs
    abstract class NavigableViewModelBase : ObservableObject
    {
        public RelayCommand PrevCommand { get; }
        public RelayCommand NextCommand { get; }
        protected List<object> Histories { get; }
        public object Current { ... }
        public int CurrentIndex { ... }
...

さらに、次ページへナビゲートするメソッドが1つあります。

NavigableViewModelBase.cs
        protected void NavigateTo(object next)
        { ... }

ナビゲーション機能を持つViewに対応するVMはこのNavigableViewModelBaseを継承するようにします。

MainViewModel.cs
    class MainViewModel : NavigableViewModelBase
    { ... }

下位VM

これも汎用のabstractを作っておきます。
こちらは簡単で、上位VMにナビゲーション指示を出すイベントなりメッセンジャーなりを持たせるだけです。今回はイベントで実装しました。

PageViewModelBase.cs
    class PageViewModelBase
    {
        public event Action<object, NavigationRequestedEventArgs> NavigationRequested;

        protected void RequestNavigate(object next)
        {
            this.NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(next));
        }
    }

今回は3種類の表示ページがあるとして、それぞれに対応するVMはこのクラスを継承します。

    class Page1Model : PageViewModelBase { ... }
    class Page1Mode2 : PageViewModelBase { ... }
    class Page1Mode3 : PageViewModelBase { ... }

すると、各ページでは、別ページにナビゲートさせたいときには次のようにVM同士の相互作用として記述できます。この書き方が今回の一番の目的。

Page1Model.cs
    this.RequestNavigate(new Page2Model()); // Page2にナビゲートする

View

最後に、これらのナビゲーションを表示するViewを実装します。Viewは上位VMのCurrentにBindし、その表示の切り替えはDataTemplateを使って行います。

MainWindow.xaml
<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApp1"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <DataTemplate DataType="{x:Type local:Page1Model}">
            <!--Page1に対応するView定義-->
        </DataTemplate>
        <DataTemplate DataType="{x:Type local:Page2Model}">
            <!--Page2に対応するView定義-->
        </DataTemplate>
        <DataTemplate DataType="{x:Type local:Page3Model}">
            <!--Page3に対応するView定義-->
        </DataTemplate>
    </Window.Resources>
    <DockPanel>
        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
            <!--戻るボタン-->
            <Button Command="{Binding PrevCommand}">Prev</Button>
            <!--進むボタン-->
            <Button Command="{Binding NextCommand}">Next</Button>
        </StackPanel>
        <!--現在の表示ページにBind-->
        <ContentControl Content="{Binding Current}"/>
    </DockPanel>
</Window>

ContentControlの表示方法決定の仕組みにDataTemplateが使われることを利用して各ページとVMを対応付けておくのがポイントです。

完成

起動と同時にPage1が表示され、

Page2ボタン押下でPage2にナビゲートし、戻るボタンが有効になり、

戻るボタンを押下するとPage1に戻って、進むボタンが有効になります。

ソース全体はこちら

まとめ

FrameとNavigationServiceを使う場合よりいい点としては、MVVMですべて管理できている点でしょうか。ナビゲーション周りのロジックも任意に変更できますし。VM同士の相互作用という観点での実装ができたのはよかったと思います。ダイアログ表示を同じ仕組みでやろうと思うとDIとかの絡みが若干複雑になりそうです。(結局Prism使うのとどちらがいいかというと…)