diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34848.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34848.cs new file mode 100644 index 000000000000..cac2eb474458 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34848.cs @@ -0,0 +1,46 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34848, "DatePicker Opened and Closed events are not raised on MacCatalyst", PlatformAffected.macOS)] +public class Issue34848 : TestContentPage +{ + DatePicker _datePicker; + Label _openStatusLabel; + Label _closeStatusLabel; + protected override void Init() + { + _openStatusLabel = new Label + { + AutomationId = "Issue34848OpenStatusLabel", + Text = "Opened: Unknown", + HorizontalOptions = LayoutOptions.Center + }; + + _closeStatusLabel = new Label + { + AutomationId = "Issue34848CloseStatusLabel", + Text = "Closed: Unknown", + HorizontalOptions = LayoutOptions.Center + }; + + _datePicker = new DatePicker + { + AutomationId = "Issue34848TestDatePicker", + Date = DateTime.Today, + HorizontalOptions = LayoutOptions.Center + }; + + _datePicker.Opened += (s, e) => _openStatusLabel.Text = "Opened"; + _datePicker.Closed += (s, e) => _closeStatusLabel.Text = "Closed"; + + Content = new VerticalStackLayout + { + Spacing = 10, + Children = + { + _openStatusLabel, + _closeStatusLabel, + _datePicker + } + }; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34848.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34848.cs new file mode 100644 index 000000000000..11e5469f91ff --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34848.cs @@ -0,0 +1,41 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue34848 : _IssuesUITest +{ + public Issue34848(TestDevice testDevice) : base(testDevice) { } + + public override string Issue => "DatePicker Opened and Closed events are not raised on MacCatalyst"; + + [Test] + [Category(UITestCategories.DatePicker)] + public void DatePickerOpenedAndClosedEventsAreRaised() + { + App.WaitForElement("Issue34848OpenStatusLabel"); + App.WaitForElement("Issue34848CloseStatusLabel"); + App.WaitForElement("Issue34848TestDatePicker"); + + // Open the DatePicker + App.Tap("Issue34848TestDatePicker"); + +#if IOS + // iOS DatePicker uses a wheel picker, so we can just tap the "Done" button to close it + App.Tap("Done"); +#elif WINDOWS + // On Windows, we can tap a date to close the DatePicker + App.Tap("16"); +#elif ANDROID + // On Android, we can tap the "Cancel" button to close the DatePicker + App.Tap("Cancel"); +#else + // On MacCatalyst, we can tap outside the DatePicker to close it + App.TapCoordinates(30, 30); +#endif + + Assert.That(App.WaitForElement("Issue34848OpenStatusLabel").GetText(), Is.EqualTo("Opened")); + Assert.That(App.WaitForElement("Issue34848CloseStatusLabel").GetText(), Is.EqualTo("Closed")); + } +} diff --git a/src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs b/src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs index bab10c33591b..ba3b2f5ef486 100644 --- a/src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs +++ b/src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Foundation; using UIKit; using RectangleF = CoreGraphics.CGRect; @@ -7,7 +8,11 @@ namespace Microsoft.Maui.Handlers { public partial class DatePickerHandler : ViewHandler { + static readonly NSString WindowDidCloseNotification = new("NSWindowDidCloseNotification"); + readonly UIDatePickerProxy _proxy = new(); + NSObject? _windowCloseObserver; + bool _isDatePickerOpen; protected override UIDatePicker CreatePlatformView() { @@ -26,6 +31,10 @@ protected override void ConnectHandler(UIDatePicker platformView) platformView.Date = dt.ToNSDate(); } + // The compact UIDatePicker on MacCatalyst uses internal UITextField subviews + // for the date segments. Wire up EditingDidBegin directly on those fields. + WireTextFields(platformView); + base.ConnectHandler(platformView); } @@ -33,9 +42,118 @@ protected override void DisconnectHandler(UIDatePicker platformView) { _proxy.Disconnect(platformView); + UnwireTextFields(platformView); + + if (_windowCloseObserver is not null) + { + NSNotificationCenter.DefaultCenter.RemoveObserver(_windowCloseObserver); + _windowCloseObserver = null; + } + + _isDatePickerOpen = false; + base.DisconnectHandler(platformView); } + + // Recursively traverses the view hierarchy and yields all UITextField subviews. + // Used to find the internal text fields of the compact UIDatePicker on MacCatalyst. + static IEnumerable GetTextFields(UIView view) + { + foreach (var subview in view.Subviews) + { + + if (subview is UITextField textField) + { + yield return textField; + } + else + { + foreach (var nested in GetTextFields(subview)) + { + yield return nested; + } + } + } + } + + void WireTextFields(UIView view) + { + foreach (var textField in GetTextFields(view)) + { + textField.EditingDidBegin += OnEditingDidBegin; + } + } + + void UnwireTextFields(UIView view) + { + foreach (var textField in GetTextFields(view)) + { + textField.EditingDidBegin -= OnEditingDidBegin; + } + } + + void OnEditingDidBegin(object? sender, EventArgs e) + { + if (_isDatePickerOpen) + { + return; + } + + _isDatePickerOpen = true; + + // Register a one-shot observer scoped to this picker's open lifetime. + // On MacCatalyst the popover runs in an AppKit NSWindow; tapping outside + // dismisses it at the AppKit level without firing UITextField EditingDidEnd. + // Registering here (not in ConnectHandler) avoids spurious fires from + // unrelated window closes while the picker is not open. + _windowCloseObserver = NSNotificationCenter.DefaultCenter.AddObserver(WindowDidCloseNotification, OnWindowClosed); + + if (VirtualView is IDatePicker virtualView) + { + virtualView.IsFocused = virtualView.IsOpen = true; + } + } + + void OnWindowClosed(NSNotification notification) + { + // One-shot: remove the observer immediately so it won't fire again. + if (_windowCloseObserver is not null) + { + NSNotificationCenter.DefaultCenter.RemoveObserver(_windowCloseObserver); + _windowCloseObserver = null; + } + + _isDatePickerOpen = false; + + if (VirtualView is IDatePicker virtualView) + { + virtualView.IsFocused = virtualView.IsOpen = false; + } + + // On MacCatalyst the internal UITextFields stay as first responder + // (visually highlighted) even after the popover window closes. + // EndEditing(true) on the parent view does not propagate to them, + // so we must directly resign each tracked text field on the next + // run-loop iteration (the notification fires before UIKit is ready). + ResignTextFields(); + } + + void ResignTextFields() + { + var platformView = PlatformView; + CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() => + { + foreach (var textField in GetTextFields(platformView)) + { + if (textField.IsFirstResponder) + { + textField.ResignFirstResponder(); + } + } + }); + } + public static partial void MapFormat(IDatePickerHandler handler, IDatePicker datePicker) { handler.PlatformView?.UpdateFormat(datePicker); @@ -102,15 +220,11 @@ public void Connect(DatePickerHandler handler, IDatePicker virtualView, UIDatePi _handler = new(handler); _virtualView = new(virtualView); - platformView.EditingDidBegin += OnStarted; - platformView.EditingDidEnd += OnEnded; platformView.ValueChanged += OnValueChanged; } public void Disconnect(UIDatePicker platformView) { - platformView.EditingDidBegin -= OnStarted; - platformView.EditingDidEnd -= OnEnded; platformView.ValueChanged -= OnValueChanged; } @@ -122,18 +236,6 @@ void OnValueChanged(object? sender, EventArgs? e) if (VirtualView is IDatePicker virtualView) virtualView.IsFocused = true; } - - void OnStarted(object? sender, EventArgs eventArgs) - { - if (VirtualView is IDatePicker virtualView) - virtualView.IsFocused = true; - } - - void OnEnded(object? sender, EventArgs eventArgs) - { - if (VirtualView is IDatePicker virtualView) - virtualView.IsFocused = false; - } } } -} \ No newline at end of file +}