Take It for a Spin

Create a Custom Spinner Control

CoverStory

LANGUAGES: C# | XAML

TECHNOLOGIES: WPF

 

Take It for a Spin

Create a Custom Spinner Control

 

By Matthew Hess

 

In Dial It Up a Notch, we took a look at using WPF ControlTemplates to turn an existing control into a seemingly brand new control. This technique works nicely when you can find an existing control that encapsulates the behavior you want. In that case, we took the humble ListBox and morphed it into a three-way switch.

 

But what are you supposed to do when you can t find an existing control that has exactly the behavior you want? Go back to Windows Forms? Not so fast! WPF provides the ability to craft controls that have custom interaction logic, as well as custom presentation. We re going to build one such control in this article: a numeric Spinner. In the process, we ll take a look at some important and powerful WPF technologies, such as WPF data binding.

 

So what is a numeric Spinner control, anyway? It s basically a text edit with two buttons that lets you set a numeric value either by typing in the text box or clicking the buttons to increment or decrement the value. In Windows Forms and the ASP.NET AJAX toolkit, this is called a numeric UpDown. Those of you who worked in Delphi will remember this as the TSpinEdit. For nostalgia s sake, I ve chosen to name my custom control in the Delphi tradition, hence, Spinner.

 

Considering how popular this kind of control is, one might reasonably ask why WPF doesn t come with its own Spinner control? This is a good question and I would not be surprised to see this control show up in a future version of WPF. But for now, the framework doesn t have it, and we need it so let s make it. When you see how easy this is, you won t worry about waiting for that next WPF update.

 

Step 1: Inheriting from RangeBase

Because WPF ships with an abstract class named RangeBase, which defines much of the logic we need, we don t have to start completely from scratch to make our custom Spinner. WPF has several controls that inherit from RangeBase, including Slider, ProgressBar, and, interestingly, ScrollBar. What these controls have in common is that they display a numeric value within a range. Their key properties are Value, Maximum, and Minimum. To make our custom numeric UpDown, we ll descend from RangeBase, give it a ControlTemplate to provide the visuals, then handle some events to nail down the interaction logic.

 

To start with, you need to begin a .NET 3.0 project. I m going to use a Windows Application with C# as the procedural language, though you could just as easily compile this into an XML Browser Application (XBAP) and use VB.NET. Once you have your project started, you ll need to add a pair of .XAML and .cs files for the custom control. You can make these files by hand, but starting out as if you were going to make a new UserControl is convenient: click Add New Item | User Control and name the new item Spinner. This should generate the linked Spinner.XAML and Spinner.XAML.cs files for you to use. Then, working in the XAML, change the UserControl tags to RangeBase tags and remove the default Grid tags, which we won t be using, and, in fact, cannot use (as you ll soon see). Spinner.XAML should now look something like this:

 

 xmlns="http://schemas.microsoft.com/winfx/2006/

        xaml/presentation"

 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

 

Next, go into the Spinner.XAML.cs code-behind file and do two things. First, add a line for the namespace that includes RangeBase:

 

using System.Windows.Controls.Primitives;

 

and change the type of your new class to RangeBase:

 

public partial class Spinner : RangeBase

{

 // etc...

}

 

Now your class should compile. We re ready to start designing the control.

 

Step 2: Designing the Visuals

At this point, you might be tempted to start adding some visual content directly to the RangeBase. But you can t. Try it. Add a Grid or a StackPanel and try to compile your code. It fails because RangeBase is not a ContentControl. It has no place for you to add visual content directly. However, RangeBase does have a Template property. So we re going to specify the Template for our control as a resource in the XAML, then wire it up in the code-behind. This is a key conceptual point of this article. Controls like RangeBase and its new descendant Spinner have no inherent presentation. They get their visuals from their Template. And in this case, since we re making a new control, we need to supply the default Template.

 

