Skip to content

[iOS] Map: Update pin icon when Pin.ImageSource changes at runtime - #36293

Merged
kubaflo merged 10 commits into
dotnet:net11.0from
kevin68:fix/ios-map-pin-runtime-image-update
Aug 1, 2026
Merged

[iOS] Map: Update pin icon when Pin.ImageSource changes at runtime#36293
kubaflo merged 10 commits into
dotnet:net11.0from
kevin68:fix/ios-map-pin-runtime-image-update

Conversation

@kevin68

@kevin68 kevin68 commented Jul 2, 2026

Copy link
Copy Markdown

Description of Change

On iOS/Mac Catalyst, changing a Pin.ImageSource at runtime did not update the icon of a pin already displayed on the map — the new image only appeared once MapKit recreated the annotation view (e.g. the pin scrolled off-screen and back). MapPinHandler.MapImageSource was a no-op; the image is applied in MauiMKMapView.GetViewForAnnotation, which only runs on view (re)creation. Location/Label/Address update live, and Android already updates the marker icon in place, so iOS was inconsistent.

Fix: MapImageSource now refreshes the live annotation view in place via MauiMKMapView.UpdatePinImage:

  • Looks up the current MKAnnotationView for the pin (ViewForAnnotation) and re-applies (or clears) its image. Pins with no individual view — off-screen, or collapsed into a cluster — are left untouched; GetViewForAnnotation already applies the current ImageSource when they next appear.
  • Updating in place (rather than removing/re-adding the annotation) preserves selection, open callouts and cluster membership, and avoids a flicker/re-cluster.
  • ApplyCustomImageAsync now captures the requested ImageSource and drops the result if the pin's ImageSource changed while the load was in flight, so rapid successive changes can't land a stale icon.

A "Toggle Icon" and "Move & Rename" button pair is added to the CustomPinIconGallery sample to exercise runtime IMapPin property changes.

Testing

The iOS Map handler is not covered by automated tests (device tests for Map are gated to iOS/MacCatalyst but require a Google/Apple map environment). Verified manually on a physical device (iPad A16, iOS 26.5):

  • Non-clustered: changing ImageSource at runtime updates the icon in place, with no flicker and without deselecting a pin whose callout is open.
  • Clustered: changing ImageSource while pins are collapsed into a cluster is deferred; zooming in shows the pins with the updated icon.
  • Location/Label continue to update live (control case).

Issues Fixed

Fixes #36292

Kévin Baumeyer and others added 2 commits July 2, 2026 10:41
Add "Toggle Icon" (swaps ImageSource between two bundled images) and
"Move & Rename" (updates Location + Label) buttons to CustomPinIconGallery,
to exercise runtime IMapPin property changes and compare platform behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On iOS/Mac Catalyst, MapPinHandler.MapImageSource was a no-op: a pin's custom
image was only applied when MKMapView created the annotation view
(GetViewForAnnotation). Changing Pin.ImageSource on an already-displayed pin
therefore had no visible effect until the view was recreated (e.g. the pin
scrolled off-screen and back), unlike Location/Label/Address which have real
mappers.

MapImageSource now refreshes the live annotation view in place via
MauiMKMapView.UpdatePinImage: it looks up the current MKAnnotationView for the
pin and re-applies (or clears) the image. Pins with no individual view
(off-screen, or collapsed into a cluster) are left untouched — GetViewForAnnotation
already applies the current ImageSource when they next appear. Updating in place
avoids removing/re-adding the annotation, so selection, open callouts and cluster
membership are preserved.

ApplyCustomImageAsync now also captures the requested ImageSource and drops the
result if the pin's ImageSource changed while the load was in flight, so rapid
successive changes cannot land a stale icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 2, 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 -- 36293

Or

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

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Jul 2, 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 removed the s/agent-review-in-progress AI review is currently running for this PR label Jul 3, 2026
@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 5, 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 — 1 findings

See inline comments for details.

Comment thread src/Core/maps/src/Platform/iOS/MauiMKMapView.cs Outdated
@MauiBot MauiBot added 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 5, 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 5, 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?

… boundary

