Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
90f3630
[Maps] Add ClusterInfo context type
Jun 30, 2026
29c317c
[Maps] Add Map.ClusterImageProvider and ClusterImageSource
Jun 30, 2026
4af3f93
[Maps] Add IMap.GetClusterImage resolving provider then static icon
Jun 30, 2026
7900f15
[Maps] Restore trailing newline in Core.maps PublicAPI files
Jun 30, 2026
0eb7e7e
[Android] Render custom cluster image when provided
Jun 30, 2026
9a02b82
[iOS] Render custom cluster image when provided
Jun 30, 2026
882fbc9
[Maps] Demonstrate custom cluster icons in ClusteringGallery sample
Jun 30, 2026
b3ea13a
[Maps/iOS] Always call GetClusterImage even when MemberAnnotations is…
Jun 30, 2026
547f375
[Android] Fix indentation lost during rebase conflict resolution
Jul 3, 2026
c126870
[Maps] Rebuild clusters when ClusterImageSource/Provider changes
Jul 3, 2026
93c8d03
[Maps] Add test for GetClusterImage with an empty pin list
Jul 3, 2026
1435b7e
[sample] Split ClusteringGallery button row to avoid overflow
Jul 3, 2026
9b09ca0
[sample] Reset cluster-icon demo state in OnAddCustomPinsClicked
Jul 3, 2026
22480cd
[Maps] Add DIM to GetClusterImage, fix iOS cluster count, cache clust…
Jul 3, 2026
21ba3b9
[iOS] Fix MKClusterAnnotation type detection for preview.5 binding skew
Jul 3, 2026
a304310
[Maps] Make IMap.GetClusterImage a required interface member
Jul 6, 2026
9643a15
[Maps] Fix cluster-icon cache keys and add iOS load-failure fallback
Jul 6, 2026
f5d340a
[Maps] Harden cluster-icon paths: crash guards, perf, and cache lifec…
Jul 6, 2026
03d771c
[Maps] Simplify cluster-marker null check with null-conditional indexer
Jul 7, 2026
4d6d6a3
[Maps] Resolve cluster pins lazily so count-only providers pay nothing
Jul 8, 2026
e51015d
[iOS] Map: Extract cluster detection into TryGetClusterAnnotation helper
Jul 9, 2026
67a607b
[iOS] Map: Detect binding-skew clusters in selection path, keep LazyM…
Jul 10, 2026
32557ad
[Maps] Honor UriImageSource.CacheValidity in cluster icon caches
Jul 10, 2026
7025e47
[iOS] Map: Make LazyMapPinList.Count consistent with its indexer
Jul 15, 2026
69552be
[Maps] Evict cluster icon cache oldest-first instead of wiping it
Jul 15, 2026
a2ae5f0
Merge net11.0 into feature/maps-custom-cluster-appearance
Copilot Jul 29, 2026
e896c38
Fix cluster image lifecycle and caching
Copilot Jul 30, 2026
4ce9d92
Merge remote-tracking branch 'origin/net11.0' into feature/maps-custo…
Copilot Jul 30, 2026
731cb7f
Merge remote-tracking branch 'origin/net11.0' into feature/maps-custo…
Copilot Jul 30, 2026
9e7de19
Merge origin/net11.0 into feature/maps-custom-cluster-appearance
Copilot Jul 30, 2026
5962eab
Merge origin/net11.0 into feature/maps-custom-cluster-appearance
Copilot Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/Controls/Maps/src/ClusterInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using Microsoft.Maui.Devices.Sensors;

