ぴーさんログ

だいたいXamarin.Formsのブログ

Xamarin.Forms.Tizenをハックして地図を表示してみる

この記事は Xamarin(その2) Advent Calendar 2016 20日目の記事です。

Xamarin.FormsにTizenが参戦!?

Connect(); // 2016 のキーノートにて電撃的にXamarin.Forms.Tizenが発表され、一部界隈に衝撃を与えました。

衝撃を受ける様子

当惑ぶりが伺えますね。

さて、TizenでXamarin.Formsアプリを動かしてみよう!的な内容は既になかしょさん(@nakasho_devがやってくれているのでここでは省略します。

nakasho-dev.hatenablog.jp

ここ見ればすんなりいけます。

解析だ!

さあ、TizenでXamarin.Formsアプリが動く事は分かりました。

次はなぜ動くのかが気になるのでソイツを調べてみましょう。

テンプレートから生成されたプロジェクトはどうやら.NET Core Appを吐くようです。(そもそもキーノートの発表的には「Tizenが.NET Coreに対応したよ」という内容なので当然か)

f:id:ticktack623:20161219204517j:plain

projecto.jsonの内容はこんな感じ。

{
    "buildOptions": {
        "emitEntryPoint": true,
        "debugType": "portable",
        "platform": "AnyCPU",
        "preserveCompilationContext": true
    },
    "dependencies": {
        "Microsoft.NETCore.App": {
            "version": "1.0.0"
        },
        "Tizen.Library": "1.0.0-pre1",
        "Xamarin.Forms": "2.3.3.163-pre3",
        "Xamarin.Forms.Platform.Tizen": "2.3.3.163-pre3"
    },
    "runtimes": {
        "win": {},
        "linux": {}
    },
    "frameworks": {
        "netcoreapp1.0": {
            "imports": [
                "portable-net45+wp80+win81+wpa81",
                "netstandard1.6"
            ]
        }
    }
}

使用しているNuGetパッケージ(dependencies辺り)に注目します。

Xamarin.Forms本体はフォークではなく、本家をそのまま参照している模様。

Xamarin.Forms.Platform.TizenTizen.Library が気になるのでNuGet Gallaryを覗きに行きます。

Xamarin.Forms.Platform.Tizen のページ

f:id:ticktack623:20161217231126j:plain

オーナーがtizenプロジェクト、オーサーがSamsungとなっているのでバグ報告などはそちらに投げましょう。次!

Tizen.Library のページ

f:id:ticktack623:20161219204633j:plain

こちらはTizenのAPIを.NET Frameworkから呼ぶためのラッパーの集合体っぽいですね。

Dependenciesに並んでいる名前もTizenアーキテクチャ図に乗っているものとほぼ一致します。

f:id:ticktack623:20161219204647p:plain

ElmSharpだけ違う命名で気になるところ。こいつの正体はElmSharpのページへ跳ぶと説明文で正体が分かります。

ElmSharp is the top-most library based on Tizen Native UI Framwork (EFL). It provides a variety of pre-built UI components, such as layout objects and widgets, that allow you to build rich graphical user interfaces.

つまりTizenのネイティブUIのラッパーです。(なぜTizen.UIにしなかったのか)

Rendererでも覗いてみようか

NuGetパッケージを見る限り、なんとなく動きそうな土台が整っていることが分かりました。

次はRendererとか、どんな感じに実装されてるか気になりますよね。

ソースコード中の適当な所に Xamarin.Forms.Platform.Tizen.ButtonRenderer と書いてF12で定義に跳んでみましょう。

こんな感じになってます。

using System;
using Xamarin.Forms.Platform.Tizen.Native;

namespace Xamarin.Forms.Platform.Tizen
{
    public class ButtonRenderer : ViewRenderer<Button, Native.Button>, IDisposable
    {
        // 省略
    }
}

ViewRenderer<Button, Native.Button> に注目、(Xamarin.Formsから見て)ネイティブ側のコントロールとして Xamarin.Forms.Platform.Tizen.Native.Button を与えていますね。こいつ何者?

TizenのネイティブUIはElmSharpじゃなかったんかい、ということでさらにF12で跳びます。

using ElmSharp;

namespace Xamarin.Forms.Platform.Tizen.Native
{
    public class Button : ElmSharp.Button, IMeasurable
    {
        // 省略
    }
}

はい、ここでElmSharpのButtonが出てきました。

Xamarin.Formsは元来iOSやAndroidのようにリッチなUIフレームワークバックエンドと想定していたため、組み込み寄りなTizenのコントロールではギャップが大きかったのでワンクッション置いているのだと思います。

Button以外でも同様なので、整理すると以下のような関係です。

  • Xamarin.Forms.Core.dll
    • Xamarin.Forms名前空間 (抽象化されたUIコントロール)
  • Xamarin.Forms.Platform.Tizen.dll
    • Xamarin.Forms.Platform.Tizen名前空間 (各種Renderer定義)
    • Xamarin.Forms.Platform.Tizen.Native名前空間 (Rendererのネイティブ側コントロール、ElmSharpコントロールを補強)
  • ElmSharp.dll
    • ElmSharp名前空間 (TizenのネイティブUIフレームワークのラッパー)

せっかくなので地図でも表示してみよう

Xamarin.Forms.MapsのTizen版はまだ無いようです。せっかくなのでここまで調査した内容を駆使して地図を表示してみましょう。モバイルOSなのだから地図ぐらいあるはずです。

ElmSharp名前空間の中にそれっぽいのがあるか探すと ElmSharp.EvasMap が見つかります。残念ながらこれは地図ではありません。(どうやら変形に関するクラスらしい)

仕方ないのでネイティブAPIを探してP/Invokeで何とかします。

それらしいドキュメントによると、 Evas_Object * elm_map_add (Evas_Object *parent) をコールすればMap Widjetのハンドルが得られるようだ。

using System;
using System.Runtime.InteropServices;

internal static class ElementaryMap
{
    [DllImport("libelementary.so.1")]
    internal static extern IntPtr elm_map_add(IntPtr obj);
}

DllImportに指定するライブラリはElmSharp.Buttonを真似する。

using System;
using ElmSharp;
using System.Reflection;

namespace TizenApp1
{
    // ElmSharpのコントロールはLayoutの派生クラスになっていたので真似る
    public class MapView : Layout
    {
        public MapView(EvasObject parent) : base(parent)
        {
        }
        
        // ここでネイティブAPIのハンドルを返せばよさそう
        protected override IntPtr CreateHandle(EvasObject parent)
        {
            IntPtr parentHandle = (IntPtr) typeof(EvasObject).GetTypeInfo().GetProperty("Handle", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).GetMethod.Invoke(parent, new object[] { });
            return ElementaryMap.elm_map_add(parentHandle);
        }
    }
}

新しいWidjetを作るには親要素(コンテナ?)のハンドルが必要らしいのだがinternalだったのでReflectionで頑張る。

これでネイティブ地図コントロールのインスタンスが作れるようになったはずなので、Xamarin.FormsのMapとRendererをこさえる。

using Xamarin.Forms;

namespace TizenApp1
{
    public class Map : View
    {
    }
}
using System;
using Xamarin.Forms.Platform.Tizen;

[assembly: ExportRenderer(typeof(TizenApp1.Map), typeof(TizenApp1.MapRenderer))]

namespace TizenApp1
{
    public class MapRenderer : VisualElementRenderer<Map>
    {
        MapView _control;

        protected override void OnElementChanged(ElementChangedEventArgs<Map> e)
        {
            if(_control == null)
            {
                _control = new MapView(Forms.Context.MainWindow);
                SetNativeControl(_control);
            }

            base.OnElementChanged(e);
        }
    }
}

この辺は普通のXamarin.Formsの世界なので特に変わったことは無し。

public App()
{
    MainPage = new ContentPage
    {
        BackgroundColor = Color.Gray,
        Content = new Map(),
    };
}

ContentPageに組み込んでみる。

f:id:ticktack623:20161218035646j:plain

なんか出た。

Tizen端末を使った事が無いので本当にこれであってるのかイマイチ不安なんですが、ネイティブAPIを叩いて地図Widjetを出現させらるのに成功したようです。 (なおサイズ変更ができない、レイアウト周りのハンドリングが必要だろうがそこまで頑張る気力が無い……)

いずれ地図コントロールも実装されると思うのでその際に答え合わせしたいですね。

更に戦う者達

TizenでXamarin.Forms頑張りたい人向け

Xamarin.Forms.Platform.TizenのGitリポジトリ

ElmSharpのGitリポジトリ

Compliance Specification | Tizen SourceTizen 2.4 Compliance Specification for Mobile Profile (pdf) にDllImportに使いそうなライブラリの名前が載ってる。

Xamarin.Forms.Platform.Tizen最大の謎

個人的に一番気になる点、 Xamarin.Forms.Platform.Tizenはなにがしかのトリックで他assemblyのinternalメンバを参照している のである。

というのも Xamarin.Forms.Platform.XXXX を追加するには、 Xamarin.Forms.Core の internal な interface を実装する必要がある(この話は12/24に投稿されるような気がする)ため、 Xamarin.Forms.Core の AssemblyInfo.cs には各プラットフォームに対する InternalsVisibleToAttribute が記述されているのだが、 Tizen用のInternalsVisibleToは無いのに動いちゃってる不思議。

このトリックが判明すれば本家Xamarin.Formsに手を入れる事無く 対応プラットフォーム勝手に増やし放題 なのである。 (Xamarin.Forms.Platform.WindowsFormsなんてネタも夢じゃない!?)

誰か分かる人いたら教えてください…………。

アイコンTシャツのすすめ

普段はTwitterなどで電子の妖精として活躍している皆さん!、勉強会には仮初めの肉体で参加せざるを得ないため、顔とアイコンが一致せずに困った経験があるのでは?

そんな問題を解決するため、Twitterアイコン入りTシャツを作ってみました。

さらに、2016/11/12(土)に開催されたXamarin Dev Days Tokyoに実践投入しました。

見た人の反応は

  • やりすぎ
  • 分かりやすい
  • どこで作ったの?
  • いくら?

などとおおむね好評でした(?)

作り方

今回のTシャツはUTme!で作りました。(サービス開始当初の利用規約がユーザーがアップロードした画像の著作権を放棄させるようになっていて炎上したアレです)

注文の流れはこんな感じ

  1. プリントする画像をアップロードする
  2. 画像の切り抜き方を選ぶ(今回は角丸)
  3. プリントする位置と大きさを調整する(今回はデカすぎ&上すぎ)
  4. サイズと生地の色を選ぶ

料金は生地代2,000円、プリント代1,000円、消費税と送料合わせて500円、全体で3,500円といったところ。 (生地を白色にすると1,000円安くなります)

UTme!を選んだ理由は、1枚から注文できて値段がそれなりに安く、試しに作るのに丁度良かったからです。

もっと本格的なお店では色ごとにパーツをレイヤー分けする必要があったり、最低注文枚数が10枚からだったり、1枚でも値段が高かったりと、思い付きで作るにはハードルが高かったので見送りました。

次に作るときは、正面と背面にプリントしたり、文字(アカウントのID)を入れるのに挑戦したいですねー。

課題

自分が誰かは分かってもらえますが、自分には周りの人が誰だか分からない点ですかね......

Xamarin入門者の集い supported by teratail に登壇してきました

2016年10月26日(水)にレバレジーズ株式会社にて開催された「Xamarin入門者の集い supported by teratail」にゲストスピーカーとして登壇してきました。

jxug.connpass.com

ytabuchi.hatenablog.com

新しくXamarinを始めた人たちはXamarin.Formsから入ることが多いと感じていたので、Xamarin.Formsリリースから現在に至るまでどのように機能追加されてきたかという事を喋ってきました。

Xamarin.Formsを振り返る(2.3.3-pre まで) - Qiita

経緯を知ることが理解の助けになるのではないかなーと思ってこのテーマを選びました。

アンケートを見る限り、すでに触ってる人は割と興味深く聞いてくれたようですが、オチをちゃんと付けなかったので今回がXamarin初めてな人に「何を話したいのか分からない」と言われてしましました。(反省)

せっかくQiitaスライドにしたのでXamarin.Formsの更新に合わせてスライドも更新していこうかと思います。(気が向いたら)

【Xamarin.Forms】MasterDetailPageのMaster側を右寄せにするサンプル

Xamarin.FormsのMasterDetailPageのMaster部分(ドロワー?)を右寄せに改造するサンプルです。

Rendererとリフレクションを駆使して頑張ってます。AndroidとiOS(Phone)に対応。

GitHub - P3PPP/RightMasterDetailPageSample: Xamarin.FormsのMasterDetailPageのMaster側を右寄せにするサンプル

ざっくりとした説明

Android

AndroidのMasterDetailRendererの実体は DrawerLayout から派生しており、 OpenDrawer()CloseDrawer() メソッドでMaster部分を出したり引っ込めたりしています。

動かす対象のViewに android:layout_gravity="right" がセットされていると右寄せになります。対象Viewはprivateメンバになっているのでリフレクションで強引にいじります。

具体的にはこんな感じのソースをAndroidプロジェクトに追加すると右寄せに差し替えできます。

using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Android.Support.V4.Widget;
using Android.Views;
using Android.Support.V4.App;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android.AppCompat;
using MdpRight.Droid;

[assembly: ExportRenderer(typeof(MasterDetailPage), typeof(MyMasterDetailPageRenderer))]

namespace MdpRight.Droid
{
    public class MyMasterDetailPageRenderer : MasterDetailPageRenderer
    {
        protected override void OnElementChanged(VisualElement oldElement, VisualElement newElement)
        {
            base.OnElementChanged(oldElement, newElement);

            var fieldInfo = GetType().BaseType.GetField("_masterLayout", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
            var _masterLayout = (ViewGroup)fieldInfo.GetValue(this);
            var lp = new DrawerLayout.LayoutParams(_masterLayout.LayoutParameters);
            lp.Gravity = (int)GravityFlags.Right;
            _masterLayout.LayoutParameters = lp;
        }
    }
}

f:id:ticktack623:20161016220657j:plain

iOS

iOSのMasterDetailRendererの実体は UIView から派生しており、Master部分はその子Viewとして存在しています。別段特別なフィーチャーは使われておらず、UIPanGestureRecognizerとFrameの座標計算で実装されてます。

ちなみにタブレット向けと電話向けのRendererは完全に別クラスになっていて、今回いじるのは電話向けの方だけです。

GitHubから Xamarin.Forms/Xamarin.Forms.Platform.iOS/Renderers/PhoneMasterDetailRenderer.cs をコピーしてnamespaceを少し手直し、ついでに配列用のExtensionも拝借、参照できないinternalメンバやクラスをリフレクション(力ずく)で解決して、座標系さん周りをちょちょっと手直しすれば完成です。

最終的にこんな感じのソースをiOSプロジェクトに追加すると右寄せに差し替えできます。(カスタマイズ点はコメントで何となく感じ取ってください)

using System;
using System.ComponentModel;
using System.Linq;
using UIKit;
using PointF = CoreGraphics.CGPoint;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using MdpRight.iOS;

[assembly: ExportRenderer(typeof(MasterDetailPage), typeof(MyPhoneMasterDetailRenderer), UIUserInterfaceIdiom.Phone)]

namespace MdpRight.iOS
{
    public class MyPhoneMasterDetailRenderer : UIViewController, IVisualElementRenderer, IEffectControlProvider
    {
        UIView _clickOffView;
        UIViewController _detailController;

        bool _disposed;
        EventTracker _events;

        UIViewController _masterController;

        UIPanGestureRecognizer _panGesture;

        bool _presented;
        UIGestureRecognizer _tapGesture;

        VisualElementTracker _tracker;

        IPageController PageController => Element as IPageController;

        public MyPhoneMasterDetailRenderer()
        {
            // internalメンバへのアクセスをリフレクションで代行
            //if(!Forms.IsiOS7OrNewer)
            // WantsFullScreenLayout = true;
            var isiOS7OrNewer = (bool)typeof(Forms).GetProperty("IsiOS7OrNewer", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic).GetValue(null);
            if(!isiOS7OrNewer)
            {
                WantsFullScreenLayout = true;
            }
        }

        IMasterDetailPageController MasterDetailPageController => Element as IMasterDetailPageController;

        bool Presented
        {
            get { return _presented; }
            set
            {
                if(_presented == value)
                    return;
                _presented = value;
                LayoutChildren(true);
                if(value)
                    AddClickOffView();
                else
                    RemoveClickOffView();

                ((IElementController)Element).SetValueFromRenderer(MasterDetailPage.IsPresentedProperty, value);
            }
        }

        public VisualElement Element { get; private set; }

        public event EventHandler<VisualElementChangedEventArgs> ElementChanged;

        public SizeRequest GetDesiredSize(double widthConstraint, double heightConstraint)
        {
            return NativeView.GetSizeRequest(widthConstraint, heightConstraint);
        }

        public UIView NativeView
        {
            get { return View; }
        }

        public void SetElement(VisualElement element)
        {
            var oldElement = Element;
            Element = element;
            Element.SizeChanged += PageOnSizeChanged;

            _masterController = new ChildViewController();
            _detailController = new ChildViewController();

            _clickOffView = new UIView();
            _clickOffView.BackgroundColor = new Color(0, 0, 0, 0).ToUIColor();

            Presented = ((MasterDetailPage)Element).IsPresented;

            OnElementChanged(new VisualElementChangedEventArgs(oldElement, element));

            // internal classへのアクセスをリフレクションで代行
            //EffectUtilities.RegisterEffectControlProvider(this, oldElement, element);
            var tEffectUtilities = typeof(PlatformEffect).Assembly.GetTypes().FirstOrDefault(x => x.Name.EndsWith("EffectUtilities"));
            tEffectUtilities.InvokeMember("RegisterEffectControlProvider", System.Reflection.BindingFlags.InvokeMethod,
                                          null, null, new object[] { this, oldElement, element });

            // internalメンバへのアクセスをリフレクションで代行
            //if(element != null)
            // element.SendViewInitialized(NativeView);
            var sendViewInitialized = typeof(Forms).GetMethod("SendViewInitialized", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
            sendViewInitialized.Invoke(null, new object[] { element, NativeView});
        }

        public void SetElementSize(Size size)
        {
            Element.Layout(new Rectangle(Element.X, Element.Y, size.Width, size.Height));
        }

        public UIViewController ViewController
        {
            get { return this; }
        }

        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);
            PageController.SendAppearing();
        }

        public override void ViewDidDisappear(bool animated)
        {
            base.ViewDidDisappear(animated);
            PageController.SendDisappearing();
        }

        public override void ViewDidLayoutSubviews()
        {
            base.ViewDidLayoutSubviews();

            LayoutChildren(false);
        }

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

            _tracker = new VisualElementTracker(this);
            _events = new EventTracker(this);
            _events.LoadEvents(View);

            ((MasterDetailPage)Element).PropertyChanged += HandlePropertyChanged;

            _tapGesture = new UITapGestureRecognizer(() =>
            {
                if(Presented)
                    Presented = false;
            });
            _clickOffView.AddGestureRecognizer(_tapGesture);

            PackContainers();
            UpdateMasterDetailContainers();

            UpdateBackground();

            UpdatePanGesture();
        }

        public override void WillRotate(UIInterfaceOrientation toInterfaceOrientation, double duration)
        {
            if(!MasterDetailPageController.ShouldShowSplitMode && _presented)
                Presented = false;

            base.WillRotate(toInterfaceOrientation, duration);
        }

        protected override void Dispose(bool disposing)
        {
            if(disposing && !_disposed)
            {
                Element.SizeChanged -= PageOnSizeChanged;
                Element.PropertyChanged -= HandlePropertyChanged;

                if(_tracker != null)
                {
                    _tracker.Dispose();
                    _tracker = null;
                }

                if(_events != null)
                {
                    _events.Dispose();
                    _events = null;
                }

                if(_tapGesture != null)
                {
                    if(_clickOffView != null && _clickOffView.GestureRecognizers.Contains(_panGesture))
                    {
                        _clickOffView.GestureRecognizers.Remove(_tapGesture);
                        _clickOffView.Dispose();
                    }
                    _tapGesture.Dispose();
                }
                if(_panGesture != null)
                {
                    if(View != null && View.GestureRecognizers.Contains(_panGesture))
                        View.GestureRecognizers.Remove(_panGesture);
                    _panGesture.Dispose();
                }

                EmptyContainers();

                PageController.SendDisappearing();

                _disposed = true;
            }

            base.Dispose(disposing);
        }

        protected virtual void OnElementChanged(VisualElementChangedEventArgs e)
        {
            var changed = ElementChanged;
            if(changed != null)
                changed(this, e);
        }

        void AddClickOffView()
        {
            View.Add(_clickOffView);
            _clickOffView.Frame = _detailController.View.Frame;
        }

        void EmptyContainers()
        {
            foreach(var child in _detailController.View.Subviews.Concat(_masterController.View.Subviews))
                child.RemoveFromSuperview();

            foreach(var vc in _detailController.ChildViewControllers.Concat(_masterController.ChildViewControllers))
                vc.RemoveFromParentViewController();
        }

        void HandleMasterPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            // internalメンバ仕様の代わりにリテラル直打ち
            //if(e.PropertyName == Page.IconProperty.PropertyName || e.PropertyName == Page.TitleProperty.PropertyName)
            // MessagingCenter.Send<IVisualElementRenderer>(this, NavigationRenderer.UpdateToolbarButtons);
            if(e.PropertyName == Page.IconProperty.PropertyName || e.PropertyName == Page.TitleProperty.PropertyName)
                MessagingCenter.Send<IVisualElementRenderer>(this, "Xamarin.UpdateToolbarButtons");
        }

        void HandlePropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if(e.PropertyName == "Master" || e.PropertyName == "Detail")
                UpdateMasterDetailContainers();
            else if(e.PropertyName == MasterDetailPage.IsPresentedProperty.PropertyName)
                Presented = ((MasterDetailPage)Element).IsPresented;
            else if(e.PropertyName == MasterDetailPage.IsGestureEnabledProperty.PropertyName)
                UpdatePanGesture();
            else if(e.PropertyName == VisualElement.BackgroundColorProperty.PropertyName)
                UpdateBackground();
            else if(e.PropertyName == Page.BackgroundImageProperty.PropertyName)
                UpdateBackground();
        }

        void LayoutChildren(bool animated)
        {
            var frame = Element.Bounds.ToRectangleF();
            var masterFrame = frame;
            masterFrame.Width = (int)(Math.Min(masterFrame.Width, masterFrame.Height) * 0.8);

            // Master部を右寄せにする
            masterFrame.X = frame.Width - masterFrame.Width;

            _masterController.View.Frame = masterFrame;

            var target = frame;

            //if(Presented)
            // target.X += masterFrame.Width;
            if(Presented)
                target.X -= masterFrame.Width;

            if(animated)
            {
                UIView.BeginAnimations("Flyout");
                var view = _detailController.View;
                view.Frame = target;
                UIView.SetAnimationCurve(UIViewAnimationCurve.EaseOut);
                UIView.SetAnimationDuration(250);
                UIView.CommitAnimations();
            }
            else
                _detailController.View.Frame = target;

            MasterDetailPageController.MasterBounds = new Rectangle(0, 0, masterFrame.Width, masterFrame.Height);
            MasterDetailPageController.DetailBounds = new Rectangle(0, 0, frame.Width, frame.Height);

            if(Presented)
                _clickOffView.Frame = _detailController.View.Frame;
        }

        void PackContainers()
        {
            _detailController.View.BackgroundColor = new UIColor(1, 1, 1, 1);
            View.AddSubview(_masterController.View);
            View.AddSubview(_detailController.View);

            AddChildViewController(_masterController);
            AddChildViewController(_detailController);
        }

        void PageOnSizeChanged(object sender, EventArgs eventArgs)
        {
            LayoutChildren(false);
        }

        void RemoveClickOffView()
        {
            _clickOffView.RemoveFromSuperview();
        }

        void UpdateBackground()
        {
            if(!string.IsNullOrEmpty(((Page)Element).BackgroundImage))
                View.BackgroundColor = UIColor.FromPatternImage(UIImage.FromBundle(((Page)Element).BackgroundImage));
            else if(Element.BackgroundColor == Color.Default)
                View.BackgroundColor = UIColor.White;
            else
                View.BackgroundColor = Element.BackgroundColor.ToUIColor();
        }

        void UpdateMasterDetailContainers()
        {
            ((MasterDetailPage)Element).Master.PropertyChanged -= HandleMasterPropertyChanged;

            EmptyContainers();

            if(Platform.GetRenderer(((MasterDetailPage)Element).Master) == null)
                Platform.SetRenderer(((MasterDetailPage)Element).Master, Platform.CreateRenderer(((MasterDetailPage)Element).Master));
            if(Platform.GetRenderer(((MasterDetailPage)Element).Detail) == null)
                Platform.SetRenderer(((MasterDetailPage)Element).Detail, Platform.CreateRenderer(((MasterDetailPage)Element).Detail));

            var masterRenderer = Platform.GetRenderer(((MasterDetailPage)Element).Master);
            var detailRenderer = Platform.GetRenderer(((MasterDetailPage)Element).Detail);

            ((MasterDetailPage)Element).Master.PropertyChanged += HandleMasterPropertyChanged;

            _masterController.View.AddSubview(masterRenderer.NativeView);
            _masterController.AddChildViewController(masterRenderer.ViewController);

            _detailController.View.AddSubview(detailRenderer.NativeView);
            _detailController.AddChildViewController(detailRenderer.ViewController);
        }

        void UpdatePanGesture()
        {
            var model = (MasterDetailPage)Element;
            if(!model.IsGestureEnabled)
            {
                if(_panGesture != null)
                    View.RemoveGestureRecognizer(_panGesture);
                return;
            }

            if(_panGesture != null)
            {
                View.AddGestureRecognizer(_panGesture);
                return;
            }

            UITouchEventArgs shouldRecieve = (g, t) => !(t.View is UISlider);
            var center = new PointF();
            _panGesture = new UIPanGestureRecognizer(g =>
            {
                switch(g.State)
                {
                case UIGestureRecognizerState.Began:
                    center = g.LocationInView(g.View);
                    break;
                case UIGestureRecognizerState.Changed:
                    var currentPosition = g.LocationInView(g.View);
                    var motion = currentPosition.X - center.X;
                    var detailView = _detailController.View;
                    var targetFrame = detailView.Frame;
                    //if(Presented)
                    // targetFrame.X = (nfloat)Math.Max(0, _masterController.View.Frame.Width + Math.Min(0, motion));
                    //else
                    // targetFrame.X = (nfloat)Math.Min(_masterController.View.Frame.Width, Math.Max(0, motion));

                    if(Presented)
                        targetFrame.X = (nfloat)Math.Min(0, -_masterController.View.Frame.Width + Math.Max(0, motion));
                    else
                        targetFrame.X = (nfloat)Math.Max(-_masterController.View.Frame.Width, Math.Min(0, motion));
                    
                    detailView.Frame = targetFrame;
                    break;
                case UIGestureRecognizerState.Ended:
                    var detailFrame = _detailController.View.Frame;
                    var masterFrame = _masterController.View.Frame;
                    if(Presented)
                    {
                        //if(detailFrame.X < masterFrame.Width * .75)
                        // Presented = false;
                        //else
                        // LayoutChildren(true);
                        if(detailFrame.X > -masterFrame.Width * .75)
                            Presented = false;
                        else
                            LayoutChildren(true);
                    }
                    else
                    {
                        //if(detailFrame.X > masterFrame.Width * .25)
                        // Presented = true;
                        //else
                        // LayoutChildren(true);
                        if(detailFrame.X < -masterFrame.Width * .25)
                            Presented = true;
                        else
                            LayoutChildren(true);
                    }
                    break;
                }
            });
            _panGesture.ShouldReceiveTouch = shouldRecieve;
            _panGesture.MaximumNumberOfTouches = 2;
            View.AddGestureRecognizer(_panGesture);
        }

        class ChildViewController : UIViewController
        {
            public override void ViewDidLayoutSubviews()
            {
                foreach(var vc in ChildViewControllers)
                    vc.View.Frame = View.Bounds;
            }
        }

        void IEffectControlProvider.RegisterEffect(Effect effect)
        {
            var platformEffect = effect as PlatformEffect;
            // internalメンバへのアクセスをリフレクションで代行
            //if(platformEffect != null)
            // platformEffect.Container = View;
            var propInfo = typeof(PlatformEffect).GetProperty("Container", System.Reflection.BindingFlags.NonPublic);
            propInfo.SetValue(platformEffect, View);
        }
    }

    internal static class ArrayExtensions
    {
        public static T[] Insert<T>(this T[] self, int index, T item)
        {
            var result = new T[self.Length + 1];
            if(index > 0)
                Array.Copy(self, result, index);

            result[index] = item;

            if(index < self.Length)
                Array.Copy(self, index, result, index + 1, result.Length - index - 1);

            return result;
        }

        public static T[] Remove<T>(this T[] self, T item)
        {
            //return self.RemoveAt(self.IndexOf(item));
            return self.RemoveAt(Array.IndexOf(self, item));
        }

        public static T[] RemoveAt<T>(this T[] self, int index)
        {
            var result = new T[self.Length - 1];
            if(index > 0)
                Array.Copy(self, result, index);

            if(index < self.Length - 1)
                Array.Copy(self, index + 1, result, index, self.Length - index - 1);

            return result;
        }
    }
}

