В этой статья мы рассмотри пример реализации кастомных пинов для карты xamarin. Пины будут обладать тем видом, который вам нужен. Так же мы рассмотрим часть кода xamarin.maps, отвечающий за создание, отрисовку и отображение пинов.

Благо исходники xamarin.forms представлены на github, и мы можем увидеть весь код:

xamarin/Xamarin.Forms: Xamarin.Forms Official Home (github.com)

Рассматривать мы будем эти файлы с гита:

Для воспроизведения туториала у вас должны быть установлены пакеты Xamarin.Maps во все проекты, и соответственно настроен по гайду от xamarin:

Так же должен быть установлен пакет SkiaSharp и SkiaSharp.Views.Forms в основной проект (не специфичный для платформ):

Для начала реализуем тип, который станет базой для пинов, и карту, которая будет наследоваться от нативной, и обрабатывать наш пин, в основном проекте [ProjectName]:

Код CustomPin - база нашего пина
public abstract class CustomPin : Pin
    {
        public class MapMarkerInvalidateEventArgs
        {
            public double Width { get; }
            public double Height { get; }

            internal MapMarkerInvalidateEventArgs(CustomPin marker)
            {
                Width = marker.Width;
                Height = marker.Height;
            }
        }
        
        public event EventHandler<MapMarkerInvalidateEventArgs> RequestInvalidate;

// Bindable properties
        public static readonly BindableProperty WidthProperty = BindableProperty.Create(nameof(Width), typeof(double), typeof(CustomPin), 32.0, propertyChanged: OnDrawablePropertyChanged);
        public static readonly BindableProperty HeightProperty = BindableProperty.Create(nameof(Height), typeof(double), typeof(CustomPin), 32.0, propertyChanged: OnDrawablePropertyChanged);
        public static readonly BindableProperty AnchorXProperty = BindableProperty.Create(nameof(AnchorX), typeof(double), typeof(CustomPin), 0.5);
        public static readonly BindableProperty AnchorYProperty = BindableProperty.Create(nameof(AnchorY), typeof(double), typeof(CustomPin), 0.5);
        public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(CustomPin), true);
        public static readonly BindableProperty ClickableProperty = BindableProperty.Create(nameof(Clickable), typeof(bool), typeof(CustomPin), true);

// Ширина пина
        public double Width
        {
            get { return (double)GetValue(WidthProperty); }
            set { SetValue(WidthProperty, value); }
        }

// Высота пина
        public double Height
        {
            get { return (double)GetValue(HeightProperty); }
            set { SetValue(HeightProperty, value); }
        }

// Расположение пина относительно точки на карте по X
        public double AnchorX
        {
            get { return (double)GetValue(AnchorXProperty); }
            set { SetValue(AnchorXProperty, value); }
        }

// Расположение пина относительно точки на карте по Y
        public double AnchorY
        {
            get { return (double)GetValue(AnchorYProperty); }
            set { SetValue(AnchorYProperty, value); }
        }

// Виден ли пин
        public bool IsVisible
        {
            get { return (bool)GetValue(IsVisibleProperty); }
            set { SetValue(IsVisibleProperty, value); }
        }

// Интерактивен ли пин
        public bool Clickable
        {
            get { return (bool)GetValue(ClickableProperty); }
            set { SetValue(ClickableProperty, value); }
        }

        private static void OnDrawablePropertyChanged(BindableObject bindable, object oldValue, object newValue)
        {
            CustomPin marker = bindable as CustomPin;

            marker.Invalidate();
        }

        public void Invalidate()
        {
            RequestInvalidate?.Invoke(this, new MapMarkerInvalidateEventArgs(this));
        }

// Метод, который будет перезаписан в дочернем классе, в нем будет происходить отрисовка пина
        public abstract void DrawPin(SKSurface surface);
    }

Код CustomMap - наша карта с поддержкой CustomPin
public class CustomMap : Map
{
}

Простой код, большего нам и не нужно.

И так, для начала рассмотрим рендер под Android, а именно - нас интересует метод CreateMarker на 248 строке, вот так он выглядит в оригинале:

protected virtual MarkerOptions CreateMarker(Pin pin)
{
  var opts = new MarkerOptions();
  opts.SetPosition(new LatLng(pin.Position.Latitude, pin.Position.Longitude));
  opts.SetTitle(pin.Label);
  opts.SetSnippet(pin.Address);

  return opts;
}

