Skip to content

Add Pin Clustering support for Maps#33831

Merged
jfversluis merged 9 commits intonet11.0from
feature/pin-clustering
Feb 27, 2026
Merged

Add Pin Clustering support for Maps#33831
jfversluis merged 9 commits intonet11.0from
feature/pin-clustering

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 Pin Clustering support for the Maps control on iOS/MacCatalyst and Android.

Fixes #11811

Changes

New API

  • Map.IsClusteringEnabled - Enable/disable pin clustering
  • Pin.ClusteringIdentifier - Group pins into named clusters
  • Map.ClusterClicked event - Fired when a cluster marker is tapped
  • ClusterClickedEventArgs - Contains the list of pins in the clicked cluster

iOS/MacCatalyst Implementation

  • Uses native MKClusterAnnotation support
  • Cluster clicks handled via DidSelectAnnotationView delegate
  • Automatic cluster management by MapKit

Android Implementation

  • Custom grid-based clustering algorithm (no external dependencies)
  • Clusters recalculate automatically when zoom level changes
  • Programmatic cluster icons showing pin count
  • Both cluster taps and individual pin taps work correctly

Testing

  • 5 unit tests added for clustering functionality
  • Gallery sample page added (ClusteringGallery)
  • Manually verified on MacCatalyst and Android emulator

Copilot AI review requested due to automatic review settings February 2, 2026 10:15
@jfversluis jfversluis added this to the .NET 11.0-preview1 milestone Feb 2, 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 pin clustering support to the MAUI Maps control for iOS/MacCatalyst and Android platforms. When enabled, pins that are close together are automatically grouped into cluster markers showing the count of pins. Users can tap clusters to zoom in and see individual pins.

Changes:

  • New API properties: Map.IsClusteringEnabled (bool), Pin.ClusteringIdentifier (string)
  • New event: Map.ClusterClicked with ClusterClickedEventArgs containing the pins and location
  • iOS/MacCatalyst uses native MKClusterAnnotation support (iOS 11+)
  • Android implements custom grid-based clustering algorithm with programmatic cluster icons
  • Sample gallery page demonstrates clustering with 30-100+ pins
  • 5 unit tests added covering property defaults, event handling, and API implementation

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
PublicAPI.Unshipped.txt (all platforms) Adds new public API entries for clustering properties and methods
IMap.cs Adds IsClusteringEnabled property and ClusterClicked method to interface
IMapPin.cs Adds ClusteringIdentifier property to interface
Map.cs Implements IsClusteringEnabled bindable property and ClusterClicked event
Map.Impl.cs Implements IMap.ClusterClicked to raise event and return handled status
Pin.cs Adds ClusteringIdentifier bindable property with default constant
ClusterClickedEventArgs.cs New event args class containing pins and location with Handled property
MauiMKMapView.cs iOS implementation using MKClusterAnnotation with cluster view creation and zoom behavior
MapHandler.iOS.cs iOS handler mapping for IsClusteringEnabled property
MapHandler.Android.cs Android implementation with custom clustering algorithm, cluster icons, and zoom-based reclustering
MapHandler.*.cs (stubs) NotImplementedException stubs for Windows, Tizen, Standard platforms
MapTests.cs 5 new unit tests for clustering properties, events, and API contracts
ClusteringGallery.xaml/.cs Sample page demonstrating clustering with interactive controls
MapsGallery.cs Navigation button added to access clustering gallery

Comment thread src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Outdated
Comment thread src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Outdated

// Recalculate clusters when zoom level changes significantly
var currentZoom = _googleMap.CameraPosition.Zoom;
if (Math.Abs(currentZoom - _lastClusterZoom) > 0.5f)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The cluster recalculation threshold of 0.5 zoom levels may cause too-frequent reclustering, especially during smooth zoom animations. This could lead to performance issues and visual jitter. Consider increasing the threshold (e.g., 1.0 or 1.5) or implementing a debounce mechanism to avoid recalculating during continuous zoom operations.

Suggested change
if (Math.Abs(currentZoom - _lastClusterZoom) > 0.5f)
if (Math.Abs(currentZoom - _lastClusterZoom) > 1.0f)