f:id:ticktack623:20161016220719j:plain

最後に

OSS万歳!

【Xamarin.Forms】RelativeLayoutとConstraintのちょっと深い話

最近こんな質問に回答してました。

teratail.com

という訳で今回は、RelativeLayoutの子要素にConstraintを再セットすることについて少し掘り下げます。

C#でRelaytiveLayoutにレアイアウト制約付きで子要素追加する際のコードはこんな感じです。

var label = new Label
{
    Text = "Child Label"
};

var layout = RelativeLayout();
layout.Children.Add(label,
    Constraint.RelativeToParent(parent => (parent.Width / 2) - (label.Width / 2)), // xConstraint
    Constraint.RelativeToParent(parent => (parent.Height / 2) - (label.Height / 2)), // yConstraint
    Constraint.RelativeToParent(parent => parent.Width), // widthConstraint
    Constraint.Constant(50) // heightConstraint
);

teratailで回答したとおり RelativeLayout.Children.Add(View, Constraint, Constraint, Constraint, Constraint) を重ねてコールするとレイアウト制約を上書きできます。

「同じViewを何度もAddしてマズい事にならないの?」という疑問が浮かぶでしょうが、既にChildrenに存在するViewをAddすると追加処理が無視されます。(興味のある人は RelativeLayout.RelativeElementCollection -> ElementCollection<T> -> ObservableWrapper<TTrack, TRestrict> とソースを追いかけてみよう!)

