diff --git a/src/Controls/Maps/src/ClusterInfo.cs b/src/Controls/Maps/src/ClusterInfo.cs
new file mode 100644
index 000000000000..8d8543a1b4f2
--- /dev/null
+++ b/src/Controls/Maps/src/ClusterInfo.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Maui.Devices.Sensors;
+
+namespace Microsoft.Maui.Controls.Maps
+{
+ ///
+ /// Describes a pin cluster, passed to an image provider callback so the
+ /// application can produce a custom icon for the cluster marker.
+ ///
+ public sealed class ClusterInfo
+ {
+ IReadOnlyList? _pins;
+ readonly Func>? _pinsFactory;
+ string? _clusteringIdentifier;
+ readonly Func? _clusteringIdentifierFactory;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The number of pins in the cluster.
+ /// The clustering identifier shared by the cluster's pins.
+ /// The pins contained in the cluster.
+ /// The geographic location (centroid) of the cluster.
+ public ClusterInfo(int count, string clusteringIdentifier, IReadOnlyList 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> pinsFactory, Func clusteringIdentifierFactory)
+ {
+ Count = count;
+ Location = location;
+ _pinsFactory = pinsFactory;
+ _clusteringIdentifierFactory = clusteringIdentifierFactory;
+ }
+
+ /// Gets the number of pins contained in the cluster.
+ /// This is the authoritative member count, independent of how many entries holds.
+ public int Count { get; }
+
+ /// Gets the clustering identifier shared by the pins in this cluster.
+ /// Falls back to when no member pin could be resolved.
+ public string ClusteringIdentifier => _clusteringIdentifier ??= _clusteringIdentifierFactory!();
+
+ /// Gets the pins contained in this cluster.
+ ///
+ /// On some platforms (iOS) not every cluster member can be resolved back to a ,
+ /// so this list can contain fewer than entries - use for badge numbers.
+ ///
+ public IReadOnlyList Pins => _pins ??= _pinsFactory!();
+
+ /// Gets the geographic location (centroid) of the cluster.
+ public Location Location { get; }
+ }
+}
diff --git a/src/Controls/Maps/src/HandlerImpl/Map.Impl.cs b/src/Controls/Maps/src/HandlerImpl/Map.Impl.cs
index 8800d90b192d..2fa2c981cdad 100644
--- a/src/Controls/Maps/src/HandlerImpl/Map.Impl.cs
+++ b/src/Controls/Maps/src/HandlerImpl/Map.Impl.cs
@@ -1,11 +1,14 @@
-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
+ public partial class Map : IMap, IMapClusterImageProvider, IEnumerable
{
IList IMap.Elements => _mapElements.Cast().ToList();
@@ -13,6 +16,8 @@ public partial class Map : IMap, IEnumerable
Location? IMap.LastUserLocation => _lastUserLocation;
+ int IMapClusterImageProvider.ClusterImageVersion => _clusterImageVersion;
+
void IMap.Clicked(Location location) => MapClicked?.Invoke(this, new MapClickedEventArgs(location));
bool IMap.ClusterClicked(IReadOnlyList pins, Location location)
@@ -24,6 +29,39 @@ bool IMap.ClusterClicked(IReadOnlyList pins, Location location)
return args.Handled;
}
+ Microsoft.Maui.IImageSource? IMapClusterImageProvider.GetClusterImage(IReadOnlyList 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().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>()?.LogWarning(ex, "ClusterImageProvider threw; falling back to the static or default cluster icon");
+ }
+ }
+
+ return ClusterImageSource;
+ }
+
void IMap.UserLocationUpdated(Location location)
{
if (Equals(_lastUserLocation, location))
diff --git a/src/Controls/Maps/src/Map.cs b/src/Controls/Maps/src/Map.cs
index e0c46ab7cd11..9131c6a7190f 100644
--- a/src/Controls/Maps/src/Map.cs
+++ b/src/Controls/Maps/src/Map.cs
@@ -34,6 +34,11 @@ public partial class Map : View
/// Bindable property for .
public static readonly BindableProperty IsClusteringEnabledProperty = BindableProperty.Create(nameof(IsClusteringEnabled), typeof(bool), typeof(Map), default(bool));
+ /// Bindable property for .
+ 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));
+
/// Bindable property for .
public static readonly BindableProperty MapStyleProperty = BindableProperty.Create(nameof(MapStyle), typeof(string), typeof(Map), default(string));
@@ -58,6 +63,8 @@ public partial class Map : View
MapSpan? _visibleRegion;
MapSpan? _lastMoveToRegion;
Location? _lastUserLocation;
+ Func? _clusterImageProvider;
+ int _clusterImageVersion;
///
/// Initializes a new instance of the class with a region.
@@ -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();
+ }
+
///
/// Gets or sets a value that indicates if scrolling by user input is enabled. Default value is .
/// This is a bindable property.
@@ -139,7 +154,50 @@ public bool IsClusteringEnabled
}
///
- /// Gets or sets the style of the map. Default value is .
+ /// Gets or sets a static custom icon used for every cluster marker when clustering is enabled.
+ /// Ignored if returns a non-null image for a cluster.
+ /// When (and no provider image is returned) the default cluster marker is used.
+ /// This is a bindable property.
+ ///
+ ///
+ /// 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 .
+ /// Changing this value rebuilds existing cluster markers immediately.
+ ///
+ public ImageSource? ClusterImageSource
+ {
+ get => (ImageSource?)GetValue(ClusterImageSourceProperty);
+ set => SetValue(ClusterImageSourceProperty, value);
+ }
+
+ ///
+ /// Gets or sets a callback that returns a custom icon for a cluster marker, computed from the
+ /// supplied (count, clustering identifier, pins, location).
+ /// Return to fall back to , then to the
+ /// default cluster marker. Only used when clustering is enabled.
+ ///
+ ///
+ /// The callback returns the complete icon (draw the count yourself if desired). The returned
+ /// is loaded asynchronously by the platform handler, like
+ /// . Setting this value rebuilds existing cluster markers immediately.
+ ///
+ public Func? 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();
+ }
+ }
+
+ ///
+ /// Gets or sets the style of the map. Default value is .
/// This is a bindable property.
///
public MapType MapType
@@ -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().Any(pin => pin.Label is null))
diff --git a/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index afb23c4a7a8b..8cbb399e9fcf 100644
--- a/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -1,5 +1,4 @@
#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! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
@@ -7,7 +6,17 @@ 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.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -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!
diff --git a/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index afb23c4a7a8b..8cbb399e9fcf 100644
--- a/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -1,5 +1,4 @@
#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! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
@@ -7,7 +6,17 @@ 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.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -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!
diff --git a/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index afb23c4a7a8b..8cbb399e9fcf 100644
--- a/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -1,5 +1,4 @@
#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! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
@@ -7,7 +6,17 @@ 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.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -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!
diff --git a/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index afb23c4a7a8b..51bb52730870 100644
--- a/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -1,6 +1,12 @@
#nullable enable
Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.ClusterClickedEventArgs
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.ClusterClickedEventArgs(System.Collections.Generic.IReadOnlyList! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.get -> bool
@@ -8,6 +14,10 @@ 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -34,6 +44,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!
diff --git a/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index afb23c4a7a8b..51bb52730870 100644
--- a/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -1,6 +1,12 @@
#nullable enable
Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.ClusterClickedEventArgs
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.ClusterClickedEventArgs(System.Collections.Generic.IReadOnlyList! pins, Microsoft.Maui.Devices.Sensors.Location! location) -> void
Microsoft.Maui.Controls.Maps.ClusterClickedEventArgs.Handled.get -> bool
@@ -8,6 +14,10 @@ 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -34,6 +44,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!
diff --git a/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt
index b7563d703b56..8cbb399e9fcf 100644
--- a/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -6,7 +6,17 @@ 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.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -33,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!
diff --git a/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index b7563d703b56..8cbb399e9fcf 100644
--- a/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -6,7 +6,17 @@ 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.ClusterInfo
+Microsoft.Maui.Controls.Maps.ClusterInfo.ClusterInfo(int count, string! clusteringIdentifier, System.Collections.Generic.IReadOnlyList! 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.Map.ClusterClicked -> System.EventHandler?
+Microsoft.Maui.Controls.Maps.Map.ClusterImageProvider.get -> System.Func?
+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?
@@ -33,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!
diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml
index 0fe56c34aa2f..0f3a965320a9 100644
--- a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml
+++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml
@@ -8,33 +8,45 @@
mc:Ignorable="d"
x:Class="Maui.Controls.Sample.Pages.MapsGalleries.ClusteringGallery"
Title="Pin Clustering">
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml.cs b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml.cs
index a80eb89d76c6..6433e7486891 100644
--- a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml.cs
+++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/ClusteringGallery.xaml.cs
@@ -41,6 +41,12 @@ void OnAdd100PinsClicked(object? sender, EventArgs e)
void OnAddCustomPinsClicked(object? sender, EventArgs e)
{
+ // Clear any cluster-icon demo state so these pins can't be swallowed into a
+ // custom-icon cluster bubble - the point is to show each pin's own ImageSource.
+ clusterMap.ClusterImageProvider = null;
+ clusterMap.ClusterImageSource = null;
+ clusterMap.Pins.Clear();
+
// Spread these pins far apart so they stay un-clustered (cluster of 1)
// even with clustering enabled. Their custom ImageSource must still be
// applied - this is the case the Android handler previously dropped.
@@ -70,6 +76,29 @@ void OnClearPinsClicked(object? sender, EventArgs e)
UpdateStatus();
}
+ void OnUseClusterProviderClicked(object? sender, EventArgs e)
+ {
+ // Dynamic icon: dotnet_bot for small clusters, a different image for large ones.
+ clusterMap.ClusterImageSource = null;
+ clusterMap.ClusterImageProvider = info =>
+ info.Count >= 10
+ ? ImageSource.FromFile("coffee.png")
+ : ImageSource.FromFile("dotnet_bot.png");
+ clusterMap.Pins.Clear();
+ AddPins(60);
+ UpdateStatus();
+ }
+
+ void OnUseStaticClusterIconClicked(object? sender, EventArgs e)
+ {
+ // Static icon: one image for every cluster.
+ clusterMap.ClusterImageProvider = null;
+ clusterMap.ClusterImageSource = ImageSource.FromFile("dotnet_bot.png");
+ clusterMap.Pins.Clear();
+ AddPins(60);
+ UpdateStatus();
+ }
+
void OnClusteringToggled(object? sender, ToggledEventArgs e)
{
clusterMap.IsClusteringEnabled = e.Value;
diff --git a/src/Controls/src/Core/Properties/AssemblyInfo.cs b/src/Controls/src/Core/Properties/AssemblyInfo.cs
index 68b62e3f7e06..de25650e0e78 100644
--- a/src/Controls/src/Core/Properties/AssemblyInfo.cs
+++ b/src/Controls/src/Core/Properties/AssemblyInfo.cs
@@ -28,6 +28,7 @@
[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.UITest.Validator")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.Build.Tasks")]
[assembly: InternalsVisibleTo("Microsoft.Maui")]
+[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.Maps")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.Pages")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.Pages.UnitTests")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Controls.CarouselView")]
diff --git a/src/Controls/tests/Core.UnitTests/MapTests.cs b/src/Controls/tests/Core.UnitTests/MapTests.cs
index 1c2c96357f7c..77ad0b18f11d 100644
--- a/src/Controls/tests/Core.UnitTests/MapTests.cs
+++ b/src/Controls/tests/Core.UnitTests/MapTests.cs
@@ -8,7 +8,9 @@
using System.Threading.Tasks;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Devices.Sensors;
+using Microsoft.Maui.Graphics;
using Microsoft.Maui.Maps;
+using Microsoft.Maui.Maps.Handlers;
using Xunit;
namespace Microsoft.Maui.Controls.Core.UnitTests
@@ -930,10 +932,10 @@ public void MapClickedAndLongClickedCanCoexist()
public void MapLongClickedDoesNotFireWithoutHandler()
{
var map = new Map();
-
+
// Should not throw when no handler is attached
var exception = Record.Exception(() => ((IMap)map).LongClicked(new Location(37.7749, -122.4194)));
-
+
Assert.Null(exception);
}
@@ -962,7 +964,7 @@ public void MapLongClickedHandlerCanBeRemoved()
int fireCount = 0;
EventHandler handler = (s, e) => fireCount++;
-
+
map.MapLongClicked += handler;
((IMap)map).LongClicked(location);
Assert.Equal(1, fireCount);
@@ -1136,5 +1138,490 @@ public void ClickedFiresEventWithNoElements()
Assert.Equal(location.Latitude, eventArgs.Location.Latitude);
Assert.Equal(location.Longitude, eventArgs.Location.Longitude);
}
+
+ [Fact]
+ public void ClusterInfoExposesConstructorValues()
+ {
+ var pins = new List { new Pin { Label = "A" }, new Pin { Label = "B" } };
+ var location = new Location(1.0, 2.0);
+
+ var info = new ClusterInfo(2, "restaurants", pins, location);
+
+ Assert.Equal(2, info.Count);
+ Assert.Equal("restaurants", info.ClusteringIdentifier);
+ Assert.Same(pins, info.Pins);
+ Assert.Equal(location, info.Location);
+ }
+
+ [Fact]
+ public void ClusterImageSourceDefaultIsNull()
+ {
+ var map = new Map();
+ Assert.Null(map.ClusterImageSource);
+ }
+
+ [Fact]
+ public void ClusterImageSourceCanBeSet()
+ {
+ var map = new Map();
+ var image = ImageSource.FromFile("cluster.png");
+ map.ClusterImageSource = image;
+ Assert.Same(image, map.ClusterImageSource);
+ }
+
+ [Fact]
+ public void ClusterImageProviderDefaultIsNull()
+ {
+ var map = new Map();
+ Assert.Null(map.ClusterImageProvider);
+ }
+
+ [Fact]
+ public void ClusterImageProviderCanBeSet()
+ {
+ var map = new Map();
+#nullable enable
+ Func provider = _ => null;
+#nullable restore
+ map.ClusterImageProvider = provider;
+ Assert.Same(provider, map.ClusterImageProvider);
+ Assert.IsAssignableFrom(map);
+ Assert.True(((IMapClusterImageProvider)map).ClusterImageVersion > 0);
+ }
+
+ [Fact]
+ public void GetClusterImagePrefersProviderOverStatic()
+ {
+ var map = new Map();
+ var providerImage = ImageSource.FromFile("provider.png");
+ var staticImage = ImageSource.FromFile("static.png");
+ map.ClusterImageSource = staticImage;
+ map.ClusterImageProvider = _ => providerImage;
+
+ var pins = new List { new Pin { Label = "A", ClusteringIdentifier = "cafes" } };
+ var result = ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2));
+
+ Assert.Same(providerImage, result);
+ }
+
+ [Fact]
+ public void GetClusterImageFallsBackToStaticWhenProviderNullOrAbsent()
+ {
+ var map = new Map();
+ var staticImage = ImageSource.FromFile("static.png");
+ map.ClusterImageSource = staticImage;
+ // no provider
+ var pins = new List { new Pin { Label = "A" } };
+ Assert.Same(staticImage, ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2)));
+
+ // provider that returns null also falls back
+ map.ClusterImageProvider = _ => null;
+ Assert.Same(staticImage, ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2)));
+ }
+
+ [Fact]
+ public void GetClusterImageReturnsNullWhenNothingConfigured()
+ {
+ var map = new Map();
+ var pins = new List { new Pin { Label = "A" } };
+ Assert.Null(((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2)));
+ }
+
+ [Fact]
+ public void GetClusterImagePassesClusterInfoToProvider()
+ {
+ var map = new Map();
+#nullable enable
+ ClusterInfo? captured = null;
+ map.ClusterImageProvider = info => { captured = info; return null; };
+#nullable restore
+
+ var pins = new List
+ {
+ new Pin { Label = "A", ClusteringIdentifier = "cafes" },
+ new Pin { Label = "B", ClusteringIdentifier = "cafes" }
+ };
+ ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(10, 20));
+
+ Assert.NotNull(captured);
+ Assert.Equal(2, captured!.Count);
+ Assert.Equal("cafes", captured.ClusteringIdentifier);
+ Assert.Equal(2, captured.Pins.Count);
+ Assert.Equal(new Location(10, 20), captured.Location);
+ }
+
+ [Fact]
+ public void GetClusterImageWithEmptyPinsUsesDefaultIdentifierAndFallsBackToStatic()
+ {
+ var map = new Map();
+ var staticImage = ImageSource.FromFile("static.png");
+ map.ClusterImageSource = staticImage;
+
+#nullable enable
+ ClusterInfo? captured = null;
+ map.ClusterImageProvider = info => { captured = info; return null; };
+#nullable restore
+
+ var result = ((IMapClusterImageProvider)map).GetClusterImage(new List(), 0, new Location(1, 2));
+
+ Assert.NotNull(captured);
+ Assert.Equal(0, captured!.Count);
+ Assert.Equal(Pin.DefaultClusteringIdentifier, captured.ClusteringIdentifier);
+ Assert.Empty(captured.Pins);
+ Assert.Same(staticImage, result);
+ }
+
+ [Fact]
+ public void GetClusterImageUsesAuthoritativeCountIndependentOfResolvedPins()
+ {
+ // Regresses an iOS scenario: MKClusterAnnotation.MemberAnnotations.Length is the true
+ // cluster size, but GetPinForAnnotation can resolve fewer pins into the passed list
+ // (e.g. a lookup miss). Count must reflect the true size, not pins.Count.
+ var map = new Map();
+#nullable enable
+ ClusterInfo? captured = null;
+ map.ClusterImageProvider = info => { captured = info; return null; };
+#nullable restore
+
+ var pins = new List { new Pin { Label = "A" } };
+ ((IMapClusterImageProvider)map).GetClusterImage(pins, 5, new Location(1, 2));
+
+ Assert.NotNull(captured);
+ Assert.Equal(5, captured!.Count);
+ Assert.Single(captured.Pins);
+ }
+
+ [Fact]
+ public void SettingClusterImageSourceRebuildsPins()
+ {
+ var map = new Map { IsClusteringEnabled = true };
+ var handler = new UpdateValueTrackingHandlerStub();
+ map.Handler = handler;
+ handler.UpdatedProperties.Clear();
+
+ map.ClusterImageSource = ImageSource.FromFile("cluster.png");
+
+ Assert.Contains(nameof(IMap.Pins), handler.UpdatedProperties);
+ }
+
+ [Fact]
+ public void SettingClusterImageProviderRebuildsPins()
+ {
+ var map = new Map { IsClusteringEnabled = true };
+ var handler = new UpdateValueTrackingHandlerStub();
+ map.Handler = handler;
+ handler.UpdatedProperties.Clear();
+
+ map.ClusterImageProvider = _ => null;
+
+ Assert.Contains(nameof(IMap.Pins), handler.UpdatedProperties);
+ }
+
+ [Fact]
+ public void ChangingClusterImageSourceContentsRebuildsPins()
+ {
+ var map = new Map { IsClusteringEnabled = true };
+ var handler = new UpdateValueTrackingHandlerStub();
+ map.Handler = handler;
+ var source = new FileImageSource { File = "first.png" };
+ map.ClusterImageSource = source;
+ handler.UpdatedProperties.Clear();
+
+ source.File = "second.png";
+
+ Assert.Contains(nameof(IMap.Pins), handler.UpdatedProperties);
+ }
+
+ [Fact]
+ public void ClusterImageSourceInheritsBindingContext()
+ {
+ var map = new Map();
+ var source = new FileImageSource();
+ map.ClusterImageSource = source;
+
+ var bindingContext = new object();
+ map.BindingContext = bindingContext;
+
+ Assert.Same(bindingContext, source.BindingContext);
+ Assert.Same(map, source.Parent);
+ }
+
+ [Fact]
+ public void SettingClusterImageSourceDoesNotRebuildPinsWhenClusteringDisabled()
+ {
+ // Cluster images are only consumed while clustering is on, so there is nothing to
+ // rebuild - enabling clustering later re-runs the pins mapper anyway.
+ var map = new Map();
+ var handler = new UpdateValueTrackingHandlerStub();
+ map.Handler = handler;
+ handler.UpdatedProperties.Clear();
+
+ map.ClusterImageSource = ImageSource.FromFile("cluster.png");
+
+ Assert.DoesNotContain(nameof(IMap.Pins), handler.UpdatedProperties);
+ }
+
+ [Fact]
+ public void SettingSameClusterImageProviderMethodGroupDoesNotRebuildPins()
+ {
+ // Delegate.Equals compares target+method, so re-assigning the same method group
+ // (e.g. from OnAppearing on every navigation) must short-circuit.
+ var map = new Map { IsClusteringEnabled = true };
+ map.ClusterImageProvider = StaticProvider;
+ var handler = new UpdateValueTrackingHandlerStub();
+ map.Handler = handler;
+ handler.UpdatedProperties.Clear();
+
+ map.ClusterImageProvider = StaticProvider;
+
+ Assert.DoesNotContain(nameof(IMap.Pins), handler.UpdatedProperties);
+
+ static ImageSource StaticProvider(ClusterInfo info) => null;
+ }
+
+ [Fact]
+ public void GetClusterImageFallsBackToStaticWhenProviderThrows()
+ {
+ var map = new Map();
+ var staticImage = ImageSource.FromFile("static.png");
+ map.ClusterImageSource = staticImage;
+ map.ClusterImageProvider = _ => throw new InvalidOperationException("boom");
+
+ var pins = new List { new Pin { Label = "A", ClusteringIdentifier = "cafes" } };
+
+ var exception = Record.Exception(() => ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2)));
+
+ Assert.Null(exception);
+ var result = ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2));
+ Assert.Same(staticImage, result);
+ }
+
+ [Fact]
+ public void GetClusterImageReturnsNullWhenProviderThrowsAndNoStatic()
+ {
+ var map = new Map();
+ map.ClusterImageProvider = _ => throw new InvalidOperationException("boom");
+
+ var pins = new List { new Pin { Label = "A", ClusteringIdentifier = "cafes" } };
+
+ IImageSource result = null;
+ var exception = Record.Exception(() => result = ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2)));
+
+ Assert.Null(exception);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetClusterImagePassesDefaultIdentifierWhenPinIdentifierIsNull()
+ {
+ var map = new Map();
+#nullable enable
+ ClusterInfo? captured = null;
+ map.ClusterImageProvider = info => { captured = info; return null; };
+#nullable restore
+
+ var pin = new Pin { Label = "A" };
+ pin.ClusteringIdentifier = null;
+
+ var pins = new List { pin };
+ ((IMapClusterImageProvider)map).GetClusterImage(pins, pins.Count, new Location(1, 2));
+
+ Assert.NotNull(captured);
+ Assert.Equal(Pin.DefaultClusteringIdentifier, captured!.ClusteringIdentifier);
+ }
+
+ [Fact]
+ public void ClusterInfoConstructorThrowsOnNullArguments()
+ {
+ var pins = new List { new Pin { Label = "A" } };
+ var location = new Location(1, 2);
+
+ Assert.Throws(() => new ClusterInfo(1, null, pins, location));
+ Assert.Throws(() => new ClusterInfo(1, "cafes", null, location));
+ Assert.Throws(() => new ClusterInfo(1, "cafes", pins, null));
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForFileImageSourceIsStableAcrossInstances()
+ {
+ var first = new FileImageSource { File = "icon.png" };
+ var second = new FileImageSource { File = "icon.png" };
+
+ var firstKey = MapHandler.GetClusterIconCacheKey(first);
+ var secondKey = MapHandler.GetClusterIconCacheKey(second);
+
+ Assert.NotNull(firstKey);
+ Assert.StartsWith("file:", firstKey, StringComparison.Ordinal);
+ Assert.Equal(firstKey, secondKey);
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForUriImageSourceDependsOnCachingEnabled()
+ {
+ var uri = new Uri("https://example.com/icon.png");
+
+ var cachingEnabled = new UriImageSource { Uri = uri, CachingEnabled = true };
+ var cachingDisabled = new UriImageSource { Uri = uri, CachingEnabled = false };
+
+ var enabledKey = MapHandler.GetClusterIconCacheKey(cachingEnabled);
+ var disabledKey = MapHandler.GetClusterIconCacheKey(cachingDisabled);
+
+ Assert.NotNull(enabledKey);
+ Assert.StartsWith("uri:", enabledKey, StringComparison.Ordinal);
+ Assert.Null(disabledKey);
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForUriImageSourceRequiresPositiveValidity()
+ {
+ var source = new UriImageSource
+ {
+ Uri = new Uri("https://example.com/icon.png"),
+ CachingEnabled = true,
+ CacheValidity = TimeSpan.Zero
+ };
+
+ Assert.Null(MapHandler.GetClusterIconCacheKey(source));
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForFontImageSourceContainsGlyph()
+ {
+ var font = new FontImageSource { Glyph = "A", FontFamily = "F", Size = 24, Color = Colors.White };
+
+ var key = MapHandler.GetClusterIconCacheKey(font);
+
+ Assert.NotNull(key);
+ Assert.Contains("A", key, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForFontImageSourceDistinguishesWeight()
+ {
+ var regular = new FakeFontImageSource
+ {
+ Glyph = "A",
+ Color = Colors.White,
+ Font = Font.OfSize("F", 24).WithWeight(FontWeight.Regular)
+ };
+ var bold = new FakeFontImageSource
+ {
+ Glyph = "A",
+ Color = Colors.White,
+ Font = Font.OfSize("F", 24).WithWeight(FontWeight.Bold)
+ };
+
+ var regularKey = MapHandler.GetClusterIconCacheKey(regular);
+ var boldKey = MapHandler.GetClusterIconCacheKey(bold);
+
+ Assert.NotNull(regularKey);
+ Assert.NotNull(boldKey);
+ Assert.NotEqual(regularKey, boldKey);
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyForFontImageSourceDistinguishesAutoScaling()
+ {
+ var scalingEnabled = new FakeFontImageSource
+ {
+ Glyph = "A",
+ Color = Colors.White,
+ Font = Font.OfSize("F", 24, enableScaling: true)
+ };
+ var scalingDisabled = new FakeFontImageSource
+ {
+ Glyph = "A",
+ Color = Colors.White,
+ Font = Font.OfSize("F", 24, enableScaling: false)
+ };
+
+ var enabledKey = MapHandler.GetClusterIconCacheKey(scalingEnabled);
+ var disabledKey = MapHandler.GetClusterIconCacheKey(scalingDisabled);
+
+ Assert.NotNull(enabledKey);
+ Assert.NotNull(disabledKey);
+ Assert.NotEqual(enabledKey, disabledKey);
+ }
+
+ [Fact]
+ public void GetClusterIconCacheKeyIsNullForStreamOrMissingSource()
+ {
+ var stream = new StreamImageSource();
+
+ Assert.Null(MapHandler.GetClusterIconCacheKey(stream));
+ Assert.Null(MapHandler.GetClusterIconCacheKey(null));
+ }
+
+ [Fact]
+ public async Task ClusterIconCacheCoalescesConcurrentLoads()
+ {
+ var cache = new ClusterIconCache