Этот метод отвечает за создание пина, важным элементом здесь, как нетрудно догадаться, является "MarkerOptions opts", тип MarkerOptions содержит метод SetIcon(BitmapDescriptor), которое мы и используем для отрисовывания пина. Для этого необходимо создать дочерний класс к этому отрисовщику в проекте [ProjectName].Android, рекомендую создать в нем папку Renderers:

Код заготовки класса отрисовщика будет выглядеть следующим образом:

[assembly: Xamarin.Forms.ExportRenderer(typeof(CustomMap), typeof(CustomMapRenderer))]
namespace [ProjectName].Droid.Renderers
{
    public class CustomMapRenderer : MapRenderer
    {
        public CustomMapRenderer(Context context) : base(context){}

        protected override MarkerOptions CreateMarker(Pin pin)
        {
            return base.CreateMarker(pin);
        }
    }
}

Пока что он никак не меняет логику поведения класса родителя. Давайте добавим обработку нашего CustomPin, результирующий код будет выглядеть следующим образом:

[assembly: Xamarin.Forms.ExportRenderer(typeof(CustomMap), typeof(CustomMapRenderer))]
namespace [ProjectName].Droid.Renderers
{
    public class CustomMapRenderer : MapRenderer
    {
        public CustomMapRenderer(Context context) : base(context){}

        protected override MarkerOptions CreateMarker(Pin pin)
        {
        // Получаем настроенный в базовом классе MarkerOptions
            var opts = base.CreateMarker(pin);
				// Если наш маркер - кастомный...
            if(pin is CustomPin cpin)
            {
            // ... то получаем SKPixmap с изображением пина
                SKPixmap markerBitmap = DrawMarker(cpin);
						// Задаем изображение пина и видимость
                opts.SetIcon(BitmapDescriptorFactory.FromBitmap(markerBitmap.ToBitmap()))
                       .Visible(cpin.IsVisible);
            // Выставляем якоря
                opts.Anchor((float)cpin.AnchorX, (float)cpin.AnchorY);
            }

            return opts;
        }

        private SKPixmap DrawMarker(CustomPin skPin)
        {
        // Считаем размер изображения пина согласно Density устройства
            double bitmapWidth = skPin.Width * Context.Resources.DisplayMetrics.Density;
            double bitmapHeight = skPin.Height * Context.Resources.DisplayMetrics.Density;
        // Создаем сурфейс для отрисовки
            SKSurface surface = SKSurface.Create(new SKImageInfo((int)bitmapWidth, (int)bitmapHeight, SKColorType.Rgba8888, SKAlphaType.Premul));
				// Заливаем сурфейс цветом Transparent
            surface.Canvas.Clear(SKColor.Empty);
        // Просим пин отрисовать изображение на сурфейс
            skPin.DrawPin(surface);
				// Получаем пиксели, которые можно перевести в BitMap
            return surface.PeekPixels();
        }
    }
}

Отлично, для Android мы все сделали, теперь попробуем проверить наше решение при помощи такого кастомного тестового пина (Создавать его нужно в основном проекте [ProjectName]):

