ぴーさんログ

だいたいXamarin.Formsのブログ

【Xamarin.Forms】ViewRendererと仲良くなるための簡易チュートリアル

この記事はXamarin.Formsの標準コントロールだけでは対応しきれなくなった時、ViewRendererを駆使した独自コントロールで乗り越えるためのチュートリアルです。

目次

Xamarin.Formsコントロールの仕組み

最初にXamarin.Formsのコントロールがどのような仕組みで成り立っているか確認しておきましょう。

Xamarin.Formsでは各プラットフォームのコントロールをラップし、抽象化したコントロールを定義する事でView層コードの共通化を可能としています。 (XAMLで記述できますがWPFのように自由なUIレンダリングができるわけではありません)

この記事内では抽象化されたコントロールの事を Formsコントロール 、各プラットフォームのコントロールの事を Nativeコントロールと呼ぶ事にします。そして、FormsコントロールとNativeコントロールを結びつけるのがRendererです。

Rendererの主な役割はNativeコンロールの生成、Formsコントロールのプロパティ値変更をNativeコントロールへ伝達すること、逆にNativeコントロールのイベトをFormsコントロールに伝播させることなどです。

独自のコントロールを作るには、PCLプロジェクトでFomrsコントロールを定義、各プラットフォームプロジェクトでRendererを定義し、必要に応じて各プラットフォームプロジェクトでカスタムコントロールを定義します。

Xamarin.Forms公開当初は、既存コントロールのちょっとしたカスタマイズでもCustom Rendererが推奨されていました(それしか方法がなかったとも言う)、しかし現在ではそういった場合にはEffectsを利用するのが良いでしょう。

独自のコントロールを作る

さて、概要を把握したところで実際に独自のコントロールを作って表示してみましょう。 今回はBoxViewっぽいもの作ります。

※以降の解説コードではPCLプロジェクト構成を前提として、iOSで実装したサンプルを示します。

Formsコントロールの定義

PCLプロジェクトに Xamarin.Forms.View を継承した MyBoxView を定義。中身はありません。

using System;
using Xamarin.Forms;

namespace ViewRendererTutorial
{
    public class MyBoxView : View
    {
    }
}

Rendererの定義

iOSプロジェクトに MyBoxViewRenderer を定義します。

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using UIKit;
using ViewRendererTutorial;
using ViewRendererTutorial.iOS;

// FormsコントロールとRendererの対応を宣言
[assembly: ExportRenderer (typeof (MyBoxView), typeof (MyBoxViewRenderer))]

namespace ViewRendererTutorial.iOS
{
    // ViewRendererの型引数にFormsコントロールとNativeコントロールを与える
    public class MyBoxViewRenderer : ViewRenderer<MyBoxView, UIView>
    {
        protected override void OnElementChanged (ElementChangedEventArgs<MyBoxView> e)
        {
            // Nativeコントロールのインスタンス生成はココ!
            if (Control == null) {
                var nativeControl = new UIView ();
                nativeControl.BackgroundColor = Color.Lime.ToUIColor ();
                SetNativeControl (nativeControl);
            }

            base.OnElementChanged (e);
        }
    }
}

Nativeコンロールのインスタンス生成は OnElementChanged 内で行います。 ButtonRendererやImageRendererなどから派生する場合はこの処理は実装済みですが、ViewRendererから派生する場合は自分で書かなくてはなりません。

これまでの実装で独自のコントロールを表示する事ができます。

f:id:ticktack623:20160611114508j:plain

<StackLayout VerticalOptions="Center">
    <local:MyBoxView x:Name="myBoxView" HeightRequest="100" />
    <Label Text="ViewRenderer tutorial" VerticalOptions="Center" HorizontalOptions="Center" />
</StackLayout>

Binding可能なプロパティでNaitiveコントロールと連携する

続いて、FormsコントロールにBindablePropertyを追加してみましょう。(BindingはXAMLの華ですからね!)

BoxViewらしくColorPropertyを追加する事にします。 (実は前段までの実装で、BackGroundColorを変えるだけで同じ事ができますが説明のために目を瞑ってください)

Formsコントロールの定義

中身が空だったMyBoxViewにBindablePropertyを追加します。

using System;
using Xamarin.Forms;

