Take It for a Spin

Create a Custom Spinner Control

Matthew Hess

October 30, 2009

15 Min Read
ITPro Today logo in a gray background | ITPro Today

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 ControlTemplatesto turn an existing control into a seemingly brand new control. This techniqueworks nicely when you can find an existing control that encapsulates thebehavior you want. In that case, we took the humble ListBox and morphed it intoa three-way switch.

 

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

 

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

 

Considering how popular this kind of control is, one mightreasonably ask why WPF doesn t come with its own Spinner control? This is agood question and I would not be surprised to see this control show up in afuture version of WPF. But for now, the framework doesn t have it, and we needit so let s make it. When you see how easy this is, you won t worry aboutwaiting 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 fromscratch to make our custom Spinner. WPF has several controls that inherit fromRangeBase, including Slider, ProgressBar, and, interestingly, ScrollBar. What thesecontrols have in common is that they display a numeric value within a range.Their key properties are Value, Maximum, and Minimum. To make our customnumeric UpDown, we ll descend from RangeBase, give it a ControlTemplate toprovide the visuals, then handle some events to nail down the interactionlogic.

 

To start with, you need to begin a .NET 3.0 project. I mgoing to use a Windows Application with C# as the procedural language, thoughyou 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 pairof .XAML and .cs files for the custom control. You can make these files byhand, but starting out as if you were going to make a new UserControl isconvenient: 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 youto use. Then, working in the XAML, change the UserControl tags to RangeBasetags 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 likethis:

 

 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 dotwo 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 startdesigning the control.

 

Step 2: Designing the Visuals

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

 

A numeric UpDown is basically a text box with two buttonsnext to it for incrementing and decrementing the value. For the Template of ourcontrol, 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 thetext box and the right two cells hold two buttons. For the buttons we ll useRepeatButtons so the user can hold them down and have their value keepchanging. Figure 1 shows the start of our Template.

 

    TargetType="{x:TypeRangeBase}">                                                                         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= * ratherthan Auto. Why is this? This allows the consumer of the control to specify awidth 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 to10, they might set a fixed width of 40 so the text box portion would be sizedappropriately to the expected content.   Now we need to wire up the control so it uses thisTemplate. We can do that with a single line of code in the constructor of theclass:  public Spinner(){ InitializeComponent(); this.Template =(ControlTemplate)this.Resources[                  "spinnerTemplate"]; }   At this point, you could place an instance of your Spinneron a Window or a Page and have it render. It wouldn t do anything yet, but itwould 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 whatwe want the user to be able to edit through the TextBox. Your first thought maybe that it s going to take a ton of procedural code to wire up thisinteraction. It s not. We can use WPF data binding to do the trick; here s thedata binding expression we ll use:  Text="{Binding RelativeSource= {RelativeSourceAncestorType={x:Type RangeBase}}, Path=Value,Mode=TwoWay}"/>   Now this is a doozy of an expression. To explain exactlywhat it means, let s start with something a bit simpler. You may have seenexamples of using WPF data binding to bind a text box to a property of abusiness object. For example, let s say we wanted the text box to display theage 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. Whatthis markup means is: create a binding object where the source of the bindingis the object named person , the property of the source is called Age , andthe mode is TwoWay so that changes to the text box update the object and viceversa. Then I attach this binding to the Text property of my TextBox. Sometimesit s easier to understand this sort of XAML markup if you translate it into itscorresponding procedural representation. Here s how to create this same bindingin 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 reusing. In the previous simple binding expression, we used the ElementNameproperty to specify the source of the binding. But here, we re usingRelativeSource instead:  RelativeSource AncestorType={x:Type RangeBase}}   This means that instead of specifying a source explicitlyby name, we re going to specify the source by its relative relationship to thetarget in the visual tree. In this case, we want to bind to the first ancestorof type RangeBase in the visual tree. If you think about it, you ll see thatthis 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 andchanges to TextBox.Text will be reflected in Value.  Step 4: Wiring the Buttons Our next step involves supplying some event handlers forthe buttons to actually increment and decrement the Value of our control. Tobegin, modify the XAML of our ControlTemplate to specify two Click eventhandlers 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 decrementoperation. 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 correspondingproperty named LargeChange that controls the large jumps. I wish there was aneasy way to determine that the RepeatButton Click event was a repeat clickrather than a single click. That way we could jump the Value up or down byLargeChange. But without quite a bit of code involving a timer, I m not surehow to do this (if you know, please tell me).   At this point, it s probably a good idea to place our newcontrol 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. Themarkup 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="SpinnerTest" 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 forour project. Also note that when you run this, the button styling will dependon your Windows Theme. Under Vista you get Aero buttons, as shown in Figure 3.  
