C#
.NET
MAUI
7 minute read | March 22, 2024

.NET MAUI custom map pins

The MAUI map will display pins with whatever the default icon is for the platform it is running on, but what if we want to use our own icons? To do that we'll need to write a custom map handler for each platform that we want to taget. This guide will cover both Android and iOS.

In order to improve performance we are going to override some built in behaviour which should easily allow us to render thousands of icons with minimal delay.

Setup

Lets start by installing the MAUI maps NuGet package into our project if you haven't already done so

Cli
dotnet add package Microsoft.Maui.Controls.Maps

Create a MapPin model

Now lets create a new class to represent our custom map pin. The Id needs to be something unqiue and the IconSrc will be the filename of the icon to use minus the extension.

C#
public class MapPin { public string Id { get; set; } public Location Position { get; set; } public string Icon { get; set; } public ICommand ClickedCommand { get; set; } public MapPin(Action<MapPin> clicked) { ClickedCommand = new Command(() => clicked(this)); } }

Create a custom map

The next step is to subclass the MAUI Map class so we can add our own custom collection of pins.

C#
public class MapEx : Microsoft.Maui.Controls.Maps.Map { public List<MapPin> CustomPins { get { return (List<MapPin>)GetValue(CustomPinsProperty); } set { SetValue(CustomPinsProperty, value); } } public static readonly BindableProperty CustomPinsProperty = BindableProperty.Create(nameof(CustomPins), typeof(List<MapPin>), typeof(MapEx), null); }

Setup the map

Add our new map control to whatever page you want to display the map on. Make sure to add the correct xmlns entry for your namespace.

Xaml
<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:ctrl="clr-namespace:MauiCustomMapIcons.Controls" x:Class="MauiCustomMapIcons.MainPage"> <Grid> <ctrl:MapEx CustomPins="{Binding Pins}" /> </Grid> </ContentPage>

In the code behind lets add a few pins, the Id of each pin can be whatever you want it to be. Make sure to add the icons you want to use to the Resources/Images/ folder.

