-
Notifications
You must be signed in to change notification settings - Fork 2k
[iOS] Map: Update pin icon when Pin.ImageSource changes at runtime #36293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2153617
197086e
b2cb88b
981e5df
6ce52cf
429ffab
09934f1
fa0c761
8a9d158
a0b4df3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ public class MauiMKMapView : MKMapView | |
| object? _lastTouchedView; | ||
| UITapGestureRecognizer? _mapClickedGestureRecognizer; | ||
| bool _isClusteringEnabled; | ||
| IMKAnnotation? _suppressClickForAnnotation; | ||
|
|
||
| UILongPressGestureRecognizer? _mapLongClickedGestureRecognizer; | ||
| List<IMapElement>? _trackedMapElements; | ||
|
|
@@ -344,21 +345,64 @@ void ZoomToShowClusterPins(IMKAnnotation[] annotations) | |
| SetVisibleMapRect(paddedRect, true); | ||
| } | ||
|
|
||
| // Refreshes a pin already on the map after its ImageSource changed at runtime. Pins with no | ||
| // individual view (off-screen or collapsed into a cluster) are skipped; GetViewForAnnotation | ||
| // applies the current ImageSource when they appear. | ||
| internal void UpdatePinImage(IMapPin pin) | ||
| { | ||
| if (pin.MarkerId is not IMKAnnotation annotation || ViewForAnnotation(annotation) is not MKAnnotationView view) | ||
| return; | ||
|
|
||
| bool hasCustomImage = pin.ImageSource is not null; | ||
| bool viewShowsCustomImage = view is not MKMarkerAnnotationView && view is not MKPinAnnotationView; | ||
|
|
||
| if (hasCustomImage == viewShowsCustomImage) | ||
| { | ||
| // The current view type still matches; refresh the image in place. Don't pre-clear the | ||
| // custom image: ApplyCustomImageAsync only assigns on success, so a failed load keeps the | ||
| // previous icon (matching Android) instead of blanking a visible pin. | ||
| if (hasCustomImage) | ||
| ApplyCustomImageAsync(view, pin).FireAndForget(); | ||
| else | ||
| view.Image = null; | ||
| return; | ||
| } | ||
|
|
||
| // ImageSource crossed the null/non-null boundary: custom-image pins and default pins use | ||
| // different annotation view types, so re-add the annotation to let GetViewForAnnotation | ||
| // 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 6ce52cf.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Logic and Correctness —
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not applying the suggested placement: clearing |
||
| SelectAnnotation(annotation, false); | ||
| } | ||
| } | ||
|
|
||
| async System.Threading.Tasks.Task ApplyCustomImageAsync(MKAnnotationView annotationView, IMapPin pin) | ||
| { | ||
| _handlerRef.TryGetTarget(out IMapHandler? handler); | ||
| if (handler?.MauiContext == null || pin.ImageSource == null) | ||
| return; | ||
|
|
||
| // Capture the annotation before the async operation to detect reuse | ||
| // Capture the annotation and requested source before the async operation, to detect both | ||
| // view reuse and a newer ImageSource change that started while this load was in flight. | ||
| var targetAnnotation = annotationView.Annotation; | ||
| var requestedSource = pin.ImageSource; | ||
|
|
||
| try | ||
| { | ||
| using var result = await pin.ImageSource.GetPlatformImageAsync(handler.MauiContext); | ||
| using var result = await requestedSource.GetPlatformImageAsync(handler.MauiContext); | ||
|
|
||
| // Verify the annotation view hasn't been reused for a different pin | ||
| if (annotationView.Annotation != targetAnnotation) | ||
| // Drop this load if the view was disposed/released, reused, or the pin's ImageSource changed since. | ||
| if (annotationView.Handle == IntPtr.Zero || annotationView.Annotation != targetAnnotation || !ReferenceEquals(pin.ImageSource, requestedSource)) | ||
| return; | ||
|
|
||
| if (result?.Value is UIImage image) | ||
|
|
@@ -628,6 +672,10 @@ void Cleanup() | |
| RegionChanged -= MkMapViewOnRegionChanged; | ||
| DidSelectAnnotationView -= MkMapViewOnAnnotationViewSelected; | ||
| 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; | ||
| _clusterIconCache.Clear(); | ||
| _clusterImageOwner = null; | ||
| _clusterImageVersion = int.MinValue; | ||
|
|
@@ -652,6 +700,16 @@ void MkMapViewOnAnnotationViewSelected(object? sender, MKAnnotationViewEventArgs | |
| { | ||
| var annotation = e.View.Annotation; | ||
|
|
||
| // Selection was restored programmatically by UpdatePinImage; the user did not tap the pin. | ||
| // Only consume (and clear) the suppressor when this event is for the matching annotation, so | ||
| // a user tap on a different pin while the restore is pending isn't swallowed by mistake. | ||
| if (annotation is not null && _suppressClickForAnnotation is not null && | ||
| (ReferenceEquals(annotation, _suppressClickForAnnotation) || annotation.Handle == _suppressClickForAnnotation.Handle)) | ||
| { | ||
| _suppressClickForAnnotation = null; | ||
| return; | ||
| } | ||
|
|
||
| // Handle cluster annotation selection | ||
| if (annotation is not null && TryGetClusterAnnotation(annotation) is MKClusterAnnotation clusterAnnotation) | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[moderate] Regression Prevention —
UpdatePinImageintroduces the runtimeImageSourcerefresh path for iOS/MacCatalyst but no automated device test covers it. The three key transitions—custom→custom swap, custom→null (revert to platform default), and null→custom (first assignment after pin is already on map)—are exercised only by the new manual gallery buttons. A device test that adds a pin, changes itsImageSourcethrough each boundary, and asserts the annotation view type viaViewForAnnotationwould close this regression gap (the pre-flight review flagged this gap independently).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in 09934f1: a device test in
MapTests.iOS.cs("Pin ImageSource Runtime Change Updates Annotation View") covering the three transitions on a pin already on the map — null→custom asserts the view swaps to a customMKAnnotationViewwith a loaded image, custom→custom asserts the image instance changes in place, custom→null asserts the view reverts toMKMarkerAnnotationView. The annotation is re-resolved on every poll since boundary transitions remove/re-add it, and the platform view is given an explicit frame because the test harness attaches it unsized and MapKit only materializes annotation views for a laid-out map.