Introduction
In my previous article for a simple TimeSpan
UserControl
, I mentioned that I would use sliders for the hours, minutes, and seconds, because there wasn't an adequate spinner-type control in WPF 4.0.
To rectify this and scratch the proverbial itch, I thought that I would write one myself:
However, once again I started with a UserControl
, but then thought that I would refactor it back into a custom control library, this article is the result of that.
A Brief Note on Custom Controls versus UserControls
Custom controls and UserControls are both similar, but have slightly different uses:
UserControl
- Derived classes inherit from
UserControl
.
- Consists of a XAML and a code-behind file.
- Cannot be styled/templated, i.e., consumers of the
UserControl
cannot change its style.
- Uses multiple existing controls to create a new control.
Custom Control
- Derived classes inherit from
Control
.
- Consists of a code file and a default style in Themes/Generic.xaml.
- Can be styled/templated.
- Used to build a custom control library.
In short, if we want to create a reusable control that can be themed by consumers, we need to choose the latter, i.e. a custom Control
The SpinnerControl
Starting from scratch, we would create a new custom control library in Visual Studio (2010) that gives us the basic custom control code that
has a static constructor:
static SpinnerControl()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(SpinnerControl),
new FrameworkPropertyMetadata(typeof(SpinnerControl)));
}
and a default Themes\Generic.xaml file for our generic control theme.
Our SpinnerControl
has these two parts: the content of the control, i.e., the Value
and the value's range,
and the theme of the control, so we will describe each part separately below.
Requirements
We want the spinner control to do several things:
- Respond to mouse clicks
- Repeat when the mouse button is held down
- Respond to the keyboard
- Display a formatted value
- Respect a lower and upper bound
- Increase/decrease with a uniform step size
By listing our requirements, we can gain a coarse overview of what code we need to write.
The Layout
Quite simply, using a Grid
gives you the most flexible starting point for a control. So we are going to aim for something rather traditional:
This is a single row grid, with two columns:
- the first column contains a standard
TextBox
control
- the second column contains a further grid, with one column and two rows, containing two buttons
We will return to the implementation of this as the generic theme later.
The SpinnerControl Implementation
We will use typical .NET control property names for custom control properties, which leads to a consistent and straightforward set of names:
Value
: Contains the value of the control.
Minimum
: Sets the minimum value the control can go to.
Maximum
: Sets the maximum the control can go to.
Change
(such as SmallChange
on a SliderControl
): The step-size the Value
changes by when it is increased or decreased.
We additionally expose
DecimalPlaces
: The number of decimal places we want to display
FormattedValue
: The Value
formatted to the required number of decimal places.
All of these properties are normal Control
properties that are exposed via the usual 'pattern' of creating a DependencyProperty
with callbacks to a coercion function and an OnChanged
function, e.g., for Value
:
[Category("SpinnerControl")]
public decimal Value
{
get { return (decimal)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
private static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal), typeof(SpinnerControl),
new FrameworkPropertyMetadata(DefaultValue,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
));
It is also worth pointing out that the setter is a minimal implementation: whist OnValueChanged
could be called in the Value
's setter,
this would not be called during pure XAML binding as the setter above is only called in procedural code: XAML just calls SetValue(ValueProperty, value)
directly!
We return to this property later.
Also note that we use the Category
attribute for each property so that our custom control attributes are grouped together in the property pane in Visual Studio:
There is one property that is read-only: FormattedValue
is a special case as clearly we do not want the consumer of the control to change this value,
we just want to return it to them so that they can display it, etc. Hence we only want to produce a getter for this property.
Consulting the MSDN documentation, we can see the method
RegisterAttachedReadOnly
provides this functionality for us:
This method returns the type DependencyPropertyKey
, whereas RegisterAttached
returns the type DependencyProperty
. Typically, the keys that represent
read-only properties are not made public, because the keys can be used to set the dependency property value by calling SetValue(DependencyPropertyKey, Object)
.
Your class design will affect your requirements, but it is generally recommended to limit the access and visibility of any DependencyPropertyKey
to only those
parts of your code that are necessary to set that dependency property as part of the class or application logic. It is also recommended that you expose
a dependency property identifier for the read-only dependency property, by exposing the value of DependencyPropertyKey.DependencyProperty
as a public static readonly field on your class. [sic.]
So for the FormattedValue
, we simply have:
private static readonly DependencyPropertyKey FormattedValuePropertyKey =
DependencyProperty.RegisterAttachedReadOnly(
"FormattedValue", typeof(string), typeof(SpinnerControl),
new PropertyMetadata(DefaultValue.ToString()));
private static readonly DependencyProperty FormattedValueProperty =
FormattedValuePropertyKey.DependencyProperty;
public string FormattedValue
{
get
{
return (string)GetValue(FormattedValueProperty);
}
}
The consumers of this control can therefore access the property FormattedValue
as a read-only string, and Value
as a read-write numeric value.
The Underlying Value
As we are going to be repeatedly adding a small Change
many times to the number, there is the potential for rounding errors to occur within the control,
so we choose to use a decimal
to represent the underlying control value. Using Round
and NumberFormatInfo
, we can accurately
display the value in the control.
It is worth noting that the decimal
representation is rather inefficient compared to a double
except that this is in a GUI control,
and not some tightly bound loop within computation code: so any inefficiency is barely noticeable.
Since this is the value that we expose to the consumer of this control, it also makes sense to ensure that it binds two ways by default such as the Value
property on a Slider
. We achieve this by registering the ValueProperty
using FrameworkPropertyMetadata
with the appropriate enumeration option:
private static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal), typeof(SpinnerControl),
new FrameworkPropertyMetadata(DefaultValue,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
));
As you can see, we also add the hooks OnValueChanged
and CoerceValue
. The OnValueChanged
method is important because it allows
an opportunity to update the FormattedValue
we described previously. CoerceValue
is always called before the value of the property is set: this gives
you the opportunity to update the property with a value of your choosing: in our case we want to ensure that the range of the value is between the supplied minimum and maximum,
and also to ensure that the value is correctly rounded to the number of decimal places that we specified:
private static object CoerceValue(DependencyObject obj, object value)
{
decimal newValue = (decimal)value;
SpinnerControl control = (SpinnerControl)obj;
newValue = Math.Max(control.MinimumValue,
Math.Min(control.MaximumValue, newValue));
newValue = Decimal.Round(newValue, control.DecimalPlaces);
return newValue;
}
Commands
In order for the user to interact with the control, we have to provide two commands that we can bind to, these are called IncreaseCommand
and DecreaseCommand
. These are simply defined in the standard boilerplate way for custom controls:
public static RoutedCommand IncreaseCommand { get; set; }
public static void OnIncreaseCommand(Object sender, ExecutedRoutedEventArgs e)
{
SpinnerControl control = sender as SpinnerControl;
if (control != null)
{
control.OnIncrease();
}
}
protected void OnIncrease()
{
Value += Change;
}
public static RoutedCommand DecreaseCommand { get; set; }
public static void OnDecreaseCommand(Object sender, ExecutedRoutedEventArgs e)
{
SpinnerControl control = sender as SpinnerControl;
if (control != null)
{
control.OnDecrease();
}
}
protected void OnDecrease()
{
Value -= Change;
}
private static void InitializeCommands()
{
IncreaseCommand = new RoutedCommand("IncreaseCommand", typeof(SpinnerControl));
DecreaseCommand = new RoutedCommand("DecreaseCommand", typeof(SpinnerControl));
CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl),
new CommandBinding(IncreaseCommand, OnIncreaseCommand));
CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl),
new CommandBinding(DecreaseCommand, OnDecreaseCommand));
CommandManager.RegisterClassInputBinding(typeof(SpinnerControl),
new InputBinding(IncreaseCommand, new KeyGesture(Key.Up)));
CommandManager.RegisterClassInputBinding(typeof(SpinnerControl),
new InputBinding(DecreaseCommand, new KeyGesture(Key.Down)));
}
and we update our static constructor:
static SpinnerControl()
{
InitializeCommands();
DefaultStyleKeyProperty.OverrideMetadata(typeof(SpinnerControl),
new FrameworkPropertyMetadata(typeof(SpinnerControl)));
}
so that we register the commands with the control. Whilst the OnDecreaseCommand
and OnIncreaseCommand
methods are static,
their method prototype/declaration is such that it receives a reference of the object (i.e., SpinnerControl
) that generated the event,
hence we can cast the object back to a SpinnerControl
and that call increases/decreases on the control that initiated the event.
Events
We should also add a ValueChanged
event to the control. This is achieved as follows:
private static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent("ValueChanged", RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<decimal>), typeof(SpinnerControl));
public event RoutedPropertyChangedEventHandler<decimal> ValueChanged
{
add { AddHandler(ValueChangedEvent, value); }
remove { RemoveHandler(ValueChangedEvent, value); }
}
and then call RaiseEvent
with RoutedPropertyChangedEventArgs
as an argument.
The SpinnerControl Layout or Generic Theme
As you saw above, we have:
which is a single row grid, with two columns:
- the first column contains a standard
TextBox
control
- the second column contains a further grid, with one column and two rows, containing two buttons
Firstly, I am a fan of re-use (and not-invented-here...), so the XAML in the attached zip file is arranged into a few files such that I can re-use the brushes
used to paint the up and down arrow in other controls should I require them. This is achieved by a successive use of MergedDictionaries
:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Btl.Controls">
<ResourceDictionary.MergedDictionaries>
-->
<ResourceDictionary Source="/Btl.Controls.MyControls;component/Themes/SpinnerControl.Generic.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
It is worth familiarizing yourself with the Source
notation for XAML if you have never seen it before.
The layout of the control came from a few considerations. Initially, I thought that it would perhaps be possible to adopt a scrollbar for the up/down
arrows as they are visually close to what we require and I hoped it would not require much editing in XAML to achieve a standard 'spinner'.
Just a quick glance at Google results for scrollbars in WPF indicates that it is less than trivial.
So the obvious choice is that if we have two commands, one for up and one for down, and we are clicking, then we want to just use two buttons arranged in the way shown above.
In our requirement list at the beginning of the article, we see that we want a button that repeats the up/down command when we hold the mouse button down.
If you look at the inheritance hierarchy for the ButtonBase
class, you will see that there is a RepeatButton
that does the job for us.
The XAML (elided for brevity, please see the zip file for the full version) looks something like this:
<ResourceDictionary xmlns:my="clr-namespace:Btl.Controls">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Btl.Controls.MyControls;component/Resources/AllBrushes.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style TargetType="{x:Type my:SpinnerControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type my:SpinnerControl}">
<Grid Margin="3">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Grid.Row="0" Grid.Column="0"
Text="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=FormattedValue, Mode=OneWay}" IsReadOnly="True" />
<Grid Grid.Column="1" >
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<RepeatButton Grid.Row="0" Grid.Column="1"
Command="{x:Static my:SpinnerControl.IncreaseCommand}">
<RepeatButton.Content>
<Rectangle Fill="{StaticResource brush.scroll.up}" />
</RepeatButton.Content>
</RepeatButton>
<RepeatButton Grid.Row="1" Grid.Column="1"
Command="{x:Static my:SpinnerControl.DecreaseCommand}">
<RepeatButton.Content>
<Rectangle Fill="{StaticResource brush.scroll.down}" />
</RepeatButton.Content>
</RepeatButton>
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
The XAML performs the following tasks:
- Defines the generic template style for the
SpinnerControl
.
- Binds the
FormattedValue
to the TextBox
Content
. Note that the binding is OneWay
, i.e., the getter we mentioned previously.
- Binds the up and down commands to the up and down buttons.
- Uses our two XAML brushes:
brush.scroll.up
and brush.scroll.down
, to paint two rectangles, that are the Content
of the two buttons.
The XAML only uses three properties of the SpinnerControl
: FormattedValue
, IncreaseCommand
, and DecreaseCommand
.
I think this is a simple XAML example that probably does not need further explanation.
I am also a big fan of Operating System default themes: hence the default theme for this is, I hope, something that is subtle and looks like it belongs
on a standard desktop rather than some garish high contrast applications that you see littered around the web.
A Custom Theme
To conclude this article, you can apply a custom theme to the control. Having said that, I don't like garish colours, I will use something bright:
How do we achieve this? In our MainWindow
, we need to apply the custom theme to the SpinnerControl
:
<btl:SpinnerControl Name="spinnerControl2"
Style="{StaticResource CustomSpinnerControlStyle}" />
And we clearly have to create the CustomSpinnerControlStyle
(again the XAML is elided as it is rather dull):
<Window.Resources>
<Style x:Key="CustomSpinnerControlStyle" TargetType="{x:Type btl:SpinnerControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type btl:SpinnerControl}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Grid.Column="0"
Background="LightGreen"
BorderBrush="PaleGreen"
IsReadOnly="True"
Text="{Binding FormattedValue,
Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}" />
<Grid Grid.Column="1"
<DockPanel>
<RepeatButton Background="LightGreen" BorderBrush="PaleGreen"
Content="+" BorderThickness="1" FontSize="12"
Command="{x:Static btl:SpinnerControl.IncreaseCommand}"
DockPanel.Dock="Right" />
<RepeatButton Background="LightGreen" BorderBrush="PaleGreen"
Content="-" BorderThickness="1" FontSize="12"
Command="{x:Static btl:SpinnerControl.DecreaseCommand}"
DockPanel.Dock="Left" />
</DockPanel>
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
Note how we change the layout of the up/down buttons, and change their contents and colour. The control is then finally hosted in a main window:
Concluding Notes
It is probably worth mentioning to the reader again as I said at the start, that this control started off as a UserControl
similar to the one that
I created for selecting a TimeSpan
, and then I retro-fitted the project to become a custom Control
. Whilst writing this article,
and re-reading it and checking what was written, I made further changes to the control and revised its layout, naming conventions, and so on.
I will mention that this control was influenced by the NumericUpDown
control
in Kevin Moore's/Pixel Lab's Bag o' Tricks and I learnt a huge amount by studying it.
Of course, as a Spinner Control is, by its very nature, one of the simplest controls and by adopting standard .NET naming conventions, any similarity is going to be inevitable,
and any mistakes in the code are mine.
Looking for the correct methods and classes for the control has been an exercise in walking up and down inheritance trees in the MSDN documentation, which I would recommend
to casual WPF users as you will learn about other functionality in the framework that has some bearing on the project you are working on.
If you liked this article, please remember to vote on it and/or leave a comment below. Thanks!
References
As usual, a list of references:
History
- 16/01/2012: 1.00 - First revision.
- 17/01/2012: 1.01 - Added the
ValueChanged
event, and renamed properties.
- 19/01/2012: 1.02 - Fixed potential databinding under/over run, and bound left/right arrow keys.