ぴーさんログ

だいたいXamarin.Formsのブログ

Build 2016とXamarin.Forms

Build 2016で 超!エキサイティン!! な発表がありました、なんとVisual Studioの全エディション(Community含む)にXamarinライセンスが無料で付くようになりました。

さらに、諸々のSDKオープンソースになります。

In addition to these important steps, we are announcing today our commitment to open source the Xamarin SDKs for Android, iOS, and Mac under the MIT license in the coming months. This includes native API bindings and the basic command-line tools necessary to develop mobile apps. It also includes our popular cross-platform native UI toolkit, Xamarin.Forms.

Xamarin for Everyone | Xamarin Blog

これには Xamarin.Forms も含まれるので、前回の記事で言う所の「変化球:オープンソースになる」が正解とあいなりました。

実際にオープンソースになるのは少し先のようですが楽しみですね!

Xamarin買収でXamarin.Formsが今後どうなるか考えてみる

結構今更なタイミングではありますがXamarin.Formsが今後どうなるかつらつらと考えてみました。

総評としては「UIデザイナが追加されたら様子見してた人達も突入してもOK」といったところでしょうか。

思いつくパターン

強化される

MSの圧倒的リソースが投入され、UIデザイナが追加されたり、パフォーマンスが向上したりする。 当然、UWP対応も正式版となりさらに強化される。

なくなる

MS主導で別の事にリソースを集中させる等の理由で開発停止。 (代わりに新プロジェクトが始まるといいね)

現状維持

今まで通りのペースで開発続行。UIデザイナを作る余裕は無いんじゃないかな。 (デザイナ? ねぇよ、んなモン)

変化球:オープンソースになる

2通り思いつく。

無いだろなーと思うパターン

UWPに置き換えられる

UWPのコードをiOSAndroidで動くようにするパターン。 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

【ソース公開】Xamarin.Forms製のAED検索アプリ、iOS版もリリースしました!

Android版に続き、Xamarin.Forms製アプリ「AEDオープンデータ検索」のiOS版をリリースしました!

iOS版のコードも公開中のGitHubリポジトリに含まれていますよ!

https://github.com/P3PPP/XFAedSearch

f:id:ticktack623:20160304165532j:plain

主な機能

近くにあるAEDを地図に表示します。

AED情報のソースは初音 玲さん( @hatsune_ )の AEDオープンデータプラットフォーム です。

f:id:ticktack623:20160304165746p:plain:w220 f:id:ticktack623:20160304165802p:plain:w220

当然といえば当然ですがAndroid版と同じです。

一応Android版のリンクのリンクも

Get it on Google Play

Xamarin.Forms.Mapsのバグを報告しようとしたら既にチケット上がってた

Xamarin.Forms.Maps.Mapのバグを発見しました。

「Xamarin.Forms.Maps.MapのAndroid実装側で GoogleMap.CameraChange イベントにハンドラを追加すると、 Map.VisibleRegion (地図が表示してる範囲の情報)が更新されなくなる」というものバグを報告しようとしましたが、少し前に同様のチケットが登録されていたのでやめました。(バグ報告者になるチャンスを逃した)

せっかくなので確認コード上げときます。

実行するとMap.VisibleRegionの中味が常にnullになります。 ちなみにカスタムRendererの代わりにEffectでやると、中身は入ってるが更新されない状態になります。

ライブラリバージョン

  • Xamarin.Forms 2.0.1
  • Xamarin.Forms.Maps 2.0.1
  • Xamarin.GooglePlayService 26.0.0

AndroidManifest等、地図を使うのに必要な準備は終わってるものとします。

Shared / PCL プロジェクトのコード

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

namespace XFApp17
{
    public class MyMap : Map
    {
    }

