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

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

```c#
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を指定します。

```c#
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`で指定したパラメータの型が一致していないとコンパイルエラーになります。

```c#
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`で指定した戻り値の型が一致していないとコンパイルエラーになります。

```c#
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`も両方の型を指定することになります。

```c#
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は、`useModalNavigation`、`animated`に加えて、以下の引数を取ることが出来ます。

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

```c#
NavigateAsync<NextPageViewModel>(wrapInNavigationPage: true);
```

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

```c#
NavigateAsync<NextPageViewModel>(noHistory: true);
```

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

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

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

```c#
NavigateAsync<NextPageViewModel>(replaced: true);
```

## GoBackAsync、GoBackToRootAsyncの引数

### canNavigate
`GoBackAsync`、`GoBackToRootAsync`も`NavigateAsync`と同じように`canNavigate`を指定できます。

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

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

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

NavigateAsync(navigation);
```

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

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

NavigateAsync(navigation);
```

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

```c#
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`を指定することもできます。ただし、遷移前に呼び出されるのは、最後の遷移で指定したものだけです。途中のものは、遷移後に呼び出されます。

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

NavigateAsync(navigation);
```

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

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

NavigateAsync(navigation);
```

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

```c#
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`を独自のものにすることもできます。

```c#
NavigationNameProvider.DefaultNavigationPageName = nameof(MyNavigationPage);
```

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

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

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