Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A WPF Spinner Custom Control

0.00/5 (No votes)
16 Jan 2012 3  
A WPF custom spinner control.

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:

spinner with generic theme

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:

Grid

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:

Grid

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:

/// <summary>
/// Dependency property identifier for the formatted value with limited 
/// write access to the underlying read-only dependency property:  we
/// can only use SetValue on this, not on the property itself.
/// </summary>
private static readonly DependencyPropertyKey FormattedValuePropertyKey = 
    DependencyProperty.RegisterAttachedReadOnly(
    "FormattedValue", typeof(string), typeof(SpinnerControl),
    new PropertyMetadata(DefaultValue.ToString()));

/// <summary>
/// The dependency property for the formatted value.
/// </summary>
private static readonly DependencyProperty FormattedValueProperty = 
               FormattedValuePropertyKey.DependencyProperty;

/// <summary>
/// Returns the formatted version of the value, with the specified
/// number of DecimalPlaces.
/// </summary>
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;

    //  ensure that the value stays within the bounds of the minimum and
    //  maximum values that we define.
    newValue = Math.Max(control.MinimumValue, 
                        Math.Min(control.MaximumValue, newValue));
    //  then ensure the number of decimal places is correct.
    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;
}

/// <summary>
/// Since we're using RoutedCommands for the increase/decrease commands, we need to
/// register them with the command manager so we can tie the events
/// to callbacks in the control.
/// </summary>
private static void InitializeCommands()
{
    //  create static instances
    IncreaseCommand = new RoutedCommand("IncreaseCommand", typeof(SpinnerControl));
    DecreaseCommand = new RoutedCommand("DecreaseCommand", typeof(SpinnerControl));

    //  register the command bindings - if the buttons get clicked, call these methods.
    CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl), 
                   new CommandBinding(IncreaseCommand, OnIncreaseCommand));
    CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl), 
                   new CommandBinding(DecreaseCommand, OnDecreaseCommand));

    //  lastly bind some inputs:  i.e. if the user presses up/down arrow 
    //  keys, call the appropriate commands.
    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:

/// <summary>
/// The ValueChangedEvent, raised if  the value changes.
/// </summary>
private static readonly RoutedEvent ValueChangedEvent = 
    EventManager.RegisterRoutedEvent("ValueChanged", RoutingStrategy.Bubble,
    typeof(RoutedPropertyChangedEventHandler<decimal>), typeof(SpinnerControl));

/// <summary>
/// Occurs when the Value property changes.
/// </summary>
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:

Grid

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>
        <!--  We have to use this 'component' relative notation to load  the 
        other dictionaries at runtime  -->
        <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:

spinner with custom theme

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:

spinner

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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here