そのため、結果としてレイアウト制約だけが上書きされます。

......そうはいっても何度もAddを繰り返すのは気持ちが悪いですよね。

実は他に RelativeLayout.SetBoundsConstraint(View, BoundsConstraint) を使う方法もあります。

先ほどのlayout.Children.Addと同等のレイアウト制約をセットすると次のようなコードになります。

RelativeLayout.SetBoundsConstraint(label,
    BoundsConstraint.FromExpression(
        () => new Rectangle(
            (layout.Width / 2) - (label.Width / 2),
            (layout.Height / 2) - (label.Height / 2),
            layout.Width,
            50),
        null));

BoundsConstraintBoundsConstraint.FromExpression() で作ります。第1引数には対象Viewの位置とサイズを表すRectangleを返す式、第2引数には式の中で参照されるViewのコレクションをセットします。第2引数はおそらく式で参照するViewが解放されないように参照を持っておくための物ではないかと思われます。基本的に内部で使用するものと想定しているのか妙なデザインですね。

RelativeLayout.Children.Add()で渡したConstraintは最終的にこのBoundsConstraintに変換されます。

以下のような流れです。

RelativeLayout.Children.Add()で子要素とConstraintをセット。
↓
子ViewがRelativeLayout.Childrenに追加される。
X,Y,Width,HeightのConstraintがBoundsConstraintに変換され子Viewにセットされる。
↓
RelativeLayoutのサイズ変更などのタイミングでBoundsConstraintを元に子Viewの位置とサイズが解決される。

