Skip to content

Add Custom Pin Icons support for Maps#33950

Merged
jfversluis merged 8 commits intonet11.0from
feature/custom-pin-icons
Mar 2, 2026
Merged

Add Custom Pin Icons support for Maps#33950
jfversluis merged 8 commits intonet11.0from
feature/custom-pin-icons

Conversation

@jfversluis
Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description

Adds the ability to use custom images for map pins instead of the default platform markers.

Fixes #10400

Changes

API Changes

  • Added IImageSource? ImageSource property to IMapPin interface
  • Added ImageSource bindable property to Pin class

Platform Implementation

  • iOS/MacCatalyst: Uses MKAnnotationView with custom image (scaled to 32x32 points)
  • Android: Loads image before marker creation via BitmapDescriptorFactory.FromBitmap() (scaled to 64x64 pixels)
  • Both platforms maintain aspect ratio when scaling

Usage

var pin = new Pin
{
    Label = "Custom Pin",
    Location = new Location(47.6062, -122.3321),
    ImageSource = "my_custom_icon.png"
};
map.Pins.Add(pin);

Testing

  • Added 5 unit tests for ImageSource property
  • Added CustomPinIconGallery sample page
  • Verified on Android emulator and MacCatalyst

Copilot AI review requested due to automatic review settings February 9, 2026 09:12
@jfversluis jfversluis added this to the .NET 11.0-preview1 milestone Feb 9, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class support for custom pin icons in .NET MAUI Maps by introducing a new ImageSource property on map pins and wiring platform renderers to apply that image when creating the native marker/annotation view.

Changes:

  • Added IMapPin.ImageSource (Core Maps) and Pin.ImageSource bindable property (Controls Maps).
  • Implemented custom icon application for pins on Android (via MarkerOptions.SetIcon) and iOS/MacCatalyst (via MKAnnotationView.Image).
  • Added unit tests for the new Pin.ImageSource property and a new sample gallery page to demonstrate usage.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/Core/maps/src/Core/IMapPin.cs Adds IMapPin.ImageSource to the Core Maps pin contract.
src/Core/maps/src/Handlers/MapPin/MapPinHandler.cs (+ platform partials) Adds ImageSource to the pin handler mapper and introduces per-platform MapImageSource methods (currently no-ops on some platforms).
src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Loads/scales an image and sets a custom marker icon before calling Map.AddMarker().
src/Core/maps/src/Platform/iOS/MauiMKMapView.cs Chooses a custom annotation view and asynchronously loads/scales an image for the annotation view.
src/Controls/Maps/src/Pin.cs + HandlerImpl/Pin.Impl.cs Adds bindable Pin.ImageSource and exposes it via explicit IMapPin.ImageSource.
src/Controls/tests/Core.UnitTests/PinTests.cs Adds unit tests validating default value, property change notifications, and interface projection.
src/Controls/samples/.../CustomPinIconGallery.xaml(.cs) + MapsGallery.cs Adds a sample page and navigation entry demonstrating custom pin icons.
PublicAPI.Unshipped.txt (multiple TFMs) Declares the newly shipped APIs across target frameworks.

