kamulog

xamarin.formsのネタなど

prism.forms 6.3.0-pre1 のNavigationServiceをカスタマイズしてみた例

この記事はXamarin Advent Calendar 2016 - Qiitaの21日目の記事です。

Navigationに関しては散々出尽くした&すでにガチな記事があるので気がひけるのですが、せっかく作ったので紹介させてください。よろしくお願いします。

prismのNavigationServiceは便利ですよね。かなり高機能で使うべきものなんですがTabbedPageを絡めたコンテンツを作成しているとうーーんという箇所があります。
prismの6.3.0-pre1からNavigationService内の多くのメソッドの修飾子がprotectedになり、派生クラスからoverrideしたり普通に呼び出したりできるようになったので、うーんという箇所をカバーできるようにオレオレカスタマイズに挑戦してみました。

※ガチな記事

どれも必読です!

www.nuits.jp tamafuyou.hatenablog.com

前提知識

www.nuits.jp www.nuits.jp www.nuits.jp

完成したもの

github.com

prismのNavigationServiceにおけるTabbedPageの挙動

prism.formsのNavigationServiceでは
NavigateAsync("NavigationPage/MainPage")
みたいに遷移指定するんですが、これってTabPageの子までは生成してくれないんですよね。

NavigateAsync("MyTabbed")
とするとあらかじめ設定してある最初のTabページが表示されるんですが、子が設定してないと真っ白ページになってしまいます。

では
NavigateAsync("MyTabbed/MainPage")
なんかにしてみると自動的にTabにMainPageを配置してくれたりは…しないんですw

これに関しては

public class MyTabbed:TabbedPage
{
    public MyTabbed()
    {
        Children.Add(new NaviA { Title = "Tab1" });
        Children.Add(new NaviB { Title = "Tab2" });
    }
}

こんな感じであらかじめNavigationPageを子にもつTabbedPageを定義しておくと
NavigateAsync("MyTabbed/NaviA/MainPage")
これでMainPageを配置してくれます。

このままMainPageで
NavigateAsync("NextPage")
こんな感じで次のページに遷移するようにしてやるとNaviAを使ってNextPageに遷移してくれます。

2つ目以降のタブを配置するには、続けて
NavigateAsync("NaviB/SubPage")
のように指定すると2番目のタブにSubPageを配置してくれます。2つ目以降はMyTabbedからの相対パスにする必要があるようです。

MainPageにTabbedPageを配置する場合のNavigateAsyncの指定方法

前項を踏まえてMainPageにTabbedPageを配置するにはAppクラスで以下のように組み立てていきます。 あらかじめTabbedPageには必要なNavigationPage子要素をセットしておきます。

//App.xaml.cs
protected override async void OnInitialized()
{
    InitializeComponent();
    
    //後のTabページから生成していく
    await NavigationService.NavigateAsync("MyTabbed/NaviB/SecondPage");
    //最初に表示するTabページを最後にNavigateする
    await NavigationService.NavigateAsync("NaviA/FirstPage");
}

これでFirstPageViewModelでNavigationServiceを使って
await _navigationService.NavigateAsync("NextPage");
みたいにすればちゃんとNextPageに遷移します。

f:id:kamusoft:20161213202700p:plain:w180 f:id:kamusoft:20161213202702p:plain:w180 f:id:kamusoft:20161213202704p:plain:w180

この時点での問題点

このようにしてMainPageを指定すれば一見問題ないように見えますし、実際あんまり問題はありませんw
ですがNavigationPageの部分をNavigationServiceを介さずに生成しているため、「戻るボタン」(物理・ソフト両方)で戻った時のINavigationAware.OnNavigatedToが動作しません。

prismはNavigationServiceの中でINavigationAwareの動作を呼び出してるので、手動で作成した場合は当然ながら発動しません。
またNavigationServiceの中でページを生成した時にBehaviorを適用してBackボタンで戻った時や、TabbedPageのIActiveAwareの挙動を設定してるのですが、これも当然ありません。

というわけでその辺をなんとかするMainPageのCreateメソッドをNavigationServiceを拡張して作成していきます。

UnityPageNavigationServiceの拡張

prism.unity.formsを使っている場合はUnityPageNavigationServiceを拡張していきます。

Prism/UnityPageNavigationService.cs at master · PrismLibrary/Prism · GitHub

//MyPageNavigationService.cs
public class MyPageNavigationService:UnityPageNavigationService
{
    //Containerがとれると何かと便利なので…
    public IUnityContainer Container { get;private set; }
    IApplicationProvider _app;