namespace ViewRendererTutorial
{
    public class MyBoxView : View
    {
        // BindablePropertyを追加
        public static readonly BindableProperty ColorProperty =
            BindableProperty.Create (nameof (Color), typeof (Color), typeof (MyBoxView), default (Color),
             propertyChanged: (bindable, oldValue, newValue) =>
                    ((MyBoxView)bindable).Color = (Color)newValue);

        public Color Color {
            get { return (Color)GetValue (ColorProperty); }
            set { SetValue (ColorProperty, value); }
        }
    }
}

Rendererの定義

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using UIKit;
using ViewRendererTutorial;
using ViewRendererTutorial.iOS;

[assembly: ExportRenderer (typeof (MyBoxView), typeof (MyBoxViewRenderer))]

namespace ViewRendererTutorial.iOS
{
    public class MyBoxViewRenderer : ViewRenderer<MyBoxView, UIView>
    {
        protected override void OnElementChanged (ElementChangedEventArgs<MyBoxView> e)
        {
            if (Control == null) {
                var nativeControl = new UIView ();
                SetNativeControl (nativeControl);
            }

            if (e.NewElement != null) {
                // Formsコントロールのプロパティ値を反映
                UpdateColor ();
            }

            base.OnElementChanged (e);
        }

        protected override void OnElementPropertyChanged (object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged (sender, e);
            
            // プロパティ値の変更を反映
            if (e.PropertyName == MyBoxView.ColorProperty.PropertyName) {
                UpdateColor ();
            }
        }

        private void UpdateColor ()
        {
            if (Element == null)
                return;

            Control.BackgroundColor = Element.Color.ToUIColor ();
        }
    }
}

OnElementChanged で最初のプロパティ値を反映、 OnElementPropertyChanged で変更されたプロパティ値をFormsコントロールに反映させればOKです。

f:id:ticktack623:20160611115325g:plain

<StackLayout VerticalOptions="Center">
    <local:MyBoxView x:Name="myBoxView" HeightRequest="100"
            Color="Lime" />
    <Label Text="ViewRenderer tutorial" VerticalOptions="Center" HorizontalOptions="Center" />
    <Button Text="Change Color" Clicked="ButtonClicked" />
</StackLayout>
Random random = new Random();
void ButtonClicked (object sender, System.EventArgs e)
{
    myBoxView.Color = Color.FromRgb (
        random.Next (255),
        random.Next (255),
        random.Next (255));
}

BindablePropertyの変更が反映されていますね!

NativeコントロールからFormsコントロールにメッセージを送る

MyBoxViewにクリック機能を追加してみましょう。 FormsコントロールにClickedイベントを追加し、Naitiveコントロールがハンドルしたユーザー操作を伝播させます。

クリック対応自体はTapGestureRecognizerを使うとFormsコントロール側だけで完結できますが、これから解説する方法は他にも応用が効きますよ。

Formsコントロールの定義

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

// 指定したassemblyにinternal要素へのアクセスを許可する
[assembly: InternalsVisibleTo ("ViewRendererTutorial.iOS")]

namespace ViewRendererTutorial
{
    public class MyBoxView : View
    {
        public static readonly BindableProperty ColorProperty =
            BindableProperty.Create (nameof (Color), typeof (Color), typeof (MyBoxView), default (Color),
             propertyChanged: (bindable, oldValue, newValue) =>
                    ((MyBoxView)bindable).Color = (Color)newValue);

        public Color Color {
            get { return (Color)GetValue (ColorProperty); }
            set { SetValue (ColorProperty, value); }
        }
        
        // イベントを追加
        public event EventHandler Clicked;

        // Rendererからのシグナルを受け取る
        internal void SendClicked ()
        {
            Clicked?.Invoke (this, EventArgs.Empty);
        }
    }
}

Formsコントロールにイベントを追加、Rendererから発火してもらうために SendClicked メソッドを用意します。 また、internalメソッドを呼んでもらうために InternalsVisibleToAttribute でNativeプラットフォームAssemblyからのアクセスを許可します。

このアプローチはXamarin.Forms標準のButton、ButtonRendererのそれとほぼ同じです。

Rendererの定義

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using UIKit;
using ViewRendererTutorial;
using ViewRendererTutorial.iOS;

[assembly: ExportRenderer (typeof (MyBoxView), typeof (MyBoxViewRenderer))]