namespace Microsoft.Maui.Controls.Maps
{
/// <summary>
/// Describes a pin cluster, passed to an image provider callback so the
/// application can produce a custom icon for the cluster marker.
/// </summary>
public sealed class ClusterInfo
{
IReadOnlyList<Pin>? _pins;
readonly Func<IReadOnlyList<Pin>>? _pinsFactory;
string? _clusteringIdentifier;
readonly Func<string>? _clusteringIdentifierFactory;

/// <summary>
/// Initializes a new instance of the <see cref="ClusterInfo"/> class.
/// </summary>
/// <param name="count">The number of pins in the cluster.</param>
/// <param name="clusteringIdentifier">The clustering identifier shared by the cluster's pins.</param>
/// <param name="pins">The pins contained in the cluster.</param>
/// <param name="location">The geographic location (centroid) of the cluster.</param>
public ClusterInfo(int count, string clusteringIdentifier, IReadOnlyList<Pin> pins, Location location)
{
Count = count;
_clusteringIdentifier = clusteringIdentifier ?? throw new ArgumentNullException(nameof(clusteringIdentifier));
_pins = pins ?? throw new ArgumentNullException(nameof(pins));
Location = location ?? throw new ArgumentNullException(nameof(location));
}

// Lazy path used by the handler: defers the O(members × pins) resolution until the provider
// actually reads Pins/ClusteringIdentifier, so a count-only provider pays nothing.
internal ClusterInfo(int count, Location location, Func<IReadOnlyList<Pin>> pinsFactory, Func<string> clusteringIdentifierFactory)
{
Count = count;
Location = location;
_pinsFactory = pinsFactory;
_clusteringIdentifierFactory = clusteringIdentifierFactory;
}

/// <summary>Gets the number of pins contained in the cluster.</summary>
/// <remarks>This is the authoritative member count, independent of how many entries <see cref="Pins"/> holds.</remarks>
public int Count { get; }

/// <summary>Gets the clustering identifier shared by the pins in this cluster.</summary>
/// <remarks>Falls back to <see cref="Pin.DefaultClusteringIdentifier"/> when no member pin could be resolved.</remarks>
public string ClusteringIdentifier => _clusteringIdentifier ??= _clusteringIdentifierFactory!();

/// <summary>Gets the pins contained in this cluster.</summary>
/// <remarks>
/// On some platforms (iOS) not every cluster member can be resolved back to a <see cref="Pin"/>,
/// so this list can contain fewer than <see cref="Count"/> entries - use <see cref="Count"/> for badge numbers.
/// </remarks>
public IReadOnlyList<Pin> Pins => _pins ??= _pinsFactory!();

/// <summary>Gets the geographic location (centroid) of the cluster.</summary>
public Location Location { get; }
}
}
42 changes: 40 additions & 2 deletions src/Controls/Maps/src/HandlerImpl/Map.Impl.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.Devices.Sensors;
using Microsoft.Maui.Maps;