Review feedback: custom-image pins use a plain MKAnnotationView while
default pins use MKMarkerAnnotationView/MKPinAnnotationView, so updating
the image in place is only valid while the view type still matches the
new ImageSource. Clearing a custom image left a blank MKAnnotationView
instead of restoring the default marker, and setting an image on a
default pin loaded it into a marker view.

UpdatePinImage now refreshes in place only when the current view type
matches; when ImageSource crosses the null/non-null boundary it removes
and re-adds the annotation so GetViewForAnnotation recreates the view
through the standard path. A previously selected pin is re-selected,
with an annotation-matched one-shot guard so the programmatic selection
does not raise a synthetic PinClicked.

The sample gallery Toggle Icon button now cycles custom A -> custom B ->
default to exercise both in-place swaps and boundary transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kubaflo

This comment has been minimized.

@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 15, 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 15, 2026
@kubaflo

kubaflo commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

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

@kubaflo

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

Tests Failure Analysis

@kevin68 — test-failure review results are available based on commit fa0c761.
To request a fresh review after new comments, commits, or CI runs, comment /review tests.

Overall Not ready Failures 52 Regressed vs base 22 Baseline 11 on base

Test Failure Review: Not ready - click to expand

Overall verdict: Not ready. 22 legs/failures are red on this PR but green across all 5 recent net11.0 base builds sampled per definition and red on none — a deterministic regression vs base — so a green verdict is forbidden. 11 of the 52 distinct failures also appear on the base branch, but none qualify as dismissible pre-existing-on-base or known-issue (all are indeterminate/flaky), and 30 failures plus 23 unexplained build legs remain unattributed and need a human.

  • ✗ PR-related — BoxView UI-test fixture timeouts (~20 tests): the entire BoxView suite failed at OneTimeSetUp with System.TimeoutException : Timed out waiting for Go To Test button to appear, red on the PR and green on all 5 sampled base builds; e.g. BoxView_Color.
  • ✗ PR-related — MacCatalyst Shell/Navigation regressions (~5 tests): Shell title/nav-bar fixtures time out (regressed-vs-base), e.g. ShellNavigationPageTitleNotClipped; also Issue2951Test and FlyoutContentTests.
  • i Uncertain — Unexplained build legs and aborted/canceled checks (~26 legs): 23 failed build legs produced no extractable failure and 3 MacCatalyst checks were cancelled; open each leg's log before trusting the result.
  • i Uncertain — Device-test crashes and unverified green checks (~17): Essentials/Core work items crashed (OpenAppPackageFileAsync NRE, incomplete Helix work items) and 7 green device-test checks could not confirm Failed==0.
  • i Uncertain — Image/ImageButton visual-diff flakiness (~9 tests): VisualTestUtils.VisualTestFailedException also fails on base (flaky), e.g. VerifyImageAspect_FillWithImageSourceFromUri.

Coverage: 141 checks · 125 passing · 14 failing · 2 pending · 0 inaccessible · 1 unmapped · 23 unexplained build legs · 0 unaccounted failing checks · 3 aborted failing checks · 0 canceled-build checks · 7 device-test unverified · 30 unattributed · 22 regressed-vs-base. Deterministic ceiling: Not ready — 22 leg/failures are a deterministic regression vs base; 2 checks still pending; 3 checks did not finish cleanly.

Builds (this PR): maui-pr 1511226, maui-pr-devicetests 1511228, maui-pr-uitests 1511227. Base sampling (net11.0, 5 recent builds per definition): maui-pr 1501450, maui-pr-devicetests 1501451, maui-pr-uitests 1501538.

Recommended action

Investigate the BoxView and MacCatalyst Shell/Navigation fixture timeouts — they regress cleanly vs base and are the strongest PR-caused signal; then have a human read the 23 unexplained build legs and confirm the 7 unverified device-test checks before merging.

@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 29, 2026
@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) and removed s/agent-gate-failed AI could not verify tests catch the bug labels Jul 29, 2026
@MauiBot

MauiBot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

AI Review Summary

@kevin68 — new AI review results are available based on this last commit: fa0c761.

Gate Passed Confidence Low Platform iOS


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ✅ PASSED