    public class App : Application
    {
        public App()
        {
            var map = new MyMap();
            var label = new Label {
                HorizontalOptions = LayoutOptions.Center,
                VerticalOptions = LayoutOptions.Center,
                BackgroundColor = Color.Silver,
                TextColor = Color.White,
                InputTransparent = false,
            };
            var button = new Button {
                Text = "Show VisibleRegion",
                HorizontalOptions = LayoutOptions.Center,
                VerticalOptions = LayoutOptions.End,
            };
            button.Clicked += (sender, e) => {
                if(map.VisibleRegion == null)
                {
                    label.Text = "MyMap.VisibleRegion is null.";
                }
                else
                {
                    label.Text = $"Latitude:{map.VisibleRegion.Center.Latitude}," + Environment.NewLine + 
                        $"Longitude:{map.VisibleRegion.Center.Longitude}, " + Environment.NewLine + 
                        $"Radius:{map.VisibleRegion.Radius.Meters}";
                }
            };

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

Androidプロジェクトのコード

using System;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using Xamarin.Forms.Maps;
using Xamarin.Forms.Maps.Android;
using Android.Gms.Maps;
using XFApp17;
using XFApp17.Droid;

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

namespace XFApp17.Droid
{
    public class MyMapRenderer : MapRenderer, IOnMapReadyCallback
    {
        GoogleMap map;

        protected override void OnElementChanged(ElementChangedEventArgs<View> e)
        {
            base.OnElementChanged(e);

            if(e.OldElement != null)
            {
                map.CameraChange -= OnCameraChange;
            }

            if(e.NewElement != null)
            {
                ((MapView)Control).GetMapAsync(this);
            }
        }

        public void OnMapReady (GoogleMap googleMap)
        {
            map = googleMap;

            map.CameraChange += OnCameraChange;
        }

        void OnCameraChange (object sender, GoogleMap.CameraChangeEventArgs e)
        {
            Console.WriteLine("OnCameraChange");
        }
    }
}

【ソース公開】Xamarin.Forms製のアプリをリリースしました (Android版)

先日、Xamarin.Forms製のAED検索アプリをGoogle Playストアにリリースしました。(iOS版は申請作業中)

AEDオープンデータ検索

f:id:ticktack623:20160225143901j:plain:w450

Get it on Google Play

ついでにソースコードGitHubで公開してます。(公開事例ですよー!)

https://github.com/P3PPP/XFAedSearch

主な機能

近くにあるAEDを地図に表示します。

AED情報のソースは初音 玲さん( @hatsune_ )の AEDオープンデータプラットフォーム です。

f:id:ticktack623:20160225144109p:plain:w250  f:id:ticktack623:20160225144125p:plain:w250

Xamarin.Formsで諦めたこと(全画面コンテンツとNavigationBar)

iOSの「マップ」のように、コンテンツを大きく見せるため、画面をタップするとナビゲーションバー(Xamarin.Formsではこの表記)が引っ込むアプリがありますね?

例(iOSの「マップ」アプリ)

f:id:ticktack623:20160222194448g:plain:w250

NavigationPage.SetHasNavigationBar() でナビゲーションバーの有無を切り替えればXamarin.Formsでこれを再現できるのではと考えましたが、結果は残念なものとなりました...

iOS / Android

f:id:ticktack623:20160222194545g:plain:w250 f:id:ticktack623:20160222194625g:plain:w265

ナビゲーションバーが切り替わる際に、画面下部に空白ができているのが分かるでしょうか?

これはNavigationPageの中のPageがナビゲーションバーの高さ分縮小されるためです。

  • バー非表示 → 表示
    1. Pageが縮小される(下に空白ができる)
    2. バーのアニメーションに合わせてPageが移動する
    3. 移動が完了して空白が埋まる
  • バー表示 → 非表示
    1. バーのアニメーションに合わせてPageが移動する
    2. 移動が完了して空白ができる
    3. Pageが拡大され、空白が埋まる

Pageのサイズが変わってしまっており、レイアウト調整などでは解決できないため諦めることにしました。 (頑張るならNavigationPageのカスタムRendererだろうか...)

おまけ

再現コード

public App()
{
    var boxView = new BoxView {
        Color = Color.Olive,
    };
    var page = new ContentPage {
        Content = boxView,
        Title = "Title",
    };
    boxView.GestureRecognizers.Add(
        new TapGestureRecognizer((_) => 
            NavigationPage.SetHasNavigationBar(page, !NavigationPage.GetHasNavigationBar(page))
        ));
    MainPage = new NavigationPage(page);
}