namespace Microsoft.Maui.Controls.Maps
{
public partial class Map : IMap, IEnumerable<IMapPin>
public partial class Map : IMap, IMapClusterImageProvider, IEnumerable<IMapPin>
{
IList<IMapElement> IMap.Elements => _mapElements.Cast<IMapElement>().ToList();

IList<IMapPin> IMap.Pins => _pins.Cast<IMapPin>().ToList();

Location? IMap.LastUserLocation => _lastUserLocation;

int IMapClusterImageProvider.ClusterImageVersion => _clusterImageVersion;

void IMap.Clicked(Location location) => MapClicked?.Invoke(this, new MapClickedEventArgs(location));

bool IMap.ClusterClicked(IReadOnlyList<IMapPin> pins, Location location)
Expand All @@ -24,6 +29,39 @@ bool IMap.ClusterClicked(IReadOnlyList<IMapPin> pins, Location location)
return args.Handled;
}

Microsoft.Maui.IImageSource? IMapClusterImageProvider.GetClusterImage(IReadOnlyList<IMapPin> pins, int count, Location location)
{
var provider = ClusterImageProvider;
if (provider is not null)
{
// The provider is app code invoked from platform callbacks (a native MapKit delegate on
// iOS, a fire-and-forget task on Android) where an unhandled exception either crashes the
// app or silently drops the cluster marker - degrade to the static/default icon instead.
try
{
// Pins/identifier are resolved lazily: a provider that only reads Count (like the
// sample) never triggers the platform's O(members × pins) resolution scan.
var image = provider(new ClusterInfo(count, location,
() => pins.OfType<Pin>().ToList(),
() =>
{
foreach (var pin in pins)
if (pin is Pin controlPin)
return controlPin.ClusteringIdentifier ?? Pin.DefaultClusteringIdentifier;
return Pin.DefaultClusteringIdentifier;
}));
if (image is not null)
return image;
}
catch (Exception ex)
{
Handler?.MauiContext?.Services?.GetService<ILogger<Map>>()?.LogWarning(ex, "ClusterImageProvider threw; falling back to the static or default cluster icon");
}
}

return ClusterImageSource;
}

void IMap.UserLocationUpdated(Location location)
{
if (Equals(_lastUserLocation, location))
Expand Down
114 changes: 113 additions & 1 deletion src/Controls/Maps/src/Map.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public partial class Map : View
/// <summary>Bindable property for <see cref="IsClusteringEnabled"/>.</summary>
public static readonly BindableProperty IsClusteringEnabledProperty = BindableProperty.Create(nameof(IsClusteringEnabled), typeof(bool), typeof(Map), default(bool));

/// <summary>Bindable property for <see cref="ClusterImageSource"/>.</summary>
public static readonly BindableProperty ClusterImageSourceProperty = BindableProperty.Create(nameof(ClusterImageSource), typeof(ImageSource), typeof(Map), default(ImageSource),
propertyChanging: (b, o, n) => ((Map)b).OnClusterImageSourceChanging((ImageSource?)o),
propertyChanged: (b, o, n) => ((Map)b).OnClusterImageSourceChanged((ImageSource?)n));

/// <summary>Bindable property for <see cref="MapStyle"/>.</summary>
public static readonly BindableProperty MapStyleProperty = BindableProperty.Create(nameof(MapStyle), typeof(string), typeof(Map), default(string));

Expand All @@ -58,6 +63,8 @@ public partial class Map : View
MapSpan? _visibleRegion;
MapSpan? _lastMoveToRegion;
Location? _lastUserLocation;
Func<ClusterInfo, ImageSource?>? _clusterImageProvider;
int _clusterImageVersion;

/// <summary>
/// Initializes a new instance of the <see cref="Map"/> class with a region.
Expand All @@ -82,6 +89,14 @@ public Map() : this(new MapSpan(new Devices.Sensors.Location(20.793062527, -156.
{
}

protected override void OnBindingContextChanged()
{
if (ClusterImageSource is not null)
SetInheritedBindingContext(ClusterImageSource, BindingContext);

base.OnBindingContextChanged();
}

/// <summary>
/// Gets or sets a value that indicates if scrolling by user input is enabled. Default value is <see langword="true"/>.
/// This is a bindable property.
Expand Down Expand Up @@ -139,7 +154,50 @@ public bool IsClusteringEnabled
}

/// <summary>
/// Gets or sets the style of the map. Default value is <see cref="MapType.Street"/>.
/// Gets or sets a static custom icon used for every cluster marker when clustering is enabled.
/// Ignored if <see cref="ClusterImageProvider"/> returns a non-null image for a cluster.
/// When <see langword="null"/> (and no provider image is returned) the default cluster marker is used.
/// This is a bindable property.
/// </summary>
/// <remarks>
/// No pin count is drawn over the image. Each platform scales it to a marker-sized icon
/// (Android fits within 64 pixels, iOS within 32 points), matching <see cref="Pin.ImageSource"/>.
/// Changing this value rebuilds existing cluster markers immediately.
/// </remarks>
public ImageSource? ClusterImageSource
{
get => (ImageSource?)GetValue(ClusterImageSourceProperty);
set => SetValue(ClusterImageSourceProperty, value);
}

/// <summary>
/// Gets or sets a callback that returns a custom icon for a cluster marker, computed from the
/// supplied <see cref="ClusterInfo"/> (count, clustering identifier, pins, location).
/// Return <see langword="null"/> to fall back to <see cref="ClusterImageSource"/>, then to the
/// default cluster marker. Only used when clustering is enabled.
/// </summary>
/// <remarks>
/// The callback returns the complete icon (draw the count yourself if desired). The returned
/// <see cref="ImageSource"/> is loaded asynchronously by the platform handler, like
/// <see cref="Pin.ImageSource"/>. Setting this value rebuilds existing cluster markers immediately.
/// </remarks>
public Func<ClusterInfo, ImageSource?>? ClusterImageProvider
{
get => _clusterImageProvider;
set
{
// Delegate.Equals compares target+method, so re-assigning the same method group
// (e.g. from OnAppearing on every navigation) short-circuits instead of rebuilding
// every cluster marker.
if (Equals(_clusterImageProvider, value))
return;
_clusterImageProvider = value;
OnClusterImageChanged();
}
}

/// <summary>
/// Gets or sets the style of the map. Default value is <see cref="MapType.Street"/>.
/// This is a bindable property.
/// </summary>
public MapType MapType
Expand Down Expand Up @@ -333,6 +391,60 @@ void OnRegionPropertyChanged(MapSpan? newRegion)
}
}

void OnClusterImageSourceChanging(ImageSource? oldSource)
{
if (oldSource is null)
return;

CancelOldClusterImageSource(oldSource);
oldSource.SourceChanged -= OnClusterImageSourceSourceChanged;
oldSource.Parent = null;
SetInheritedBindingContext(oldSource, null);
}

void OnClusterImageSourceChanged(ImageSource? newSource)
{
if (newSource is not null)
{
newSource.SourceChanged += OnClusterImageSourceSourceChanged;
newSource.Parent = this;
SetInheritedBindingContext(newSource, BindingContext);
}

OnClusterImageChanged();
}

void OnClusterImageSourceSourceChanged(object? sender, EventArgs e) => OnClusterImageChanged();

async void CancelOldClusterImageSource(ImageSource oldSource)
{
try
{
await oldSource.Cancel();
}
catch (ObjectDisposedException)
{
}
}

// Rebuild pins/clusters so a changed ClusterImageSource/ClusterImageProvider is reflected
// immediately, instead of waiting for the next unrelated recluster (e.g. a zoom).
void OnClusterImageChanged()
{
unchecked
{
_clusterImageVersion++;
}

// Cluster images are only consumed while clustering is on; enabling clustering later
// re-runs the pins mapper anyway (MapIsClusteringEnabled calls MapPins on both
// platforms), so nothing is lost by skipping the rebuild here.
if (!IsClusteringEnabled)
return;

Handler?.UpdateValue(nameof(IMap.Pins));
}

void PinsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems is not null && e.NewItems.Cast<Pin>().Any(pin => pin.Label is null))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
#nullable enable

Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler?
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.ClusterClickedEventArgs(System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.get -> bool
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.set -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Pins.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>!
Microsoft.Maui.Controls.Maps.ClusterInfo
Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterInfo.ClusteringIdentifier.get -> string!
Microsoft.Maui.Controls.Maps.ClusterInfo.Count.get -> int
Microsoft.Maui.Controls.Maps.ClusterInfo.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.ClusterInfo.Pins.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>!
Microsoft.Maui.Controls.Maps.Map.ClusterClicked -> System.EventHandler<Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs!>?
Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func<Microsoft.Maui.Controls.Maps.ClusterInfo!, Microsoft.Maui.Controls.ImageSource?>?
Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.set -> void
Microsoft.Maui.Controls.Maps.Map.ClusterImageSource.get -> Microsoft.Maui.Controls.ImageSource?
Microsoft.Maui.Controls.Maps.Map.ClusterImageSource.set -> void
Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabled.get -> bool
Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabled.set -> void
Microsoft.Maui.Controls.Maps.Map.LastUserLocation.get -> Microsoft.Maui.Devices.Sensors.Location?
Expand All @@ -34,6 +43,8 @@ Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs
Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs.UserLocationChangedEventArgs(Microsoft.Maui.Devices.Sensors.Location! location) -> void
const Microsoft.Maui.Controls.Maps.Pin.DefaultClusteringIdentifier = "maui_default_cluster" -> string!
override Microsoft.Maui.Controls.Maps.Map.OnBindingContextChanged() -> void
static readonly Microsoft.Maui.Controls.Maps.Map.ClusterImageSourceProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabledProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.MapStyleProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty!
Expand Down
13 changes: 12 additions & 1 deletion src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
#nullable enable

Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler?
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.ClusterClickedEventArgs(System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.get -> bool
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.set -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Pins.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>!
Microsoft.Maui.Controls.Maps.ClusterInfo
Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterInfo.ClusteringIdentifier.get -> string!
Microsoft.Maui.Controls.Maps.ClusterInfo.Count.get -> int
Microsoft.Maui.Controls.Maps.ClusterInfo.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.ClusterInfo.Pins.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Maui.Controls.Maps.Pin!>!
Microsoft.Maui.Controls.Maps.Map.ClusterClicked -> System.EventHandler<Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs!>?
Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func<Microsoft.Maui.Controls.Maps.ClusterInfo!, Microsoft.Maui.Controls.ImageSource?>?
Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.set -> void
Microsoft.Maui.Controls.Maps.Map.ClusterImageSource.get -> Microsoft.Maui.Controls.ImageSource?
Microsoft.Maui.Controls.Maps.Map.ClusterImageSource.set -> void
Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabled.get -> bool
Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabled.set -> void
Microsoft.Maui.Controls.Maps.Map.LastUserLocation.get -> Microsoft.Maui.Devices.Sensors.Location?
Expand All @@ -34,6 +43,8 @@ Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs
Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs.Location.get -> Microsoft.Maui.Devices.Sensors.Location!
Microsoft.Maui.Controls.Maps.UserLocationChangedEventArgs.UserLocationChangedEventArgs(Microsoft.Maui.Devices.Sensors.Location! location) -> void
const Microsoft.Maui.Controls.Maps.Pin.DefaultClusteringIdentifier = "maui_default_cluster" -> string!
override Microsoft.Maui.Controls.Maps.Map.OnBindingContextChanged() -> void
static readonly Microsoft.Maui.Controls.Maps.Map.ClusterImageSourceProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.IsClusteringEnabledProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.MapStyleProperty -> Microsoft.Maui.Controls.BindableProperty!
static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty!
Expand Down
Loading
Loading