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
108 changes: 107 additions & 1 deletion src/Controls/tests/DeviceTests/Elements/DatePicker/DatePickerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Maui.Hosting;
using Microsoft.Maui.Platform;
using Xunit;
using static Microsoft.Maui.DeviceTests.AssertHelpers;

namespace Microsoft.Maui.DeviceTests;

Expand Down Expand Up @@ -68,6 +69,111 @@ await CreateHandlerAndAddToWindow<DatePickerHandler>(datePicker, handler =>
}
#endif

#if MACCATALYST
[Fact(DisplayName = "Focus Opens DatePicker When Virtual Focus Is Stale")]
public async Task FocusOpensDatePickerWhenVirtualFocusIsStale()
{
SetupBuilder();

var datePicker = new DatePicker
{
Date = new DateTime(2026, 5, 20),
WidthRequest = 200,
HeightRequest = 44
};

await CreateHandlerAndAddToWindow<DatePickerHandler>(datePicker, async handler =>
{
((IView)datePicker).IsFocused = true;
datePicker.IsOpen = false;

var focusResult = datePicker.Focus();

Assert.True(focusResult);
await AssertEventually(
() => datePicker.IsFocused && datePicker.IsOpen,
message: "DatePicker focus did not open the picker when virtual focus was stale.");

handler.Invoke(nameof(IView.Unfocus), null);
});
}

[Fact(DisplayName = "IsOpen Raises Single Opened And Closed Events")]
public async Task IsOpenRaisesSingleOpenedAndClosedEvents()
{
SetupBuilder();

var openedCount = 0;
var closedCount = 0;
var datePicker = new DatePicker
{
Date = new DateTime(2026, 5, 20),
WidthRequest = 200,
HeightRequest = 44
};

datePicker.Opened += (_, _) => openedCount++;
datePicker.Closed += (_, _) => closedCount++;

await CreateHandlerAndAddToWindow<DatePickerHandler>(datePicker, async handler =>
{
datePicker.IsOpen = true;

await AssertEventually(
() => datePicker.IsFocused && datePicker.IsOpen,
message: "DatePicker did not enter the focused/open state.");

datePicker.IsOpen = false;

await AssertEventually(
() => !datePicker.IsFocused && !datePicker.IsOpen,
message: "DatePicker did not leave the focused/open state.");

Assert.Equal(1, openedCount);
Assert.Equal(1, closedCount);
});
}

[Fact(DisplayName = "Repeated Focus While Open Raises No Extra Opened Or Closed Events")]
public async Task RepeatedFocusWhileOpenRaisesNoExtraOpenedOrClosedEvents()
{
SetupBuilder();

var openedCount = 0;
var closedCount = 0;
var datePicker = new DatePicker
{
Date = new DateTime(2026, 5, 20),
WidthRequest = 200,
HeightRequest = 44
};

datePicker.Opened += (_, _) => openedCount++;
datePicker.Closed += (_, _) => closedCount++;

await CreateHandlerAndAddToWindow<DatePickerHandler>(datePicker, async handler =>
{
Assert.True(datePicker.Focus());
await AssertEventually(
() => datePicker.IsFocused && datePicker.IsOpen,
message: "DatePicker did not enter the focused/open state.");

Assert.Equal(1, openedCount);
Assert.Equal(0, closedCount);

Assert.True(datePicker.Focus());
await Task.Delay(100);

Assert.True(datePicker.IsFocused);
Assert.True(datePicker.IsOpen);
Assert.Equal(1, openedCount);
Assert.Equal(0, closedCount);

handler.Invoke(nameof(IView.Unfocus), null);
});
}
#endif

string GetDisplayedText(DatePickerHandler handler)
{
var platformView = handler.PlatformView;
Expand Down Expand Up @@ -151,4 +257,4 @@ await InvokeOnMainThreadAsync(() =>
Assert.True(eventFired, "DateSelected event should fire when platform view date changes");
});
}
}
}
136 changes: 120 additions & 16 deletions src/Core/src/Handlers/DatePicker/DatePickerHandler.MacCatalyst.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,27 @@ public partial class DatePickerHandler : ViewHandler<IDatePicker, UIDatePicker>
NSObject? _windowCloseObserver;
bool _isDatePickerOpen;

static DatePickerHandler()
{
// IDatePicker does not implement IPicker, so Focus/Unfocus overrides must
// run through an IView mapper instead of DatePickerHandler.CommandMapper entries.
var macCatalystOverrides = new CommandMapper<IView, IViewHandler>(CommandMapper.Chained ?? ViewCommandMapper)
{
[nameof(IView.Focus)] = MapMacCatalystFocus,
[nameof(IView.Unfocus)] = MapMacCatalystUnfocus,
};

CommandMapper.Chained = macCatalystOverrides;
}

protected override UIDatePicker CreatePlatformView()
{
return new UIDatePicker { Mode = UIDatePickerMode.Date, TimeZone = new NSTimeZone("UTC") };
}

bool _syncingOpenState;
bool _nativeOpenState;