namespace ViewRendererTutorial.iOS
{
    public class MyBoxViewRenderer : ViewRenderer<MyBoxView, UIView>
    {
        protected override void OnElementChanged (ElementChangedEventArgs<MyBoxView> e)
        {
            if (Control == null) {
                var nativeControl = new UIView ();
                SetNativeControl (nativeControl);
                
                // NativeコントロールがタップされたらFormsコントロールにシグナルを送る
                nativeControl.AddGestureRecognizer (
                    new UITapGestureRecognizer (() => Element?.SendClicked ()));
            }

            if (e.NewElement != null) {
                UpdateColor ();
            }

            base.OnElementChanged (e);
        }

        protected override void OnElementPropertyChanged (object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged (sender, e);

            if (e.PropertyName == MyBoxView.ColorProperty.PropertyName) {
                UpdateColor ();
            }
        }

        private void UpdateColor ()
        {
            if (Element == null)
                return;

            Control.BackgroundColor = Element.Color.ToUIColor ();
        }
    }
}

RendererはFormsコントロールの事を知っているのでメソッドを叩くのも容易です。

サンプルでは省略していますが、本番ではDisposeのタイミングでイベントハンドラの解除などをきちんと行いましょう。

f:id:ticktack623:20160611115857g:plain

<StackLayout VerticalOptions="Center">
    <local:MyBoxView x:Name="myBoxView" HeightRequest="100"
            Color="Lime"
            Clicked="MyBoxViewClicked" />
    <Label Text="ViewRenderer tutorial" VerticalOptions="Center" HorizontalOptions="Center" />
</StackLayout>
void MyBoxViewClicked (object sender, System.EventArgs e)
{
    DisplayAlert ("ViewRendererTutorial", "MyBoxView Clicked.", "OK");
}

クリックイベントをハンドルできました!

FormsコントロールからNativeコントロールにメッセージを送る

先程とは逆にFormsコントロールからNativeコントロールにメッセージを送る場面を考えてみます。

例えばNativeコントロールを操作するAPIを抽象化するケースがそれにあたります。

サンプルとして簡単なMapコントロールを定義し、地図の表示位置を移動させるAPIを実装してみましょう。 (これはXamarin.Forms.Mapsの実装を簡略化したものです)

Formsコントロールの定義

using System;
using Xamarin.Forms;

namespace ViewRendererTutorial
{
    public class MyMap : View
    {
        public void MoveToResion (double latitude, double longitude)
        {
            // Rendererにメッセージを送る
            MessagingCenter.Send (this, "MyMapMoveToRegion", new Tuple<double, double> (latitude, longitude));
        }
    }
}

Formsコントロールに抽象化したAPIを定義、内部でMessagingCenterを利用してRendererにメッセージを送ります。

Rendererの定義

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using UIKit;
using MapKit;
using CoreLocation;
using ViewRendererTutorial;
using ViewRendererTutorial.iOS;

[assembly: ExportRenderer (typeof (MyMap), typeof (MyMapRenderer))]

namespace ViewRendererTutorial.iOS
{
    public class MyMapRenderer : ViewRenderer<MyMap, MKMapView>
    {
        protected override void OnElementChanged (ElementChangedEventArgs<MyMap> e)
        {
            if (Control == null) {
                var nativeControl = new MKMapView ();
                SetNativeControl (nativeControl);
            }

            // Formsコントロールからのメッセージを受け取る
            MessagingCenter.Subscribe<MyMap, Tuple<double, double>> (this, "MyMapMoveToRegion",
                                                                     (sender, args) =>  MoveToRegion(args.Item1, args.Item2), Element);
            base.OnElementChanged (e);
        }

        private void MoveToRegion (double latitude, double longitude)
        {
            var mapRegion = new MKCoordinateRegion (
                    new CLLocationCoordinate2D (latitude, longitude),
                    new MKCoordinateSpan (1.0d, 1.0d));
            Control.SetRegion (mapRegion, true);
        }
    }
}

OnElementChanged でFormsコントロールからのメッセージを購読します。 (本当はElement更新時のSubscribe、UnSubscribe、Dispose時のUnSbscribeをしないとダメですよ)

f:id:ticktack623:20160611120428g:plain

FormsコントロールからRenderer経由でNativeコントロールを操作できていますね。

さらなるステップアップ

Xamarin.FormsはOSSなので既存のソースコードを見て勉強することができます。

github.com

FormsコントロールはXamarin.Forms.Core、RendererはXamarin.Forms.Platform.【プラットフォーム】/Renderers にあります。

Xamarin.Formsのソースを読んでクールなコントロールを作りましょう。