Platform: IOS · Base: net11.0 · Merge base: 3a5e745d

Test Without Fix (expect FAIL) With Fix (expect PASS)
📱 MapTests (PinImageSourceRuntimeChangeUpdatesAnnotationView) Category=Map ✅ FAIL — 203s ✅ PASS — 47s
🔴 Without fix — 📱 MapTests (PinImageSourceRuntimeChangeUpdatesAnnotationView): FAIL ✅ · 203s

(no coded error found; showing last 1200 chars)

  "exitCode": 1,
        "exitCodeName": "TESTS_FAILED",
        "platform": "apple",
        "device": "iPhone 11 Pro",
        "deviceOsVersion": "26.5",
        "files": [
          {
            "name": "test-ios-simulator-64_26.5-36622F8B-1A37-46F7-A12B-246F9F69DF0D.log",
            "type": "executionlog"
          },
          {
            "name": "list-ios-simulator-64_26.5-20260729_130442.log",
            "type": "devicelist"
          },
          {
            "name": "test-ios-simulator-64_26.5-20260729_130448.log",
            "type": "testlog"
          },
          {
            "name": "iPhone 11 Pro.log",
            "type": "systemlog"
          },
          {
            "name": "Microsoft.Maui.Controls.DeviceTests.log",
            "type": "systemlog"
          },
          {
            "name": "com.microsoft.maui.controls.devicetests.log",
            "type": "applicationlog"
          },
          {
            "name": "xunit-test-ios-simulator-64_26.5-20260729_130448.xml",
            "type": "xmllog"
          }
        ]
      }
      <<XHARNESS_RESULT_END>>
XHarness exit code: 1 (TESTS_FAILED)
  Passed: 0
  Failed: 0
  Tests completed with exit code: 1

🟢 With fix — 📱 MapTests (PinImageSourceRuntimeChangeUpdatesAnnotationView): PASS ✅ · 47s

(no coded error found; showing last 1200 chars)

": "Q7949YDCFY-1",
        "exitCode": 0,
        "exitCodeName": "SUCCESS",
        "platform": "apple",
        "device": "iPhone 11 Pro",
        "deviceOsVersion": "26.5",
        "files": [
          {
            "name": "test-ios-simulator-64_26.5-36622F8B-1A37-46F7-A12B-246F9F69DF0D.log",
            "type": "executionlog"
          },
          {
            "name": "list-ios-simulator-64_26.5-20260729_130558.log",
            "type": "devicelist"
          },
          {
            "name": "test-ios-simulator-64_26.5-20260729_130604.log",
            "type": "testlog"
          },
          {
            "name": "iPhone 11 Pro.log",
            "type": "systemlog"
          },
          {
            "name": "Microsoft.Maui.Controls.DeviceTests.log",
            "type": "systemlog"
          },
          {
            "name": "com.microsoft.maui.controls.devicetests.log",
            "type": "applicationlog"
          },
          {
            "name": "xunit-test-ios-simulator-64_26.5-20260729_130604.xml",
            "type": "xmllog"
          }
        ]
      }
      <<XHARNESS_RESULT_END>>
XHarness exit code: 0
  Passed: 6
  Failed: 0
  Tests completed successfully

📁 Fix files reverted (4 files)
  • src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/CustomPinIconGallery.xaml
  • src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/CustomPinIconGallery.xaml.cs
  • src/Core/maps/src/Handlers/MapPin/MapPinHandler.iOS.cs
  • src/Core/maps/src/Platform/iOS/MauiMKMapView.cs

📱 UI Tests — Button,Label,Layout

Detected UI test categories: Button,Label,Layout

Deep UI tests — 354 passed, 0 failed across 3 categories on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
Button 71/72 ✓
Label 89/91 ✓
Layout 194/199 ✓
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)

📋 Pre-Flight — Context & Validation

Issue: #36293 - Runtime Pin.ImageSource changes do not update visible iOS map pin icons
PR: #36293 - [iOS] Map: Update pin icon when Pin.ImageSource changes at runtime
Platforms Affected: iOS, MacCatalyst
Files Changed: 2 implementation, 3 test/sample