ここで注目したいのは次の2点。

  • 必ず1度はRelativeLayout.Children.Add()する必要がある
  • Addする前にRelativeLayout.SetBoundsConstraint()してはならない(上書きされるから)

そんな風に気を使ってとっつきにくいBoundsConstraintよりは、RelativeLayout.Children.Add()を繰り返す方が分かりやすいのではないかなー、という訳で例の回答でしたとさ。

Xamarinでの開発はWindowsとMacのどちらが良いのか?

時々聞かれるのですが......

大前提として、WindowsVisual Studioをメインに使っていくにしてもiOSアプリのビルドにはMacが必要です。 モバイルアプリを(クロスプラットフォームで)開発しようというのに、iOSに対応しないという事は基本的に無いでしょう。

よって争点は、WindowsMacを揃えた上で Visual Studio(Windows) で開発するのと、 Xamarin Studio(Mac) で開発するのではどちらが良いのかという話になります。

結論

WindowsをメインにするにしてもMac単体で開発できるようにはしておけ

Visual StudioiOSアプリ開発ではVSがMacと通信しつつ強調動作するのですが、この通信部分の調子が悪くなりやすくVSを再起動する事もしばしば。(部品が多くなると故障発生率が高まるという話ですね)

運悪くハマってVSからビルドできない状態が続く場合に備え、MacのXamarin Studioで開発出来る体制を整えておきましょう。

