Prism.Forms で Shell みたいな画面作りたい

Xamarin.Forms でアプリ作るときには結構な人がお世話になる Prism !!
この Prism ですが、現状 Shell には対応していません。Shell が割とバギーな雰囲気があるせいもあるのかもしれませんが、Shell で作れる以下のような画面作りたいっていうのはよくある話ですよね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/f82642c7-b89d-2ecf-69d2-87ba34969881.png)

じゃぁ Prism を使った状態でこんな雰囲気のページを作るには？ということになるのですが、大体以下のような感じです。

- MasterDetailPage でフライアウトメニューを作る
- MasterDetailPage の中に NavigationPage と表示させたいページを表示するようにナビゲーションする
- タブにしたい場合は MasterDetailPage の中に TabbedPage を置いて、その中に NavigationPage でラップしたページを表示させる

大体こんな感じです。

## やってみた

とりあえずやってみた。

![shelllike2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70288/852f3733-2b06-d482-e3a7-f045107a8d3b.gif)

登録しているページは以下のような感じです

```csharp
containerRegistry.RegisterForNavigation<NavigationPage>();
containerRegistry.RegisterForNavigation<MainPage, MainPageViewModel>(); // MasterDetailPage
containerRegistry.RegisterForNavigation<ViewA, ViewAViewModel>(); // ただの Page
containerRegistry.RegisterForNavigation<ViewB, ViewBViewModel>(); // ただの Page
containerRegistry.RegisterForNavigation<ViewC, ViewCViewModel>(); // ただの Page
containerRegistry.RegisterForNavigation<MyTabbedPage, MyTabbedPageViewModel>(); // TabbedPage
```

今回は初期状態では MasterDetailPage の中に 空の NavigationPage を表示しておきました。

```csharp
protected override async void OnInitialized()
{
    InitializeComponent();

    await NavigationService.NavigateAsync("MainPage/NavigationPage");
}
```

MasterDetailPage を継承した MainPage では以下のように CollectionView でフライアウトメニューの項目を表示するようにしています。

```xml:MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<MasterDetailPage
    x:Class="ShallEmulation.Views.MainPage"
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:ShallEmulation.ViewModels"
    xmlns:prism="http://prismlibrary.com"
    Title="{Binding Source={RelativeSource Mode=Self}, Path=Detail.CurrentPage.Title}"
    prism:ViewModelLocator.AutowireViewModel="True"
    Visual="Material">

    <MasterDetailPage.Master>
        <ContentPage Title="Menu">
            <StackLayout>
                <Label
                    Margin="5"
                    FontSize="Large"
                    Text="Pages" />
                <CollectionView
                    x:Name="collectionView"
                    ItemsSource="{Binding MasterMenuItems}"
                    SelectedItem="{Binding SelectedMenuItem}"
                    SelectionChangedCommand="{Binding NavigateCommand}"
                    SelectionMode="Single">
                    <CollectionView.ItemTemplate>
                        <DataTemplate x:DataType="local:MasterMenuItem">
                            <Grid Padding="15">
                                <Label Text="{Binding Text}" />
                            </Grid>
                        </DataTemplate>
                    </CollectionView.ItemTemplate>
                </CollectionView>
            </StackLayout>
        </ContentPage>
    </MasterDetailPage.Master>

</MasterDetailPage>
```

MainPageViewModel で実際にメニューに表示する項目の管理やメニューの項目が選択されたときに画面遷移を行うようにしています。

```csharp:MainPageViewModel.cs
using Prism.Commands;
using Prism.Navigation;
using System.Linq;
using static Prism.Navigation.KnownNavigationParameters;

namespace ShallEmulation.ViewModels
{
    public class MainPageViewModel : ViewModelBase
    {
        private MasterMenuItem _selectedMenuItem;
        public MasterMenuItem SelectedMenuItem
        {
            get { return _selectedMenuItem; }
            set { SetProperty(ref _selectedMenuItem, value); }
        }

        private DelegateCommand _navigateCommand;
        public DelegateCommand NavigateCommand => _navigateCommand ??= new DelegateCommand(NavigateExecute);

        private async void NavigateExecute()
        {
            if (SelectedMenuItem is null) return;
            await NavigationService.NavigateAsync(SelectedMenuItem.Path);
        }

        public MasterMenuItem[] MasterMenuItems { get; } = new MasterMenuItem[]
        {
            new("ViewA", "NavigationPage/ViewA"),
            new("ViewB", "NavigationPage/ViewB"),
            new("ViewC", "NavigationPage/ViewC"),
            new("Tab", $"MyTabbedPage?{CreateTab}=NavigationPage|ViewA&" +
                $"{CreateTab}=NavigationPage|ViewB&" +
                $"{CreateTab}=NavigationPage|ViewC&" +
                $"{SelectedTab}=ViewB"),
        };

        public MainPageViewModel(INavigationService navigationService) : base(navigationService)
        {
            Title = "Menues";
        }

        public override void OnNavigatedTo(INavigationParameters parameters)
        {
            if (SelectedMenuItem is null)
            {
                SelectedMenuItem = MasterMenuItems.First();
                NavigateExecute();
            }
        }
    }

    public record MasterMenuItem(string Text, string Path);
}
```

タブのページのところだけ以下のように画面遷移の文字列がちょっと複雑になっています。これでタブの中に3ページ作って ViewB を初期表示にしています。

```csharp
new("Tab", $"MyTabbedPage?{CreateTab}=NavigationPage|ViewA&" +
    $"{CreateTab}=NavigationPage|ViewB&" +
    $"{CreateTab}=NavigationPage|ViewC&" +
    $"{SelectedTab}=ViewB"),
```


## 戻るボタンで一旦トップに戻りたい

Shell でも先日やった戻るボタンで一旦トップに戻る実装ですが、今回の場合は MainPage でやれば OK です。

```csharp:MainPage.xaml.cs
protected override bool OnBackButtonPressed()
{
    var vm = (MainPageViewModel)BindingContext;
    var topPage = vm.MasterMenuItems.First();
    if (vm.SelectedMenuItem.Path == topPage.Path)
    {
        return base.OnBackButtonPressed();
    }
    else
    {
        vm.SelectedMenuItem = topPage;
        return true;
    }
}
```

今回は、お試しで愚直に View に書いてますが OnBackButtonPressed のハンドリングは View でするとしても中身の処理は ViewModel に突っ込んだ方がいいと思います。

これで ViewA 以外で戻るボタンを押したら ViewA に行きます。

## まとめ

とりあえず Prism 使うと Shell は諦めることになるので、こんな感じで Prism の強力なナビゲーション機能を使って実現することになるかなと思います。

ソースは GitHub に上げてます。

https://github.com/runceel/Prism.Forms.ShellLike