Key Findings

  • PR updates iOS/MacCatalyst MapPinHandler.MapImageSource from no-op to refresh the owning MauiMKMapView annotation view for visible pins.
  • Runtime custom-image swaps are async and must guard stale image loads, reused/released annotation views, and handler lifecycle.
  • Crossing the custom/default native view-type boundary currently removes and re-adds the annotation to let MapKit recreate the expected view type.
  • Prior inline reviews flagged blank markers, undisposed image results, stale selection suppressor, lifecycle cleanup, and MacCatalyst test assumptions; current code appears to address those specific findings.
  • Gate was supplied as passed: tests fail without fix and pass with fix. Gate was not re-run and gate/content.md was not touched.

Code Review Summary

Verdict: NEEDS_DISCUSSION
Confidence: low
Errors: 0 | Warnings: 1 | Suggestions: 0

Key code review findings:

  • src/Core/maps/src/Platform/iOS/MauiMKMapView.cs:283-289_suppressClickForAnnotation can remain armed if MapKit never delivers the programmatic DidSelectAnnotationView for the re-added annotation, potentially swallowing the next genuine tap on that same pin.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #36293 Refresh visible iOS annotation views; remove/re-add annotations when custom/default view type changes; preserve selection with one-shot suppressor; guard stale async image loads. ✅ PASSED (Gate) MapPinHandler.iOS.cs, MauiMKMapView.cs, MapTests.iOS.cs, sample gallery files Original PR

🔬 Code Review — Deep Analysis

Code Review — PR #36293

Independent Assessment

What this changes: iOS/MacCatalyst map pins now refresh visible annotation views when Pin.ImageSource changes after the pin is already on the map. Same native-view-type custom swaps update in place; custom/default boundary transitions remove and re-add the annotation; async image loads discard stale results.
Inferred motivation: Make runtime pin icon updates behave like other live pin properties and like Android.

Reconciliation with PR Narrative

Author claims: Runtime Pin.ImageSource changes did not update visible iOS/MacCatalyst pins; this fixes live refresh, stale async loads, selected pins, clustered/offscreen pins, and adds manual/sample plus device-test coverage.
Agreement/disagreement: Mostly matches. One nuance: the current implementation is not purely in-place; it intentionally removes/re-adds annotations when crossing the custom/default view-type boundary.

Prior Review Reconciliation

Prior ❌ Error Finding Source Status Evidence
Clearing ImageSource left a custom view blank instead of restoring a default marker. MauiBot inline review ✅ Fixed UpdatePinImage now detects custom/default view-type mismatch and removes/re-adds the annotation (MauiMKMapView.cs:260-289).
GetPlatformImageAsync result was not disposed. MauiBot inline review ✅ Fixed Current code uses using var result = await requestedSource.GetPlatformImageAsync(...) (MauiMKMapView.cs:306).
Failed custom replacement load blanked the current marker. MauiBot inline review ✅ Fixed Same-type custom refresh no longer clears first; it assigns only after a successful load (MauiMKMapView.cs:263-317).
Suppressor could be consumed by the wrong selected annotation. MauiBot inline review ✅ Fixed Suppression is consumed only for the matching annotation (MauiMKMapView.cs:489-493).
Suppressor could survive detach/reattach. MauiBot inline review ✅ Fixed Cleanup() clears _suppressClickForAnnotation (MauiMKMapView.cs:477-479).
MacCatalyst test expected only iOS marker type. MauiBot inline review ✅ Fixed Test accepts MKMarkerAnnotationView or MKPinAnnotationView (MapTests.iOS.cs:149-151, 186-189).

External Output Contract

Consumer token/pattern Producer location Producer emission condition Consumer assumption Ordinary negative case Downstream effect
N/A N/A No changed regex/string classifier for external tool output. N/A N/A N/A

Blast Radius Assessment

  • Runs for all instances: No — MapImageSource only runs when a pin’s ImageSource property maps/changes.
  • Startup impact: Low — initial image path is existing GetViewForAnnotation; new logic mainly affects runtime updates.
  • Static/shared state: No global state. New _suppressClickForAnnotation is per MauiMKMapView.