    //MainPageがとれると何かと略
    public Page MainPage {
        get {
            return _app.MainPage;
        }
    }

    public MyPageNavigationService(IUnityContainer container, IApplicationProvider applicationProvider, ILoggerFacade logger)
        :base(container,applicationProvider,logger)
    {
        _app = applicationProvider;
        Container = container;
    }

    //MainPageとして使うTabbedPageを生成する
    public TabbedPage CreateMainTabbedPage(string tabbedName, IEnumerable<NavigationPage> children)
    {
        var tabbedPage = CreatePageFromSegment(tabbedName) as TabbedPage;

        foreach (var c in children) {
            tabbedPage.Children.Add(c);
        }

        return tabbedPage;
    }

    //NavigationPageを生成する
    public async Task<NavigationPage> CreateNavigationPage(string navName, string pageName, NavigationParameters parameters = null)
    {
        //BaseのCreatePageFromSegmentを使ってページを作ってもらう
        var naviPage = CreatePageFromSegment(navName) as NavigationPage;
        var contentPage = CreatePageFromSegment(pageName);

        if (parameters == null) {
            parameters = new NavigationParameters();
        }

        //手動でOnNavigatingToを呼び出す
        PageUtilities.OnNavigatingTo(contentPage, parameters);

        await naviPage.PushAsync(contentPage, false); //アニメーションは無意味なのでしない

        //手動でOnNavigatedToを呼び出す
        PageUtilities.OnNavigatedTo(contentPage, parameters);

        return naviPage;
    }
}

ソース中のコメントでほぼ説明終わってますが、要するにNavigationServiceを使ってやるであろう手順をエミュレートしているだけですね。CreatePageFromSegmentというメソッドはページを作成したりBehaviorを適用したりしていて、ここを通して作成することでNavigationServiceを使って生成したページと同じ状態にしています。 NavigationPageもこれでしかるべき動作をするものにすることができます。

UnityPageNavigationServiceクラスはPageNavigationServiceを継承しているので、このように拡張したクラスを使えばPageNavigationServiceのほとんどのメソッドが利用可能になるので、かなり自由に拡張することができます。

Prism/PageNavigationService.cs at master · PrismLibrary/Prism · GitHub

この拡張したMyPageNavigationServiceですが、実際に使うにはもう一手間必要です。

拡張したNavigationServiceを通常使うものにする

Appクラスに以下のメソッドのoverrideを追記します。

//App.xaml.cs
protected override void ConfigureContainer()
{
    base.ConfigureContainer();

    Container.RegisterType<INavigationService, MyPageNavigationService>("MyPageNavigationService");
}

protected override INavigationService CreateNavigationService()
{
    return Container.Resolve<INavigationService>("MyPageNavigationService");
}

ConfigureContainerでは通常使うNavigationServiceとして今回拡張したものを登録して、 CreateNavigationServiceでは、ViewModelLocatorでViewModelを生成する時に今回の拡張したクラスを生成するように指定します。 これで各ViewModelのコンストラクタのnavigationServiceという引数に自動で差し替えた方のNavigationServiceを注入してくれるようになります。

Prism/PrismApplication.cs at master · PrismLibrary/Prism · GitHub

作成したNavigationServiceでMainPage生成

これでやっとNavigationServiceの差し替えが完了したのでさっき作った関数でMainPageを生成するようにOnInitializedを書き換えます。

//App.xaml.cs
protected override async void OnInitialized()
{
    InitializeComponent();

    var nav = (MyPageNavigationService)NavigationService;

    MainPage = nav.CreateMainTabbedPage(nameof(MyTabbed), new List<NavigationPage> {
            await nav.CreateNavigationPage(nameof(NaviA),nameof(FirstPage)),
            await nav.CreateNavigationPage(nameof(NaviB),nameof(SecondPage)),
        });

}

nameofを使ってますが、ここはRegisterTypeForNavigationで指定した名前を指定します。

動作確認用のViewModel記述

INavigationAwareやIActiveAwareの通過を確認するためにViewModelには以下のようにしてLogをとってXaml側でLabelのTextにBindingしています。

ついでに6.3.0-pre1より登場したIActiveAwareについても少し。
IActiveAwareはINavigationAwareのようにメソッドが定義されてるのではなく、IsActiveプロパティとIsActiveChangedイベントが定義されています。NavigationServieからメソッドが呼び出されるのではなく、IsActiveに値をセットするところまでしかしてくれません。普通にOnActive()呼んでくれた方が楽なのにと思いましたが、これXamarinのためにあるものじゃなくて昔からあるっぽいInterfaceなんですよね。他プラットフォームと合わせるためですかね。