internal sealed class CirclePin : CustomPin
    {
        // Сохраненный Bitmap
        SKBitmap pinBitmap;

        // Конструктор принимает string - это текст внутри круга
        public CirclePin(string text)
        {
            // Отступ текста от краев круга
            int circleOffset = 10;

            // Минимальный размер круга, при маленьком тексте
            int minSize = 40;

            // Размер шрифта текста
            int textSize = 18;

            // Задание цвета текста
            Color tempColor = Color.White;
            // Перевод из Color в SKColor
            SKColor textColor = new SKColor((byte)(tempColor.R * 255), (byte)(tempColor.G * 255), (byte)(tempColor.B * 255));

            // Задание цвета круга
            tempColor = Color.Black;
            // Перевод из Color в SKColor
            SKColor circleColor = new SKColor((byte)(tempColor.R * 255), (byte)(tempColor.G * 255), (byte)(tempColor.B * 255));

            PrepareBitmap(circleOffset, circleColor, text, textSize, textColor, minSize);
        }

        private void PrepareBitmap(int circleOffset, SKColor circleColor, string text, float textSize, SKColor textColor, int minSize, int iconSize = 28)
        {
            int width;
            float den = (float)DeviceDisplay.MainDisplayInfo.Density;

            // Удваиваем отступ, т.к. он будет с 2-х сторон одинаковый
            circleOffset *= 2;

            using (var font = SKTypeface.FromFamilyName("Arial"))
            using (var textBrush = new SKPaint
            {
                Typeface = font,
                TextSize = textSize * den,
                IsAntialias = true,
                Color = textColor,
                TextAlign = SKTextAlign.Center,
            })
            {
                // Высчитывание размера текста
                SKRect textRect = new SKRect();
                textBrush.MeasureText(text, ref textRect);

                // Ширина текста в dip
                width = Math.Max((int)(Math.Ceiling(textRect.Width) / den) + circleOffset, minSize);

                // Задаем размер пина согласно ширине в dip
                Width = Height = width;

                // Ширина текста в пикселях
                width = (int)Math.Floor(width * den);

                // Создаем Bitmap для отрисовки
                pinBitmap = new SKBitmap(width, width, SKColorType.Rgba8888, SKAlphaType.Premul);

                using (var canvas = new SKCanvas(pinBitmap))
                {
                    using (var circleBrush = new SKPaint
                    {
                        IsAntialias = true,
                        Color = circleColor
                    })
                    {
                        //Отрисовка круга
                        canvas.DrawRoundRect(new SKRoundRect(new SKRect(0, width, width, 0), width / 2f), circleBrush);

                        //Отрисовка текста
                        canvas.DrawText(text, width * 0.5f, width * 0.5f - textRect.MidY, textBrush);

                        canvas.Flush();
                    }
                }
            }

        }

        public override void DrawPin(SKSurface surface)
        {
            // Получаем канвас из сурфейса, для отрисовки
            SKCanvas canvas = surface.Canvas;

            // Отрисовываем на канвас наш сохраненный Bitmap
            canvas.DrawBitmap(pinBitmap, canvas.LocalClipBounds.MidX - pinBitmap.Width / 2f, canvas.LocalClipBounds.MidY - pinBitmap.Height / 2f);
        }
    }

Для проверки я немного изменил код стандартного MainPage.xaml, добавив в него карту, и код MainPage.xaml.cs для добавления проверочного набора пинов:

Код MainPage.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:xcmpexample="clr-namespace:XCMPExample"
             x:Class="[ProjectName].MainPage">

    <StackLayout>
        <Frame BackgroundColor="#2196F3" Padding="24" CornerRadius="0">
            <Label Text="Welcome to Xamarin.Forms!" HorizontalTextAlignment="Center" TextColor="White" FontSize="36"/>
        </Frame>
      	<!-- Карта -->
        <xcmpexample:CustomMap x:Name="customMap"/>
    </StackLayout>

</ContentPage>

Код MainPage.xaml.cs
public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();

            Random random = new Random();
            for (int i = 0; i < 100; i++)
            {
                string universalFillData = i.ToString();
                customMap.Pins.Add(new CirclePin(universalFillData)
                {
                    Label = universalFillData,
                    Address = universalFillData,
                    Position = new Position(
                    /// Устанавливаем координаты Москвы +-1
                    /// чтобы было проще найти наши пины
                        random.NextDouble() + 55,
                        random.NextDouble() + 37)
                });
            }
        }
    }

Получившийся результат:

Стандартная карта, с той же логикой в xaml.cs, но с Map вместо CustomMap в xaml.

Наша CustomMap

Теперь реализуем это же для iOs, для этого рассмотрим нативный для xamarin отрисовщик на iOs, нас интересует метод CreateAnnotation на 214 строке и метод GetViewForAnnotation на 224 строке. В оригинале эти методы выглядят так:

CreateAnnotation
protected virtual IMKAnnotation CreateAnnotation(Pin pin)
		{
			return new MKPointAnnotation
			{
				Title = pin.Label,
				Subtitle = pin.Address ?? "",
				Coordinate = new CLLocationCoordinate2D(pin.Position.Latitude, pin.Position.Longitude)
			};
		}