CI Status

  • Required-check result: gh pr checks --required unavailable due missing GitHub auth; public check-runs for head fa0c761 show aggregate failures and pending/in-progress runs.
  • Classification: undetermined; failures were not attributed to this PR in this local review.
  • Action taken: capped confidence and verdict per code-review rule prohibiting LGTM on red/pending/undetermined CI.

Findings

⚠️ Warning — Selection suppressor can remain armed if MapKit does not deliver the programmatic select event

MauiMKMapView.cs:283-289 sets _suppressClickForAnnotation before SelectAnnotation(annotation, false), and the only normal non-cleanup path that clears it is a later matching DidSelectAnnotationView (MauiMKMapView.cs:482-493). If MapKit drops or defers that programmatic selection event after remove/re-add, a later real user selection of the same annotation can be swallowed. This is not proven as a current failure, but it is an unresolved lifecycle risk around a one-shot latch.

Failure-Mode Probing

  • Custom→custom swap: refreshes existing custom view and drops stale async results by captured source/annotation.
  • Custom→null / null→custom: removes/re-adds annotation to get correct native view type.
  • Offscreen/clustered pin: no current view is found, so next GetViewForAnnotation applies current ImageSource.
  • Handler disconnect: async path checks zero handle; suppressor is cleared in Cleanup().
  • Programmatic selection suppression: unresolved if DidSelectAnnotationView never arrives.

Verdict: NEEDS_DISCUSSION

Confidence: low (platform handler/UI plumbing plus CI unavailable/red/pending caps confidence)
Summary: The main runtime image update approach looks sound and prior major findings appear fixed. I would still discuss the one-shot selection suppressor lifecycle and wait for CI classification before treating this as merge-ready.


🛠️ Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix Defer selected custom/default boundary rebuild until deselection to avoid programmatic reselection and suppressor state. ⚠️ Blocked 1 file Runner failed before tests; not demonstrably better because selected pin can remain visually stale until deselection.
2 try-fix Replace default/custom view split with one MAUI-owned image-backed annotation view. ❌ Failed self-review 0 files Rejected before implementation due native default marker fidelity/accessibility/clustering risk.
3 try-fix Keep native boundary rebuild but make selection suppressor self-clear on next main-loop tick. ⚠️ Blocked 1 file Runner failed before tests; localized but still has MapKit callback timing risk.
PR PR #36293 Refresh custom images in place; remove/re-add on custom/default boundary; restore selection with annotation-matched suppressor; guard stale async image loads. ✅ PASSED (Gate) 5 files Original PR.

Cross-Pollination

Model Round New Ideas? Details
maui-expert-reviewer 1 Yes Selection-stable deferred boundary rebuild. Avoids suppressor but defers selected visual boundary updates until deselection.
maui-expert-reviewer 2 Yes Unified in-place annotation view. Avoids boundary and suppressor but fails native defaults preservation review.
maui-expert-reviewer 3 Yes Transaction-scoped selection restore. Bounds suppressor lifetime but depends on MapKit callback timing.
maui-expert-reviewer 4 No Meaningfully different localized approaches are exhausted: remaining variants either repeat PR remove/re-add logic, replace native marker semantics, defer visual updates, or use arbitrary timing.

Exhausted: Yes
Selected Fix: PR #36293 — Gate already passed, and no alternative candidate both passed tests and proved safer. Candidate 3 is the closest localized improvement idea, but it needs selected-pin timing tests before it can be considered better than the PR.

Candidate Details

try-fix-1 — Selection-Stable Deferred Boundary Rebuild

Approach: Defer custom/default annotation-view boundary rebuilds while the pin is selected, then rebuild after DidDeselectAnnotationView. Same-type updates still refresh in place. This avoids the PR's programmatic SelectAnnotation suppression path entirely.

Diff: See CustomAgentLogsTmp/PRState/36293/PRAgent/try-fix/attempt-1/fix.diff.

Test Results: ⚠️ Blocked. Command attempted twice:

pwsh -NoProfile -File .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter 'Category=Map' -OutputDirectory 'artifacts/log/pr36293-try-fix-1'

Both attempts failed before tests ran with:

Run-DeviceTests.ps1: A positional parameter cannot be found that accepts argument 'Assets'.

