MvvmCrossのiOS UniversalアプリでiPadで画面分割を作る

Xamarin Advent Calendar 2014 の 15 日目です。

4 日目には MvvmCross のプラグインの作り方を紹介しましたが、今回は MvvmCross の View 表示まわりに割り込みをして高度な表示機能を追加する IMvxViewPresenter の使い方をご紹介します。

MvvmCrossのView表示の仕組み

ところで、MvvmCross では ViewModel で ShowViewModel を実行するとサクッと View が表示されますが、実際は下で以下のような動きをしています。

  • MvxViewModelRequest が作られて、IMvxViewDispatcher.ShowViewModel が呼ばれる。
  • IMvxViewPresenter.Show にリクエストが転送される
  • IMvxViewPresenter の中で対応する View が生成されて表示される

IMvxViewPresenter には ViewModel から View を表示する以外にも、表示の変更要求メッセージである PresentationHint を受け取って処理することができます。MvvmCross では ViewPresenter を実装してカスタマイズすることで高度な画面表示の要求に応えることができます。

ViewPresenter 実装例

今回は以下のような、iPhone アプリをベースに考えます。メニュー(FirstView)があり、支店の一覧(BranchListView)があって、支店の詳細(BranchView)を表示しています。

スクリーンショット 2014-12-14 3.24.20.png

今回、このアプリの iPad 版を作ることになり、メニューはそのまま出すけど、支店の一覧と詳細は画面分割で 1 つの画面に表示という要求になっています。

スクリーンショット 2014-12-14 3.26.42.png

本来であれば UISplitViewController の出番なのですが、MvvmCross はデフォルトでルートが UINavigationController であり、SplitViewController が NavigationController の中に入るのは iOS プログラマとしてはきな臭さ MAX なので絶対やめた方がいいという考えに至ります。

そこで、iPad 用には画面分割するコンテナ ViewController を用意し、その中に ViewController を ChildViewController として追加する方法で対応することにします。

今回は以下の手順で対応していきます。

  • コンテナとなる ViewController とその中に入る ViewController の親子関係を定義するインターフェイスの作成
  • 親となる ViewController の作成
  • 子となる ViewController の設定
  • MvxViewPresenter の作成
  • MvxViewPresenter を MvvmCross に登録

インターフェイスの定義

まず、親子関係を定義するためのインターフェイスを用意します。

まず、子の ViewController には IPadContainerChild というインターフェイスを用意します。親となる ViewController の型を返します。

using System;

namespace SplitContainerSample.Touch.Views
{
    public interface IPadContainerChild
    {
        Type ContainerType { get; }
    }
}

親となる ViewController には IPadContainer というインターフェイスを用意します。

using System;
using Cirrious.MvvmCross.Views;

namespace SplitContainerSample.Touch.Views
{
    public interface IPadContainer
    {
        bool ShowView(IMvxView view);
    }
}

親となる ViewController の作成

支店詳細と支店一覧を格納する ViewController を作ります。BranchPadContainerなどとします。

スクリーンショット 2014-12-14 2.46.36.png

Xcode で画面分割された View を作ります。xib ファイルで View を 2 枚並べて、Autolayout でいい感じに分割されるようにし、左側の支店リストに当たる部分を BranchListViewContainer、右側の詳細に当たる部分を BranchViewContainer を名前をつけてアウトレット接続しています。(下の図はわかりやすいように色をつけています)

スクリーンショット 2014-12-14 2.48.44.png

using System;
using System.Drawing;

using MonoTouch.Foundation;
using MonoTouch.UIKit;
using Cirrious.MvvmCross.Views;

namespace SplitContainerSample.Touch.Views
{
    public partial class BranchPadContainer : UIViewController, IPadContainer
    {
        public BranchPadContainer() : base("BranchPadContainer", null)
        {
        }

        protected override void Dispose(bool disposing)
        {
            BranchListView = null;
            BranchView = null;
            base.Dispose(disposing);
        }

        public override void ViewDidLoad()
        {
            base.ViewDidLoad();
        }

        #region ChildView Controller

        public BranchListView BranchListView
        {
            get { return _branchListView; }
            set
            {
                SwitchChildViewController(ref _branchListView, value, BranchListViewContainer);
            }
        }
        BranchListView _branchListView;

        public BranchView BranchView
        {
            get { return _branchView; }
            set
            {
                SwitchChildViewController(ref _branchView, value, BranchViewContainer);
            }
        }
        BranchView _branchView;

        /// <summary>
        /// 子 ViewController を切り替える
        /// </summary>
        /// <typeparam name="T">対象の子ViewControllerの型</typeparam>
        /// <param name="assignViewController">ViewController を格納している変数</param>
        /// <param name="newViewController">新しい ViewController</param>
        /// <param name="containerView">ViewController を表示する View</param>
        private void SwitchChildViewController<T>(ref T assignViewController, UIViewController newViewController, UIView containerView)
            where T : UIViewController
        {
            if (assignViewController != null)
            {
                assignViewController.WillMoveToParentViewController(null);
                assignViewController.View.RemoveFromSuperview();
                assignViewController.RemoveFromParentViewController();
                assignViewController.Dispose();
            }
            assignViewController = newViewController as T;
            if (assignViewController != null) {
                AddChildViewController(assignViewController);
                assignViewController.View.Frame = BranchListViewContainer.Bounds;
                containerView.AddSubview(assignViewController.View);
                assignViewController.DidMoveToParentViewController(this);
            }
            return assignViewController;
        }
          
        #endregion

        #region IPadContainer implementation

        public bool ShowView(IMvxView view)
        {
            if (view is BranchListView)
            {
                BranchListView = view as BranchListView;
                return true;
            }
            if (view is BranchView)
            {
                BranchView = view as BranchView;
                return true;
            }
            return false;
        }