Comment on lines 414 to +430
void AddPins(IList pins)
{
//Mapper could be called before we have a Map ready
_pins = pins;
if (Map == null || MauiContext == null)
return;

if (_markers == null)
_markers = new List<Marker>();

foreach (var p in pins)
{
IMapPin pin = (IMapPin)p;
Marker? marker;
AddPinAsync(pin).FireAndForget();
}
_pins = null;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddPins kicks off AddPinAsync(pin).FireAndForget() for each pin, but there’s no cancellation/generation check. If MapPins runs again (e.g., pins collection changes) and removes markers while earlier AddPinAsync calls are still awaiting image loads, those tasks can still add markers afterward, leaving stale/duplicate markers on the map. Consider tracking a version/cancellation token and bailing out before Map.AddMarker if a newer MapPins run occurred or the pin is no longer present.

Copilot uses AI. Check for mistakes.
@jfversluis
Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 3 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 26, 2026

Independent Code Review

Summary: Adds ImageSource property to Pin for custom marker icons. Android renders via BitmapDescriptorFactory.FromBitmap, iOS via MKAnnotationView.Image.

✅ What Looks Good

  • Clean property addition following existing patterns
  • iOS implementation handles annotation view reuse correctly (checks annotationView.Annotation != targetAnnotation after async)
  • Both platforms scale images to reasonable sizes (64x64 Android, 32x32pt iOS)
  • Good unit tests including interface implementation verification

🔴 Issues

1. Thread-safety bug in AddPinAsync
AddPins now fires AddPinAsync for each pin via FireAndForget. All tasks run concurrently but mutate _markers without synchronization:

if (_markers == null)
    _markers = new List<Marker>();
_markers.Add(marker);

List<T>.Add is not thread-safe. With 50 pins, this can corrupt the list.

2. Android indentation error
The re-check after async has broken indentation:

// Re-check after async operation since handler may have been disconnected
if (Map == null || MauiContext == null)
return;

The return is not indented inside the if block. This will compile correctly but is clearly a formatting mistake.

3. Debug.WriteLine for image load failures
Silent failure in Release builds where Debug.WriteLine is stripped. Use ILogger for consistency with the rest of the codebase.

4. No bitmap disposal after SetIcon
Android DrawableToBitmap creates bitmaps but they're never disposed after being passed to BitmapDescriptorFactory.FromBitmap. The intermediate bitmap should be disposed.

5. iOS UIGraphics.BeginImageContextWithOptions is deprecated
On newer iOS versions, UIGraphicsImageRenderer should be used instead.

🟡 Nits

  • MapPinHandler.MapImageSource is a no-op on every platform — the actual work is done in MapHandler. The mapper entry is documented but misleading since it never does anything.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 26, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 33950

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 33950"

@jfversluis
Copy link
Copy Markdown
Member Author

Thanks for the thorough review @kubaflo! Addressed several issues:

Fixed in latest commit:

  • Indentation error — Fixed the broken indentation in Android \AddPinAsync\ re-check after async
  • CancellationToken — Added \CancellationTokenSource\ to \AddPins/\AddPinAsync\ to prevent stale markers when the pin collection changes during async image loading
  • Debug.WriteLine → ILogger — Both Android and iOS now use \ILogger\ for image load failures instead of \Debug.WriteLine\

Responses to other points:

Thread-safety in _markers.Add — The \AddPinAsync\ calls all originate from the UI thread (\AddPins\ is called from the handler mapper). \FireAndForget\ resumes on the captured sync context (UI thread), so the \Map.AddMarker\ call and _markers.Add\ always run on the UI thread. The concurrent image loading happens off-thread, but the marker addition is serialized through the UI thread.

Bitmap disposal after SetIcon — \BitmapDescriptorFactory.FromBitmap()\ copies the bitmap data internally, so the intermediate bitmap can be safely recycled. However, calling \�itmap.Recycle()\ immediately after might cause issues on some Android versions. The bitmap will be GC'd naturally. This is consistent with how Google Maps samples handle it.

iOS UIGraphics.BeginImageContextWithOptions deprecated — Good observation. \UIGraphicsImageRenderer\ is the modern replacement. Filed for a follow-up to avoid scope creep.

MapPinHandler.MapImageSource no-op — Correct observation. The mapper entry exists for API completeness (property changes go through the pin handler's mapper), but the actual work is done in \MapHandler.AddPinAsync\ during pin creation. This is a design trade-off: loading the image during \GetViewForAnnotation\ / \AddMarker\ is more efficient than re-loading on every property change.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 27, 2026

🔍 Round 2 Review — PR #33950 (Custom Pin Icons)

Updated with author response analysis

✅ Fixed Since Round 1

  • CancellationTokenSource (_addPinsCts) now cancels stale pin additions when AddPins is called again.
  • Post-async null checks: if (ct.IsCancellationRequested || Map == null || MauiContext == null) return; properly guards against disconnected handlers.
  • Android bitmap scaling: DrawableToBitmap properly disposes intermediate bitmaps when scaled != bitmap, and disposes the canvas.
  • iOS annotation reuse detection: Captures targetAnnotation before async and compares after.
  • Proper logging: Both platforms use ILogger instead of Debug.WriteLine.

⚠️ Issues & Author Responses

1. 🐛 iOS variable shadowing — potential compile errorStill flagged (NOT addressed by author)

async Task ApplyCustomImageAsync(MKAnnotationView annotationView, IMapPin pin)
{
    _handlerRef.TryGetTarget(out IMapHandler? handler);  // ← declares handler
    ...
    catch (Exception ex)
    {
        if (_handlerRef.TryGetTarget(out var handler))   // ← redeclares handler (CS0136?)

The handler variable in the catch block appears to shadow the outer declaration, which should be CS0136 in C#. The author's response did not address this point. Please verify this compiles on iOS target — if it does, I may be wrong about the scoping rules, but it's worth double-checking.

2. Race condition on _markers ListRetracted
Author correctly explains: FireAndForget resumes on the captured SynchronizationContext (UI thread). Since AddPins is called from the handler mapper on the UI thread, all AddPinAsync continuations (including _markers.Add) are serialized through the UI dispatcher. The concurrent image loading happens off-thread, but marker addition is UI-thread-serialized. I was wrong about the race condition. ✅

3. Android bitmap not disposed after SetIconSoftened to informational
Author notes: BitmapDescriptorFactory.FromBitmap() copies the bitmap data internally. Calling bitmap.Recycle() immediately might cause issues on some Android versions. The bitmap will be GC'd naturally. This is consistent with Google Maps sample code patterns. While explicit disposal would be ideal from a memory perspective, the practical risk is low. ✅

4. iOS UIGraphics.BeginImageContextWithOptions deprecatedAcknowledged, follow-up filed
Author acknowledges and filed for a follow-up PR to avoid scope creep. Using UIGraphicsImageRenderer is the modern replacement but not a blocking issue. ✅

5. Hardcoded icon sizes (64x64 Android, 32x32 iOS)Still noted as enhancement opportunity
Different pin types may need different sizes. Consider adding an IconSize property in a future enhancement. Not a blocker for initial implementation.

Summary

My most significant concern (race condition on _markers) was wrong — the SynchronizationContext capture in FireAndForget serializes the mutations on the UI thread. The iOS variable shadow issue (#1) is the only remaining concern that needs verification. If it compiles, this PR is ready.

@jfversluis
Copy link
Copy Markdown
Member Author

Response to Round 2 Review

Good catch on the iOS variable shadowing @kubaflo!

1. iOS variable shadowing - Fixed
You were right - handler on line 139 and out var handler on line 163 would cause CS0136 on iOS builds. Renamed the catch block variable to currentHandler to avoid the conflict. This wasn't caught in our local validation because we were building Windows TFM only.

Thanks for the thorough second look!

@jfversluis
Copy link
Copy Markdown
Member Author

/azp run maui-pr

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 27, 2026

🔍 Round 3 — PR #33950 (Custom Pin Icons)

✅ iOS Variable Shadowing — Fixed

Verified the fix: out var handlerout var currentHandler in the catch block of ApplyCustomImageAsync. CS0136 is resolved. Good that this was caught before merge — building only the Windows TFM wouldn't surface iOS-specific compiler errors.

📋 Final Status — Ready to merge

All original issues are now resolved:

  • ✅ CancellationTokenSource for stale pin cancellation
  • ✅ Post-async null/disconnection guards
  • ✅ ILogger instead of Debug.WriteLine
  • ✅ iOS annotation reuse detection
  • ✅ iOS variable shadow fix (currentHandler)
  • ℹ️ UIGraphics deprecation — filed for follow-up (non-blocking)
  • ℹ️ Hardcoded icon sizes — enhancement opportunity (non-blocking)

Recommendation: Merge when CI passes. The Build Analysis failure appears to be infrastructure-level (same failure across all 8 map PRs, not PR-specific).

Adds ImageSource property to Pin for custom map marker icons.

- Add IImageSource? ImageSource to IMapPin interface
- Add bindable ImageSource property to Pin class
- iOS: Use MKAnnotationView with custom image instead of MKMarkerAnnotationView
- Android: Load image before creating marker via BitmapDescriptorFactory
- Add 5 unit tests for ImageSource property
- Add CustomPinIconGallery sample page

Fixes #10400
jfversluis and others added 7 commits March 1, 2026 18:32
- Fix race condition: verify annotation view hasn't been reused after async load
- Clear annotation image on reuse before loading new image
- Dispose intermediate bitmap and recycle after scaling (Android)
- Re-check Map/MauiContext after await (Android)
- Log warnings instead of silently swallowing image load failures
- Fix XML docs: image is scaled, not displayed at natural size
- Fix comment: image is from file, not embedded resource
- Fix comment: pixels, not dp
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ion type

- Qualify System.Exception in Android handler (vs Java.Lang.Exception)
- Add using System for EventArgs
- Fix nullable event handler parameters
- Fully qualify Location type to avoid XAML sourcegen conflict

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix indentation error in AddPinAsync re-check after async
- Add CancellationToken to AddPinAsync to prevent stale markers when
  pin collection changes during async image loading
- Replace Debug.WriteLine with ILogger for image load failures (Android/iOS)
- Use null-coalescing assignment for _markers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rename 'handler' to 'currentHandler' in catch block to avoid
CS0136 conflict with outer scope 'handler' variable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jfversluis jfversluis force-pushed the feature/custom-pin-icons branch from 36424e7 to 3989ba6 Compare March 1, 2026 17:39
@jfversluis jfversluis disabled auto-merge March 2, 2026 10:43
@jfversluis jfversluis merged commit 7c14d5c into net11.0 Mar 2, 2026
25 of 29 checks passed
@jfversluis jfversluis deleted the feature/custom-pin-icons branch March 2, 2026 10:43
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

🚨 API change(s) detected @davidortinau FYI

@dotnet dotnet deleted a comment from dotnet-policy-service bot Mar 2, 2026
@github-actions github-actions bot locked and limited conversation to collaborators Apr 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants