В этой статья мы рассмотри пример реализации кастомных пинов для карты xamarin. Пины будут обладать тем видом, который вам нужен. Так же мы рассмотрим часть кода xamarin.maps, отвечающий за создание, отрисовку и отображение пинов.
Благо исходники xamarin.forms представлены на github, и мы можем увидеть весь код:
xamarin/Xamarin.Forms: Xamarin.Forms Official Home (github.com)
Рассматривать мы будем эти файлы с гита:
iOs.MapRenderer Xamarin.Forms/MapRenderer.cs at 5.0.0 · xamarin/Xamarin.Forms (github.com)
Android.MapRenderer Xamarin.Forms/MapRenderer.cs at 5.0.0 · xamarin/Xamarin.Forms (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.
Статья составлялась на основе кода из проекта, над которым я работаю, поэтому могут быть упущены некоторые свойства, которые нигде не используются. Но если я допустил такое упущение - на работу карты это не повлияет.