C#
public partial class MainPage : ContentPage { private List<MapPin> _pins; public List<MapPin> Pins { get { return _pins; } set { _pins = value; OnPropertyChanged(); } } public MainPage() { InitializeComponent(); BindingContext = this; Pins = new List<MapPin>() { new MapPin(MapPinClicked) { Id = Guid.NewGuid().ToString(), Position = new Location(51.731551, -0.156230), IconSrc = "icon_type_one" }, new MapPin(MapPinClicked) { Id = Guid.NewGuid().ToString(), Position = new Location(51.762951, -0.182317), IconSrc = "icon_type_two" }, new MapPin(MapPinClicked) { Id = Guid.NewGuid().ToString(), Position = new Location(51.754034, -0.074997), IconSrc = "icon_type_three" }, new MapPin(MapPinClicked) { Id = Guid.NewGuid().ToString(), Position = new Location(51.704029, -0.135474), IconSrc = "icon_type_four" } }; } private void MapPinClicked(MapPin pin) { // Handle pin click } }

Android Handler

Add a new class to the Platforms/Android/ folder, in the example I'm calling it CustomMapHandler. This code takes care of loading the GoogleMap, loading and caching the icons we are using and displaying the pins on the map. It also connects up the click handler for each pin.

If you don't want to scale your icons to a particular size you can remove the iconSize code

C#
public class CustomMapHandler : MapHandler { private const int _iconSize = 60; private readonly Dictionary<string, BitmapDescriptor> _iconMap = []; public static new IPropertyMapper<MapEx, CustomMapHandler> Mapper = new PropertyMapper<MapEx, CustomMapHandler>(MapHandler.Mapper) { [nameof(MapEx.CustomPins)] = MapPins }; public Dictionary<string, (Marker Marker, MapPin Pin)> MarkerMap { get; } = []; public CustomMapHandler() : base(Mapper) { } protected override void ConnectHandler(MapView platformView) { base.ConnectHandler(platformView); var mapReady = new MapCallbackHandler(this); PlatformView.GetMapAsync(mapReady); } private static new void MapPins(IMapHandler handler, Microsoft.Maui.Maps.IMap map) { if (handler.Map is null || handler.MauiContext is null) { return; } if (handler is CustomMapHandler mapHandler) { foreach (var marker in mapHandler.MarkerMap) { marker.Value.Marker.Remove(); } mapHandler.MarkerMap.Clear(); mapHandler.AddPins(); } } private BitmapDescriptor GetIcon(string icon) { if (_iconMap.TryGetValue(icon, out BitmapDescriptor? value)) { return value; } var drawable = Context.Resources.GetIdentifier(icon, "drawable", Context.PackageName); var bitmap = BitmapFactory.DecodeResource(Context.Resources, drawable); var scaled = Bitmap.CreateScaledBitmap(bitmap, _iconSize, _iconSize, false); bitmap.Recycle(); var descriptor = BitmapDescriptorFactory.FromBitmap(scaled); _iconMap[icon] = descriptor; return descriptor; } private void AddPins() { if (VirtualView is MapEx mapEx && mapEx.CustomPins != null) { foreach (var pin in mapEx.CustomPins) { var markerOption = new MarkerOptions(); markerOption.SetTitle(string.Empty); markerOption.SetIcon(GetIcon(pin.IconSrc)); markerOption.SetPosition(new LatLng(pin.Position.Latitude, pin.Position.Longitude)); var marker = Map.AddMarker(markerOption); MarkerMap.Add(marker.Id, (marker, pin)); } } } public void MarkerClick(object sender, GoogleMap.MarkerClickEventArgs args) { if (MarkerMap.TryGetValue(args.Marker.Id, out (Marker Marker, MapPin Pin) value)) { value.Pin.ClickedCommand?.Execute(null); } } } public class MapCallbackHandler : Java.Lang.Object, IOnMapReadyCallback { private readonly CustomMapHandler mapHandler; public MapCallbackHandler(CustomMapHandler mapHandler) { this.mapHandler = mapHandler; } public void OnMapReady(GoogleMap googleMap) { mapHandler.UpdateValue(nameof(MapEx.CustomPins)); googleMap.MarkerClick += mapHandler.MarkerClick; } }

iOS Handler

In a similar vain to Android above, add a new class to Platforms/iOS/ folder. In the example I'm calling it CustomMapHandler. This code implements the same functionality as the Android Handler.

C#
public class CustomMapHandler : MapHandler { private readonly Dictionary<string, UIImage> _iconMap = []; public static new IPropertyMapper<MapEx, CustomMapHandler> Mapper = new PropertyMapper<MapEx, CustomMapHandler>(MapHandler.Mapper) { [nameof(MapEx.CustomPins)] = MapPins }; public Dictionary<IMKAnnotation, MapPin> MarkerMap { get; } = []; public CustomMapHandler() : base(Mapper) { } protected override void ConnectHandler(MauiMKMapView platformView) { base.ConnectHandler(platformView); platformView.DidSelectAnnotationView += CustomMapHandler_DidSelectAnnotationView; platformView.GetViewForAnnotation += GetViewForAnnotation; } protected override void DisconnectHandler(MauiMKMapView platformView) { base.DisconnectHandler(platformView); platformView.DidSelectAnnotationView -= CustomMapHandler_DidSelectAnnotationView; platformView.GetViewForAnnotation -= GetViewForAnnotation; } private void CustomMapHandler_DidSelectAnnotationView(object sender, MKAnnotationViewEventArgs e) { if (MarkerMap.TryGetValue(e.View.Annotation, out MapPin value)) { value.ClickedCommand?.Execute(null); PlatformView.DeselectAnnotation(e.View.Annotation, false); } } private static new void MapPins(IMapHandler handler, Microsoft.Maui.Maps.IMap map) { if (handler is CustomMapHandler mapHandler && handler.VirtualView is MapEx mapEx) { handler.PlatformView.RemoveAnnotations(mapHandler.MarkerMap.Select(x => x.Key).ToArray()); mapHandler.MarkerMap.Clear(); mapHandler.AddPins(); } } private void AddPins() { if (VirtualView is MapEx mapEx) { foreach (var pin in mapEx.CustomPins) { var marker = new MKPointAnnotation(new CLLocationCoordinate2D(pin.Position.Latitude, pin.Position.Longitude)); PlatformView.AddAnnotation(marker); MarkerMap.Add(marker, pin); } } } private MKAnnotationView GetViewForAnnotation(MKMapView mapView, IMKAnnotation annotation) { if (annotation == null || annotation is MKUserLocation) { return null; } var customPin = GetCustomPin(annotation); if (customPin == null) { return null; } return mapView.DequeueReusableAnnotation(customPin.Id) ?? new MKAnnotationView { Image = GetIcon(customPin.IconSrc), CanShowCallout = false }; } private MapPin GetCustomPin(IMKAnnotation mkPointAnnotation) { if (MarkerMap.TryGetValue(mkPointAnnotation, out MapPin value)) { return value; } return null; } private UIImage GetIcon(string icon) { if (_iconMap.TryGetValue(icon, out UIImage? value)) { return value; } var image = UIImage.FromBundle(icon); _iconMap[icon] = image; return image; } }

Final step

The last thing we need to do is register our new handlers. To do that open MauiProgram.cs and add the ConfigureMauiHandlers function to the MAUIAppBuilder.

C#
public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }) .ConfigureMauiHandlers(handlers => { #if ANDROID handlers.AddHandler<Microsoft.Maui.Controls.Maps.Map, MauiCustomMapIcons.Platforms.Android.CustomMapHandler>(); #elif IOS handlers.AddHandler<Microsoft.Maui.Controls.Maps.Map, MauiCustomMapIcons.Platforms.iOS.CustomMapHandler>(); #endif }); #if DEBUG builder.Logging.AddDebug(); #endif return builder.Build(); }

A fully working example can be found on GitHub here.