[Maps] Support custom icons for cluster markers - #36336
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two new public members to support custom cluster marker images: - ClusterImageProvider: A Func callback to compute custom icons per cluster - ClusterImageSource: A static bindable property for a default cluster icon Both properties work together: provider has priority, falls back to source, then to default cluster marker. Platform handlers load returned ImageSource asynchronously like Pin.ImageSource. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… null Move GetClusterImage call out of the `if (members != null)` gate so that a static ClusterImageSource applies even for degenerate clusters with null members. When members is null an empty pin list is passed. Add a brief comment explaining that GetPinForAnnotation resolves reliably for cluster members via MarkerId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting Map.ClusterImageSource or Map.ClusterImageProvider on a map that already has pins was a silent no-op until the next unrelated recluster (e.g. a zoom), since neither property was wired to trigger a handler update. Both now call the same Handler.UpdateValue(Pins) hook already used elsewhere in Map.cs to force a full pin/cluster rebuild, so a changed cluster icon is reflected immediately. ClusterImageSource gets a propertyChanged callback (fires for C#, XAML, and binding sets). ClusterImageProvider becomes a manually backed property with the same side effect in its setter, since a plain Func delegate isn't a meaningful bindable value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers the degenerate cluster case (e.g. iOS MKClusterAnnotation with null MemberAnnotations): Count=0, DefaultClusteringIdentifier, and falling back to a static ClusterImageSource. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Six demo buttons in one non-wrapping HorizontalStackLayout clipped off narrow viewports. Group pin-management and cluster-icon-demo actions into two separate scrollable rows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This handler added 5 far-apart custom-icon pins without clearing existing pins or an active ClusterImageProvider/Source, so clicking a cluster-icon demo button first could swallow the new pins into a custom-icon cluster bubble instead of showing their own ImageSource - defeating the point of the demo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…er icons Three related fixes from the maui-expert-reviewer rubric pass: 1. IMap.GetClusterImage now has a default implementation (=> null) instead of a plain abstract member, removing the theoretical breaking-interface-change risk the rubric flags for any future third-party IMap implementer (Map is the only one today, but this costs nothing and matches the rubric's own recommended mitigation). 2. GetClusterImage gains an explicit 'count' parameter, independent of the 'pins' list length. On iOS, ClusterInfo.Count previously came from how many of a cluster's MemberAnnotations could be resolved back to a Pin via GetPinForAnnotation, so a lookup miss silently under-reported the cluster size to ClusterImageProvider. iOS now passes MemberAnnotations.Length as the authoritative count; Android is unaffected (already passed the true cluster.Pins.Count). Covered by a new unit test that decouples count from pins.Count. 3. Both platforms now cache the loaded/rasterized cluster icon keyed by ImageSource reference identity, so a static ClusterImageSource (or a provider returning a stable instance) is decoded once instead of on every recluster (zoom/pan). Caches are cleared on handler disconnect/cleanup to avoid holding bitmaps across the handler's lifetime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On the currently-installed iOS 26.5/net11 preview.5 workload, MapKit's cluster annotations were sometimes handed to our GetViewForAnnotation delegate as a generic MapKit.MKAnnotationWrapper rather than the concrete MKClusterAnnotation subclass, so 'annotation is MKClusterAnnotation' silently failed and our custom cluster-icon code (and the default count-glyph fallback) was never reached — MapKit still clustered natively, but always fell through to per-pin handling instead. Confirmed on a physical iPad via device console logs: every wrapped annotation successfully re-resolved to a valid MKClusterAnnotation (with correct MemberAnnotations) via Runtime.GetNSObject<T> on its native handle. Only attempt that re-resolution when the runtime type is exactly the ambiguous wrapper - Runtime.GetNSObject<T> throws InvalidCastException (not null) for a handle whose real native class doesn't match T, so trying it unconditionally on every regular pin (typically a plain MKPointAnnotation) crashed the app instead of falling through to normal pin rendering. Verified on device: cluster markers now render both the static and provider-based custom icons correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36336Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36336" |
|
Hey there @@kevin68! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
Hey there @kevin68! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 4 findings
See inline comments for details.
kubaflo
left a comment
There was a problem hiding this comment.
Could you please check the ai's suggestions?
…m-cluster-appearance
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
…m-cluster-appearance\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 7c7f8afa-6548-4a5c-aa86-946e2db624ef
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c7f8afa-6548-4a5c-aa86-946e2db624ef
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c7f8afa-6548-4a5c-aa86-946e2db624ef
|
/azp run maui-pr |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
Latest synchronized head is conflict-free and the full |
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 of Change
Adds a custom-icon API for Maps cluster markers on Android and iOS/MacCatalyst.
New public API on
Map:ClusterImageProvider(Func<ClusterInfo, ImageSource?>?) — dynamic, per-cluster icon with highest priority.ClusterImageSource(bindableImageSource?) — static icon for all clusters, used when the provider is unset or returns null.ClusterInfo— read-only context (Count,ClusteringIdentifier,Pins,Location) passed to the provider.The handler consumes this through the optional
IMapClusterImageProvidercapability rather than adding a required member toIMap, so existing externalIMapimplementations remain source-compatible.ClusterImageVersionprovides a change token for precise cache invalidation.Resolution order: provider → static source → existing default bubble. Apps that do not configure either property retain the existing behavior.
Changing
ClusterImageSourceorClusterImageProvider, including mutating an existing image source, rebuilds current clusters immediately.ClusterImageSourcefollows the standard MAUI image lifecycle: parenting, inherited binding context,SourceChangedupdates, and cancellation when replaced.Both platforms use a bounded LRU cache keyed by stable image content. Same-key concurrent loads are coalesced into one decode/rasterization, ordinary pin updates preserve warm entries, and cache invalidation tracks both the owning map and
ClusterImageVersion. URI sources honorCachingEnabledand positiveCacheValidity.On iOS/MacCatalyst, image-service results and scaled
UIImageownership are disposed deterministically across annotation reuse, uncached loads, eviction, cleanup, and pooled map reuse.iOS bug found and fixed along the way: on the iOS 26.5/.NET 11 preview 5 bindings, MapKit can hand
GetViewForAnnotationa cluster annotation wrapped as a genericMapKit.MKAnnotationWrapperinstead of the concreteMKClusterAnnotationsubclass. The implementation falls back toRuntime.GetNSObject<MKClusterAnnotation>(annotation.Handle)only when the native object is a cluster annotation, preserving custom rendering, count glyphs, and cluster selection.The
ClusteringGallerysample includes Custom Cluster Icon (provider) and Static Cluster Icon actions to exercise both modes.What NOT to Do (for future agents)
GetClusterImagemember toIMap; it breaks external implementations.netstandard2.0, where it fails with CS8701.Testing
MapTestspass.Controls.Mapsbuilds fornetstandard2.0,net11.0-android37.0,net11.0-ios26.5, andnet11.0-maccatalyst26.5.Issues Fixed
Fixes #36335