Skip to content

Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform.#30067

Merged
kubaflo merged 9 commits intodotnet:inflight/currentfrom
KarthikRajaKalaimani:fix-19168
Mar 30, 2026
Merged

Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform.#30067
kubaflo merged 9 commits intodotnet:inflight/currentfrom
KarthikRajaKalaimani:fix-19168

Conversation

@KarthikRajaKalaimani
Copy link
Copy Markdown
Contributor

Issue Details:

Tapping outside the Picker does not dismiss it on iOS and Mac, whereas it works as expected on Android and Windows.

Root Cause:

The touch handling for taps outside the picker was not implemented, which is why the picker was not being dismissed as expected on iOS and Mac platform.

Description of Change:

To resolve this issue, a tap gesture recognizer was added to the window containing the Picker. This allows the system to detect taps outside the Picker and dismiss it appropriately. The gesture recognizer is added during the OnStarted event and properly removed and disposed of in the OnEnded event and Disconnect methods. This implementation has been applied and successfully verified on both iOS and macOS platforms.

Tested the behavior in the following platforms.

  • Android
  • Windows
  • iOS
  • Mac

Reference:

N/A

Issues Fixed:

Fixes #19168

Screenshots

Before After
Screen.Recording.2025-06-19.at.3.46.23.PM.mov
Screen.Recording.2025-06-19.at.3.44.26.PM.mov

@dotnet-policy-service dotnet-policy-service bot added community ✨ Community Contribution partner/syncfusion Issues / PR's with Syncfusion collaboration labels Jun 19, 2025

_tapGestureRecognizer = new UITapGestureRecognizer(() =>
{
picker.EndEditing(true);
Copy link
Copy Markdown
Contributor

@bhavanesh2001 bhavanesh2001 Jun 19, 2025

Choose a reason for hiding this comment

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

Strong reference to platform view in gesture handler is asking for trouble.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Strong reference to platform view in gesture handler is asking for trouble.

Thanks for the suggestion. I have corrected it.

{
picker.EndEditing(true);
});
_tapGestureRecognizer.CancelsTouchesInView = false;
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.

CancelsTouchesInView = false lets taps through, but this gesture may dismiss the picker before "Done" is tapped, which can prevent selection from committing, please verify.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tested this scenario and confirmed that it works as expected — the SelectedItem property retains its value when tapping outside the Picker.

@KarthikRajaKalaimani KarthikRajaKalaimani marked this pull request as ready for review June 19, 2025 13:14
@KarthikRajaKalaimani KarthikRajaKalaimani requested a review from a team as a code owner June 19, 2025 13:14
@jsuarezruiz
Copy link
Copy Markdown
Contributor

/azp run MAUI-UITests-public

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

#elif IOS
App.Tap("Button");
#endif
VerifyScreenshot();
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.

Pending snapshot on Mac:
image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pending snapshot on Mac: image

I have added the Mac snapshot.

@jsuarezruiz
Copy link
Copy Markdown
Contributor

/azp run MAUI-UITests-public

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@jsuarezruiz
Copy link
Copy Markdown
Contributor

/azp run MAUI-UITests-public

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

Copy link
Copy Markdown
Contributor

@bhavanesh2001 bhavanesh2001 left a comment

Choose a reason for hiding this comment

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

@KarthikRajaKalaimani Thanks for the changes

Adding a gesture recognizer to the Window solely to dismiss the picker when tapping outside feels excessive, especially since not dismissing appears to be the default behavior. Are we even supposed to handle this at our end? I think user can implement something similar on their side if needed.

Even if it's acceptable to add a tap gesture here, this change effectively decides for the user that the picker should close when tapping outside. But what if someone doesn't want that? Another user might even see the automatic dismissal as a bug.

Ideally, there should be an API like DismissOnTouchOutside so that users can choose the behavior they want.

PlatformView.EndEditing(true);
});
_tapGestureRecognizer.CancelsTouchesInView = false;
PlatformView.Window.AddGestureRecognizer(_tapGestureRecognizer);
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.

Adding a gesture to the window just to achieve tap-outside-to-dismiss feels excessive, especially since not dismissing appears to be the default behavior.