Figure 3: The Spinner in a testwindow.   Now that we ve got a working control, let s examine a fewinteresting cases. What happens when you type a value in the text box that isout 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 acceptanything you type. And if you re running in a debugger, you ll see that thedata binding is silently throwing exceptions whenever the value you type doesn tconvert to a decimal or is out of range. Obviously, we can t accept a controlwhere the text box s text can get out of sync with Value. We ll want to enforcesome constraints and close these loopholes.  Step 5: Locking Down the TextBox We ll begin by adding markup for two event handlers on theTextBox, one for the KeyDown event and one for the LostFocus event. The markupfor 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 tostub 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 furtherrouting of the event. The first thing I want to do is allow certain keystrokesthrough in all cases. We can do this by defining a list of Keys we will alwaysallow:  private static readonly Key[] AlwaysAllowedKeys = { Key.Tab, Key.Back,Key.Delete };   Then in our event handler, we can simply return out of theroutine 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 asKey.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 sameapproach. 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 weneed to explicitly include the keys for both the standard numbers and thekeypad. Second, notice that I ve included a few keys having to do with negativenumbers. This includes the minus key on the regular keyboard and the subtractkey on the number pad. This allows the user to type potentially valid values suchas -5. You may choose to handle negatives differently. For example, you couldonly allow the minus keys if Minimum is less than zero.   Our KeyPress event handler is almost done. The last thingwe need to do is very simply suppress all other keyboard input:  e.Handled = true;   Now our TextBox should no longer accept typed inputs wedon t want. Next, on to the LostFocus event handler.   First, let s discuss why we need the LostFocus eventhandler 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 orRight-Click | Paste to paste an illegal value. In addition, the user can typein a value that is out of range or invalid for other reasons. So, despite thescreening that our KeyDown handler gives, we still need to validate the text ofthe TextBox and in some cases, reset it.   You might wonder why we re validating in LostFocus? Theanswer has to do with data binding. When you create a data binding, one of theproperties you can set is UpdateSourceTrigger. This controls when the value isupdated from the target to the source. The default behavior when the target isthe Text property of a TextBox is UpdateSourceTrigger.LostFocus, so this is thepoint at which we want to intercept the value (it lets us check it just beforethe 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 toinspect the actual numeric value. We ll want to get this value in a try-catchso it doesn t blow up if the user has managed to get a non-numeric string inthe 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, wecan 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 wherethe typed value is not an increment of the SmallChange property. This case isa bit interesting. Imagine that you want your Spinner to only allow theselection of multiples of five. In that case, you d set SmallChange=5 andLargeChange=5 (or some multiple of 5). Then, if the user typed in 12 or someother 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 setsboth TextBox.Text and Value. Why? In the other cases, the Value property wouldnot be set because RangeBase was checking for conversion and range errors. Butthis remainder error doesn t cause RangeBase to throw an exception. RangeBasethinks that 12 is a perfectly fine value, so we need to set both Value and Textto keep them in sync.   At this point, our interaction logic is complete and ourcontrol should be completely functional take it for a spin and test it out!  Conclusion Looking back at this control, there are a few things thatyou might think to improve. For example, I personally don t like the Spinner sheight. This is because the RepeatButtons are enforcing a default margin aroundtheir content, so the buttons get too tall, thereby stretching the controlvertically. You can fix this by including a ControlTemplate for theRepeatButtons in the Spinner.XAML. Figure 4 shows how the Spinner might lookwith buttons templated this way.  
Figure 4: The Spinner with templated buttons.   Another feature that could be improved is the handling ofthe arrow keys. A similar range control, Slider, allows the user to incrementand decrement Value using the arrow keys. Right now, these keystrokes areswallowed. Similarly, if the user wanted to Shift-Arrow to select and changetext, they couldn t.   I ve addressed both of these issues in the downloadablesample project accompanying this article. And I m sure there are other thingswe could do to improve the control. However, we ve built quite a lot offunctionality with only 50 lines of XAML and a hundred of C#. I hope I ve gotthe gears of your imagination spinning. As I ve mentioned, WPF is a wide anddeep topic. This Spinner control merely scratches the surface of what spossible.   Files referenced inthis article are available for download.  Matthew Hess is asoftware 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 yourquestions, corrections, suggestions, and humor at mailto:[email protected].      

Read more about:

Microsoft
Sign up for the ITPro Today newsletter
Stay on top of the IT universe with commentary, news analysis, how-to's, and tips delivered to your inbox daily.

You May Also Like