A numeric UpDown is basically a text box with two buttons next to it for incrementing and decrementing the value. For the Template of our control, then, we re going to use a grid as the main layout device. Specifically, we ll use a two-by-two grid where the left two cells hold the text box and the right two cells hold two buttons. For the buttons we ll use RepeatButtons so the user can hold them down and have their value keep changing. Figure 1 shows the start of our Template.

 

 

  TargetType="{x:Type RangeBase}">

   

     

       

       

     

     

       

       

     

     

       Name="txtBox"

       Grid.Row="0"

       Grid.Column="0"

       Grid.RowSpan="2"/>

     

       Name="upBtn"

       Grid.Row="0"

       Grid.Column="1"

       Content="+"/>

     

       Name="downBtn"

       Grid.Row="1"

       Grid.Column="1"

       Content="-"/>

   

 

Figure 1: The ControlTemplate.

 

Notice that the first column is set to Width= * rather than Auto. Why is this? This allows the consumer of the control to specify a width for the control and have the text box portion fill the available space. For example, if the screen designer knew that the spinner had a range from 1 to 10, they might set a fixed width of 40 so the text box portion would be sized appropriately to the expected content.

 

Now we need to wire up the control so it uses this Template. We can do that with a single line of code in the constructor of the class:

 

public Spinner()

{

 InitializeComponent();

 this.Template = (ControlTemplate)this.Resources[

                  "spinnerTemplate"];

}

 

At this point, you could place an instance of your Spinner on a Window or a Page and have it render. It wouldn t do anything yet, but it would look pretty! Now we need to tackle the interaction logic.

 

Step 3: Binding the TextBox to the Value

The fundamental property of a RangeBase control is Value. The Value of our Spinner is what we want the TextBox to display, and also what we want the user to be able to edit through the TextBox. Your first thought may be that it s going to take a ton of procedural code to wire up this interaction. It s not. We can use WPF data binding to do the trick; here s the data binding expression we ll use:

 

Text="{Binding RelativeSource=

 {RelativeSource AncestorType={x:Type RangeBase}},

 Path=Value, Mode=TwoWay}"/>

 

Now this is a doozy of an expression. To explain exactly what it means, let s start with something a bit simpler. You may have seen examples of using WPF data binding to bind a text box to a property of a business object. For example, let s say we wanted the text box to display the age of a person object. You might use an expression like this:

 

Text="{Binding ElementName=person, Path=Age, Mode=TwoWay}"

 

The keyword Binding is an XAML markup extension. What this markup means is: create a binding object where the source of the binding is the object named person , the property of the source is called Age , and the mode is TwoWay so that changes to the text box update the object and vice versa. Then I attach this binding to the Text property of my TextBox. Sometimes it s easier to understand this sort of XAML markup if you translate it into its corresponding procedural representation. Here s how to create this same binding in C#:

 

Binding bnd = new Binding();

bnd.Source = person;

bnd.Path = new PropertyPath(person.AgeProperty);

txt.SetBinding(TextBox.Text, bnd);

 

Now let s tackle the actual binding expression we re using. In the previous simple binding expression, we used the ElementName property to specify the source of the binding. But here, we re using RelativeSource instead:

 

RelativeSource AncestorType={x:Type RangeBase}}

 

This means that instead of specifying a source explicitly by name, we re going to specify the source by its relative relationship to the target in the visual tree. In this case, we want to bind to the first ancestor of type RangeBase in the visual tree. If you think about it, you ll see that this turns out to be the Spinner control itself! So with this one line of XAML, we ve made it so that changes to Value will be reflected in TextBox.Text and changes to TextBox.Text will be reflected in Value.

 

Step 4: Wiring the Buttons

Our next step involves supplying some event handlers for the buttons to actually increment and decrement the Value of our control. To begin, modify the XAML of our ControlTemplate to specify two Click event handlers on the buttons:

 

 Name="upBtn"

 ...

 Click="UpBtnClick"/>

 Name="downBtn"

 ...

 Click="DownBtnClick"/>

 

Then, in the code-behind file, create the event handlers:

 

protected void UpBtnClick(object sender, RoutedEventArgs e)

{

 Value += SmallChange;

}

protected void DownBtnClick(object sender, RoutedEventArgs e)

{

 Value -= SmallChange;

}

 

This code introduces us to a new property of RangeBase: SmallChange. This represents the small value for an increment or decrement operation. This is often 1, but if you want a Spinner that only stores, say, increments of 10, you could set SmallChange=10. There is a corresponding property named LargeChange that controls the large jumps. I wish there was an easy way to determine that the RepeatButton Click event was a repeat click rather than a single click. That way we could jump the Value up or down by LargeChange. But without quite a bit of code involving a timer, I m not sure how to do this (if you know, please tell me).

 