バージョン管理をTFVCにするとMac側で辛くなるのでGitを選択するのが無難だと思います。

【Xamarin.Forms 2.3.3 -pre2】XAML内でのネイティブView定義とBindingのサポート

Xamarin.Forms 2.3.3 -pre2でXAML内でネイティブプラットフォーム(iOSAndroid、UWP)のコントロールを配置できるようになります。 これはXamarin.Forms 2.2で追加されたNative Embeddingという機能の発展系であると言えます。

ticktack.hatenablog.jp

Native Embedding - Xamarin

おさらい

まずはXF 2.2で追加された時点のNative Embeddingがどんな物だったか確認しましょう。 使用時のコードはこんな感じです。

            // CotentPageのコンストラクタの中
#if __IOS__
            var uiLabel = new UILabel {
                MinimumFontSize = 14f,
                Lines = 0,
                LineBreakMode = UILineBreakMode.WordWrap,
                Text = text + "(iOS)",
            };
            Content = uiLabel.ToView ();
#elif __ANDROID__
            var textView = new TextView(Forms.Context) {
                Text = text + "(Android)",
                TextSize = 14,
            };
            Content = textView.ToView ();
#endif

Sharedプロジェクト内にifディレクティブでプラットフォーム固有のコードを定義します。 ネイティブプラットフォームのコントロールを(ToViewなどの)拡張メソッドでXamarin.FormsのViewに変換してXamarin.Formsのレイアウトの中に組み込んでいます。 UIKitなどのプラットフォーム固有ライブラリへの参照が発生するため、PCLでは使用する事ができずSharedプロジェクト専用となっていました。