Copilot uses AI. Check for mistakes.
Comment thread src/Core/maps/src/Handlers/Map/MapHandler.Android.cs
Comment thread src/Core/maps/src/Handlers/Map/MapHandler.iOS.cs Outdated
Comment on lines +139 to +143
else if (OperatingSystem.IsIOSVersionAtLeast(11))
{
// Clear clustering identifier when disabled
mapPin.ClusteringIdentifier = null;
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

If clustering is disabled via toggling IsClusteringEnabled from true to false, this code clears the ClusteringIdentifier on pin views. However, if clustering is re-enabled later, pins will have lost their original ClusteringIdentifier values. Consider preserving the identifier in the underlying IMapPin data and only conditionally applying it to the view, rather than clearing it.

Copilot uses AI. Check for mistakes.
Comment thread src/Core/maps/src/Core/IMap.cs
Comment thread src/Controls/Maps/src/Pin.cs
Comment thread src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Outdated
@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 IsClusteringEnabled property, Pin.ClusteringIdentifier, ClusterClicked event, and IMap.ClusterClicked interface method. Includes sample and unit tests.

✅ What Looks Good

  • Well-designed API: IsClusteringEnabled on Map, ClusteringIdentifier on Pin, ClusterClicked event with Handled property
  • ClusterClickedEventArgs.Handled allows suppressing default zoom behavior — nice escape hatch
  • DefaultClusteringIdentifier constant avoids magic strings
  • Good test coverage (7 tests)

🔴 Issues

1. Android implements custom clustering, not Google Maps Utils
The diff shows MapCluster and manual cluster management fields, suggesting a custom implementation rather than using the widely-used android-maps-utils clustering library. Custom clustering implementations are notoriously difficult to get right (spatial indexing, animation, performance with 1000+ pins). Why not use the established library?

2. OfType<Pin>() cast in ClusterClicked loses non-Pin implementations

var controlPins = pins.OfType<Pin>().ToList();

This will silently drop any IMapPin implementations that aren't Pin. This could lose data.

3. No iOS implementation visible
MapKit has native clustering support via MKClusterAnnotation. Is it implemented? The diff was truncated but no iOS handler changes were shown in the visible portion.

4. Breaking interface change
Adding ClusterClicked and IsClusteringEnabled to IMap breaks any existing external implementations of the interface.

🟡 Nits

  • Sample uses _random as an instance field without seed — results aren't reproducible for debugging
  • Consider making DefaultClusteringIdentifier internal or at least documenting why it's public

@github-actions
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 -- 33831

Or

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

@jfversluis
Copy link
Copy Markdown
Member Author

Thanks for the review @kubaflo! Addressed the unresolved items and responding to other points:

Unresolved thread 1: Cluster recalculation threshold
Fixed — increased from 0.5 to 1.0 zoom levels to reduce jitter during smooth zoom animations.

Unresolved thread 2: iOS ClusteringIdentifier preservation
The concern is already handled by design. The \ClusteringIdentifier\ on the view (\MKAnnotationView) is transient — it's cleared when clustering is disabled. But the original value is preserved in the \IMapPin\ data (the model). When clustering is re-enabled, \GetPinForAnnotation()\ re-reads the identifier from the model and reapplies it to the view. Added a clarifying comment.

1. Custom clustering vs Google Maps Utils
Intentional design choice. Adding \�ndroid-maps-utils\ would introduce an external dependency with its own versioning/compatibility concerns. The custom grid-based approach is simpler, has zero external dependencies, and works well for the typical pin counts in mobile apps (~hundreds, not thousands). For very large datasets, we can recommend the Google Maps Utils library as a community solution.

2. OfType() cast
\Pin\ is the only public implementation of \IMapPin\ in MAUI. The \OfType()\ filters correctly for the public API surface. External \IMapPin\ implementations would need to be wrapped or use the \IMap.ClusterClicked\ interface method directly (which receives \IReadOnlyList).

3. Breaking interface change
These are new APIs in .NET 11. \IMap\ is an internal implementation detail between the control and handler — external implementations of \IMap\ are not a supported scenario. The interface is in \Microsoft.Maui.Maps\ namespace, not intended for external implementation.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 27, 2026

🔍 Round 2 Review — PR #33831 (Pin Clustering)

Updated with author response analysis

✅ What Looks Good

  • ClusterClickedEventArgs.Handled property lets consumers suppress default zoom-to-cluster behavior — excellent UX pattern.
  • iOS implementation correctly uses MKClusterAnnotation and iOS 11+ version checks.
  • Pin.ClusteringIdentifier with DefaultClusteringIdentifier constant allows category-based clustering.
  • IsClusteringEnabled is a proper BindableProperty with XAML support.
  • Tests cover enable/disable toggle, cluster event firing, and default values.

⚠️ Issues & Author Responses

1. Custom clustering vs Google Maps UtilsRetracted
Author explains: android-maps-utils would introduce an external dependency with versioning/compatibility concerns. The custom grid-based approach is simpler, has zero external dependencies, and works for typical pin counts (~hundreds). For very large datasets, Google Maps Utils can be recommended as a community solution. Valid trade-off. ✅

2. OfType<Pin>() silently drops non-Pin IMapPin implementationsSoftened
Author notes Pin is the only public IMapPin implementation in MAUI. External IMapPin implementations can use IMap.ClusterClicked directly (which receives IReadOnlyList<IMapPin>). This is technically valid, but the silent filtering without logging still feels like a subtle trap. Remaining suggestion: Consider logging a debug warning if pins.Count != controlPins.Count to help future developers who implement IMapPin understand why their pins are missing from cluster events.

3. Breaking IMap/IMapPin interface changesRetracted
Author explains IMap is an internal contract between MAUI controls and handlers, not intended for external implementation. These are new .NET 11 APIs. Consistent with the handler pattern across all MAUI controls. ✅

4. iOS clustering identifier lifecycleRetracted
Author clarifies: ClusteringIdentifier on the view (MKAnnotationView) is transient, but the value is preserved in the IMapPin model. When clustering is re-enabled, GetPinForAnnotation() re-reads the identifier from the model and reapplies it. Clarifying comment added. ✅

5. Thread safety of GetPinForAnnotation during cluster eventsStill noted (minor, pre-existing pattern)
If AddPins/RemovePins race with OnClusterClicked iterating member annotations, the pin lookup could have issues. However, this is a pre-existing pattern throughout the map handler, not introduced by this PR. Low risk since handler mapper calls are UI-thread-serialized.

Summary

Author's responses were convincing across the board. The custom clustering approach over external dependencies is a valid architectural choice. The OfType<Pin>() filtering is the only point I still flag, and even that is a minor suggestion rather than a blocker. This PR is in good shape.

@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 #33831 (Pin Clustering) — Final Recommendation

One minor suggestion remains (logging when OfType<Pin>() filters out non-Pin implementations) but it's not a blocker — Pin is currently the only IMapPin implementation.

Recommendation: Merge when CI passes. The Build Analysis failure is infrastructure-level.

Merge order note: This PR modifies IMap.cs and IMapPin.cs interfaces. Merging after #33432 and #33792 would minimize rebase conflicts.

@jfversluis
Copy link
Copy Markdown
Member Author

/rebase

jfversluis and others added 9 commits February 27, 2026 15:29
- Add IsClusteringEnabled property to Map
- Add ClusteringIdentifier property to Pin
- Add ClusterClicked event with ClusterClickedEventArgs
- Implement iOS clustering using MKClusterAnnotation
- Add Android/Windows/Tizen stubs
- Add unit tests for clustering functionality
- Add ClusteringGallery sample page

Fixes #11811
Implements built-in pin clustering for Android platform:
- Grid-based clustering algorithm that groups nearby pins
- Cluster radius adjusts based on zoom level
- Cluster markers with visual count indicator
- ClusterClicked event fires when cluster is tapped
- Default behavior zooms to show cluster contents
- Respects ClusteringIdentifier for grouping

Implementation notes:
- Uses custom clustering algorithm (no external library dependency)
- Creates cluster markers with programmatic blue circle icons
- Clusters dynamically adjust when IsClusteringEnabled changes
Switch from gesture recognizers to delegate-based handling for
cluster clicks. MapKit intercepts taps before gesture recognizers,
so cluster clicks need to be handled in the annotation selection
delegate instead.

- Remove gesture recognizer attachment for clusters
- Handle cluster selection in MkMapViewOnAnnotationViewSelected
- Disable callout for cluster markers (don't need them)
- Deselect cluster after handling to allow re-selection
- Reduce cluster radius for more reasonable grouping
- Add IOnCameraIdleListener to recalculate clusters when zoom changes
- Clusters now properly split as user zooms in
- Both cluster and individual pin clicks work correctly
- Fix clustering identifier to use Pin.DefaultClusteringIdentifier constant
- Fix distance calculation with cos(latitude) adjustment for accuracy near poles
- Dispose Paint/Canvas objects to prevent native memory leaks
- Add null-safety for VirtualView.ClusterClicked
- Clear stale cluster data when clustering is disabled
- Document cluster radius constant
- Fix iOS duplicate pins when toggling clustering (remove before re-add)
- Fix nullable test variable
- Await DisplayAlert in gallery sample
- Clear _clusters and _clusterMarkers when clustering is disabled
- Use 'using' for Canvas and Paint to prevent native memory leaks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace Pin.DefaultClusteringIdentifier with literal string (handler can't reference Controls)
- Remove non-existent RemovePins call (AddPins already clears annotations)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Increase cluster recalculation threshold from 0.5 to 1.0 zoom levels
  to reduce visual jitter during smooth zoom animations
- Clarify iOS ClusteringIdentifier comment: view property is transient,
  IMapPin data preserves the original value for re-enablement

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions bot force-pushed the feature/pin-clustering branch from 1621e29 to d4ef0e7 Compare February 27, 2026 15:29
@jfversluis jfversluis disabled auto-merge February 27, 2026 18:14
@jfversluis jfversluis merged commit c7e6e46 into net11.0 Feb 27, 2026
20 of 27 checks passed
@jfversluis jfversluis deleted the feature/pin-clustering branch February 27, 2026 18:14
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

🚨 API change(s) detected @davidortinau FYI

@dotnet dotnet deleted a comment from dotnet-policy-service bot Feb 27, 2026
davidortinau added a commit to dotnet/docs-maui that referenced this pull request Mar 10, 2026
- Add Pin Clustering section to map.md with API reference, XAML/C# examples,
  and platform notes (iOS/Mac Catalyst via MKClusterAnnotation, Android
  grid-based, Windows not yet supported)
- Update whats-new/dotnet-11.md with Map pin clustering entry and sample badge

Upstream PR: dotnet/maui#33831

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions bot locked and limited conversation to collaborators Mar 30, 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.

4 participants