At this point, it s probably a good idea to place our new control on a screen of some kind so we can test it and see how it s behaving. Go into the XAML for Window1 (or Page1) and include a Spinner to try it. The markup will look something like Figure 2.

 

 xmlns="http://schemas.microsoft.com/winfx/2006/

        xaml/presentation"

 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

 xmlns:src="clr-namespace:SpinnerTest"

 Title="Spinner Test" Height="300" Width="300" >

 

   

     Minimum="0"

     Maximum="100"

     SmallChange="1"

     LargeChange="5"

     Width="50"

     Margin="10"/>

 

Figure 2: Testing the Spinner in a window.

 

Notice that we added a reference to the CLR namespace for our project. Also note that when you run this, the button styling will depend on your Windows Theme. Under Vista you get Aero buttons, as shown in Figure 3.

 


Figure 3: The Spinner in a test window.

 

Now that we ve got a working control, let s examine a few interesting cases. What happens when you type a value in the text box that is out of range? What happens when you type a non-numeric value in the text box, or clear it entirely? What happens when you type a decimal value in the text box, then click the increment button? You ll see that the text box will accept anything you type. And if you re running in a debugger, you ll see that the data binding is silently throwing exceptions whenever the value you type doesn t convert to a decimal or is out of range. Obviously, we can t accept a control where the text box s text can get out of sync with Value. We ll want to enforce some constraints and close these loopholes.

 

Step 5: Locking Down the TextBox

We ll begin by adding markup for two event handlers on the TextBox, one for the KeyDown event and one for the LostFocus event. The markup for your TextBox should now look something like this:

 

 Name="txtBox"

 ...

 KeyDown="OnKeyDown"

 LostFocus="OnLostFocus"/>

 

Next, for your code to compile and run, you ll need to stub out some event handlers:

 

protected void OnKeyDown(object sender, KeyEventArgs e)

{

}

protected void OnLostFocus(object sender, RoutedEventArgs e)

{

}

 

We re handling KeyDown so we can suppress unwanted keystrokes (like alphabetical characters). If the pressed Key is one we want to suppress, we ll set the Handled property of KeyEventArgs to true to prevent further routing of the event. The first thing I want to do is allow certain keystrokes through in all cases. We can do this by defining a list of Keys we will always allow:

 

private static readonly Key[] AlwaysAllowedKeys =

 { Key.Tab, Key.Back, Key.Delete };

 

Then in our event handler, we can simply return out of the routine when we encounter one of these Keys:

 

if (((Ilist)AlwaysAllowedKeys).Contains(e.Key)) return;

 

There may be other Keys you want to always allow, such as Key.Enter and Key.Return, depending on other behaviors you may want to support. The next thing we want to do is allow numeric keystrokes. We can take the same approach. Here s the list:

 

private static readonly Key[] NumericKeys = { Key.D0,

   Key.D1, Key.D2, Key.D3, Key.D4,  Key.D5, Key.D6, Key.D7,

   Key.D8, Key.D9, Key.NumPad0, Key.NumPad1, Key.NumPad2,

   Key.NumPad3, Key.NumPad4, Key.NumPad5, Key.NumPad6,

   Key.NumPad7, Key.NumPad8, Key.NumPad9, Key.OemMinus,

   Key.Subtract };

 

And here s the code to allow these keys through:

 

if (((Ilist)NumericKeys).Contains(e.Key)) return;

 

There are two things of note here. First, notice that we need to explicitly include the keys for both the standard numbers and the keypad. Second, notice that I ve included a few keys having to do with negative numbers. This includes the minus key on the regular keyboard and the subtract key on the number pad. This allows the user to type potentially valid values such as -5. You may choose to handle negatives differently. For example, you could only allow the minus keys if Minimum is less than zero.

 

Our KeyPress event handler is almost done. The last thing we need to do is very simply suppress all other keyboard input:

 

e.Handled = true;

 

Now our TextBox should no longer accept typed inputs we don t want. Next, on to the LostFocus event handler.

 