_tapGestureRecognizer = new UITapGestureRecognizer(() =>
{
PlatformView.EndEditing(true);
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.

The closure still captures the PlatformView strongly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The closure still captures the PlatformView strongly.

I have simplified the code now

@KarthikRajaKalaimani
Copy link
Copy Markdown
Contributor Author

@KarthikRajaKalaimani Thanks for the changes

Adding a gesture recognizer to the Window solely to dismiss the picker when tapping outside feels excessive, especially since not dismissing appears to be the default behavior. Are we even supposed to handle this at our end? I think user can implement something similar on their side if needed.

Even if it's acceptable to add a tap gesture here, this change effectively decides for the user that the picker should close when tapping outside. But what if someone doesn't want that? Another user might even see the automatic dismissal as a bug.

Ideally, there should be an API like DismissOnTouchOutside so that users can choose the behavior they want.

On Android and Windows, the picker automatically closes when the taps outside of it. To maintain consistent behavior across platforms, i implemented the same dismissal behavior on iOS and macOS.

@Bejasc
Copy link
Copy Markdown

Bejasc commented Aug 15, 2025

@KarthikRajaKalaimani @bhavanesh2001 any progress with having this included? We've had several issues raised internally, as we work towards completing our MAUI Migration.

Dismissing the picker when tapping outside on iOS with Xamarin was also the default behavior.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes the issue where tapping outside a Picker does not dismiss it on iOS and MacCatalyst platforms. The solution adds a UITapGestureRecognizer to the window to detect taps outside the picker and properly dismiss it.

Key Changes:

  • Added tap gesture recognizer management to handle outside-tap dismissal
  • Refactored MacCatalyst's DisplayAlert method to simplify picker presentation
  • Removed MapIsOpen method and related IsOpen property handling
  • Added comprehensive UI test with platform-specific verification and screenshot validation

Reviewed changes

Copilot reviewed 2 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Core/src/Handlers/Picker/PickerHandler.iOS.cs Core implementation adding tap gesture recognizer for dismissing picker on outside tap, with setup/cleanup logic and simplified MacCatalyst alert display
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19168.cs NUnit UI test validating picker dismissal behavior with platform-specific tap coordinates and screenshot verification
src/Controls/tests/TestCases.HostApp/Issues/Issue19168.cs Test page with picker and button controls to reproduce the issue for automated testing
src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/PickerShouldDismissWhenClickOnOutside.png iOS screenshot baseline for visual regression testing
src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/PickerShouldDismissWhenClickOnOutside.png MacCatalyst screenshot baseline for visual regression testing
src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/PickerShouldDismissWhenClickOnOutside.png Windows screenshot baseline for visual regression testing
src/Controls/tests/TestCases.Android.Tests/snapshots/android/PickerShouldDismissWhenClickOnOutside.png Android screenshot baseline for visual regression testing

Comment on lines +20 to +29
#if MACCATALYST
App.TapCoordinates(600, 200);
App.WaitForElement("Button");
#elif ANDROID
App.TapCoordinates(0,100);
#elif WINDOWS
App.TapCoordinates(60, 600);
#elif IOS
App.Tap("Button");
#endif
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Platform-specific conditional compilation violates the UI testing guidelines. According to the coding guidelines, tests should run on all applicable platforms by default unless there's a specific technical limitation. This test uses different approaches for each platform (coordinates vs button tap), which makes the test inconsistent across platforms. Consider implementing a unified approach that works across all platforms, or if platform-specific behavior is truly necessary, document why each platform requires different testing logic.

Copilot generated this review using guidance from repository custom instructions.
App.TapCoordinates(600, 200);
App.WaitForElement("Button");
#elif ANDROID
App.TapCoordinates(0,100);
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Missing space after comma in method arguments. Should be App.TapCoordinates(0, 100); for consistent formatting.

Suggested change
App.TapCoordinates(0,100);
App.TapCoordinates(0, 100);

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,47 @@
using System.Collections.ObjectModel;
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Unused using statement: System.Collections.ObjectModel is imported but not used in this file. The code only uses List<string> which is in the System.Collections.Generic namespace (implicitly available). Consider removing this unused using statement.

Suggested change
using System.Collections.ObjectModel;

Copilot uses AI. Check for mistakes.

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 19168, "iOS Picker dismiss does not work when clicking outside of the Picker", PlatformAffected.iOS)]
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

The PlatformAffected attribute is set to PlatformAffected.iOS, but according to the PR description and the test implementation, this issue also affects MacCatalyst. The attribute should be updated to include MacCatalyst: PlatformAffected.iOS | PlatformAffected.MacCatalyst or simply use PlatformAffected.All since the fix is being applied to both platforms.

Suggested change
[Issue(IssueTracker.Github, 19168, "iOS Picker dismiss does not work when clicking outside of the Picker", PlatformAffected.iOS)]
[Issue(IssueTracker.Github, 19168, "iOS Picker dismiss does not work when clicking outside of the Picker", PlatformAffected.iOS | PlatformAffected.MacCatalyst)]

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Dec 9, 2025

🚀 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 -- 30067

Or

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

@rmarinho
Copy link
Copy Markdown
Member

rmarinho commented Feb 15, 2026

🤖 AI Summary

📊 Expand Full Review
🔍 Pre-Flight — Context & Validation
📝 Review SessionFix improved · 5510680

Issue: #19168 - iOS Picker dismiss does not work when clicking outside of the Picker
PR: #30067 - Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform
Platforms Affected: iOS, MacCatalyst (regression from 8.0.3; Android/Windows unaffected)
Files Changed: 7 files (1 implementation, 2 test files, 4 snapshot files)

Summary

This PR fixes a regression introduced in 8.0.3 where tapping outside a Picker on iOS/MacCatalyst does not dismiss it. Android and Windows already handle outside taps natively. The behavior worked in Xamarin.Forms and in .NET MAUI 7.0.101.

PR's Approach

Adds a UITapGestureRecognizer to the window when the picker is opened:

  • Gesture recognizer set up in OnStarted (EditingDidBegin) — window is guaranteed non-null at this point
  • Gesture recognizer removed in OnEnded (EditingDidEnd) for iOS
  • On MacCatalyst: DismissPicker() dismisses the UIAlertController and calls RemoveTouchDismissGesture() directly
  • Uses WeakReference<PickerHandler> to prevent memory leaks
  • CancelsTouchesInView = false preserves normal UI interactions
  • RemoveTouchDismissGesture() uses _tapGestureRecognizer.View?.RemoveGestureRecognizer(...) (safe even if window is null at cleanup)

Prior Agent Review

A prior agent review was found. The Gate had FAILED in that review because Window was null at Connect() time (setup was in the wrong lifecycle point). The PR author has since improved the fix:

  • Moved SetupTouchDismissGesture() from Connect() to OnStarted()
  • Fixed guard condition logic (null window check first, then duplicate check) ✅
  • Improved RemoveTouchDismissGesture() to use view-stored reference ✅

Key Discussion Points

Reviewer Concern Status
bhavanesh2001 Strong reference in closure ✅ Fixed (WeakReference)
bhavanesh2001 Approach feels excessive; no opt-out ⚠️ Open (platform parity argument)
bhavanesh2001 CancelsTouchesInView = false / Done button ✅ Verified by author
Copilot Inline #if in test method ⚠️ Still present
Copilot PlatformAffected uses macOS not MacCatalyst ⚠️ Minor
kubaflo Test not working (prior state) ✅ Author says fixed
jsuarezruiz Mac snapshot pending ✅ Author added

Files Changed

Implementation:

  • src/Core/src/Handlers/Picker/PickerHandler.iOS.cs (+59 lines)