XF 2.3.3では

こんなXAMLが動くようになります。

XAML

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:ios="clr-namespace:UIKit;assembly=Xamarin.iOS;targetPlatform=iOS"
             xmlns:androidWidget="clr-namespace:Android.Widget;assembly=Mono.Android;targetPlatform=Android"
             xmlns:formsandroid="clr-namespace:Xamarin.Forms;assembly=Xamarin.Forms.Platform.Android;targetPlatform=Android"
             xmlns:win="clr-namespace:Windows.UI.Xaml.Controls;assembly=Windows, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null, ContentType=WindowsRuntime;targetPlatform=Windows"
             x:Class="XFApp38.XFApp38Page">
    <ContentPage.Content>
        <StackLayout VerticalOptions="Center">
            <!-- iOSのコントロール -->
            <ios:UITextView Text="{Binding NativeText, Mode=TwoWay, UpdateSourceEventName=Ended}"
                View.BackgroundColor="{Binding Color}" />
            <ios:UILabel Text="{Binding NativeText}" View.BackgroundColor="Lime" />
            
            <!-- Androidのコントロール -->
            <androidWidget:TextView Text="{Binding NativeText}" x:Arguments="{x:Static formsandroid:Forms.Context}"/>
            
            <!-- UWPのコントロール -->
            <win:TextBlock Text="This is TextBlock"/>
        </StackLayout>
    </ContentPage.Content>
