ぴーさんログ

だいたいXamarin.Formsのブログ

【Xamarin.Forms】Effectsを使ったトリック 1

Xamarin.Forms 2.1.0 がリリースされたのでEffectを利用したトリックを解説します。

今回は Xamarin.Forms.Maps.Map (にアタッチしたBehavior)に地図のタップイベントを生やすというものです。

同様のことがカスタムMapクラス&カスタムRendererで実現できますが、これから紹介する手法の方が再利用製に優れます。 (既存のカスタムMapライブラリに対しても適用できたり) 一方で、Effectを使う都合上インスタンス生成に介入できない点には注意が必要です。

地図を使うための準備(AndroidManifestやAPIキー、info.plist等)については割愛します。 (大体はここに書いてある→『 Map Control - Xamarin 』)

作るもの

  • MapExtensionBehavior
    • Mapクラスの拡張機能を提供するクラス、タップイベントはここに生やす
  • MapEffect (iOS/Android)
    • Effectの実装クラス、ネイティブのイベントをハンドルする

コード

今回のサンプルはSharedプロジェクト構成です。 PCLの場合は System.Runtime.CompilerServices.InternalsVisibleToAttribute を使うなどする必要があります。多分。

MapExtensionBehavior

Sharedプロジェクトに追加します。

using System;
using Xamarin.Forms;
using Xamarin.Forms.Maps;

namespace EffectsTric
{
    public class MapExtensionBehavior : Behavior<Map>
    {
        private readonly string effectId = "MapExtensions.MapEffect";
        private Effect effect;
        private IMapEffect mapEffect;

        protected override void OnAttachedTo(Map bindable)
        {
            base.OnAttachedTo(bindable);

            if(bindable as Map == null)
                return;

            effect = Effect.Resolve(effectId);
            mapEffect = effect as IMapEffect;
            // ネイティブのEffect実装から上がったイベントを右から左します
            mapEffect.MapTapped += (sender, e) => 
                MapTapped?.Invoke(this, new MapTappedEventArgs(e.Position));

            bindable.Effects.Add (effect);
        }

        protected override void OnDetachingFrom(Map bindable)
        {
            bindable?.Effects?.Remove(effect);
            effect = null;
            mapEffect = null;

            base.OnDetachingFrom(bindable);
        }
        
        public event EventHandler<MapTappedEventArgs> MapTapped;

        // ネイティブのEffect実装からイベントを上げてもらうためのインターフェース
        internal interface IMapEffect
        {
            event EventHandler<MapTappedEventArgs> MapTapped;
        }
    }
    
    // タップ座標を受け取るためのEventArgs
    [Serializable]
    public sealed class MapTappedEventArgs : EventArgs
    {
        public Position Position
        {
            get;
        }

        public MapTappedEventArgs(Position position)
        {
            Position = position;
        }
    }
}

MapEffect (iOS)

iOSプロジェクトに追加します。

using System;
using System.Linq;
using Xamarin.Forms;
using Xamarin.Forms.Maps;
using Xamarin.Forms.Platform.iOS;
using MapKit;
using CoreLocation;
using UIKit;
using EffectsTric;

[assembly: ResolutionGroupName ("MapExtensions")]
[assembly: ExportEffect (typeof (EffectsTric.iOS.MapEffect), "MapEffect")]

namespace EffectsTric.iOS
{
    public class MapEffect : PlatformEffect, MapExtensionBehavior.IMapEffect
    {
        private MKMapView mapView;
        private MapExtensionBehavior behavior;
        private UITapGestureRecognizer tapGesture;

       #region implemented abstract members of Effect

        protected override void OnAttached()
        {
            mapView = Control as MKMapView;
            if(mapView == null)
                return;

            behavior = (Element as Map)?.Behaviors?.OfType<MapExtensionBehavior>()?.FirstOrDefault();
            if(behavior == null)
                return;
            
            // MKMapViewにGestureRecognizerを追加
            tapGesture = new UITapGestureRecognizer((recognizer) => 
            {
                // タップ座標 → 緯度経度 → Position型に変換してイベント発火
                var point = recognizer.LocationInView(mapView);
                var coordinate = mapView.ConvertPoint(point, mapView);
                MapTapped?.Invoke(this, new MapTappedEventArgs(new Position(
                    coordinate.Latitude, coordinate.Longitude)));
            })
            {
                NumberOfTapsRequired = 1,

            };
            mapView.AddGestureRecognizer(tapGesture);
        }

