HandyControl
HandyControl copied to clipboard
[Feature request] Add a TimeSpan editor - similar to TimePicker
Is your feature request related to a problem? Please describe.
The TimePicker control binds to a DateTime and only allows the specification of a 24-hour time. Further, the PropertyGrid cannot bind to a TimeSpan and there is no suitable editor built-in.
Describe the solution you'd like
A TimeSpanPicker control that binds to a TimeSpan and allows the editing of TimeSpans, including the specification of days (e.g. 15.12:00:00 for 15 and a half days). A TimeSpanPropertyEditor can then be added as the default editor for TimeSpan in a PropertyGrid.
Describe alternatives you've considered
Currently, I am creating a custom editor to achieve the same result and referencing it via an Editor attribute. However, TimeSpan is a common enough CLR type that its omission is strange. A number of other WPF libraries bind their TimePicker equivalents to TimeSpan rather than DateTime for this reason. However having a TimePicker and a new TimeSpanPicker is a reasonable compromise.
Additional context N/A
Here is the workaround I went with. I hope the description of my journey demonstrates the benefit of some of the below recommendations in improving extensibility. It will also make it easier to implement directly in HandyControl if desired.
First I added a new Attached property:
/// <summary>
/// Allows a TimePicker to be bound to a <see cref="TimeSpan"/> instead of a <see cref="DateTime"/>.
/// </summary>
public class TimePickerAttach
{
/// <summary>
/// Access the internal 'ShowConfirmButtonProperty' on clock.
/// </summary>
private static readonly DependencyProperty s_showConfirmButtonProperty = (DependencyProperty)typeof(ClockBase)
.GetField("ShowConfirmButtonProperty", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!;
public static readonly DependencyProperty SelectedTimeSpanProperty = DependencyProperty.RegisterAttached(
"SelectedTimeSpan",
typeof(TimeSpan?),
typeof(TimePickerAttach),
new FrameworkPropertyMetadata(OnSelectedTimeSpanChanged)
{
BindsTwoWayByDefault = true,
Inherits = false,
DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
});
public static object? GetSelectedTimeSpan(DependencyObject element) => element.GetValue(SelectedTimeSpanProperty);
public static void SetSelectedTimeSpan(DependencyObject element, object? value) =>
element.SetValue(SelectedTimeSpanProperty, value);
private static void OnSelectedTimeSpanChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TimePicker picker)
return;
SetIsMonitoring(picker, true);
var dateTime = GetDateTime(e.NewValue);
if (Equals(dateTime, picker.SelectedTime)) return;
picker.SelectedTime = dateTime;
}
private static void PickerOnSelectedTimeChanged(object? sender, FunctionEventArgs<DateTime?> e)
{
if (sender is not TimePicker picker) return;
var timeSpan = GetTimeSpan(e.Info);
if (Equals(timeSpan, picker.GetValue(SelectedTimeSpanProperty))) return;
picker.SetCurrentValue(SelectedTimeSpanProperty, timeSpan);
}
private static TimeSpan? GetTimeSpan(object? dateTime) => dateTime is DateTime d
? d - DateTime.Today
: null;
private static DateTime? GetDateTime(object? timeSpan) => timeSpan is TimeSpan t
? DateTime.Today + t
: null;
public static readonly DependencyProperty AsListProperty = DependencyProperty.RegisterAttached(
"AsList",
typeof(bool?),
typeof(TimePickerAttach),
new FrameworkPropertyMetadata(OnAsListChanged)
{
Inherits = false, DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
});
public static object? GetAsList(DependencyObject element) => element.GetValue(AsListProperty);
public static void SetAsList(DependencyObject element, object? value) =>
element.SetValue(AsListProperty, value);
private static void OnAsListChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TimePicker picker)
return;
if (e.NewValue as bool? == true)
{
if (picker.Clock is not ListClock)
{
picker.Clock = new ListClock();
// Remove confirm button and update instantly.
picker.Clock.SetCurrentValue(s_showConfirmButtonProperty, false);
picker.Clock.DisplayTimeChanged += (_, args) => picker.SelectedTime = args.Info;
}
}
SetIsMonitoring(picker, true);
}
// ReSharper disable once InconsistentNaming
private static readonly DependencyProperty IsMonitoringProperty = DependencyProperty.RegisterAttached(
"IsMonitoring",
typeof(bool),
typeof(TimePickerAttach),
new FrameworkPropertyMetadata(false, OnIsMonitoringChanged)
{
Inherits = false,
IsNotDataBindable = true,
DefaultUpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
});
private static object? GetIsMonitoring(DependencyObject element) => element.GetValue(IsMonitoringProperty);
private static void SetIsMonitoring(DependencyObject element, object? value) =>
element.SetValue(IsMonitoringProperty, value);
private static void OnIsMonitoringChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TimePicker picker) return;
if (e.NewValue as bool? == true)
{
picker.SelectedTimeChanged += PickerOnSelectedTimeChanged;
}
else
{
picker.SelectedTimeChanged -= PickerOnSelectedTimeChanged;
}
}
}
This allows you to bind a TimeSpan directly to a TimePicker, e.g:
<hc:TimePicker hc:TitleElement.Title="Save Interval :"
hc:InfoElement.Placeholder="Please select the minimum time between saves."
helpers:TimePickerAttach.SelectedTimeSpan="{Binding Path=Settings.SaveAfter}"
helpers:TimePickerAttach.AsList="True"
Style="{StaticResource TimePickerExtend}" />
The AsList attached property also removes the Confirm button and updates as soon as you change the span (which is really nice).
Of course, this behaviour could be implemented straight into TimePicker by exposing a SelectedTimeSpan property directly (+associated events).
Further, I would ask that the ShowConfirmButton is made public and ListClock changed to update SelectedTime whenever DisplayTime changes, when there is not confirm button. The TimePicker without a confirm button is really nice, but I had to use reflection to achieve it (see s_showConfirmButtonProperty in the code above).
Secondly, I then updated TimePropertyEditor to the following:
public class TimePropertyEditor : PropertyEditorBase
{
private DependencyProperty? _dependencyProperty;
private FrameworkElement? _element;
public override FrameworkElement CreateElement(PropertyItem propertyItem)
{
// We support DateTime or TimeSpan properties.
if (propertyItem.PropertyType.IsAssignableTo(typeof(DateTime)))
{
_element = new DateTimePicker { IsEnabled = !propertyItem.IsReadOnly};
_dependencyProperty = DateTimePicker.SelectedDateTimeProperty;
}
else if (propertyItem.PropertyType.IsAssignableTo(typeof(TimeSpan)))
{
var picker = new TimePicker {IsEnabled = !propertyItem.IsReadOnly};
picker.SetValue(TimePickerAttach.AsListProperty, true);
_element = picker;
_dependencyProperty = TimePickerAttach.SelectedTimeSpanProperty;
}
else
{
// Unsupported type, return readonly editor
_element = new TextBox {IsEnabled = false};
_dependencyProperty = TextBox.TextProperty;
}
return _element;
}
public override DependencyProperty GetDependencyProperty() =>
_dependencyProperty ?? throw new InvalidOperationException();
}
This has the advantage of binding to either a DateTime or a TimeSpan, and works beautifully.
Finally, I created a customer PropertyResolver, by extending it and overriding CreateDefaultEditor to return my new, improved, TimePropertyEditor:
internal class CustomPropertyResolver : PropertyResolver
{
public override PropertyEditorBase CreateDefaultEditor(Type type)
{
if (type.IsAssignableTo(typeof(TimeSpan)) || type.IsAssignableTo(typeof(DateTime)))
return new TimePropertyEditor();
return base.CreateDefaultEditor(type);
}
}
At this point, I became 'stuck', as the PropertyGrid.PropertyResolver is a readonly property, so I could not easily set it to my custom version. Please could this property be made settable? The alternative is to extend PropertyGrid itself to override the PropertyResolver (which is at least virtual), but overriding a WPF control is a lot more work, so for now I just explicitly set the EditorAttribute on my model properties, e.g:
[Category("General")]
[DisplayName("Delay")]
[Description("The delay.")]
[DefaultValue(typeof(TimeSpan), "00:00:03")]
[Editor("MyNameSpace.TimePropertyEditor, MyAssembly", (string?)null)]
public TimeSpan Delay
{
get => GetSetting<TimeSpan>();
set => SetSetting(value);
}
TL;DR Recommendations
- Make
Clock.ShowConfirmButtonavailable onListClock, whenfalse, updateSelectedTimewhenDisplayTimechanges, rather than requiring a confirm button to be clicked. (This is an easy enhancement that has great results) - Add
SelectedTimeSpanand associated events toTimePicker, and keep it in sync withSelectedTime, allowing theTimePickerto be used for bothDateTime?andTimeSpan?types. Alternatively, add a newTimeSpanPickerthat supports days as well. - Update
TimePropertyEditorto supportTimeSpan, or create a separateTimeSpanPropertyEditorto supportTimeSpanand add toPropertyResolver. - Make
PropertyGrid.PropertyResolversettable to easily allow for custom resolvers (currently you have to extendPropertyGriditself