internal bool UpdateImmediately { get; set; } = true;

protected override void ConnectHandler(UIDatePicker platformView)
Expand Down Expand Up @@ -109,10 +125,7 @@ void OnEditingDidBegin(object? sender, EventArgs e)
// 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;
}
SetVirtualOpenStateFromNative(true);
}

void OnWindowClosed(NSNotification notification)
Expand All @@ -126,10 +139,7 @@ void OnWindowClosed(NSNotification notification)

_isDatePickerOpen = false;

if (VirtualView is IDatePicker virtualView)
{
virtualView.IsFocused = virtualView.IsOpen = false;
}
SetVirtualOpenStateFromNative(false);

// On MacCatalyst the internal UITextFields stay as first responder
// (visually highlighted) even after the popover window closes.
Expand Down Expand Up @@ -195,7 +205,103 @@ public static partial void MapFlowDirection(IDatePickerHandler handler, IDatePic

internal static partial void MapIsOpen(IDatePickerHandler handler, IDatePicker datePicker)
{
if (handler is DatePickerHandler datePickerHandler)
datePickerHandler.SyncOpenState(datePicker.IsOpen, refreshIfAlreadyFirstResponder: datePicker.IsOpen);
}

static void MapMacCatalystFocus(IViewHandler handler, IView view, object? args)
{
if (args is not FocusRequest request)
return;

if (handler is not DatePickerHandler datePickerHandler ||
datePickerHandler.VirtualView is not IDatePicker datePicker)
{
request.TrySetResult(false);
return;
}

var didOpen = datePickerHandler.SyncOpenState(shouldBeOpen: true, refreshIfAlreadyFirstResponder: !datePicker.IsOpen);
request.TrySetResult(didOpen);
}

static void MapMacCatalystUnfocus(IViewHandler handler, IView view, object? args)
{
if (handler is not DatePickerHandler datePickerHandler)
return;

datePickerHandler.SyncOpenState(shouldBeOpen: false, refreshIfAlreadyFirstResponder: false);
}

bool SyncOpenState(bool shouldBeOpen, bool refreshIfAlreadyFirstResponder)
{
if (_syncingOpenState)
return _nativeOpenState || PlatformView.IsFirstResponder;

_syncingOpenState = true;
try
{
var platformView = PlatformView;
bool actualOpen;
if (shouldBeOpen)
{
if (platformView.IsFirstResponder && refreshIfAlreadyFirstResponder)
platformView.ResignFirstResponder();

var didBecomeFirstResponder = platformView.IsFirstResponder;
if (!platformView.IsFirstResponder)
didBecomeFirstResponder = platformView.BecomeFirstResponder();

// Stock UIDatePicker on MacCatalyst does not reliably report first-responder
// state from a programmatic open request; custom platform views use native results.
var canAssumeStockPickerOpened = platformView.GetType() == typeof(UIDatePicker) &&
platformView.Enabled &&
platformView.UserInteractionEnabled;

actualOpen = platformView.IsFirstResponder || didBecomeFirstResponder || canAssumeStockPickerOpened;
}
else
{
if (platformView.IsFirstResponder || _nativeOpenState)
platformView.ResignFirstResponder();

actualOpen = platformView.IsFirstResponder;
}

SetVirtualOpenState(actualOpen);
return actualOpen;
}
finally
{
_syncingOpenState = false;
}
}

void SetVirtualOpenStateFromNative(bool isOpen)
{
if (_syncingOpenState)
return;

_syncingOpenState = true;
try
{
SetVirtualOpenState(isOpen);
}
finally
{
_syncingOpenState = false;
}
}

void SetVirtualOpenState(bool isOpen)
{
_nativeOpenState = isOpen;

if (VirtualView is IDatePicker virtualView)
{
virtualView.IsOpen = isOpen;
virtualView.IsFocused = isOpen;
}
}

void SetVirtualViewDate()
Expand All @@ -211,14 +317,10 @@ void SetVirtualViewDate()
class UIDatePickerProxy
{
WeakReference<DatePickerHandler>? _handler;
WeakReference<IDatePicker>? _virtualView;

IDatePicker? VirtualView => _virtualView is not null && _virtualView.TryGetTarget(out var v) ? v : null;

public void Connect(DatePickerHandler handler, IDatePicker virtualView, UIDatePicker platformView)
{
_handler = new(handler);
_virtualView = new(virtualView);

platformView.ValueChanged += OnValueChanged;
}
Expand All @@ -230,11 +332,13 @@ public void Disconnect(UIDatePicker platformView)

void OnValueChanged(object? sender, EventArgs? e)
{
if (_handler is not null && _handler.TryGetTarget(out var handler) && handler.UpdateImmediately)
handler.SetVirtualViewDate();
if (_handler is not null && _handler.TryGetTarget(out var handler))
{
if (handler.UpdateImmediately)
handler.SetVirtualViewDate();

if (VirtualView is IDatePicker virtualView)
virtualView.IsFocused = true;
handler.SetVirtualOpenStateFromNative(true);
}
}
}
}
Expand Down
Loading
Loading