Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue34848.cs
Original file line number Diff line number Diff line change
@@ -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
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
136 changes: 119 additions & 17 deletions src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using Foundation;
using UIKit;
using RectangleF = CoreGraphics.CGRect;
Expand All @@ -7,7 +8,11 @@ namespace Microsoft.Maui.Handlers
{
public partial class DatePickerHandler : ViewHandler<IDatePicker, UIDatePicker>
{
static readonly NSString WindowDidCloseNotification = new("NSWindowDidCloseNotification");

readonly UIDatePickerProxy _proxy = new();
NSObject? _windowCloseObserver;
bool _isDatePickerOpen;

protected override UIDatePicker CreatePlatformView()
{
Expand All @@ -26,16 +31,129 @@ 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);
}

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<UITextField> 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);

Comment thread
SubhikshaSf4851 marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
}
}
}
Loading