Failure Analysis: The local runner/configuration failed before build/deploy/test execution, so candidate behavior is unverified. Expert review identified a product tradeoff: selected pins crossing the custom/default boundary retain the old native view type until deselection, so this is not demonstrably better than the PR without additional selected-pin tests.

try-fix-1 diff

diff --git a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
index 756b78fe3b..d8ae5271f0 100644
--- a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
+++ b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
@@ -20,6 +20,7 @@ namespace Microsoft.Maui.Maps.Platform
 		UITapGestureRecognizer? _mapClickedGestureRecognizer;
 		bool _isClusteringEnabled;
 		IMKAnnotation? _suppressClickForAnnotation;
+		IMKAnnotation? _pendingImageRebuildForAnnotation;
 
 		UILongPressGestureRecognizer? _mapLongClickedGestureRecognizer;
 		List<IMapElement>? _trackedMapElements;
@@ -277,17 +278,14 @@ namespace Microsoft.Maui.Maps.Platform
 			// recreate the view through the standard path. Restore selection without raising a
 			// synthetic PinClicked.
 			bool wasSelected = SelectedAnnotations?.Any(a => ReferenceEquals(a, annotation) || a.Handle == annotation.Handle) == true;
-			RemoveAnnotation(annotation);
-			AddAnnotation(annotation);
-
 			if (wasSelected)
 			{
-				// DidSelectAnnotationView may fire after the view is (re)created rather than inside
-				// SelectAnnotation, so suppression is annotation-matched rather than a flag scoped to
-				// this call; it's consumed by the matching event, or dropped in Cleanup.
-				_suppressClickForAnnotation = annotation;
-				SelectAnnotation(annotation, false);
+				_pendingImageRebuildForAnnotation = annotation;
+				return;
 			}
+
+			RemoveAnnotation(annotation);
+			AddAnnotation(annotation);
 		}
 
 		async System.Threading.Tasks.Task ApplyCustomImageAsync(MKAnnotationView annotationView, IMapPin pin)
@@ -443,6 +441,7 @@ namespace Microsoft.Maui.Maps.Platform
 		{
 			RegionChanged += MkMapViewOnRegionChanged;
 			DidSelectAnnotationView += MkMapViewOnAnnotationViewSelected;
+			DidDeselectAnnotationView += MkMapViewOnAnnotationViewDeselected;
 			DidUpdateUserLocation += MkMapViewOnUserLocationUpdated;
 
 			AddGestureRecognizer(_mapClickedGestureRecognizer = new UITapGestureRecognizer(OnMapClicked)
@@ -472,11 +471,27 @@ namespace Microsoft.Maui.Maps.Platform
 			}
 			RegionChanged -= MkMapViewOnRegionChanged;
 			DidSelectAnnotationView -= MkMapViewOnAnnotationViewSelected;
+			DidDeselectAnnotationView -= MkMapViewOnAnnotationViewDeselected;
 			DidUpdateUserLocation -= MkMapViewOnUserLocationUpdated;
 
 			// Annotations survive detach/reattach, so drop any pending click suppression to prevent it
 			// from swallowing the next real tap after navigation or a Shell tab switch.
 			_suppressClickForAnnotation = null;
+			_pendingImageRebuildForAnnotation = null;
+		}
+
+		void MkMapViewOnAnnotationViewDeselected(object? sender, MKAnnotationViewEventArgs e)
+		{
+			var annotation = e.View.Annotation;
+			if (annotation is null || _pendingImageRebuildForAnnotation is null ||
+				!(ReferenceEquals(annotation, _pendingImageRebuildForAnnotation) || annotation.Handle == _pendingImageRebuildForAnnotation.Handle))
+			{
+				return;
+			}
+
+			_pendingImageRebuildForAnnotation = null;
+			RemoveAnnotation(annotation);
+			AddAnnotation(annotation);
 		}
 
 		void MkMapViewOnAnnotationViewSelected(object? sender, MKAnnotationViewEventArgs e)

try-fix-2 — Unified In-Place iOS Pin Annotation View

Approach: Use a single MAUI-owned MKAnnotationView representation for both default and custom-image pins so all ImageSource changes can update the same view in place, with no annotation remove/re-add and no selection suppressor.