First, let s discuss why we need the LostFocus event handler in the first place. Doesn t our KeyDown ensure valid input? Well, almost. The user can still do things like Delete to clear the field or Right-Click | Paste to paste an illegal value. In addition, the user can type in a value that is out of range or invalid for other reasons. So, despite the screening that our KeyDown handler gives, we still need to validate the text of the TextBox and in some cases, reset it.

 

You might wonder why we re validating in LostFocus? The answer has to do with data binding. When you create a data binding, one of the properties you can set is UpdateSourceTrigger. This controls when the value is updated from the target to the source. The default behavior when the target is the Text property of a TextBox is UpdateSourceTrigger.LostFocus, so this is the point at which we want to intercept the value (it lets us check it just before the value is sent to our bound range control).

 

The first situation we want to catch is a blank TextBox. For that, we can simply substitute the minimum value:

 

if (string.IsNullOrEmpty(txt.Text))

{

 txt.Text = sld.Minimum.ToString();

 return;

}

 

For the next series of validations, we re going to want to inspect the actual numeric value. We ll want to get this value in a try-catch so it doesn t blow up if the user has managed to get a non-numeric string in the TextBox (for example, by typing -5-1 or something like that):

 

int val;

try

{

 val = int.Parse(txt.Text);

}

catch

{

 txt.Text = sld.Minimum.ToString();

 return;

}

 

Now that we have the integer value that the user typed, we can check it against our allowed range:

 

if (val < this.Minimum)

{

 txt.Text = this.Minimum.ToString();

 return;

}

if (val > this.Maximum)

{

 txt.Text = this.Maximum.ToString();

 return;

}

The last situation we need to check for is the case where the typed value is not an increment of the SmallChange property. This case is a bit interesting. Imagine that you want your Spinner to only allow the selection of multiples of five. In that case, you d set SmallChange=5 and LargeChange=5 (or some multiple of 5). Then, if the user typed in 12 or some other number not divisible by 5, you d need to catch and correct it:

 

int remainder = (int)(val % this.SmallChange);

if (remainder != 0)

{

 int correctedVal = (int)this.Minimum;

 if ((val - remainder) >= this.Minimum)

   correctedVal = val - remainder;

 else if ((val + remainder) <= this.Maximum)

   correctedVal = val + remainder;

 txt.Text = correctedVal.ToString();

 Value = correctedVal;

}

 

The only thing unusual about this block is that it sets both TextBox.Text and Value. Why? In the other cases, the Value property would not be set because RangeBase was checking for conversion and range errors. But this remainder error doesn t cause RangeBase to throw an exception. RangeBase thinks that 12 is a perfectly fine value, so we need to set both Value and Text to keep them in sync.

 

At this point, our interaction logic is complete and our control should be completely functional take it for a spin and test it out!

 

Conclusion

Looking back at this control, there are a few things that you might think to improve. For example, I personally don t like the Spinner s height. This is because the RepeatButtons are enforcing a default margin around their content, so the buttons get too tall, thereby stretching the control vertically. You can fix this by including a ControlTemplate for the RepeatButtons in the Spinner.XAML. Figure 4 shows how the Spinner might look with buttons templated this way.

 


Figure 4: The Spinner with templated buttons.

 

Another feature that could be improved is the handling of the arrow keys. A similar range control, Slider, allows the user to increment and decrement Value using the arrow keys. Right now, these keystrokes are swallowed. Similarly, if the user wanted to Shift-Arrow to select and change text, they couldn t.

 

I ve addressed both of these issues in the downloadable sample project accompanying this article. And I m sure there are other things we could do to improve the control. However, we ve built quite a lot of functionality with only 50 lines of XAML and a hundred of C#. I hope I ve got the gears of your imagination spinning. As I ve mentioned, WPF is a wide and deep topic. This Spinner control merely scratches the surface of what s possible.

 

Files referenced in this article are available for download.

 

Matthew Hess is a software developer in Albuquerque, NM. He grew up on a diet of Delphi, but has since switched to almost pure .NET. You can reach Matthew with your questions, corrections, suggestions, and humor at mailto:[email protected].

 

 

 

Hide comments

Comments

  • Allowed HTML tags: <em> <strong> <blockquote> <br> <p>

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
Publish