I’ve had a busy month on the road visiting clients (and catching up afterwards), so my blog has been pretty quite. But I’ve finally had some time to sit down and play with a couple of ideas I’ve had around using the path list box. I’ve been wanting to make a dial control for quite a while and when the path list box came along in Silverlight 4 it sparked a couple of ideas of how I might do it fairly easily.
Here is a sample application with a single Dial UserControl scaled to a couple of different sizes. At the moment, the only way to change the value of the dial is to click on one of the numbers or lights (one goes to eleven):
You can grab the source here, although at this stage it is still in the “experimental” phase (and I think I left a couple of unused test control templates in there) so play with it at your own risk. There is a bit of left over unused code from the process of trying different things out. I also don’t do much in the way of error or bounds checking so it’s probably quite easy to set the Value and MaxValue to values that cause Silverlight and/or Blend to die horribly. I am going to turn it into a fully skinnable custom control with all the right kinds of checks, but thought it worth putting a post up now and going over some of the challenges I faced with this particular control. It has been nearly a month after all.
Working with the PathListBox
I used the PathListBox control for the dial numbers and lights. If you haven’t used the Path ListBox before then here is a good series on it. The finished UserControl has a MaxValue property that lets you specify how many numbers should appear on the dial, but at the start I just had a the shapes for the dial and used a PathListBox with some fixed items in it.
The first challenge was to get the PathListBoxItem control template to show a mix of orientations. There are two kinds of orientations you can have with a PathListBox: None and OrientToPath. The effects of each of these settings can be seen in the image to the right. If I used Orientation = None the numbers end up being rotated. If I use OrientToPath the light ends up below the number.
What I needed was a way to have the numbers use Orientation = None, and the lights to use Orientation = OrientToPath.
The solution was to change the control template setting that Blend creates by default. The outermost grid in the control template has the following XAML when the template is created:
1: <Grid Background="{TemplateBinding Background}" RenderTransformOrigin="0.5,0.5">
2: <Grid.RenderTransform>
3: <TransformGroup>
4: <ScaleTransform/>
5: <SkewTransform/>
6: <RotateTransform Angle="{Binding OrientationAngle, RelativeSource={RelativeSource TemplatedParent}}"/>
7: <TranslateTransform/>
8: </TransformGroup>
9: </Grid.RenderTransform>
10:
11: ....
12:
13: </grid>
I changed this to use two child grids and moved the binding to OrientationAngle into one of the child grids:
1: <Grid Background="{TemplateBinding Background}" RenderTransformOrigin="0.5,0.5">
2: <grid>
3: <Grid.RenderTransform>
4: <TransformGroup>
5: <ScaleTransform/>
6: <SkewTransform/>
7: <RotateTransform Angle="{Binding OrientationAngle, RelativeSource={RelativeSource TemplatedParent}}"/>
8: <TranslateTransform/>
9: </TransformGroup>
10: </Grid.RenderTransform>
11:
12: .... items in this grid will be oriented to the path
13:
14: </grid>
15: <grid>
16:
17: .... items in this grid will not be oriented
18:
19: </grid>
20: </grid>
Now with the PathListBox.Orientation=OrientToPath, only the first grid (containing the light) will rotate, the second grid (containing the number) will be unaffected. Even though the light itself is round and isn’t visually affected by the Orientation, its placement is affected.
Creating the Dial Numbers
I added a Value and a MaxValue dependency property to the UserControl. The MaxValue determines how high the dial goes up to, and the Value determines the current value of the dial. The UserControl has it’s own internal list of items that it recreates whenever MaxValue changes. The PathListBox binds to this list for it’s ItemsSource, and the data template for the PathListBoxItem binds to values on the classes inside the list. This approach reflects a half solution for something that I may or may not do when converting it to a custom control, but the relevant part here is that the list items are driven by the MaxValue property on the UserControl.
Making the Knob
The dial knob presented a couple of challenges. The first was to create the ridged look around its edge. I played with a couple of ideas, but the simplest solution was to use the Star RegularPolygon, which is available under the “Shapes” category in Blend. The RegularPolygon has an InnerRadius property which I set to 98% so that the points are only on the edge, and it has a PointCount property to determine, unsurprisingly, the number of points around the edge. The challenge was that I wanted the dial to work at different sizes, but the PointCount that works for a large dial doesn’t work for a small dial.
I used the Width of the dial to drive the PointCount with a new property on the UserControl and a ValueConverter. I was originally going to just use a ValueConverter and bind to ActualWidth, but there is a known bug where the notification for the change in ActualWidth is not being fired so this always returns 0 to the converter unless the form is resized. The suggested work around is to attach to the SizeChanged event and force a re-layout, but I decided that was too much of a hack, so I added my own DialWidth dependency property and set that to the actual width in the SizeChanged event.
I wrote the ValueConverter to be a generic division converter that returned an integer number; the ConverterParameter can be set to the desired denominator. In the end I could probably have done away with the value converter and DialWidth property and just added my own PointCount property that was recalculated on SizeChanged – and I may yet do that when I convert this into a custom control.
The second challenge was to have the knob rotate to the correct value (including the ridges, center graphic, and position light), but keep the LinearGradient brush fills oriented the same so the light still appears to come from the top:
The knob is contained within two child grids (I used two to have the lights sit above the drop shadow of the dial). The topmost grid has it’s rotation translation template-bound to a new RotationAngle dependency property on the UserControl which is calculated whenever the Value property changes. The gradient brushes of the shapes that make up the knob have their rotation translation bound to the RotationAngle too, but they also use another simple ValueConverter I wrote to return the negative of the value passed to it. So the gradients are rotated anti-clockwise the same amount that the grid containing the dial is rotated clockwise.
The Next Step
As part of converting it to a custom control, I plan to add some better interaction support. Currently the only way to interact with it is by clicking on the lights or numbers to spin the dial to that position. I will add support for the mouse wheel, and (hopefully) support for clicking the knob and dragging it to the desired position (with snapping). I also plan to structure it so that it’s easy to skin and produce some nicely different styles of dials.
So grab the source if you want to have a look, but be warned – it’s a little messy in there, and the documentation is almost non-existent.
Hmm at first was was unsure about the Design, then I saw how fast I could set all the dials to say "2 dots".
ReplyDeleteIt was very fast. And then I can see the "current value" pretty fast. So the Design is different but effective and useful.
There's a bug... If you click on the number to which the dial is currently set, say 5, the 5 dot goes dark, but the dial stays pointed to the 5.
ReplyDeleteThanks for finding that! The light comes on as part of the Selected state of the path list box item, so I'm guessing that clicking the number when it's already selected is unselecting the item, putting it back into its base state. I'll fix that when I turn it into a custom control.
ReplyDeleteOk - I've fixed it now. Source updated too.
ReplyDelete