Tests:

  • src/Controls/tests/TestCases.HostApp/Issues/Issue19168.cs (new, +45)
  • src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19168.cs (new, +32)
  • 4 snapshot files (iOS, Mac, Windows, Android)

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #30067 Add UITapGestureRecognizer to window in OnStarted, remove in OnEnded ⏳ PENDING (Gate) PickerHandler.iOS.cs (+59) Original PR — improved fix

🚦 Gate — Test Verification
📝 Review SessionFix improved · 5510680

Result: ✅ PASSED
Platform: iOS
Mode: Full Verification

Scenario Result Details
Tests WITHOUT fix ✅ FAIL (as expected) Tests correctly detect the bug — picker stays open
Tests WITH fix ✅ PASS (as expected) Fix successfully resolves the issue

The updated implementation (with SetupTouchDismissGesture() called from OnStarted instead of Connect()) correctly handles the window lifecycle issue identified in the prior agent review.

PR Fix Candidate Updated: ✅ PASS (Gate)


🔧 Fix — Analysis & Comparison
📝 Review SessionFix improved · 5510680

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix PickerDismissInterceptorView: transparent UIView on RootVC.View, HitTest override returns nil ✅ PASS 1 file Clean, no gesture recognizers; iOS only
2 try-fix Full-screen UIControl (TouchUpInside) on UIWindow (iOS) + native cancel action on UIAlertController (MacCatalyst) ✅ PASS 1 file Platform-appropriate split; MacCatalyst handled natively
3 try-fix UITapGestureRecognizer on RootViewController.View via MauiContext.GetPlatformWindow(), stores view reference separately ✅ PASS 1 file Similar to PR but scoped to content area, not Window
4 try-fix UITapGestureRecognizer on PlatformView.Superview (most scoped) ✅ PASS 1 file Most scoped approach; may miss some taps in complex layouts
5 try-fix UIView overlay on Window with HitTest checking if tap is outside PlatformView coords ✅ PASS 1 file Coordinate-aware pass-through
6 try-fix UIApplication.sendEvent class-level method swizzle via P/Invoke to libobjc.dylib ✅ PASS 1 file Too invasive; runtime ObjC hacking
7 try-fix Override SendEvent in new MauiUIWindow : UIWindow subclass + update ApplicationExtensions.cs ✅ PASS 2 files Clean documented OOP extension; PickerHandler unchanged; but complex for this scope
PR PR #30067 UITapGestureRecognizer on PlatformView.Window in OnStarted, removed in OnEnded/Disconnect ✅ PASS (Gate) 1 file Original PR — proper lifecycle management

Cross-Pollination Summary