GetViewForAnnotation
protected virtual MKAnnotationView GetViewForAnnotation(MKMapView mapView, IMKAnnotation annotation)
		{
			MKAnnotationView mapPin = null;

			// https://bugzilla.xamarin.com/show_bug.cgi?id=26416
			var userLocationAnnotation = Runtime.GetNSObject(annotation.Handle) as MKUserLocation;
			if (userLocationAnnotation != null)
				return null;

			const string defaultPinId = "defaultPin";
			mapPin = mapView.DequeueReusableAnnotation(defaultPinId);
			if (mapPin == null)
			{
				mapPin = new MKPinAnnotationView(annotation, defaultPinId);
				mapPin.CanShowCallout = true;
			}

			mapPin.Annotation = annotation;
			AttachGestureToPin(mapPin, annotation);

			return mapPin;
		}

Логика работы интерфейса на iOs довольно заметно отличается от ее реализации на Android, в частности на iOs используются Annotation и AnnotationView, в кратце это информация и изображение пина соответственно (хотя такой ответ не точный, но информацию об этом можно найти в открытых источниках). В общем, нам нужны собственные Annotation и AnnotationView, создавать мы их будем в [ProjectName].iOs проекте:

CustomPinAnnotation
public class CustomPinAnnotation : MKPointAnnotation
    {
    // Сохраняем ссылку на пин, понадобится в будущем
        public CustomPin SharedPin { get; }

        public CustomPinAnnotation(CustomPin pin)
        {
            SharedPin = pin;

            Title = pin.Label;
            Subtitle = pin.Address;
            // Переводим координаты в CL для iOs
            Coordinate = ToLocationCoordinate(pin.Position);
        }

        public override string Title
        {
            get => base.Title;
            set
            {
                if (Title != value)
                {
                    string titleKey = nameof(Title).ToLower();

                    WillChangeValue(titleKey);
                    base.Title = value;
                    DidChangeValue(titleKey);
                }
            }
        }

        public override string Subtitle
        {
            get => base.Subtitle;
            set
            {
                if (Subtitle != value)
                {
                    string subtitleKey = nameof(Subtitle).ToLower();

                    WillChangeValue(subtitleKey);
                    base.Subtitle = value;
                    DidChangeValue(subtitleKey);
                }
            }
        }

        public override CLLocationCoordinate2D Coordinate
        {
            get => base.Coordinate;
            set
            {
                if (Coordinate.Latitude != value.Latitude ||
                    Coordinate.Longitude != value.Longitude)
                {
                    string coordinateKey = nameof(Coordinate).ToLower();

                    WillChangeValue(coordinateKey);
                    base.Coordinate = value;
                    DidChangeValue(coordinateKey);
                }
            }
        }

        private CLLocationCoordinate2D ToLocationCoordinate(Position self)
        {
            return new CLLocationCoordinate2D(self.Latitude, self.Longitude);
        }
    }

CustomPinAnnotationView
public class CustomPinAnnotationView : MKAnnotationView
    {
    		// Сохраняем название View
        public const string ViewIdentifier = nameof(CustomPinAnnotationView);
				// Сохраняем ссылку на кастомную аннотацию
        private CustomPinAnnotation _SkiaAnnotation => base.Annotation as CustomPinAnnotation;
     		// Токен остановки обновления изображения
        private CancellationTokenSource _imageUpdateCts;
        // Density экрана для высчитывания размера в пикселях
        private nfloat _screenDensity;

        public CustomPinAnnotationView(CustomPinAnnotation annotation) : base(annotation, ViewIdentifier)
        {
            _screenDensity = UIScreen.MainScreen.Scale;
        }

        internal async void UpdateImage()
        {
            CustomPin pin = _SkiaAnnotation?.SharedPin;
            UIImage image;
            CancellationTokenSource renderCts = new CancellationTokenSource();

            _imageUpdateCts?.Cancel();
            _imageUpdateCts = renderCts;

            try
            {
            		// Рисуем пин асинхронно
                image = await RenderPinAsync(pin, renderCts.Token).ConfigureAwait(false);

                renderCts.Token.ThrowIfCancellationRequested();

                Device.BeginInvokeOnMainThread(() =>
                {
                    if (!renderCts.IsCancellationRequested)
                    {
                    		// Задаем полученное изображение синхронно в потоке UI
                        Image = image;
                        Bounds = new CGRect(CGPoint.Empty, new CGSize(pin.Width, pin.Height));
                    }
                });
            }
            catch (OperationCanceledException)
            {
                // Ignore
            }
            catch (Exception e)
            {
                System.Diagnostics.Debug.WriteLine("Failed to render pin annotation: \n" + e);
            }
        }

        private Task<UIImage> RenderPinAsync(CustomPin pin, CancellationToken token = default(CancellationToken))
        {
            return Task.Run(() =>
            {
            		// Высчитываем размеры по аналогии с Android отрисовщиком
                double bitmapWidth = pin.Width * _screenDensity;
                double bitmapHeight = pin.Height * _screenDensity;
								
                // Отрисовываем пин по аналогии с Android отрисовщиком
                using (SKSurface surface = SKSurface.Create(new SKImageInfo((int)bitmapWidth, (int)bitmapHeight, SKColorType.Rgba8888, SKAlphaType.Premul)))
                {
                    surface.Canvas.Clear(SKColor.Empty);
                    pin.DrawPin(surface);

                    return surface.PeekPixels().ToUIImage();
                }
            }, token);
        }

        public void UpdateAnchor()
        {
            CenterOffset = new CGPoint(Bounds.Width * (0.5 - _SkiaAnnotation.SharedPin.AnchorX),
                                       Bounds.Height * (0.5 - _SkiaAnnotation.SharedPin.AnchorY));
        }
    }