        protected override void OnDetached()
        {
            mapView.RemoveGestureRecognizer(tapGesture);
            tapGesture.Dispose();
            tapGesture = null;
            mapView = null;
            behavior = null;
        }

       #endregion

        public event EventHandler<MapTappedEventArgs> MapTapped;
    }
}

MapEffect (Android)

Androidプロジェクトに追加します。

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using Android.Gms.Maps;
using Xamarin.Forms.Maps;
using System.Linq;
using EffectsTric;

[assembly: ResolutionGroupName ("MapExtensions")]
[assembly: ExportEffect (typeof (EffectsTric.Droid.MapEffect), "MapEffect")]

namespace EffectsTric.Droid
{
    public class MapEffect : PlatformEffect, MapExtensionBehavior.IMapEffect
    {
        public event EventHandler<MapTappedEventArgs> MapTapped;

        private GoogleMap googleMap;
        private MapExtensionBehavior behavior;

       #region implemented abstract members of Effect

        protected override void OnAttached()
        {
            var mapView = Control as MapView;
            if(mapView == null)
                return;

            behavior = (Element as Map)?.Behaviors?.OfType<MapExtensionBehavior>()?.FirstOrDefault();
            if(behavior == null)
                return;
            
            // GoogleMapインスタンスを得る
            var callback = new OnMapReadyCallback();
            callback.OnMapReadyAction = (gMap) => {
                googleMap = gMap;
                googleMap.MapClick += GoogleMap_MapClick;
            };
            mapView.GetMapAsync(callback);
        }

        protected override void OnDetached()
        {
            if(googleMap != null)
            {
                googleMap.MapClick -= GoogleMap_MapClick;
                googleMap = null;
            }
        }

       #endregion

        void GoogleMap_MapClick (object sender, GoogleMap.MapClickEventArgs e)
        {
            // GoogleMapのクリックイベントから緯度経度をPosition型に変換してイベント発火
            MapTapped?.Invoke(this, new MapTappedEventArgs(
                new Position(e.Point.Latitude,
                    e.Point.Longitude)));
        }

        // GoogleMapインスタンスを得るのに使う
        class OnMapReadyCallback : Java.Lang.Object, IOnMapReadyCallback
        {
            public Action<GoogleMap> OnMapReadyAction;

            public void OnMapReady(GoogleMap googleMap)
            {
                OnMapReadyAction?.Invoke(googleMap);
            }
        }
    }
}

動作サンプル

使用側のサンプルコード

public App()
{
    // タップ座標確認用のラベル
    var label = new Label {
        HorizontalOptions = LayoutOptions.Center,
        VerticalOptions = LayoutOptions.Center,
        InputTransparent = true,
        BackgroundColor = Color.Gray.MultiplyAlpha(0.3),
        TextColor = Color.Black,
    };
    
    // Map機能を拡張するBehavior
    var behavior = new MapExtensionBehavior();
    behavior.MapTapped += (sender, e) => {
        label.Text = e.Position.Latitude + ", " + e.Position.Longitude;
    };
    
    // ここがカスタムMapになってもBehaviorは使い回せる
    var map = new Map {
        IsShowingUser = true,
        Behaviors = {
            behavior
        },
    };

    // 国会議事堂付近
    var position = new Position(35.675889, 139.744972);
    map.MoveToRegion(new MapSpan(
        position, 0.8, 0.8));

    MainPage = new ContentPage {
        Content = new Grid {
            Children = {
                map,
                label,
            },
        },
    };
}

Androidエミュレータで地図使えるようにするのはちょっと大変なので動画はiOSだけ。

f:id:ticktack623:20160309201635g:plain