Skip to content

[Maps] Support custom icons for cluster markers - #36336

Merged
kubaflo merged 31 commits into
dotnet:net11.0from
kevin68:feature/maps-custom-cluster-appearance
Jul 30, 2026
Merged

[Maps] Support custom icons for cluster markers#36336
kubaflo merged 31 commits into
dotnet:net11.0from
kevin68:feature/maps-custom-cluster-appearance

Conversation

@kevin68

@kevin68 kevin68 commented Jul 3, 2026

Copy link
Copy Markdown

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 (bindable ImageSource?) — 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 IMapClusterImageProvider capability rather than adding a required member to IMap, so existing external IMap implementations remain source-compatible. ClusterImageVersion provides 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 ClusterImageSource or ClusterImageProvider, including mutating an existing image source, rebuilds current clusters immediately. ClusterImageSource follows the standard MAUI image lifecycle: parenting, inherited binding context, SourceChanged updates, 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 honor CachingEnabled and positive CacheValidity.

On iOS/MacCatalyst, image-service results and scaled UIImage ownership 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 GetViewForAnnotation a cluster annotation wrapped as a generic MapKit.MKAnnotationWrapper instead of the concrete MKClusterAnnotation subclass. The implementation falls back to Runtime.GetNSObject<MKClusterAnnotation>(annotation.Handle) only when the native object is a cluster annotation, preserving custom rendering, count glyphs, and cluster selection.

The ClusteringGallery sample includes Custom Cluster Icon (provider) and Static Cluster Icon actions to exercise both modes.

What NOT to Do (for future agents)

  • Do not add a required GetClusterImage member to IMap; it breaks external implementations.
  • Do not use a default interface implementation; the Maps projects target netstandard2.0, where it fails with CS8701.
  • Do not maintain separate dictionary and FIFO-order structures; concurrent loads can make them diverge and evict live entries.
  • Do not clear all cluster images for ordinary pin collection changes.

Testing

  • 98 focused MapTests pass.
  • Controls.Maps builds for netstandard2.0, net11.0-android37.0, net11.0-ios26.5, and net11.0-maccatalyst26.5.
  • Manually verified by the author on an Android tablet and iPad: static/provider icons render, update live, and survive re-zoom.

Issues Fixed

Fixes #36335

Note: targeting net11.0, not main — pin clustering (#33831) currently exists on net11.0.

Kévin Baumeyer and others added 15 commits July 3, 2026 16:48
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>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🚀 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 -- 36336

Or

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

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Jul 3, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Jul 3, 2026

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Expert Review — 4 findings

See inline comments for details.

Comment thread src/Core/maps/src/Core/IMap.cs Outdated
Comment thread src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Outdated
Comment thread src/Core/maps/src/Platform/iOS/MauiMKMapView.cs Outdated
Comment thread src/Core/maps/src/Platform/iOS/MauiMKMapView.cs Outdated
@MauiBot MauiBot added s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Jul 3, 2026
MauiBot

This comment was marked as outdated.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Jul 3, 2026
kubaflo
kubaflo previously requested changes Jul 3, 2026

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you please check the ai's suggestions?

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
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
@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
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
@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
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
@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@kubaflo
kubaflo requested a review from jfversluis July 30, 2026 19:13
@kubaflo

kubaflo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Latest synchronized head is conflict-free and the full maui-pr build plus Build Analysis passed. All review threads are resolved. Requesting a fresh maintainer approval to clear the final merge gate.

@kubaflo
kubaflo merged commit c0c5ac3 into dotnet:net11.0 Jul 30, 2026
31 checks passed
@github-actions github-actions Bot added this to the .NET 11.0-preview7 milestone Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants