Xamarin.Forms: Кастомные пины для xamarin.maps

от автора

В этой статья мы рассмотри пример реализации кастомных пинов для карты 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.

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что используете Вы: Xamarin или MAUI?
0% Еще пользуюсь Xamarin 0
0% Уже перешел на MAUI 0
Никто еще не голосовал. Воздержался 1 пользователь.

ссылка на оригинал статьи https://habr.com/ru/post/682134/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *