Introduction
In my previous two articles, I first described a simple short TimeSpan UserControl
that used sliders to pick the hours, minutes, and seconds of a TimeSpan
. In my second article, I described a custom spinner
control that could be used to replace the sliders.
In this third article, I will update the TimeSpan
control to use the SpinnerControl
, and make the TimeSpan
control a custom control,
rather than a UserControl
(in particular so that we can apply custom themes to it). I refer to this as a ShortTimeSpanControl
as it only represents
a positive TimeSpan
from 00:00:00 to 23:59:59. A full TimeSpan
is described here:
A TimeSpan value can be represented as [-]d.hh:mm:ss.ff, where the optional minus sign indicates a negative time interval,
the d component is days, hh is hours as measured on a 24-hour clock, mm is minutes, ss is seconds, and ff is fractions of a second.
That is, a time interval consists of a positive or negative number of days without a time of day, or a number of days with a time of day, or only a time of day.
With the generic theme supplied, this is a selection control, where the user can choose a TimeSpan
. As it is a custom control,
you can apply any custom theme you like, and, perhaps, just make it a read-only control (interactively, that is) that updates its Value
via data-binding.
Requirements
We would like the control to look something like this:
where we have three spinner controls for 0..23 hours, 0..59 minutes, and 0..59 seconds.
The control should:
- Return and accept a
TimeSpan
Value
.
- Keep the
TimeSpan
bounded.
- Raise an event when
Value
changes.
Data Binding and Not Repeating Yourself
We need to consider how to get the values from the SpinnerControl
to update the value in the ShortTimeSpanControl
and vice-versa.
In the spinner control in the previous article, we set:
private static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal), typeof(SpinnerControl),
new FrameworkPropertyMetadata(DefaultValue,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
));
such that the Value
property binds two-way by default.
On our ShortTimeSpanControl
, we create the following dependency properties with two-way binding: Hours
, Minutes
, and Seconds
.
Then by simply using XAML data-binding, we can bind the three SpinnerControl
s to the respective properties that they represent, and the framework takes care
of the binding and update notifications for us.
However, this means that not only do we have a TimeSpan
representing the underlying value of the control, but we also have a copy of the hours, minutes,
and seconds, which is a little bit awkward as there isn't a single point of truth for the TimeSpan
value
that we are holding.
However, as long as we keep all the data in sync, we will not have a problem. To do this, we just need to check that if we set any property to a new value,
that it doesn't match the existing value, and if it does, then do not call the setter on the other properties.
For example: when Value
changes, we need to update the Hours
, Minutes
, and Seconds
properties:
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs args)
{
var control = d as ShortTimeSpanControl;
if (d == null)
return;
var oldValue = (TimeSpan)args.OldValue;
var newValue = (TimeSpan)args.NewValue;
if (oldValue != newValue)
{
control.SetValue(HoursProperty, (object)newValue.Hours);
control.SetValue(MinutesProperty, (object)newValue.Minutes);
control.SetValue(SecondsProperty, (object)newValue.Seconds);
}
var e = new RoutedPropertyChangedEventArgs<TimeSpan>(oldValue, newValue, ValueChangedEvent);
control.OnValueChanged(e);
}
Databinding and CoerceValueCallback with the Spinner Control
Whilst I was adding my spinner control to the time span control generic theme, I discovered some odd behaviour: the problem was that my Value
dependency property
in the timespan control was over-running its bounds, but only during the XAML databinding to the spinner control. My decrease command in the spinner control was:
protected void OnDecrease()
{
Value -= Change;
}
which causes the over/under-run, and the CoerceValueCallback
that should have prevented this was:
private static decimal LimitValueByBounds(decimal newValue, SpinnerControl control)
{
newValue = Math.Max(control.Minimum, Math.Min(control.Maximum, newValue));
newValue = Decimal.Round(newValue, control.DecimalPlaces);
return newValue;
}
private static object CoerceValue(DependencyObject obj, object value)
{
decimal newValue = (decimal)value;
SpinnerControl control = obj as SpinnerControl;
if (control != null)
{
newValue = LimitValueByBounds(newValue, control);
}
return newValue;
}
It seems that the data-binding update occurs first, performed by the WPF framework (when it calls SetValue
'implicitly' on the dependency property),
allowing the under-run, and then the CoerceValueCallback
is called after the data-binding update. It turns out that this has been covered
in numerous places on the web, e.g., here,
but this item on the MS Connect site describes the problem, and the reasoning behind the behaviour.
I tried swapping out the SpinnerControl
with a Slider
and found that the Slider
behaves as expected,
implying that the problem is not really something to do with WPF, but rather how we approach the problem. The solution in my case is to also limit the bounds
during the OnDecrease
and OnIncrease
commands that were causing the issue:
protected void OnDecrease()
{
Value = LimitValueByBounds(Value - Change, this);
}
By adding this trivial fix, we prevent the Value
from ever going below our minimum.
Events
As we have four properties, and each can change, it makes sense to create four corresponding events:
These are just typical boilerplate custom control event implementations.
Conclusion
There is not a lot else to say about this control. It is really just a case of creating four DependencyProperty
fields, and four corresponding events.
All the validation work is delegated to the underlying SpinnerControl
s. The only extra work that we have to perform is that since we are storing
the TimeSpan
in two places (Value
, and Hours
, Minutes
, Seconds
), we ensure that the data is kept
up to date equally in all the properties.
As mentioned at the beginning, this control is only supposed to represent a short and well defined TimeSpan
from 00:00:00 to 23:59:59.
This control could of course be extended to represent a full TimeSpan
.
Please leave any comments or suggestions below, and don't forget to vote up the article if it is helpful. Thanks!