Xamarin
Xamarin.Forms
Prism.Forms

Prism.FormsでViewModel Firstな画面遷移を実現するライブラリを作ってみた

Prism.Formsを使ってViewModelから画面遷移を行う場合、デフォルトでは以下の様にページの名前を指定する必要があります。

navigationService.NavigateAsync("MainPage");

ViewModelはViewについて知らないという、MVVMの原則からすると、これはちょっと嫌な感じです。ViewModelからは、ViewModelを指定して遷移したいものです。(これを、ViewModel Firstな画面遷移と言います)

なので、ViewModel Firstを実現するためのライブラリPrism.NavigationExを作成して、NuGetで公開しました。現行版である、Prism.Forms 7.0.0.396に対応しています。また、プレリリース版として、7.1.0.279-pre対応版も用意しました。

NuGet
https://www.nuget.org/packages/Prism.NavigationEx/

GitHub
https://github.com/f-miyu/Prism.NavigationEx

Prism.NavigationExの機能

Prism.NavigationExは、ViewModelを指定して画面遷移できるのはもちろん、他にも以下の機能を備えています。

  • タイプセーフなパラメータ渡し
  • async/awaitを用いた遷移先からの戻り値の受け取り
  • 遷移処理ごとにその遷移が可能かどうかの判定処理を設定できる機能
  • DeepLinkやTabbedPageのサポート
  • Viewの一括登録

使い方

ViewModelのクラスは、NavigationViewModelを継承する必要があります。NavigateAsyncの型パラメータに遷移するViewModelを指定します。

public class MainPageViewModel : NavigationViewModel
{
    public DelegateCommand GoToNextCommand { get; }

    public MainPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoToNextCommand = new DelegateCommand(() => NavigateAsync<NextPageViewModel>());
    }
}

public class NextPageViewModel : NavigationViewModel
{
    public NextPageViewModel(INavigationService navigationService) : base(navigationService)
    {
    }
}

パラメータを受け取りたい場合は、NavigationViewModelにパラメータの型を指定して、抽象メソッドのPrepareを実装します。Prepareメソッドでは、引数として遷移元から渡されたパラメータが渡されるので、それを使って初期化処理を行います。
NavigateAsyncは、遷移するViewModelと、パラメータの型を指定して、引数にパラメータを渡します。遷移先のNavigationViewModelのパラメータの型とNavigateAsyncで指定したパラメータの型が一致していないとコンパイルエラーになります。

public class MainPageViewModel : NavigationViewModel
{
    public DelegateCommand GoToNextCommand { get; }

    public MainPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoToNextCommand = new DelegateCommand(() => NavigateAsync<NextPageViewModel, int>(100));
    }
}

public class NextPageViewModel : NavigationViewModel<int>
{
    private int _parameter;

    public NextPageViewModel(INavigationService navigationService) : base(navigationService)
    {
    }

    public override void Prepare(int parameter)
    {
        _parameter = parameter;
    }
}

遷移先から戻り値を受け取る場合は、NavigationViewModelResultを継承します。NavigateAsyncは、遷移するViewModelと、戻り値の型を指定して、awaitで戻るのを待つことが出来ます。
戻り値は、INavigationResult型で、Successプロパティがtrueなら、DataプロパティにGoBackAsyncの引数で与えた戻り値が入っています。ナビゲーションバーの戻るボタンが押されて戻って来た場合などは、Successプロパティは、falseになります。
これも、遷移先のNavigationViewModelResultの戻り値の型とNavigateAsyncで指定した戻り値の型が一致していないとコンパイルエラーになります。

public class MainPageViewModel : NavigationViewModel
{
    public DelegateCommand GoToNextCommand { get; }

    public MainPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoToNextCommand = new DelegateCommand(async () =>
        {
            var result = await NavigateAsync<NextPageViewModel, string>();
            if (result.Success)
            {
                var data = result.Data;
            }
        });
    }
}

public class NextPageViewModel : NavigationViewModelResult<string>
{
    public DelegateCommand GoBackCommand { get; }

    public NextPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoBackCommand = new DelegateCommand(() => GoBackAsync("result"));
    }
}

パラメータも戻り値も必要な場合は、NavigationViewModelに両方の型を指定します。NavigateAsyncも両方の型を指定することになります。

public class MainPageViewModel : NavigationViewModel
{
    public DelegateCommand GoToNextCommand { get; }

    public MainPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoToNextCommand = new DelegateCommand(async () =>
        {
            var result = await NavigateAsync<NextPageViewModel, int, string>(100);
            if (result.Success)
            {
                var data = result.Data;
            }
        });
    }
}

public class NextPageViewModel : NavigationViewModel<int, string>
{
    private int _parameter;

    public DelegateCommand GoBackCommand { get; }

    public NextPageViewModel(INavigationService navigationService) : base(navigationService)
    {
        GoBackCommand = new DelegateCommand(() => GoBackAsync("result"));
    }

    public override void Prepare(int parameter)
    {
        _parameter = parameter;
    }
}

NavigateAsyncの引数

NavigateAsyncは、useModalNavigationanimatedに加えて、以下の引数を取ることが出来ます。

wrapInNavigationPage

trueなら、NavigationPageの入れ子になります。使用されるNavigationPageは、後述するNavigationNameProviderでカスタマイズすることが出来ます。