</ContentPage>

コードビハインド

using System.ComponentModel;
using System.Runtime.CompilerServices;
using Xamarin.Forms;

namespace XFApp38
{
    public partial class XFApp38Page : ContentPage
    {
        public XFApp38Page ()
        {
            BindingContext = new ViewModel ();
            InitializeComponent ();
        }
    }

    public class ViewModel : INotifyPropertyChanged
    {
        private string _NativeText = "Hoge text";
        public string NativeText {
            get { return _NativeText; }
            set {
                if (_NativeText == value)
                    return;

                _NativeText = value;
                RaisePropertyChanged ();
            }
        }

        private Color _Color = Color.FromHex("#AAAAAA");
        public Color Color {
            get { return _Color; }
            set {
                if (_Color == value)
                    return;

                _Color = value;
                RaisePropertyChanged ();
            }
        }

       #region INPC
        public event PropertyChangedEventHandler PropertyChanged;

        private void RaisePropertyChanged ([CallerMemberName]string propertyName = "")
        {
            PropertyChanged?.Invoke (this, new PropertyChangedEventArgs (propertyName));
        }
       #endregion
    }
}

StackLayoutの中にiOSAndroid、UWP固有のコントロールが配置されています。

ネイティブコントロールの配置方法

Xamarin.Forms用のカスタムコントロールを配置する場合と同じように、xmlnsを宣言して対象クラスが定義されているassemblyを参照します。 通常と異なる点は targetPlatform=〜〜 を追加すること、これにより実行時のプラットフォームと異なるものを無視することができます。 (おおむねC#でのifディレクティブに相当するものと考えて良いでしょう)

ネイティブコントロールのプロパティへのBinding

ネイティブコントロールに対してもBindingを使うことができ、可能ならば2Way Bindingもサポートされます。 変更通知機構を持たないプロパティに対しては、Binding構文に追加された UpdateSourceEventName パラメータでトリガーを指定できます。 ("ios:UITextView"の部分で使用しています)

Xamarin Forms Viewのプロパティへのアクセス

ネイティブコントロールをラップするViewのパラメータに値やBindingをセットすることも可能です。("View.BackgroundColor=〜〜"の部分) ラッパーである NativeViewWrapper クラスは View のサブクラスなので、指定できるプロパティはViewと同じと考えれば良いでしょう。

ここがスゴイ

何と言ってもPCLでもプラットフォーム固有のコントロールを埋め込めることが素晴らしい! XAMLは明示的に指定しない限りテキストファイルとしてアプリに埋め込まれ、実行時に解釈されます。なので動作プラットフォーム以外のコントロールを無視すればエラーにならないんですね。いやー画期的。

さらにUpdateSourceEventNameを利用した任意イベントでの2Way Bindingまで用意されていて隙がありません。

制限事項

XamlC(XAMLの事前コンパイル)との併用ができません。

コンパイル時にプラットフォーム固有クラスへの参照が発生してしまうからでしょうね。 Sharedプロジェクトならばビルドターゲット以外のプラットフォーム部分を削った上でXAMLコンパイルできそうですが...

PCLの場合は....、参照しているプラットフォーム固有クラスの空実装dllを生成して、実行時に本物と差し代わるようにしてやればイケるでしょうか.....

UWPコントロールに対してBindingを設定するとエラーになります。サンプルコードでもUWPのTextBlockだけはリテラルだったので現時点では未対応なのかも?