Diff: Empty. The candidate was rejected before implementation.

Test Results: ❌ Failed at expert self-review; tests were not run.

Failure Analysis: The approach is materially different but not safer: replacing MKMarkerAnnotationView/MKPinAnnotationView with an image-backed custom view risks native-default fidelity, accessibility, clustering/collision behavior, and future MapKit styling. It would need a broader design and test matrix, so it is not demonstrably better than PR #36293.

try-fix-2 diff

try-fix-3 — Transaction-Scoped Selection Restore

Approach: Keep the PR's remove/re-add boundary transition and native default marker behavior, but make _suppressClickForAnnotation self-clear on the next main-loop tick if MapKit does not deliver the matching programmatic DidSelectAnnotationView.

Diff: See CustomAgentLogsTmp/PRState/36293/PRAgent/try-fix/attempt-3/fix.diff.

Test Results: ⚠️ Blocked. Command attempted:

pwsh -NoProfile -File .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform ios -TestFilter 'Category=Map' -OutputDirectory 'artifacts/log/pr36293-try-fix-3'

It failed before tests ran with:

Run-DeviceTests.ps1: A positional parameter cannot be found that accepts argument 'Assets'.

Failure Analysis: The local runner/configuration prevented empirical evaluation. The candidate is localized and avoids an indefinitely armed suppressor, but self-review found a remaining timing risk: if MapKit delivers the programmatic selection callback after the next main-loop tick, the synthetic callback may no longer be suppressed. This is not demonstrably better than the PR without targeted selected-pin timing tests.

try-fix-3 diff

diff --git a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
index 756b78fe3b..6043741012 100644
--- a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
+++ b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs
@@ -2,6 +2,7 @@
 using System.Collections;
 using System.Collections.Generic;
 using System.Linq;
+using CoreFoundation;
 using CoreLocation;
 using MapKit;
 using Microsoft.Extensions.DependencyInjection;
@@ -287,6 +288,14 @@ namespace Microsoft.Maui.Maps.Platform
 				// this call; it's consumed by the matching event, or dropped in Cleanup.
 				_suppressClickForAnnotation = annotation;
 				SelectAnnotation(annotation, false);
+				DispatchQueue.MainQueue.DispatchAsync(() =>
+				{
+					if (_suppressClickForAnnotation is not null &&
+						(ReferenceEquals(annotation, _suppressClickForAnnotation) || annotation.Handle == _suppressClickForAnnotation.Handle))
+					{
+						_suppressClickForAnnotation = null;
+					}
+				});
 			}
 		}
 

📝 Recommended PR Title & Description

Assessment: ✏️ Recommend updating — the title is accurate, but the description's Testing section is stale because the PR now adds an automated iOS device regression test.

Recommended title

[iOS] Map: Update pin icon when Pin.ImageSource changes at runtime

Recommended description

### Description of Change

On iOS/Mac Catalyst, changing a `Pin.ImageSource` at runtime did not update the icon of a pin already displayed on the map — the new image only appeared once MapKit recreated the annotation view (e.g. the pin scrolled off-screen and back). `MapPinHandler.MapImageSource` was a no-op; the image is applied in `MauiMKMapView.GetViewForAnnotation`, which only runs on view (re)creation. `Location`/`Label`/`Address` update live, and Android already updates the marker icon in place, so iOS was inconsistent.

**Fix:** `MapImageSource` now refreshes the live annotation view via `MauiMKMapView.UpdatePinImage`:

- Looks up the current `MKAnnotationView` for the pin (`ViewForAnnotation`) and re-applies the image in place when the current native view type still matches the requested state. Pins with no individual view — off-screen, or collapsed into a cluster — are left untouched; `GetViewForAnnotation` already applies the current `ImageSource` when they next appear.
- When `ImageSource` crosses the custom/default boundary, removes and re-adds the annotation so MapKit recreates the correct native view type (`MKAnnotationView` for custom images, `MKMarkerAnnotationView`/`MKPinAnnotationView` for default markers).
- Restores selection for selected pins after a custom/default boundary rebuild while suppressing the synthetic `PinClicked` event.
- `ApplyCustomImageAsync` now captures the requested `ImageSource` and drops the result if the annotation view was reused/released or the pin's `ImageSource` changed while the load was in flight, so rapid successive changes can't land a stale icon.