Prism/IActiveAware.cs at master · PrismLibrary/Prism · GitHub

そんなわけなんでIsActiveのSetter内でActive・非Active用のメソッドを呼び出すようにしています。IsActiveChangedは使うほどじゃないので放置です。

//FirstPageViewModel.cs(SecondPageViewModelも同様)
public event EventHandler IsActiveChanged;

private bool _IsActive;
public bool IsActive {
    get {
        return _IsActive;
    }

    set {
        _IsActive = value;
        if (value) {
            OnActive();
        }
        else {
            OnNonActive();
        }
    }
}

void OnActive()
{
    LogText += "OnActive Passed\n";
}
void OnNonActive()
{
    LogText += "OnNonActive Passed\n";
}

public void OnNavigatedFrom(NavigationParameters parameters)
{
    LogText += "OnNavigatedFrom Passed\n";
}

public void OnNavigatedTo(NavigationParameters parameters)
{
    LogText += "OnNavigatedTo Passed\n";
}

public void OnNavigatingTo(NavigationParameters parameters)
{
    LogText += "OnNavigatingTo Passed\n";
}

実行!

f:id:kamusoft:20161209230252g:plain

ちゃんとどちらのタブも初期状態でOnNavigatingToとOnNavigatedToが呼ばれてますね。
そしてNextPageに遷移した後で戻ってくるとOnNavigatedFromとOnNavigatedToがちゃんと呼ばれているのがわかります。問題なさげです。

OnNavigatingToは「戻るボタン」で戻った時には呼ばれないのは仕様のようですね。これはNavigationのイベントにポップした時のPoppedしかないためだと思われます。PoppingとかPrePopとかがあれば良いんですがね。

しかし…

タブ切り替え時にIActiveAwareが呼ばれた形跡がありません。

IActiveAwareはこのBehaviorによって呼び出されるはずですが…

Prism/MultiPageActiveAwareBehavior{T}.cs at master · PrismLibrary/Prism · GitHub

これよく見るとタブの直属の子に対してしかSetActiveにしてないんですよね。
今回tabbedPage->NavigationPage->ContentPageとNavigationPageを間にかましているので、NavigationPageのViewModelに対してIActiveAwareが発動してるみたいです。 うーん、これじゃ意味がない。 ということでもう少し手を加えます。

NavigationPageのCurrentPageにもIActiveAwareが発動するようにする

本家を真似してTabbedPageのCurrentPageのNavigationPageのCurrentPageに対してSetActiveするBehaviorを作ります。

Prism/MultiPageActiveAwareBehavior{T}.cs at master · PrismLibrary/Prism · GitHub

//TabbedPageOverNavigationPageActiveAwareBehavior.cs
public class TabbedPageOverNavigationPageActiveAwareBehavior:BehaviorBase<TabbedPage>
{
    private Page _lastNavigationCurrent;

    protected override void OnAttachedTo(TabbedPage bindable) {
        base.OnAttachedTo(bindable);
        bindable.CurrentPageChanged += CurrentPageChangedHandlerForNavigationPage;
    }

    protected override void OnDetachingFrom(TabbedPage bindable) {
        base.OnDetachingFrom(bindable);
        bindable.CurrentPageChanged -= CurrentPageChangedHandlerForNavigationPage; ;
    }

    void CurrentPageChangedHandlerForNavigationPage(object sender, EventArgs e) {

        if (_lastNavigationCurrent == null) {
            _lastNavigationCurrent = (AssociatedObject.CurrentPage as NavigationPage)?.CurrentPage;
            SetIsActive(_lastNavigationCurrent, true);
            return;
        }

        SetIsActive(_lastNavigationCurrent,false);

        _lastNavigationCurrent = (AssociatedObject.CurrentPage as NavigationPage)?.CurrentPage;

        SetIsActive(_lastNavigationCurrent,true);
    }

    void SetIsActive(object view, bool isActive) {
        PageUtilities.InvokeViewAndViewModelAction<IActiveAware>(_lastNavigationCurrent, activeAware => activeAware.IsActive = isActive);
    }
}

そしてMyPageNavigationServiceのCreateMainTabbedPageに1行追加します。

