diff --git a/src/Controls/src/Core/BindableProperty.cs b/src/Controls/src/Core/BindableProperty.cs index 0f58a074403e..413b59db530a 100644 --- a/src/Controls/src/Core/BindableProperty.cs +++ b/src/Controls/src/Core/BindableProperty.cs @@ -45,7 +45,11 @@ public sealed class BindableProperty { typeof(Uri), new UriTypeConverter() }, { typeof(Easing), new Maui.Converters.EasingTypeConverter() }, { typeof(Maui.Graphics.Color), new ColorTypeConverter() }, - { typeof(ImageSource), new ImageSourceConverter() } + { typeof(ImageSource), new ImageSourceConverter() }, +#if NET6_0_OR_GREATER + { typeof(DateTime), new DateTimeTypeConverter() }, + { typeof(TimeSpan), new TimeSpanTypeConverter() } +#endif }; internal static readonly Dictionary KnownIValueConverters = new Dictionary @@ -269,9 +273,12 @@ internal bool TryConvert(ref object value) value = Convert.ChangeType(value, returnType); return true; } - if (KnownTypeConverters.TryGetValue(returnType, out TypeConverter typeConverterTo) && typeConverterTo.CanConvertFrom(valueType)) + + Type targetType = Nullable.GetUnderlyingType(returnType) ?? returnType; + + if (KnownTypeConverters.TryGetValue(targetType, out TypeConverter typeConverterTo) && typeConverterTo.CanConvertFrom(valueType)) { - value = typeConverterTo.ConvertFromInvariantString(value.ToString()); + value = typeConverterTo.ConvertFromInvariantString(Convert.ToString(value, CultureInfo.InvariantCulture)); return true; } if (returnType.IsAssignableFrom(valueType)) diff --git a/src/Controls/src/Core/DateTimeTypeConverter.cs b/src/Controls/src/Core/DateTimeTypeConverter.cs new file mode 100644 index 000000000000..90298d1eb832 --- /dev/null +++ b/src/Controls/src/Core/DateTimeTypeConverter.cs @@ -0,0 +1,104 @@ +#if NET6_0_OR_GREATER +using System; +using System.ComponentModel; +using System.Globalization; +using Microsoft.Maui.Controls.Xaml; + +namespace Microsoft.Maui.Controls; + +internal class DateTimeTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type? sourceType) + => sourceType == typeof(DateOnly) || sourceType == typeof(string); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + => destinationType == typeof(DateTime); + + public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is DateTime dateTime) + { + return dateTime; + } + if (value is DateOnly dateOnly) + { + return dateOnly.ToDateTime(TimeOnly.MinValue); + } + if (value is string stringValue) + { + if (DateOnly.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly dateTimeOnly)) + { + return dateTimeOnly.ToDateTime(TimeOnly.MinValue); + } + else if (DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out dateTime)) + { + return dateTime; + } + } + + throw new NotImplementedException($"Cannot convert \"{value}\" into {typeof(DateTime)}"); + } + + public override object ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type? destinationType) + { + if (value is DateOnly dateOnly) + { + return ConvertToDestinationType(dateOnly, destinationType, culture); + } + else if (value is DateTime dateTime) + { + return ConvertToDestinationType(dateTime, destinationType, culture); + } + else if (value is string stringValue) + { + if (DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out dateTime)) + { + return ConvertToDestinationType(dateTime, destinationType, culture); + } + else if (DateOnly.TryParse(stringValue, CultureInfo.InvariantCulture, out dateOnly)) + { + return ConvertToDestinationType(dateOnly, destinationType, culture); + } + } + + throw new NotImplementedException($"Cannot convert \"{value}\" into {destinationType}"); + } + + static object ConvertToDestinationType(DateOnly dateOnly, Type? destinationType, CultureInfo? culture) + { + if (destinationType == typeof(string)) + { + return dateOnly.ToString( CultureInfo.InvariantCulture); + } + else if (destinationType == typeof(DateTime)) + { + return dateOnly.ToDateTime(TimeOnly.MinValue); + } + else if (destinationType == typeof(DateOnly)) + { + return dateOnly; + } + + throw new NotImplementedException($"Cannot convert \"{dateOnly}\" into {destinationType}"); + } + + static object ConvertToDestinationType(DateTime dateTime, Type? destinationType, CultureInfo? culture) + { + if (destinationType == typeof(string)) + { + return dateTime.ToString(CultureInfo.InvariantCulture); + } + else if (destinationType == typeof(DateTime)) + { + return dateTime; + } + else if (destinationType == typeof(DateOnly)) + { + return DateOnly.FromDateTime(dateTime); + } + + throw new NotImplementedException($"Cannot convert \"{dateTime}\" into {destinationType}"); + } +} + +#endif \ No newline at end of file diff --git a/src/Controls/src/Core/TimeSpanTypeConverter.cs b/src/Controls/src/Core/TimeSpanTypeConverter.cs new file mode 100644 index 000000000000..9ef1e61d8261 --- /dev/null +++ b/src/Controls/src/Core/TimeSpanTypeConverter.cs @@ -0,0 +1,102 @@ +#if NET6_0_OR_GREATER +using System; +using System.ComponentModel; +using System.Globalization; +using Microsoft.Maui.Controls.Xaml; + +namespace Microsoft.Maui.Controls; + +internal class TimeSpanTypeConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type? sourceType) + => sourceType == typeof(TimeOnly); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + => destinationType == typeof(TimeSpan); + + public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is TimeSpan timeSpan) + { + return timeSpan; + } + else if (value is TimeOnly timeOnly) + { + return timeOnly.ToTimeSpan(); + } + else if (value is string stringValue) + { + if (TimeOnly.TryParse(stringValue, culture, out var timeOnlyResult)) + { + return timeOnlyResult.ToTimeSpan(); + } + else if (TimeSpan.TryParse(stringValue, culture, out var timeSpanResult)) + { + return timeSpanResult; + } + } + throw new NotImplementedException($"Cannot convert \"{value}\" into {typeof(TimeSpan)}"); + } + + public override object ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type? destinationType) + { + if (value is TimeSpan timeSpan) + { + return ConvertToDestinationType(timeSpan, destinationType, culture); + } + else if (value is TimeOnly timeOnly) + { + return ConvertToDestinationType(timeOnly, destinationType, culture); + } + else if (value is string stringValue) + { + if (TimeOnly.TryParse(stringValue, culture, out timeOnly)) + { + return ConvertToDestinationType(timeOnly, destinationType, culture); + } + else if (TimeSpan.TryParse(stringValue, culture, out var timeSpan1)) + { + return ConvertToDestinationType(timeSpan1, destinationType, culture); + } + } + + throw new NotImplementedException($"Cannot convert \"{value}\" into {destinationType}"); + } + + static object ConvertToDestinationType(TimeOnly timeOnly, Type? destinationType, CultureInfo? culture) + { + if (destinationType == typeof(string)) + { + return timeOnly.ToString(culture); + } + else if (destinationType == typeof(TimeSpan)) + { + return timeOnly.ToTimeSpan(); + } + else if (destinationType == typeof(TimeOnly)) + { + return timeOnly; + } + + throw new NotImplementedException($"Cannot convert \"{timeOnly}\" into {destinationType}"); + } + + static object ConvertToDestinationType(TimeSpan timeSpan, Type? destinationType, CultureInfo? culture) + { + if (destinationType == typeof(string)) + { + return timeSpan.ToString(); + } + else if (destinationType == typeof(TimeSpan)) + { + return timeSpan; + } + else if (destinationType == typeof(TimeOnly)) + { + return TimeOnly.FromTimeSpan(timeSpan); + } + + throw new NotImplementedException($"Cannot convert \"{timeSpan}\" into {destinationType}"); + } +} +#endif \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/DateOnlyTypeConverterTests.cs b/src/Controls/tests/Core.UnitTests/DateOnlyTypeConverterTests.cs new file mode 100644 index 000000000000..a1f87d50bca2 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/DateOnlyTypeConverterTests.cs @@ -0,0 +1,73 @@ +#if NET6_0_OR_GREATER + +namespace Microsoft.Maui.Controls.Core.UnitTests; + +using System; +using System.ComponentModel; +using System.Globalization; +using System.Threading.Tasks; +using Xunit; + +public class DateOnlyTypeConverterTests : BaseTestFixture +{ + [Fact] + public void DateOnlyToDateTimeConversion() + { + var converter = new DateTimeTypeConverter(); + + var dateOnlyValue = new DateOnly(2025, 2, 21); + + var actualDateTime = converter.ConvertFrom(null, CultureInfo.InvariantCulture, dateOnlyValue); + var expectedDateTime = new DateTime(2025, 2, 21); + + Assert.Equal(expectedDateTime, actualDateTime); + } + + [Fact] + public void DateTimeToDateOnlyConversion() + { + var converter = new DateTimeTypeConverter(); + + var dateTimeValue = new DateTime(2025, 2, 21); + + var actualDateOnly = converter.ConvertTo(null, CultureInfo.InvariantCulture, dateTimeValue, typeof(DateOnly)); + var expectedDateOnly = new DateOnly(2025, 2, 21); + + Assert.Equal(expectedDateOnly, actualDateOnly); + } + + [Fact] + public void DateOnlyToDatePickerBinding() + { + var datePicker = new DatePicker(); + var source = new Issue20438DatePickerViewModel + { + SelectedDate = new DateOnly(2025, 3, 15) + }; + datePicker.BindingContext = source; + datePicker.SetBinding(DatePicker.DateProperty, "SelectedDate"); + var expectedDateTime = new DateTime(2025, 3, 15); + Assert.Equal(expectedDateTime, datePicker.Date); + } + + [Fact] + public void DateOnlyToNonNullableBinding() + { + var dateProperty = BindableProperty.Create("Date", typeof(DateTime), typeof(DatePicker), null, BindingMode.TwoWay); + var source = new Issue20438DatePickerViewModel + { + SelectedDate = new DateOnly(2025, 3, 15) + }; + var bo = new MockBindable { BindingContext = source }; + + bo.SetBinding(dateProperty, "SelectedDate"); + var expectedDateTime = new DateTime(2025, 3, 15); + Assert.Equal(expectedDateTime, bo.GetValue(dateProperty)); + } + + public class Issue20438DatePickerViewModel + { + public DateOnly SelectedDate { get; set; } + } +} +#endif \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/TimeOnlyTypeConverterTests.cs b/src/Controls/tests/Core.UnitTests/TimeOnlyTypeConverterTests.cs new file mode 100644 index 000000000000..913f980b1b30 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/TimeOnlyTypeConverterTests.cs @@ -0,0 +1,72 @@ +#if NET6_0_OR_GREATER + +namespace Microsoft.Maui.Controls.Core.UnitTests; + +using System; +using System.Globalization; +using System.Threading.Tasks; +using Xunit; + +public class TimeOnlyTypeConverterTests : BaseTestFixture +{ + [Fact] + public void TimeOnlyToTimeSpanConversion() + { + var converter = new TimeSpanTypeConverter(); + + var timeOnlyValue = new TimeOnly(8, 30, 0); + + var actualTimeSpan = converter.ConvertFrom(null, CultureInfo.InvariantCulture, timeOnlyValue); + var expectedTimeSpan = new TimeSpan(8, 30, 0); + + Assert.Equal(expectedTimeSpan, actualTimeSpan); + } + + [Fact] + public void TimeSpanToTimeOnlyConversion() + { + var converter = new TimeSpanTypeConverter(); + + var timeSpanValue = new TimeSpan(8, 30, 0); + + var actualTimeOnly = converter.ConvertTo(null, CultureInfo.InvariantCulture, timeSpanValue, typeof(TimeOnly)); + var expectedTimeOnly = new TimeOnly(8, 30, 0); + + Assert.Equal(expectedTimeOnly, actualTimeOnly); + } + + [Fact] + public void TimeOnlyToTimePickerBinding() + { + var timePicker = new TimePicker(); + var source = new Issue20438TimePickerViewModel + { + SelectedTime = new TimeOnly(14, 30, 0) + }; + timePicker.BindingContext = source; + timePicker.SetBinding(TimePicker.TimeProperty, "SelectedTime"); + var expectedTimeSpan = new TimeSpan(14, 30, 0); + Assert.Equal(expectedTimeSpan, timePicker.Time); + } + + [Fact] + public void TimeOnlyToNonNullableBinding() + { + var timeProperty = BindableProperty.Create("Time", typeof(TimeSpan), typeof(TimePicker), null, BindingMode.TwoWay); + var source = new Issue20438TimePickerViewModel + { + SelectedTime = new TimeOnly(14, 30, 0) + }; + var bo = new MockBindable { BindingContext = source }; + + bo.SetBinding(timeProperty, "SelectedTime"); + var expectedTimeSpan = new TimeSpan(14, 30, 0); + Assert.Equal(expectedTimeSpan, bo.GetValue(timeProperty)); + } + + public class Issue20438TimePickerViewModel + { + public TimeOnly SelectedTime { get; set; } + } +} +#endif \ No newline at end of file diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml new file mode 100644 index 000000000000..a01e2941b82f --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml.cs new file mode 100644 index 000000000000..612ec1bb5e54 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui20438.xaml.cs @@ -0,0 +1,77 @@ +#if NET6_0_OR_GREATER +using System; +using System.ComponentModel; +using NUnit.Framework; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public partial class Maui20438 : ContentPage +{ + public Maui20438() + { + InitializeComponent(); + } + + [TestFixture] + class Tests + { + [Test] + public void DateOnlyBinding([Values] XamlInflator inflator) + { + var page = new Maui20438(inflator); + Assert.That(page.datePicker.Date, Is.EqualTo(new DateTime(2025, 3, 15))); + } + + [Test] + public void TimeOnlyBinding([Values] XamlInflator inflator) + { + var page = new Maui20438(inflator); + Assert.That(page.timePicker.Time, Is.EqualTo(new TimeSpan(14, 30, 0))); + } + + [Test] + public void DateOnlyToNonNullableDateTime([Values] XamlInflator inflator) + { + var page = new Maui20438(inflator); + Assert.That(page.customDatePicker.NonNullableDateTime, Is.EqualTo(new DateTime(2025, 3, 15))); + } + + [Test] + public void TimeOnlyToNonNullableTimeSpan([Values] XamlInflator inflator) + { + var page = new Maui20438(inflator); + Assert.That(page.customTimePicker.NonNullableTimeSpan, Is.EqualTo(new TimeSpan(14, 30, 0))); + } + } +} + +public class Issue20438CustomDatePicker : DatePicker +{ + public static readonly BindableProperty NonNullableDateTimeProperty = + BindableProperty.Create(nameof(NonNullableDateTime), typeof(DateTime), typeof(Issue20438CustomDatePicker), default(DateTime)); + + public DateTime NonNullableDateTime + { + get => (DateTime)GetValue(NonNullableDateTimeProperty); + set => SetValue(NonNullableDateTimeProperty, value); + } +} + +public class Issue20438CustomTimePicker : TimePicker +{ + public static readonly BindableProperty NonNullableTimeSpanProperty = + BindableProperty.Create(nameof(NonNullableTimeSpan), typeof(TimeSpan), typeof(Issue20438CustomTimePicker), default(TimeSpan)); + + public TimeSpan NonNullableTimeSpan + { + get => (TimeSpan)GetValue(NonNullableTimeSpanProperty); + set => SetValue(NonNullableTimeSpanProperty, value); + } +} + +public class Issue20438ViewModel +{ + public DateOnly SelectedDate { get; set; } = new DateOnly(2025, 3, 15); + public TimeOnly SelectedTime { get; set; } = new TimeOnly(14, 30, 0); +} +#endif