A **"Toggle Icon"** and **"Move & Rename"** button pair is added to the `CustomPinIconGallery` sample to exercise runtime `IMapPin` property changes.

#### Testing

Added `MapTests.PinImageSourceRuntimeChangeUpdatesAnnotationView` for iOS to cover:

- Default marker -> custom image (`null` -> `ImageSource`)
- Custom image -> custom image
- Custom image -> default marker (`ImageSource` -> `null`)

Also verified manually on a physical device (iPad A16, iOS 26.5):

- Non-clustered: changing `ImageSource` at runtime updates the icon in place, with no flicker and without deselecting a pin whose callout is open.
- Clustered: changing `ImageSource` while pins are collapsed into a cluster is deferred; zooming in shows the pins with the updated icon.
- `Location`/`Label` continue to update live (control case).

### Issues Fixed

Fixes #36292

🏁 Report — Final Recommendation

Comparative Report — PR #36293

Candidates compared

Rank Candidate Test result Assessment
1 pr ✅ Passed gate Best candidate. It fixes the runtime iOS/MacCatalyst Pin.ImageSource refresh bug, preserves native default marker behavior, handles custom/default view-type boundaries, and has regression evidence.
1 pr-plus-reviewer ✅ Same as PR The expert reviewer produced no actionable inline findings, so this candidate is identical to pr. It is not ranked above pr because it contains no additional change.
3 try-fix-3 ⚠️ Blocked Localized alternative that self-clears the selection suppressor on the next main-loop tick, but tests did not run and the approach introduces timing risk if MapKit delivers the synthetic selection after the tick.
4 try-fix-1 ⚠️ Blocked Avoids the suppressor by deferring selected custom/default rebuilds until deselection, but this can leave a selected pin visually stale until the user deselects it. Tests did not run.
5 try-fix-2 ❌ Failed self-review Rejected before implementation. Replacing native default marker views with a unified image-backed custom view risks marker fidelity, accessibility, clustering/collision behavior, and future MapKit styling.

Analysis

The submitted PR is the only candidate with successful regression evidence. It addresses the core bug while preserving MapKit's native default annotation views and existing clustering/offscreen behavior. Its async image loading guards also cover rapid successive image changes and annotation view reuse.

pr-plus-reviewer is equivalent to pr because the expert reviewer wrote no actionable inline findings (inline-findings.json is []). There is therefore no reviewer-derived sandbox improvement to prefer over the raw PR.

The try-fix candidates explored useful alternatives but do not overtake the PR. try-fix-1 removes the suppressor path by deferring selected rebuilds, but trades that for stale selected-pin visuals. try-fix-3 bounds the suppressor lifetime, but relies on an arbitrary main-loop timing assumption. try-fix-2 is broader and riskier than the bug requires.

Winner

Winner: pr

The raw PR fix wins because it is the only non-failed, regression-passing implementation and the expert reviewer found no actionable changes to apply. The residual selected-pin suppressor concern is a discussion risk, but every alternative either failed self-review, was blocked without test evidence, or introduced a different product/timing tradeoff.


🧭 Next Steps — review latest findings

No alternative fix was selected for this run. Review the session findings and CI results before merging.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Jul 29, 2026
Copilot AI added 2 commits August 2, 2026 00:11
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 81d69925-4844-4957-ba6e-9164b2736888
Resolve the MauiMKMapView conflict by preserving runtime pin image staleness guards alongside the newer cluster icon lifecycle and selection handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 81d69925-4844-4957-ba6e-9164b2736888
@kubaflo
kubaflo merged commit b432501 into dotnet:net11.0 Aug 1, 2026
3 of 4 checks passed
@github-actions github-actions Bot added this to the .NET 11.0-preview7 milestone Aug 1, 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-changes-requested AI agent recommends changes - found a better alternative or issues 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.

[iOS] Map: Pin.ImageSource changes are ignored at runtime (icon not updated)

4 participants