        #endregion
    }
}

ここでのポイントは、子となる ViewController の追加や取り外しにはお作法があることです。ここでは SwitchChildViewController でまるっとまとめていますが、アニメーションなども考慮してこういう形になっています。詳しい内容は Objective-C の記事を childViewController などと調べてみてください。

また、IPadContainer の実装ですが、自分に格納できる View の場合は画面に追加して true を返し、そうでなければ false を返すようにしています。

子となる ViewController の設定

子となる ViewController では IPadViewContainerChild を実装して、親となる ViewController を返します。

namespace SplitContainerSample.Touch.Views
{
    public class BranchListView : MvxTableViewController, IPadContainerChild
    {

// ---- snip ---

        #region IPadContainerChild implementation

        public Type ContainerType
        {
            get { return typeof(BranchPadContainer); }
        }

        #endregion
    }
}

MvxViewPresenter の作成

今回は iPad だけの処理なので、iPad 専用の MvxViewPresenter のみ用意し、iPhone についてはデフォルトのものを使う方針で進めたいと思います。PadViewPresenter を作成します。

using Cirrious.MvvmCross.Touch.Views;
using Cirrious.MvvmCross.Touch.Views.Presenters;
using MonoTouch.UIKit;
using SplitContainerSample.Touch.Views;
using System;

namespace SplitContainerSample.Touch
{
    public class PadViewPresenter : MvxTouchViewPresenter
    {
        protected IPadContainer PadContainer
        {
            get
            {
                IPadContainer value = null;
                _padContainerHolder.TryGetTarget(out value);
                return value;
            }
            set
            {
                _padContainerHolder.SetTarget(value);
            }
        }
        WeakReference<IPadContainer> _padContainerHolder = new WeakReference<IPadContainer>(null);

        public PadViewPresenter(UIApplicationDelegate appDelegate, UIWindow window) : base(appDelegate, window)
        {
        }

        public override void Show(IMvxTouchView view)
        {
            if (view is IPadContainerChild)
            {
                var container = PadContainer;
                var child = view as IPadContainerChild;

                // 現在表示中のコンテナがあり、表示しようとしている View と一致している
                if ((container != null) && (container.GetType() == child.ContainerType))
                {
                    if (container.ShowView(view))
                    {
                        return;
                    }
                }

                // コンテナを作る
                container = child.ContainerType.GetConstructor(new Type[0]).Invoke(null) as IPadContainer;
                if (container == null)
                {
                    throw new InvalidOperationException();
                }
                var containerViewController = container as UIViewController;

                // View プロパティにアクセスしないと View が作成されないので先に呼んでおく
                containerViewController.View.ToString(); 

                if (container.ShowView(view))
                {
                    // 作成したコンテナで表示できれば NavigationController に作成したコンテナを追加
                    PadContainer = container;
                    this.MasterNavigationController.PushViewController(containerViewController, true);
                }
                else
                {
                    // ここまできて表示できなければそのまま表示してしまう
                    containerViewController.Dispose();
                    base.Show(view);
                }
            }
            else
            {
                base.Show(view);
            }
        }
    }
}

まず、表示中のコンテナがあり、表示しようとしている View が要求しているコンテナであればその中に表示しようとします。

表示中のコンテナがなかったり、表示中のコンテナが表示を拒否した場合は新規にコンテナを作ってその中に表示します。先に View プロパティを呼んでおかないと View が初期化されないので、先に呼ぶところまでを初期化の一環としています。(Objective-C では view を呼び出すだけで OK ですが、C# だと最適化で消えちゃうかもなあ、みたいなことを思ってとりあえず ToString を空打ちしてます。)

MvxViewPresenter を MvvmCross に登録

作成した ViewPresenter は MvvmCross によってデフォルトで作られている Setup クラスにある CreatePresenter メソッドをオーバーライドして返すことで使うことができます。

iPad 版のみ差し替えなので、UIDevice.CurrentDevice.UserInterfaceIdiom プロパティで iPad かどうかチェックしています。

using MonoTouch.UIKit;
using Cirrious.CrossCore.Platform;
using Cirrious.MvvmCross.ViewModels;
using Cirrious.MvvmCross.Touch.Platform;

namespace SplitContainerSample.Touch
{
    public class Setup : MvxTouchSetup
    {
        public Setup(MvxApplicationDelegate applicationDelegate, UIWindow window)
            : base(applicationDelegate, window)
        {
        }

        protected override IMvxApplication CreateApp ()
        {
            return new Core.App();
        }

        protected override Cirrious.MvvmCross.Touch.Views.Presenters.IMvxTouchViewPresenter CreatePresenter()
        {
            if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad)
            {
                return new PadViewPresenter(ApplicationDelegate, Window);
            }
            return base.CreatePresenter();
        }
        
        protected override IMvxTrace CreateDebugTrace()
        {
            return new DebugTrace();
        }
    }
}

しあげ

さて、iPad で画面分割のサポートがあるアプリは、一番最初の項目が選択されている場合がほとんどです。

今回も画面表示時に支店一覧の最初の項目が選択されているようにしました。

public override void ViewWillAppear(bool animated)
{
    base.ViewWillAppear(animated);

    if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad)
    {
        var indexPath = MonoTouch.Foundation.NSIndexPath.FromRowSection(0, 0);
        TableView.SelectRow(indexPath, false, UITableViewScrollPosition.None);
        TableViewSource.RowSelected(TableView, indexPath);
    }
}

最後に

今回のプロジェクトはgithubにあげてあります。この記事中の内容はかなり端折っているので、こちらを実際に動かしてもらった方がわかるとおもいます。

https://github.com/iseebi/MvvmCross-SplitContainerSample