NavigateAsync<NextPageViewModel>(wrapInNavigationPage: true);

noHistory

trueなら、ナビゲーションスタックがクリアされます。(/で始まる絶対パスでの指定になります)

NavigateAsync<NextPageViewModel>(noHistory: true);

canNavigate

遷移できるかどうかを判定するTask<bool>を返すデリゲートを渡すことが出来ます。trueを返せば遷移して、falseを返せば、遷移がキャンセルされます。
awaitで戻り値を待っている時に、falseを返した場合は、そのTaskがキャンセルされ、INavigationResultSuccessプロパティはfalseで返ってきます。

NavigateAsync<NextPageViewModel>(canNavigate: () => pageDialogService.DisplayAlertAsync("title", "message", "OK", "Cancel");

replaced

trueなら、現在のページが指定したViewModelのページに置きかわります。

NavigateAsync<NextPageViewModel>(replaced: true);

GoBackAsync、GoBackToRootAsyncの引数

canNavigate

GoBackAsyncGoBackToRootAsyncNavigateAsyncと同じようにcanNavigateを指定できます。

GoBackAsync(canNavigate: () => pageDialogService.DisplayAlertAsync("title", "message", "OK", "Cancel");

Deep Link

Deep Linkもサポートしています。
以下のように、NavigationFactoryCreateメソッドで最初の遷移先のViewModelを指定して、Addメソッドで、後続の遷移のViewModelを指定します。

var navigation = NavigationFactory.Create<MainPageViewModel>()
                                  .Add<NextPageViewModel>();
                                  .Add<NextNextPageViewModel>();

NavigateAsync(navigation);

パラメータを与える場合は、パラメータの型も指定します。

var navigation = NavigationFactory.Create<MainPageViewModel>()
                                  .Add<NextPageViewModel, int>(100);
                                  .Add<NextNextPageViewModel, int>(200);

NavigateAsync(navigation);

遷移の途中のViewModelが戻り値を受け取る必要がある場合は、以下のようにデリゲートを渡すことができます。遷移先から戻ってきた時にこのデリゲートが呼び出されます。
デリゲートを指定する場合は、戻り値の型も指定します。後続でAddするViewModelの戻り値の型は、ここで指定した戻り値の型と一致している必要があります。異なると、コンパイルエラーになります。
デリゲートの引数であるviewModelは、INavigationViewModelなので、初期化処理を行うならキャストが必要になります。これは、TabbedPageでは、表示されているタブのViewModelが渡されるので、複数の型に対応するためにINavigationViewModelを渡すようにしています。

var navigation = NavigationFactory.Create<MainPageViewModel, string>((viewModel, result) => 
                                  {
                                      if (result.Success && viewModel is MainPageViewModel mainPageViewModel)
                                      {
                                          var data = result.Data;
                                      }
                                  })
                                  .Add<NextPageViewModel, int, string>(100, (viewModel, result) => 
                                  {
                                      if (result.Success && viewModel is NextPageViewModel nextPageViewModel)
                                      {
                                          var data = result.Data;
                                      }
                                  })
                                  .Add<NextNextPageViewModel, int>(200);

NavigateAsync(navigation);

canNavigateを指定することもできます。ただし、遷移前に呼び出されるのは、最後の遷移で指定したものだけです。途中のものは、遷移後に呼び出されます。

var navigation = NavigationFactory.Create<MainPageViewModel>()
                                  .Add<NextPageViewModel>();
                                  .Add<NextNextPageViewModel>(() => pageDialogService.DisplayAlertAsync("title", "message", "OK", "Cancel"));

NavigateAsync(navigation);

TabbedPage

TabbedPageのタブも以下のようにViewModelやパラメータの型を指定することができます。
Tabクラスのコンストラクタの引数であるwrapInNavigationPageをtrueにしたら、そのタブは、NavigationPageの入れ子になります。

var navigation = NavigationFactory.Create<MyTabbedPageViewModel>(null, new Tab<FirstTabPageViewModel, string>("text", true), new Tab<SecondTabPageViewModel>());

NavigateAsync(navigation);

NavigationNameProvider

デフォルトでは、Viewのクラス名は、ViewModelのクラス名から後ろの「ViewModel」を取ったものになります。このルールは独自のものに変更することが可能です。例えば、Viewのクラス名を〇〇Viewという名前にしたい場合は、以下のように設定します。

NavigationNameProvider.SetDefaultViewModelTypeToNavigationNameResolver(viewModelType =>
{
    var suffix = "Model";
    var name = viewModelType.Name;
    if (name.EndsWith(suffix))
    {
        name = name.Substring(0, name.Length - suffix.Length);
    }
    return name;
});

また、wrapInNavigationPageをtrueにした時に使用されるNavigationPageを独自のものにすることもできます。

NavigationNameProvider.DefaultNavigationPageName = nameof(MyNavigationPage);

Viewの一括登録

以下のようにして、プロジェクトで定義してあるXamarin.Forms.Pageクラスを継承している全てのクラス(抽象クラスは除く)とNavigationPageの一括登録を行うことができます。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterForNavigation(this);
}

最後に

Prism.Formsはいいフレームワークなのですが、画面遷移のやり方に関してはやや不満があり、軽く拡張して使っていたのを、今回きちんとライブラリとしてまとめてみました。
割といい感じにできたと思うので、もしよろしければ、使ってみてください。