Как и на примере отрисовщика, который мы реализовали под Android, необходимо создать такой же в [ProjectName].iOs проекте, заготовка будет выглядеть следующим образом:

[assembly: ExportRenderer(typeof(CustomMap), typeof(CustomMapRenderer))]
namespace [ProjectName].iOS.Renderers
{
    public class CustomMapRenderer : MapRenderer
    {
        protected override IMKAnnotation CreateAnnotation(Pin pin)
        {
            return base.CreateAnnotation(pin);
        }

        protected override MKAnnotationView GetViewForAnnotation(MKMapView mapView, IMKAnnotation annotation)
        {
            return base.GetViewForAnnotation(mapView, annotation);
        }
    }
}

Теперь добавим сюда обработку для наших кастомных пинов, с поддержкой кастомных аннотаций, результируйющий отрисовщик будет следующим:

[assembly: ExportRenderer(typeof(CustomMap), typeof(CustomMapRenderer))]
namespace [ProjectName].iOS.Renderers
{
    public class CustomMapRenderer : MapRenderer
    {
        protected override IMKAnnotation CreateAnnotation(Pin pin)
        {
            if (pin is CustomPin skPin)
            {
                //Если мы обрабатываем наш кастомный пин, то создаем ему специальную аннотацию.
                IMKAnnotation result = new CustomPinAnnotation(skPin);

                skPin.MarkerId = result;

                return result;
            }
            else
                return base.CreateAnnotation(pin);
        }

        protected override MKAnnotationView GetViewForAnnotation(MKMapView mapView, IMKAnnotation annotation)
        {
            if (annotation is CustomPinAnnotation skiaAnnotation)
            {
                // Если мы обрабатываем нашу кастомную аннотацию, то получаем из нее наш пин
                CustomPin skPin = skiaAnnotation.SharedPin;

                // Проверяем на кэшированные аннотации, по совету Xamarin
                CustomPinAnnotationView pinView = mapView.DequeueReusableAnnotation(CustomPinAnnotationView.ViewIdentifier) as CustomPinAnnotationView
                                                    ?? new CustomPinAnnotationView(skiaAnnotation);

                // Добавляем жесты к пину
                base.AttachGestureToPin(pinView, annotation);

                pinView.Annotation = skiaAnnotation;
                // Отрисовываем пин
                pinView.UpdateImage();
                // Обновляем якорь
                pinView.UpdateAnchor();
                pinView.Hidden = !skPin.IsVisible;
                pinView.Enabled = skPin.Clickable;

                return pinView;
            }
            else
                return base.GetViewForAnnotation(mapView, annotation);
        }
    }
}

И на этом в принципе все, мы добавили поддержку кастомных пинов на iOs, теперь мы можем делать абсолютно любые изображения пина при помощи SkiaSharp путем простого наследования от CustomPin, и передавать их в CustomMap.Pins. Буду рад конструктивной критике и отзыву в комментариях!

Проект, который я создал по мере написания статьи можно найти на github:
AlexMorOR/Xamarin-CustomMap-with-CustomPins: Here's a solution to extend the native xamarin map to include custom image pins. (github.com)

P.S.

Статья составлялась на основе кода из проекта, над которым я работаю, поэтому могут быть упущены некоторые свойства, которые нигде не используются. Но если я допустил такое упущение - на работу карты это не повлияет.

Комментарии (0)