Custom pins for xamarin.maps

In this article, we will look at an example of implementing custom pins for a xamarin map. The pins will have the look you want. We will also look at the part of the xamarin.maps code responsible for creating, drawing and displaying pins.

Fortunately, the xamarin.forms sources are presented on github, and we can see the whole code:

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

We will consider these files from the git:

To play the tutorial, you must have the Xamarin.Maps packages installed in all projects, and configured accordingly guide from xamarin:

The SkiaSharp package and SkiaSharp.Views.Forms must also be installed in the main project (not platform-specific):

To begin with, we will implement the type that will become the base for the pins, and the map that will be inherited from the native one, and process our pin, in the main project [ProjectName]:

The CustomPin code is the base of our pin
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);
    }
Code CustomMap – our map with CustomPin support
public class CustomMap : Map
{
}

Simple code, we don’t need more.

And so, for starters, let’s look at rendering for Android, namely, we are interested in the CreateMarker method on line 248, this is how it looks in the original:

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;
}

This method is responsible for creating the pin, the important element here, as you might guess, is “MarkerOptions opts”, the MarkerOptions type contains the SetIcon(BitmapDescriptor) method, which we use to draw the pin. To do this, you need to create a child class for this renderer in the project [ProjectName].Android, I recommend creating a Renderers folder in it:

The renderer class stub code will look like this:

[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);
        }
    }
}

So far, it does not change the behavior of the parent class in any way. Let’s add processing to our CustomPin, the resulting code will look like this:

[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();
        }
    }
}

Great, we’ve done everything for Android, now let’s try to test our solution using such a custom test pin (You need to create it in the main project [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);
        }
    }

To test, I slightly modified the standard MainPage.xaml code by adding a map to it, and the MainPage.xaml.cs code to add a test set of pins:

MainPage.xaml code
<?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 code
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)
                });
            }
        }
    }

The resulting result:

Standard map, with the same logic in xaml.cs, but with Map instead of CustomMap in xaml.
Our CustomMap

Now we will implement the same for iOs, for this we will consider a xamarin native renderer on iOs, we are interested in the CreateAnnotation method on line 214 and the GetViewForAnnotation method on line 224. In the original, these methods look like this:

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;
		}

The logic of the interface on iOs is quite noticeably different from its implementation on Android, in particular, Annotation and AnnotationView are used on iOs, in short, these are information and a pin image, respectively (although this answer is not accurate, but information about this can be found in open sources). In general, we need our own Annotation and AnnotationView, we will create them in [ProjectName].ios project:

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));
        }
    }

As in the example of the renderer that we implemented for Android, you need to create the same one in [ProjectName].iOs project, the blank will look like this:

[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);
        }
    }
}

Now add processing here for our custom pins, with support for custom annotations, the resulting renderer will be as follows:

[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);
        }
    }
}

And that’s basically it, we have added support for custom pins on iOs, now we can make absolutely any pin images using SkiaSharp by simply inheriting from CustomPin, and passing them to CustomMap.Pins. I will be glad to constructive criticism and feedback in the comments!

The project that I created as I was writing the article can be found on github:
AlexMorOR/Xamarin-CustomMap-with-CustomPins: Here’s a solution to extend the native xamarin map to include custom image pins. (github.com)

PS

The article was compiled based on the code from the project I’m working on, so some properties that are not used anywhere may be missing. But if I made such an omission, it will not affect the operation of the card.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *