ぴーさんログ

だいたいXamarin.Formsのブログ

【Xamarin.Forms 2.3.3】 Platform Specifics

Xamarin.Forms 2.3.3で Platform Specifics という機能が追加されました。

これはPCLなどの共通コードからプラットフォーム固有の機能を呼び出したりする類のものです。(ただしコードビハインド限定)

Forumでの紹介文によると...

Introducing Platform Specifics! Features or behaviors that apply to one platform only can now be implemented without requiring custom renderers. These new features/behaviors can then be accessed easily via a fluent code API or XAML.

Vendors can easily add their own Platform Specifics by attaching Effects to them (see 63a924d and 1f9482e for complete example).

This feature implements the framework that enables the new API and also includes several examples of platform specific features, which can be previewed using the Platform Specifics gallery page:

  • Blur support for any VisualElement on iOS
  • Translucent navigation bar on iOS
  • Partially collapsed navigation bar (with icons!) on MasterDetailPage on Windows
  • Toolbar placement options on Windows
  • AdjustResize/AdjustPan on Android (known issue: AdjustResize disables status bar color)

とのこと。

Custom RendererやEffectsを使ってやるようなことが簡単にできる風に書かれていますね。

サンプル

さっぱり分からないと思うのでサンプルコードを用意しました。

using System;
using Xamarin.Forms;
using Xamarin.Forms.PlatformConfiguration;
using Xamarin.Forms.PlatformConfiguration.iOSSpecific;

namespace PratformSpecificsSample
{
    public class MyNaviPage : Xamarin.Forms.NavigationPage
    {
        public MyNaviPage()
        {
            BackgroundColor = Color.Pink;

            var button = new Button
            {
                Text = "Toggle Translucent",
                BackgroundColor = Color.Silver,
            };

            button.Clicked += (sender, args) => On<iOS>()
                .SetIsNavigationBarTranslucent(!On<iOS>().IsNavigationBarTranslucent());

            var content = new ContentPage
            {
                Title = "iOS Translucent Navigation Bar",
                Content = new StackLayout
                {
                    VerticalOptions = LayoutOptions.Center,
                    HorizontalOptions = LayoutOptions.Center,
                    Children = { button },
                }
            };

            PushAsync(content);
        }
    }
}

このコードを実行するとこんな感じになります。

f:id:ticktack623:20170113004240g:plain

ボタンをタップする度に、ナビゲーションバーの透過を切り替えるというiOS固有の効果が発動しています。

Platform Specifics が使われているのはこの部分、

button.Clicked += (sender, args) => On<iOS>()
    .SetIsNavigationBarTranslucent(!On<iOS>().IsNavigationBarTranslucent());

やっていることは On<iOS>().SetIsNavigationBarTranslucent() が透過ON/OFFのセット、 On<iOS>().IsNavigationBarTranslucent() が現在の透過状態の取得ですね。

Element.On<T>() を入り口として、「対象Elementとプラットフォームの組み合わせという文脈に固有な機能をPCLから呼び出せる」というのが Platform Specifics の意義といえるでしょう。

BoxViewの例

例えば、BoxViewに続けて .On<iOS>(). と入力するとインテリセンスから GetBlurEffect()UseBlurEffect() が呼び出せて...

f:id:ticktack623:20170113004413j:plain

.On<Android>(). には呼び出せるものが無い、といった塩梅です。

f:id:ticktack623:20170113004436j:plain

使い方まとめ

1. usingを追加

using Xamarin.Forms.PlatformConfiguration;
using Xamarin.Forms.PlatformConfiguration.iOSSpecific;
using Xamarin.Forms.PlatformConfiguration.AndroidSpecific;
using Xamarin.Forms.PlatformConfiguration.WindowsSpecific;

2. だいたいクラス名衝突が発生するので修正する

これはPlatform Specificsの実装が悪い。

なぜNavigationPageに対する拡張メソッドを定義するクラスを「NavigationPage」にしてしまうのか……

3. Element(ApplicationとかPageとかViewのこと)の .On<TIConfigPlatform>() を呼ぶ

Xamarin.Forms 2.3.3時点でTIConfigPlatformに入れられるのは以下の3種類。(GitHubのコード上ではTizenも追加されているので2.3.4には入ってくるかも)

public sealed class Android : IConfigPlatform { }
public sealed class iOS : IConfigPlatform { }
public sealed class Windows : IConfigPlatform { }

4. インテリセンスで使いたい機能を選ぶ

TElement.On<TIConfigPlatform>() の戻り値は IPlatformElementConfiguration<TIConfigPlatform, TElement> のインスタンスとなっており、プラットフォーム固有機能は拡張メソッドで実装されています。つまり後付けなので自分で実装するのもアリ。

ライブラリ開発者向け情報

Xamarin.Formsのカスタムコントロールなどを配布する場合、IElementConfiguration<TElement>を実装してPlatform Specificsのエントリポイント用意してあげるといいかもしれない。

(プラットフォーム固有機能は拡張メソッドで生やせるけども、エントリポイントはコントロールで実装しなければならないため)

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なんてネタも夢じゃない!?)

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

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だけはリテラルだったので現時点では未対応なのかも?