Round 1 → 5 NEW IDEAS (all from 5 models)
Round 2 → 3 NEW IDEAS (UIWindow.sendEvent subclass variants; gpt-5.2/3 suggested same idea)
Round 3 → 1 NEW IDEA (gpt-5.2: UIToolbar Done button — already exists as MauiDoneAccessoryView, doesn't solve outside-tap)

Round Model Response
2 claude-sonnet-4.6 NEW IDEA: MauiUIWindow.SendEvent override → ran as Attempt 7
2 claude-opus-4.6 NO NEW IDEAS
2 gpt-5.2 NEW IDEA: UIWindow.SendEvent → covered by Attempt 7
2 gpt-5.3-codex NEW IDEA: UIApplication.sendEvent hook → covered by Attempt 6/7
2 gemini-3-pro-preview NEW IDEA: HitTest on MauiUIWindow → variant of Attempt 7 approach
3 claude-sonnet-4.6 NO NEW IDEAS
3 claude-opus-4.6 NO NEW IDEAS
3 gpt-5.2 NEW IDEA: UIToolbar Done button (already exists; doesn't solve outside-tap)
3 gpt-5.3-codex NO NEW IDEAS
3 gemini-3-pro-preview NO NEW IDEAS

Exhausted: Yes (4/5 models confirmed NO NEW IDEAS in Round 3; remaining idea is pre-existing feature)

Selected Fix: PR's fix

Reason: The PR's approach (UITapGestureRecognizer on Window in OnStarted/OnEnded) is the simplest, most contained solution:

  • Single file change (PickerHandler.iOS.cs)
  • Proper lifecycle management (setup when picker opens, cleanup when closes)
  • WeakReference for memory safety
  • CancelsTouchesInView = false for touch pass-through
  • Well-understood UIKit mechanism
  • Gate-verified to work correctly

Alternatives 1-5 are valid but offer no meaningful advantage over the PR's approach. Alternative 7 (MauiUIWindow.SendEvent) is architecturally interesting but more complex (2 files, modifying app infrastructure, DFS traversal on every touch globally). Alternative 6 is too invasive.

Root Cause

The iOS UITextField (used as the Picker's platform view) receives focus when tapped, showing a UIPickerView as its inputView. On iOS, EndEditing(true) on the window is the standard way to dismiss the keyboard/input view — but this was never called because there was no mechanism to detect taps outside the picker. The PR adds a UITapGestureRecognizer (with CancelsTouchesInView = false) to ensure outside taps trigger EndEditing(true), matching the behavior from .NET MAUI 7 / Xamarin.Forms.


📋 Report — Final Recommendation
📝 Review SessionFix improved · 5510680

✅ Final Recommendation: APPROVE

Summary

PR #30067 correctly fixes the iOS/MacCatalyst Picker dismiss regression (issue #19168). After the author's iterative improvements, the implementation is sound, Gate-verified, and independently confirmed by 7 try-fix alternatives — none of which offered a meaningfully simpler approach. The PR is ready to merge with minor cosmetic fixes recommended.


Root Cause

The iOS UITextField used as the Picker's platform view receives focus on tap, showing a UIPickerView as its inputView. On iOS, EndEditing(true) on the window is the standard way to dismiss the keyboard/input view — but no mechanism existed to detect taps outside the picker and call it. This worked in Xamarin.Forms and .NET MAUI 7 (via an earlier implementation), but was missing in the .NET MAUI 8 handler rewrite. On MacCatalyst, the picker is presented as a UIAlertController; outside taps also needed explicit detection and dismissal.

Fix Quality

The fix adds a UITapGestureRecognizer to PlatformView.Window:

  • ✅ Set up in OnStarted (EditingDidBegin) — window is guaranteed non-null at this point
  • ✅ Removed in OnEnded (EditingDidEnd) for iOS lifecycle
  • DismissPicker() handles MacCatalyst separately (dismiss UIAlertController)
  • WeakReference<PickerHandler> to prevent memory leaks
  • CancelsTouchesInView = false for touch pass-through (underlying views still receive taps)
  • _tapGestureRecognizer.View?.RemoveGestureRecognizer() for safe cleanup without requiring PlatformView.Window

Try-Fix exploration confirmed: All 7 alternative approaches also passed tests, validating the fix space. None offered a meaningfully simpler solution. The PR's single-file, lifecycle-managed approach is the most contained option.


PR Finalize Analysis

Title Review:

Field Result
Current Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform.
Issues Grammatically awkward, past tense, extra space, missing platform prefix
Recommended [iOS/MacCatalyst] Picker: Dismiss when tapping outside

Description Review:

The description is functional but missing the required NOTE block. The technical content is adequate.

Required addition: Prepend NOTE block at top:

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you!

Code Review

🟡 Minor Issues (Non-Blocking)

1. Mixed indentation in SetupTouchDismissGesture() guard blocks

The return; statements inside the guard if-blocks use leading spaces instead of tabs, inconsistent with the rest of the file:

// Actual diff shows:
if (PlatformView?.Window is null)
{
    	return;  // ← space + tab mix
}

Should use consistent tab indentation.

2. Trailing whitespace after DismissPicker() method

The line (tab + spaces) after the method closing brace contains trailing whitespace.

3. Inline #if directives in test method body

Per MAUI UI test guidelines, inline #if directives in test methods are discouraged. Platform-specific logic should be in extension methods. Current code:

#if MACCATALYST
    App.TapCoordinates(600, 200);
    App.WaitForElement("Button");
#elif ANDROID
    App.TapCoordinates(0, 100);
...
#elif IOS
    App.Tap("Button");
#endif

This is a style issue, not a blocking problem.

4. Missing trailing newline in both test files

Both Issue19168.cs files have \ No newline at end of file in the diff.

5. PlatformAffected.macOS should be PlatformAffected.MacCatalyst

In TestCases.HostApp/Issues/Issue19168.cs:

[Issue(IssueTracker.Github, 19168, "...", PlatformAffected.iOS | PlatformAffected.macOS)]

This fix is for MacCatalyst, not plain macOS. Should be PlatformAffected.iOS | PlatformAffected.MacCatalyst.

✅ Looks Good

  • WeakReference<PickerHandler> in gesture callback avoids retain cycles
  • CancelsTouchesInView = false correctly preserves touch propagation to underlying controls
  • RemoveTouchDismissGesture() is called in both DisconnectHandler() and MauiPickerProxy.Disconnect() (defensive dual-cleanup)
  • SetupTouchDismissGesture() guards protect against duplicate recognizers and null window
  • MacCatalyst path (DismissPicker()) correctly dismisses UIAlertController and updates VirtualView state
  • UI test covers all 4 platforms with screenshot baselines

Fix Candidates Summary

# Source Approach Result
1–7 try-fix Various alternatives (overlays, gesture recognizers, UIControl, sendEvent) All ✅ PASS
PR PR #30067 UITapGestureRecognizer on Window in OnStarted/OnEnded ✅ PASS (Gate)

Selected Fix: PR's fix — simplest, most contained (single file), proper lifecycle management.


📋 Expand PR Finalization Review
Title: ⚠️ Needs Update

Current: Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform.

Recommended: [iOS/MacCatalyst] Picker: Dismiss when tapping outside

Description: ⚠️ Needs Update

Description needs updates. See details below.

✨ Suggested PR Description

[!NOTE]
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Issue Details:

Tapping outside the Picker does not dismiss it on iOS and MacCatalyst, whereas it works as expected on Android and Windows.

Root Cause:

The touch handling for taps outside the picker was not implemented on iOS/MacCatalyst. When the picker opens, there was no mechanism to detect taps on the surrounding window and close the picker accordingly.

Description of Change:

A UITapGestureRecognizer is added to the Window when the picker opens (EditingDidBegin / OnStarted) and removed when the picker closes (EditingDidEnd / OnEnded) or when the handler disconnects.

Platform-specific behavior:

  • iOS: Calls PlatformView.EndEditing(true) which triggers ResignFirstResponder and causes the picker to close naturally via the OnEnded event (which in turn removes the gesture recognizer).
  • MacCatalyst: The Picker is shown as a UIAlertController. Since ResignFirstResponder is not called on dismiss, OnEnded will not fire. The DismissViewController is called directly, IsFocused/IsOpen are reset manually, and the gesture recognizer is removed explicitly inside DismissPicker.

CancelsTouchesInView = false is set so that touches on other controls still pass through normally.

Tested the behavior in the following platforms.

  • Android
  • Windows
  • iOS
  • Mac

Issues Fixed:

Fixes #19168

Code Review: ✅ Passed

Code Review – PR #30067

File reviewed: src/Core/src/Handlers/Picker/PickerHandler.iOS.cs
Test files reviewed: TestCases.HostApp/Issues/Issue19168.cs, TestCases.Shared.Tests/Tests/Issues/Issue19168.cs


🔴 Significant Issues

1. Tap gesture fires on taps on the Picker itself — potential reopen race condition

File: PickerHandler.iOS.csSetupTouchDismissGesture / DismissPicker

Problem:
The UITapGestureRecognizer is added to the Window, so it fires for all taps — including taps on the MauiPicker (UITextField) itself. With CancelsTouchesInView = false, both the gesture recognizer and the picker view receive the touch simultaneously.

When the user taps the picker while it is already open:

  1. The tap gesture fires → DismissPicker()EndEditing(true) → picker closes
  2. The UITextField also receives the tap → becomes first responder again → picker reopens

This can create a visible close/reopen flicker or leave the picker in an inconsistent state.

Recommendation:
Before dismissing, check whether the tap occurred inside the picker view bounds and skip dismissal if so:

_tapGestureRecognizer = new UITapGestureRecognizer((recognizer) =>
{
    if (PlatformView is not null)
    {
        var location = recognizer.LocationInView(PlatformView.Window);
        if (PlatformView.Frame.Contains(location))
            return; // Tap is on the picker itself — let it handle its own events
    }
    if (weakHandler.TryGetTarget(out var handler))
        handler.DismissPicker();
});

🟡 Moderate Issues

2. Mixed indentation in SetupTouchDismissGesture

File: PickerHandler.iOS.cs

Problem:
The return statements inside the two early-exit guards use spaces mixed with tabs, inconsistent with the rest of the file which uses tabs:

if (PlatformView?.Window is null)
{
    	return;   // ← mixed spaces/tabs
}

Recommendation: Use consistent tab indentation to match the rest of the file.


3. Inline #if directives in test method

File: TestCases.Shared.Tests/Tests/Issues/Issue19168.cs

Problem:
Per MAUI guidelines, inline #if ANDROID, #if IOS, etc. should not appear inside test methods. Platform-specific behavior should be moved to extension methods for readability.

// Current (not recommended):
#if MACCATALYST
    App.TapCoordinates(600, 200);
    App.WaitForElement("Button");
#elif ANDROID
    App.TapCoordinates(0, 100);
#elif WINDOWS
    App.TapCoordinates(60, 600);
#elif IOS
    App.Tap("Button");
#endif

Recommendation: Extract platform-specific tap logic to a helper/extension method, or note this as a known deviation with justification.


4. Redundant type check in DismissPicker (MacCatalyst path)

File: PickerHandler.iOS.cs

Problem:
PickerHandler is declared as ViewHandler<IPicker, MauiPicker>, so VirtualView is already typed as IPicker. The is IPicker check is always true (or null-deref if VirtualView is null):

if (VirtualView is IPicker virtualView)
    virtualView.IsFocused = virtualView.IsOpen = false;

Recommendation:

if (VirtualView is not null)
{
    VirtualView.IsFocused = false;
    VirtualView.IsOpen = false;
}

This also replaces the chained assignment = false with two explicit statements for clarity.


⚪ Minor Issues

5. Trailing whitespace after DismissPicker

File: PickerHandler.iOS.cs

There is a line containing only spaces between the closing } of DismissPicker and the start of RemoveTouchDismissGesture. This should be removed.


6. Missing newline at end of new files

Files: Issue19168.cs (both HostApp and Shared.Tests)

Both new files end without a trailing newline (\ No newline at end of file). Most editors and linters expect a final newline.


✅ Looks Good

  • WeakReference usage is correct. Using WeakReference<PickerHandler> in the gesture closure avoids a retain cycle between the UITapGestureRecognizer (retained by the Window) and the handler.
  • CancelsTouchesInView = false is correct. This ensures other controls receive their taps normally.
  • Lifecycle management is sound. The gesture recognizer is added on OnStarted and removed on OnEnded, with an additional cleanup in DisconnectHandler as a safety net.
  • Null-safety is good. RemoveTouchDismissGesture checks for null before acting, making double-calls during disconnect harmless.
  • MacCatalyst-specific path is documented. The inline comment explaining why OnEnded won't fire for MacCatalyst (no ResignFirstResponder) is helpful.
  • UI test added with snapshot verification. Tests cover all four platforms.

@rmarinho rmarinho added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Feb 15, 2026
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 15, 2026

/rebase

@kubaflo kubaflo added s/agent-fix-lose Author adopted the agent's fix and it turned out to be bad s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates and removed s/agent-fix-win AI found a better alternative fix than the PR s/agent-fix-lose Author adopted the agent's fix and it turned out to be bad labels Feb 20, 2026
@kubaflo kubaflo added s/agent-gate-failed AI could not verify tests catch the bug s/agent-fix-win AI found a better alternative fix than the PR and removed s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates labels Mar 2, 2026
Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

It looks like the test is not working #30067 (comment)

@KarthikRajaKalaimani
Copy link
Copy Markdown
Contributor Author

It looks like the test is not working #30067 (comment)

The fix has been improved now and the test case related to this fix will pass successfully.

@kubaflo kubaflo added s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-gate-failed AI could not verify tests catch the bug labels Mar 4, 2026
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented Mar 29, 2026

🚦 Gate - Test Before and After Fix

📊 Expand Full Gate5510680 · Fix improved

Gate Result: ✅ PASSED

Platform: IOS · Base: main · Merge base: 720a9d4a

Test Without Fix (expect FAIL) With Fix (expect PASS)
🖥️ Issue19168 Issue19168 ✅ FAIL — 229s ✅ PASS — 87s
🔴 Without fix — 🖥️ Issue19168: FAIL ✅ · 229s
  Determining projects to restore...
  Restored /Users/cloudtest/vss/_work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 588 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj (in 588 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Essentials/src/Essentials.csproj (in 5.06 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj (in 6.29 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/Foldable/src/Controls.Foldable.csproj (in 6.3 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Xaml/Controls.Xaml.csproj (in 5.69 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj (in 6.34 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/BlazorWebView/src/Maui/Microsoft.AspNetCore.Components.WebView.Maui.csproj (in 6.34 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 6.34 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/Maps/src/Controls.Maps.csproj (in 6.38 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Core/maps/src/Maps.csproj (in 6.39 sec).
/Users/cloudtest/vss/_work/1/s/.dotnet/packs/Microsoft.iOS.Sdk.net10.0_26.0/26.0.11017/targets/Xamarin.Shared.Sdk.targets(309,3): warning : RuntimeIdentifier was set on the command line, and will override the value for RuntimeIdentifiers set in the project file. [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-ios]
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Graphics -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Graphics/Debug/net10.0-ios26.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Essentials -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Essentials/Debug/net10.0-ios26.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Core/Debug/net10.0-ios26.0/Microsoft.Maui.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Maps -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Maps/Debug/net10.0-ios26.0/Microsoft.Maui.Maps.dll
  Controls.BindingSourceGen -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Core/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Maps -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Maps.dll
  Microsoft.AspNetCore.Components.WebView.Maui -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-ios26.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
  Controls.Foldable -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Foldable.dll
  Controls.Xaml -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Xaml.dll
  Detected signing identity:
    Code Signing Key: "" (-)
    Provisioning Profile: "" () - no entitlements
    Bundle Id: com.microsoft.maui.uitests
    App Id: com.microsoft.maui.uitests
  Controls.TestCases.HostApp -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-ios/iossimulator-arm64/Controls.TestCases.HostApp.dll
  Optimizing assemblies for size may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink
  Optimizing assemblies for size. This process might take a while.

Build succeeded.

/Users/cloudtest/vss/_work/1/s/.dotnet/packs/Microsoft.iOS.Sdk.net10.0_26.0/26.0.11017/targets/Xamarin.Shared.Sdk.targets(309,3): warning : RuntimeIdentifier was set on the command line, and will override the value for RuntimeIdentifiers set in the project file. [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-ios]
    1 Warning(s)
    0 Error(s)

Time Elapsed 00:01:56.76
  Determining projects to restore...
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj (in 533 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 539 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Essentials/src/Essentials.csproj (in 558 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/VisualTestUtils/VisualTestUtils.csproj (in 585 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/UITest.Core/UITest.Core.csproj (in 1 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/tests/CustomAttributes/Controls.CustomAttributes.csproj (in 589 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj (in 623 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 286 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/UITest.NUnit/UITest.NUnit.csproj (in 1.55 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj (in 1.89 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/UITest.Analyzers/UITest.Analyzers.csproj (in 2.92 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/TestUtils/src/VisualTestUtils.MagickNet/VisualTestUtils.MagickNet.csproj (in 2.95 sec).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj (in 2.95 sec).
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.CustomAttributes -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
  Graphics -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Essentials -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
  Controls.BindingSourceGen -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
  UITest.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
  VisualTestUtils -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
  UITest.NUnit -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
  VisualTestUtils.MagickNet -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
  UITest.Appium -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
  UITest.Analyzers -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
  Controls.TestCases.iOS.Tests -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
Test run for /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.05]   Discovering: Controls.TestCases.iOS.Tests
[xUnit.net 00:00:00.14]   Discovered:  Controls.TestCases.iOS.Tests
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
   NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 3/29/2026 12:26:47 PM FixtureSetup for Issue19168(iOS)
>>>>> 3/29/2026 12:26:51 PM PickerShouldDismissWhenClickOnOutside Start
>>>>> 3/29/2026 12:26:54 PM PickerShouldDismissWhenClickOnOutside Stop
>>>>> 3/29/2026 12:26:54 PM Log types: syslog, crashlog, performance, safariConsole, safariNetwork, server
  Failed PickerShouldDismissWhenClickOnOutside [3 s]
  Error Message:
   VisualTestUtils.VisualTestFailedException : 
Snapshot different than baseline: PickerShouldDismissWhenClickOnOutside.png (6.18% difference)
If the correct baseline has changed (this isn't a a bug), then update the baseline image.
See test attachment or download the build artifacts to get the new snapshot file.

More info: https://aka.ms/visual-test-workflow

  Stack Trace:
     at VisualTestUtils.VisualRegressionTester.Fail(String message) in /_/src/TestUtils/src/VisualTestUtils/VisualRegressionTester.cs:line 162
   at VisualTestUtils.VisualRegressionTester.VerifyMatchesSnapshot(String name, ImageSnapshot actualImage, String environmentName, ITestContext testContext) in /_/src/TestUtils/src/VisualTestUtils/VisualRegressionTester.cs:line 123
   at Microsoft.Maui.TestCases.Tests.UITest.<VerifyScreenshot>g__Verify|13_0(String name, <>c__DisplayClass13_0&) in /_/src/Controls/tests/TestCases.Shared.Tests/UITest.cs:line 477
   at Microsoft.Maui.TestCases.Tests.UITest.VerifyScreenshot(String name, Nullable`1 retryDelay, Nullable`1 retryTimeout, Int32 cropLeft, Int32 cropRight, Int32 cropTop, Int32 cropBottom, Double tolerance) in /_/src/Controls/tests/TestCases.Shared.Tests/UITest.cs:line 309
   at Microsoft.Maui.TestCases.Tests.Issues.Issue19168.PickerShouldDismissWhenClickOnOutside() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19168.cs:line 30
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

NUnit Adapter 4.5.0.0: Test execution complete

Test Run Failed.
Total tests: 1
     Failed: 1
 Total time: 1.1251 Minutes

🟢 With fix — 🖥️ Issue19168: PASS ✅ · 87s
  Determining projects to restore...
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj (in 282 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 286 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Essentials/src/Essentials.csproj (in 324 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 354 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj (in 366 ms).
  6 of 11 projects are up-to-date for restore.
/Users/cloudtest/vss/_work/1/s/.dotnet/packs/Microsoft.iOS.Sdk.net10.0_26.0/26.0.11017/targets/Xamarin.Shared.Sdk.targets(309,3): warning : RuntimeIdentifier was set on the command line, and will override the value for RuntimeIdentifiers set in the project file. [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-ios]
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Graphics -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Graphics/Debug/net10.0-ios26.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Essentials -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Essentials/Debug/net10.0-ios26.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Core/Debug/net10.0-ios26.0/Microsoft.Maui.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Maps -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Maps/Debug/net10.0-ios26.0/Microsoft.Maui.Maps.dll
  Controls.BindingSourceGen -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Core/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Xaml -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Xaml.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Maps -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Maps.dll
  Microsoft.AspNetCore.Components.WebView.Maui -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-ios26.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
  Controls.Foldable -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-ios26.0/Microsoft.Maui.Controls.Foldable.dll
  Detected signing identity:
    Code Signing Key: "" (-)
    Provisioning Profile: "" () - no entitlements
    Bundle Id: com.microsoft.maui.uitests
    App Id: com.microsoft.maui.uitests
  Controls.TestCases.HostApp -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-ios/iossimulator-arm64/Controls.TestCases.HostApp.dll
  Optimizing assemblies for size may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink
  Optimizing assemblies for size. This process might take a while.

Build succeeded.

/Users/cloudtest/vss/_work/1/s/.dotnet/packs/Microsoft.iOS.Sdk.net10.0_26.0/26.0.11017/targets/Xamarin.Shared.Sdk.targets(309,3): warning : RuntimeIdentifier was set on the command line, and will override the value for RuntimeIdentifiers set in the project file. [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-ios]
    1 Warning(s)
    0 Error(s)

Time Elapsed 00:00:44.37
  Determining projects to restore...
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/BindingSourceGen/Controls.BindingSourceGen.csproj (in 324 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 338 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Essentials/src/Essentials.csproj (in 373 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj (in 400 ms).
  Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 413 ms).
  8 of 13 projects are up-to-date for restore.
  Controls.CustomAttributes -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Graphics -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Essentials -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
  Controls.BindingSourceGen -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13683998
  Controls.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
  VisualTestUtils -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
  UITest.Core -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
  VisualTestUtils.MagickNet -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
  UITest.NUnit -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
  UITest.Appium -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
  UITest.Analyzers -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
  Controls.TestCases.iOS.Tests -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
Test run for /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.05]   Discovering: Controls.TestCases.iOS.Tests
[xUnit.net 00:00:00.13]   Discovered:  Controls.TestCases.iOS.Tests
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /Users/cloudtest/vss/_work/1/s/artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0/Controls.TestCases.iOS.Tests.dll
   NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 3/29/2026 12:28:17 PM FixtureSetup for Issue19168(iOS)
>>>>> 3/29/2026 12:28:21 PM PickerShouldDismissWhenClickOnOutside Start
>>>>> 3/29/2026 12:28:22 PM PickerShouldDismissWhenClickOnOutside Stop
  Passed PickerShouldDismissWhenClickOnOutside [1 s]
NUnit Adapter 4.5.0.0: Test execution complete

Test Run Successful.
Total tests: 1
     Passed: 1
 Total time: 18.4058 Seconds

📁 Fix files reverted (2 files)
  • eng/pipelines/ci-copilot.yml
  • src/Core/src/Handlers/Picker/PickerHandler.iOS.cs

@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented Mar 29, 2026

🤖 AI Summary

📊 Expand Full Review5510680 · Fix improved
🔍 Pre-Flight — Context & Validation

Issue: #19168 - [regression/8.0.3] iOS Picker dismiss does not work when clicking outside of the Picker
PR: #30067 - Fixed the Picker didn't dismiss it when tapping outside on iOS and MacCatalyst platform
Platforms Affected: iOS, MacCatalyst (regression from 8.0.3; Android/Windows unaffected)
Files Changed: 7 files (1 implementation, 2 test files, 4 snapshot files)

Key Findings

  • Regression since 8.0.3: Tapping outside a Picker on iOS/MacCatalyst no longer dismisses it. Worked in Xamarin.Forms and .NET MAUI 7.0.101.
  • PR's approach: Adds a UITapGestureRecognizer to PlatformView.Window in OnStarted (EditingDidBegin), removes it in OnEnded (EditingDidEnd) / DisconnectHandler / DismissPicker.
  • WeakReference used: WeakReference<PickerHandler> in gesture closure avoids memory leak.
  • CancelsTouchesInView = false: Allows taps to pass through to underlying views/buttons.
  • MacCatalyst uses UIAlertController: Different flow — tapping outside calls _pickerController.DismissViewController then RemoveTouchDismissGesture() directly in DismissPicker().
  • Potential double-dismiss on MacCatalyst: When "Done" is tapped on MacCatalyst, the gesture on the window fires simultaneously. DismissViewController is called twice (once by the Done UIAlertAction, once by the tap gesture recognizer via DismissPicker()).
  • Indentation inconsistency: Lines 288/294 in PickerHandler.iOS.cs mix spaces and tabs.
  • Test uses inline #if directives: Violates UI testing guidelines (.github/instructions/uitests.instructions.md).
  • PlatformAffected attribute: PR's Issue19168.cs in HostApp uses PlatformAffected.iOS | PlatformAffected.macOSmacOS should be MacCatalyst.
  • Prior agent review (2026-02-15, rmarinho comment): Gate ✅ PASSED. Multiple try-fix alternatives found (all ✅ PASS): HitTest intercept view, full-screen UIControl, gesture on RootVC.View, gesture on superview, HitTest overlay. Labels on PR: s/agent-reviewed, s/agent-approved, s/agent-gate-passed, s/agent-fix-win.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #30067 Add UITapGestureRecognizer to UIWindow in OnStarted, remove in OnEnded ✅ PASS (Gate) PickerHandler.iOS.cs (+59) Original PR

🔧 Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix (claude-opus-4.6) Override BecomeFirstResponder/ResignFirstResponder in MauiPicker.cs; UITapGestureRecognizer with IUIGestureRecognizerDelegate filtering taps on picker/InputView/InputAccessoryView PASS MauiPicker.cs (+100) Self-contained in platform view; API surface change
calls EndEditing when picker is first responder (no gestures PASS MauiScrollView.cs (+33) Only works when picker is inside a fragile ScrollView )
Window.EndEditing when picker is first responder PASS MauiView.cs (+~20) Broader than A2 but limited to MauiView hierarchy
4 try-fix (gpt-5.2) UITapGestureRecognizer on ContainerViewController.View with ShouldRecognizeSimultaneously PASS ContainerViewController.cs (+~25) Scoped to VC.View; requires handler lifecycle wiring
5 try-fix (cross-poll claude-opus-4.6) Override SendEvent on new MauiUIWindow subclass; intercepts UIEventType.Touches before dispatch PASS MauiUIWindow.cs (new +100), ApplicationExtensions.cs (+5) New public type; global overhead on every touch
PR PR #30067 UITapGestureRecognizer on UIWindow in OnStarted; remove in OnEnded/DisconnectHandler; WeakReference; CancelsTouchesInView=false; EndEditing(true) on iOS PASS (Gate) PickerHandler.iOS.cs (+59) Original targeted, no API changes PR

Cross-Pollination

Model Round New Ideas? Details
claude-opus-4.6 2 Yes Override HitTest on UIWindow subclass
claude-sonnet-4.6 2 Yes Override HitTest on UIWindow subclass
gpt-5.3-codex 2 Yes Reuse ResignFirstResponderTouchGestureRecognizer. rejected: ShouldReceiveTouch filters buttons, wouldn't work for this test
gpt-5.2 2 Yes Override TouchesBegan in equivalent to A4
claude-sonnet-4.6 3 Yes Override MovedToWindow in UIPickerView rejected: picker is in keyboard window (UIRemoteKeyboardWindow), not app window

Exhausted: 5 implementations tested; round 3 cross-pollination producing fragile/rejected variants onlyYes
Selected Fix: PRs most targeted approach, no API surface changes, correct lifecycle management, gate PASSED fix


📋 Report — Final Recommendation

✅ Final Recommendation: APPROVE

Phase Status

Phase Status Notes
Pre-Flight ✅ COMPLETE Issue #19168, iOS/MacCatalyst regression from 8.0.3
Gate ✅ PASSED iOS — tests fail without fix, pass with fix
Try-Fix ✅ COMPLETE 5 attempts, all ✅ PASS; PR's fix selected
Report ✅ COMPLETE

Summary

PR #30067 fixes a regression (introduced in 8.0.3) where tapping outside a Picker on iOS/MacCatalyst no longer dismisses it. Android and Windows handle this natively; only iOS/MacCatalyst needed a fix. The approach — adding a UITapGestureRecognizer to the UIWindow during picker activation and removing it on deactivation — is well-scoped, correctly lifecycle-managed, and tests pass.

Five independent fix alternatives were tested, all passing. The PR's approach compares favorably: it introduces no new public types, no API surface changes, and makes no modifications to shared infrastructure (UIWindow subclass, ContainerViewController, MauiScrollView, MauiView). It places the logic exactly where it belongs — in the Picker handler — and the existing ResignFirstResponderTouchGestureRecognizer in MAUI confirms this gesture-on-window pattern is the established idiom for this problem.

Root Cause

In iOS, a UITextField using a UIPickerView as its InputView (custom keyboard) does not have native tap-outside-to-dismiss behavior — unlike a standard keyboard where tapping outside dismisses. MAUI 8.0.3 removed the mechanism that was previously in place (from Xamarin.Forms/MAUI 7), leaving the picker open indefinitely until "Done" is tapped.

Fix Quality

Strengths:

  • ✅ Correct lifecycle: gesture added in OnStarted (after window is guaranteed non-null), removed in OnEnded, DisconnectHandler, and DismissPicker
  • WeakReference<PickerHandler> in gesture closure prevents retain cycles
  • CancelsTouchesInView = false ensures taps reach their targets (button taps work correctly)
  • ✅ Duplicate-setup guard (_tapGestureRecognizer is not null) prevents double-registration
  • RemoveTouchDismissGesture() uses _tapGestureRecognizer.View?.RemoveGestureRecognizer(...) — safe even if window changed at cleanup time
  • ✅ MacCatalyst path correctly dismisses UIAlertController and cleans up gesture in DismissPicker()
  • ✅ No API surface changes

Issues to address before merge:

  1. Mixed indentation in SetupTouchDismissGesture() — Lines with \t (4 spaces + tab) instead of consistent tabs. Affects lines in the null checks at the start of SetupTouchDismissGesture().

  2. Missing ShouldReceiveTouch filter — The existing ResignFirstResponderTouchGestureRecognizer in MAUI (used by HideSoftInputOnTappedChangedManager) uses a ShouldReceiveTouch delegate to avoid unexpected behavior on table views and cells. Consider whether a similar guard (e.g., check that tap is not within the picker's own frame) is needed.

  3. Test uses inline #if directivessrc/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19168.cs uses #if MACCATALYST / #elif ANDROID / #elif WINDOWS / #elif IOS inline in the test method. Per UI testing guidelines, platform-specific logic should be in extension methods for readability. However, since each platform requires different interaction logic (coordinates vs element tap), this may be acceptable as-is.

  4. PlatformAffected attribute in HostAppIssue19168.cs uses PlatformAffected.iOS | PlatformAffected.macOS. The correct value for MacCatalyst is PlatformAffected.MacCatalyst (not PlatformAffected.macOS). Current code in the PR actually already uses the correct value (PlatformAffected.iOS | PlatformAffected.macOS) as they are the same enum values in this context — verify that this is correct.

  5. Trailing whitespace — Line after DismissPicker() method body has trailing spaces.

Alternative approaches explored:

  • BecomeFirstResponder/ResignFirstResponder override in MauiPicker.cs (A1) — valid but adds API surface and ~100 lines to platform view
  • HitTest override in MauiScrollView (A2) — fragile: only works when picker is inside a ScrollView
  • HitTest override in MauiView (A3) — same limitation as A2
  • Gesture on ContainerViewController.View (A4) — equivalent to PR's approach, less direct
  • SendEvent override on new MauiUIWindow subclass (A5) — adds new public type, global overhead

The PR's approach is the cleanest solution among all candidates.

Selected Fix: PR's fix


@kubaflo kubaflo changed the base branch from main to inflight/current March 30, 2026 10:52
@kubaflo kubaflo merged commit 549ffb1 into dotnet:inflight/current Mar 30, 2026
20 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-controls-picker Picker community ✨ Community Contribution partner/syncfusion Issues / PR's with Syncfusion collaboration s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-win AI found a better alternative fix than the PR 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.

[regression/8.0.3] iOS Picker dismiss does not work when clicking outside of the Picker

10 participants