//MyPageNavigationService.cs
public TabbedPage CreateMainTabbedPage(string tabbedName, IEnumerable<NavigationPage> children)
{
    var tabbedPage = CreatePageFromSegment(tabbedName) as TabbedPage;
    //作成したTabbedPageに対してNavigationPageを超えてSetActiveするためのBehaviorを適用する
    tabbedPage.Behaviors.Add(new TabbedPageOverNavigationPageActiveAwareBehavior());

    foreach (var c in children) {
        tabbedPage.Children.Add(c);
    }

    return tabbedPage;
}

これで実行すると

f:id:kamusoft:20161209230445g:plain

ちゃんとTabのActive、NonActiveを通ってますね!これでミッションコンプリートです。
ただこれだけだとMainPageのCreateメソッドを追加しただけなんで、実際にViewModelで使用するINavigationServiceに対して拡張メソッドでオレオレメソッドを追加してみました。

INavigationServiceの拡張メソッド(1) 文字列じゃなくて型で移動。

単純にいちいちnameofとかするのがめんどいのでそれをサボるためのメソッドを作成します。ViewModelFirstとかそういうガチなのではないですwすみません。

//NavigationServiceExtentions.cs
public static class NavigationServiceExtentions
{

    //Pageの型で遷移する
    public static async Task NavigateAsync<T>(this INavigationService nav,NavigationParameters param = null,bool animated=true) where T:ContentPage
    {
        if (param == null) {
            param = new NavigationParameters();
        }
        //typeのNameを指定してオリジナルを呼び出してるだけ
        await nav.NavigateAsync(typeof(T).Name,param,(bool?)false,animated);
    }

    //Modalほとんど使わないし、紛らわしいので分離する
    public static async Task NavigateModalAsync<T>(this INavigationService nav, NavigationParameters param = null, bool animated = true) where T : ContentPage
    {
        if (param == null) {
            param = new NavigationParameters();
        }
        await nav.NavigateAsync(typeof(T).Name, param, (bool?)true, animated);
    }

}

これでViewModelでこんな感じでPageの型で呼び出せるようになりました。

await _navigationService.NavigateAsync<NextPage>();

INavigationServiceの拡張メソッド(2) 型指定でTab移動できるようにする

NavigationPageをかましてのTab移動は
await nav.NavigateAsync("NaviB");
で可能なんですが、諸事情があってNavigationPageのCurrentPageを指定しても移動できるようにしたいのと例のごとく手抜き用に型指定したいので、 そんな感じのメソッドを作成します。

//NavigationServiceExtentions.cs
public static class NavigationServiceExtentions
{
    //略

    public static bool ChangeTab<T>(this INavigationService nav) where T : Page
    {
        //MyPageNavigationService経由でMainPageを取得
        var mainPage = (nav as MyPageNavigationService)?.MainPage;
        if (mainPage == null) {
            return false;
        }

        if (mainPage is TabbedPage) {
            var tabbed = mainPage as TabbedPage;
            return SearchTargetTab(tabbed, typeof(T));
        }
        else {
            return false;
        }
    }

    static bool SearchTargetTab(TabbedPage tabbed, Type target)
    {
        foreach (var child in tabbed.Children) {
            //直接の子に該当すればそれを
            if (child.GetType() == target) {
                //CurrentPageにする
                tabbed.CurrentPage = child;
                return true;
            }

            var nav = (child as NavigationPage);
            if (nav == null) {
                continue;
            }

            //子のNavigationPageのCurrentPageも確認
            if (nav.CurrentPage.GetType() == target) {
                //該当すればそのNavigationPageをCurrentPageにする
                tabbed.CurrentPage = child;
                return true;
            }
        }
        return false;
    }

}

これでViewModelでこんな感じでTabを切り替えられるようになりました。

_navigationService.ChangeTab<NaviB>();  //NavigationPageでも
_navigationService.ChangeTab<SecondPage>(); //その子のContentPageでもOK

ちゃんとIActiveAwareも発動してますね!

f:id:kamusoft:20161209230619g:plain

今回は使いませんでしたが、MyPageNavigationServiceからIUnityContainerを取れるようにしているので拡張メソッド内でResolveしたりできます。

終わりに

prism.forms 6.3.0-pre1はプレリリースなので今回ご紹介した例は使えなくなっている可能性もあります。
ですが、拡張性が格段に上がっているのでIActiveAwareやINavigationAwareの挙動も本体を維持したまま調整できますし、独自のActionを追加することもできると思います。
Navigationまわりがなぁ…と今まで敬遠してた方も6.3.0が正式リリースされたら導入してみてはいかがでしょうか?