かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Xamarin.Forms の Shell のプロジェクトテンプレート見てみた

VS 2019 RC で Xamarin.Forms プロジェクト作成しようとしたら Shell がありました。(前はなかったよね?

f:id:okazuki:20190313104523p:plain

ということで、プロジェクト新規作成して Shell ってどんなもの?っていうのを見てみようと思います。 最終的にはドキュメントもちゃんと見ないといけないけど、とりあえずどんなの?っていうのを見るときにはテンプレートが吐いてくれるコードを見るのも個人的には好きです。ということで、私が知らないプロジェクトを見るときに、どういった順番で見てるのか?とかっていうのも参考になればと思うので、このブログは普通に作業ログ的に書いていきます。

問題は、プロジェクトテンプレートの吐くコードが難解だったときですよね。例えば今回の場合は Shell の使い方を見たいって思ってるのに Shell 以外の部分がたくさんあるときです。

今回はそんなんじゃないといいなぁ。

ちなみに Shell は最新の Xamarin.Forms 3.6 時点でプレビュー機能なので、今後なにかしら変わる可能性があります。

作ってみよう

ということで Shell のテンプレートを作ってみます。MainActivity.cs(Android) や AppDelegate.cs(iOS) を見てみると以下のように実験的な機能のフラグが軒並みオンになってます。いいね。

global::Xamarin.Forms.Forms.SetFlags("Shell_Experimental", "Visual_Experimental", "CollectionView_Experimental", "FastRenderers_Experimental");

参照している Xamarin.Forms のバージョンを確認したら 3.6 の Pre 版だったので正式版アップデートしておきました。

f:id:okazuki:20190313105700p:plain

まずは全体を眺める

木を見る前に森を見ましょう。木を見ることが本当に目的の場合でも、目的の木がどこにあるのかは森全体を把握してないとたどり着くのに無駄に時間を食ってしまう可能性が高いです。

さまようことでも、新たな発見はあるのでさまようのが目的のときはいいけど、そうじゃないときは森をまず少しでもいいので見ましょう。

ということで .NET Standard のプロジェクトを開くと以下のようになってます。

f:id:okazuki:20190313110114p:plain

参照ライブラリは上記では見えてないですが確認したら Xamarin.Forms と Xamarin.Essentials だけです。その状態で MVVM パターンのソースコードを吐いているように見えます。

なので、このプロジェクトには MVVM を実現するために必要な基本的なクラスと、ロジックとかを担当するクラスが入り混じってるということになりそうです。

エントリーポイントの確認

次にエントリーポイントを見ます。Xamarin.Forms だと厳密なエントリーポイントは各プラットフォーム固有プロジェクトですが、Xamarin.Forms という単体で見たときには App.xaml.cs クラスなので、そこを見ましょう。

App クラスのコンストラクターで AppShell を MainPage にしてます。名前からして AppShelll クラスが今回目的の Shell っぽいですね。

public App()
{
    InitializeComponent();


    MainPage = new AppShell();
}

一応念のため App.xaml のほうも見て全体に影響するような仕組みが入ってないか確認します。特になさそうでした。

AppShell を確認

AppShell.xaml.cs を開いてみると単なる Shell を継承したクラスでした。

using System;
using System.Collections.Generic;

using Xamarin.Forms;

namespace MyXamarinFormsApp
{
    public partial class AppShell : Xamarin.Forms.Shell
    {
        public AppShell()
        {
            InitializeComponent();
        }
    }
}

拍子抜けするくらいに何もないですね!AppShell.xaml を開きます。 AA(アスキーアート) 見るとは思わなかった

f:id:okazuki:20190313110949p:plain

そこで簡単な Shell の説明してくれてます。ありがたい。

Shell is an all new way to quickly get started with your application. There are 3 levels to a Shell app:
* ShellItem: populates the Flyout menu along with special menu items
* ShellSection: groups of 1 or more ContentPage displayed as bottom tabs
* ShellContent: a ContentPage host. Multiple ShellContent within a ShellSection are navigable by top tabs

For more details about building apps with Shell, visit these resources:
* MSDN Article: https://aka.ms/xf-msdn
* Preview Documentation: https://aka.ms/xf-shell-docs
* Blog Introduction: https://aka.ms/xf-40-blog

PLEASE give us feedback on your experience: good, bad, and ugly.
https://www.surveymonkey.com/r/VTJNWTM

少し下に下がるとリソースでスタイル定義してます。

<Shell.Resources>
    <ResourceDictionary>
        <Color x:Key="NavigationPrimary">#2196F3</Color>
        <Style x:Key="BaseStyle" TargetType="Element">
            <Setter Property="Shell.ShellBackgroundColor" Value="{StaticResource NavigationPrimary}" />
            <Setter Property="Shell.ShellForegroundColor" Value="White" />
            <Setter Property="Shell.ShellTitleColor" Value="White" />
            <Setter Property="Shell.ShellDisabledColor" Value="#B4FFFFFF" />
            <Setter Property="Shell.ShellUnselectedColor" Value="#95FFFFFF" />
            <Setter Property="Shell.ShellTabBarBackgroundColor" Value="{StaticResource NavigationPrimary}" />
            <Setter Property="Shell.ShellTabBarForegroundColor" Value="White"/>
            <Setter Property="Shell.ShellTabBarUnselectedColor" Value="#95FFFFFF"/>
            <Setter Property="Shell.ShellTabBarTitleColor" Value="White"/>
        </Style>
        <Style TargetType="ShellItem" BasedOn="{StaticResource BaseStyle}" />
    </ResourceDictionary>
</Shell.Resources>

ShellItem のみ見た目定義かな…

その下に ShellItem, ShellSection, ShellContent の定義があります。あとはコメントで親切に各種見た目のテンプレートの例が書いてありました。

<!-- Your Pages -->
<ShellItem>
    <ShellSection Title="Browse" Icon="tab_feed.png">
        <ShellContent ContentTemplate="{DataTemplate local:ItemsPage}" />
    </ShellSection>
    <ShellSection Title="About" Icon="tab_about.png">
        <ShellContent ContentTemplate="{DataTemplate local:AboutPage}" />
    </ShellSection>
</ShellItem>

<!-- Optional Templates 
// These may be provided inline as below or as separate classes.

// This header appears at the top of the Flyout.
<Shell.FlyoutHeader>
   <DataTemplate>
        <Grid>ContentHere</Grid>
    </DataTemplate>
</Shell.FlyoutHeader>

// ItemTemplate is for ShellItems as displayed in a Flyout
<Shell.ItemTemplate>
    <DataTemplate>
        <ContentView>
            Bindable Properties: Title, Icon
        </ContentView>
    </DataTemplate>
</Shell.ItemTemplate>

// MenuItemTemplate is for MenuItems as displayed in a Flyout
<Shell.MenuItemTemplate>
    <DataTemplate>
        <ContentView>
            Bindable Properties: Text, Icon
        </ContentView>
    </DataTemplate>
</Shell.MenuItemTemplate>

動きを見てみよう

なんとなく Shell に関するコードはここらへんまでかなぁ?と思ったので動かしてみます。

起動するとこんな画面になりました。

f:id:okazuki:20190313113331p:plain

なるほど、ShellSection が下にボタンで出て ShellContent がページですね。

AppShell の XAML を見てみると以下のようなプロパティが設定されてます。

<Shell xmlns="http://xamarin.com/schemas/2014/forms" 
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
       xmlns:local="clr-namespace:MyXamarinFormsApp.Views"
       RouteHost="companyname.com"
       RouteScheme="app"
       Route="MyXamarinFormsApp"
       FlyoutBehavior="Disabled"
       Title="MyXamarinFormsApp"
       x:Class="MyXamarinFormsApp.AppShell">

コメントには Flyout がどうのって書いてありましたが Disable されてますね。FlyoutBehavior を Flyout にして、ShellItem を複数定義してみました。

<!-- FlyoutBehavior を Flyout にして ShellItem を増殖させてみた。Title プロパティもあったのでつけてみた -->
<ShellItem Title="A">
    <ShellSection Title="Browse" Icon="tab_feed.png">
        <ShellContent ContentTemplate="{DataTemplate local:ItemsPage}" />
    </ShellSection>
    <ShellSection Title="About" Icon="tab_about.png">
        <ShellContent ContentTemplate="{DataTemplate local:AboutPage}" />
    </ShellSection>
</ShellItem>
<ShellItem Title="B">
    <ShellSection Title="Browse" Icon="tab_feed.png">
        <ShellContent ContentTemplate="{DataTemplate local:ItemsPage}" />
    </ShellSection>
    <ShellSection Title="About" Icon="tab_about.png">
        <ShellContent ContentTemplate="{DataTemplate local:AboutPage}" />
    </ShellSection>
</ShellItem>
<ShellItem Title="C">
    <ShellSection Title="Browse" Icon="tab_feed.png">
        <ShellContent ContentTemplate="{DataTemplate local:ItemsPage}" />
    </ShellSection>
    <ShellSection Title="About" Icon="tab_about.png">
        <ShellContent ContentTemplate="{DataTemplate local:AboutPage}" />
    </ShellSection>
</ShellItem>

こうするとページの左上にハンバーガーメニューが表示されて…

f:id:okazuki:20190313114232p:plain

こんな感じで画面切り替えが出来ました。

f:id:okazuki:20190313114301p:plain

いいね。

気になるところが…

ShellContent の中のページが ContentTemplate で {DataTemplate local:AboutPage} のように指定してるところです。

Content が無いのだろうか?と思ってこうしてみました。

<ShellItem Title="C">
    <ShellSection Title="Browse" Icon="tab_feed.png">
        <ShellContent>
            <local:ItemsPage />
        </ShellContent>
    </ShellSection>
    <ShellSection Title="About" Icon="tab_about.png">
        <ShellContent>
            <local:AboutPage />
        </ShellContent>
    </ShellSection>
</ShellItem>

実行してみても普通に動きました。タイプ数が少ないから、DataTemplate 使ってるのかな?

Shell 内の画面遷移

ItemsPage で項目をクリックすると以下のような画面に遷移します。

f:id:okazuki:20190313114857p:plain

ListView を見てみると OnItemSelected イベントハンドラーで処理をしてるみたいなので確認してみます。

<ListView x:Name="ItemsListView"
        ItemsSource="{Binding Items}"
        VerticalOptions="FillAndExpand"
        HasUnevenRows="true"
        RefreshCommand="{Binding LoadItemsCommand}"
        IsPullToRefreshEnabled="true"
        IsRefreshing="{Binding IsBusy, Mode=OneWay}"
        CachingStrategy="RecycleElement"
        ItemSelected="OnItemSelected">

チェックしてみると普通に PushAsync してるだけでした。

async void OnItemSelected(object sender, SelectedItemChangedEventArgs args)
{
    var item = args.SelectedItem as Item;
    if (item == null)
        return;

    await Navigation.PushAsync(new ItemDetailPage(new ItemDetailViewModel(item)));

    // Manually deselect item.
    ItemsListView.SelectedItem = null;
}

右上の ADD を押すと以下のように Shell とは別の感じの画面が出てくるのですが

f:id:okazuki:20190313115242p:plain

こちらについては、単に PushModalAsync でした。

async void AddItem_Clicked(object sender, EventArgs e)
{
    await Navigation.PushModalAsync(new NavigationPage(new NewItemPage()));
}

ついでに NavigationPage でラップしてますね。NavigationPage をとっぱらってみると以下のようになって、戻れませんね…

f:id:okazuki:20190313115436p:plain

なので NavigationPage でラップしてたのはタイトル出すのと、ToolbarItems を使うためっぽいです。

ついでに NewItemPage.xaml.cs では MessageCenter で AddItem というメッセージを飛ばしてます。

async void Save_Clicked(object sender, EventArgs e)
{
    MessagingCenter.Send(this, "AddItem", Item);
    await Navigation.PopModalAsync();
}

これを受け取ってるのは ItemsViewModel.cs でした。

public ItemsViewModel()
{
    Title = "Browse";
    Items = new ObservableCollection<Item>();
    LoadItemsCommand = new Command(async () => await ExecuteLoadItemsCommand());

    MessagingCenter.Subscribe<NewItemPage, Item>(this, "AddItem", async (obj, item) =>
    {
        var newItem = item as Item;
        Items.Add(newItem);
        await DataStore.AddItemAsync(newItem);
    });
}

ここで使ってる DataStore は BaseViewModel.cs で以下のように宣言されていて、今回のコードでは MockDataStore.cs のインスタンスが返される。(どこにも DependencyService を初期化してるようなコードは見当たらないので)

MockDataSource クラスは特にデータを static に管理してる風には見えないので実際には Items.Add で見た目上は追加されてるけど特に裏では永続化されてないのでリフレッシュしたらデータは消えちゃうみたいですね。

まとめ

Shell で提供される形の仕様ですむように落とし込めたら、これは強そうだと思った!楽できそう。