Introduction
It's hard to do circles in WPF. More specifically, when I looked at designing a weather app for Windows 8, I soon realized that I would need to make my own controls to get the look I wanted, and so RadialControls was born.
Background
RadialControls is two things: a set of primitives that simplify building more complex controls, and some examples to show how they can be used together. This article is about one of those examples: the ArcSlider control...
The time picker in the picture uses the ArcSlider control to show the hour and minute hands of a clock. I'll give an overview of the controls that make the slider and the time picker in the next section, and then focus on the slider.
Let There Be Light...
RadialControls has two basic components: a halo and a ring. There are other components, such as sliders and slices, which can be used in place of the ring component. Have another look at the time picker in the image...
The time picker is made up of 3 logical rings, which are arranged in bands inside a halo. Each ring has a thickness, so that the halo acts a bit like a StackPanel
for circles (any leftover space is given to the inner ring).
<control:Halo>
<control:HaloDisc Fill="DeepSkyBlue" control:Halo.Band="2"/>
<local:ArcSlider x:Name="Minutes" control:Halo.Band="2"/>
<control:HaloDisc Fill="RoyalBlue" control:Halo.Band="1"/>
<local:ArcSlider x:Name="Hours" control:Halo.Band="1"/>
<control:HaloDisc Fill="Black"/>
<TextBlock x:Name="Display" FontSize="30" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</control:Halo>
At this point, it makes sense to outline all the controls...
Halo
- Used to arrange rings in concentric circles or bands
HaloRing
- Arranges each of its children in a circle using transforms
HaloChain
- Like HaloRing
, but all the children are grouped together
HaloDisc
- Creates an expanding, filled circle useful for backgrounds
HaloArc
/Slice
- Draws a circular arc/circle over a specified angle
Slider
- A basic circular slider, which we'll explore in the next section
The time picker extract has some of these. You can also look at the RingLabel
example to see the HaloChain
in use. Keeping to the plan, I'll now focus on the controls which are used to make the ArcSlider.
The Slidey Slope...
The ArcSlider example uses a HaloArc
to replace the boring circle that comes with the basic Slider
control, as well as adding some animation to make it behave like a button. Ignoring the fancy stuff, the control looks like this.
<control:Slider>
<control:Slider.Template>
<ControlTemplate>
<control:HaloArc x:Name="Arc" Tension="0.5" Spread="20"
Angle="{Binding Angle, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
Offset="{Binding Offset, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"/>
</ControlTemplate>
</control:Slider.Template>
</control:Slider>
Increasing (or decreasing) the angle will move the slider around in a circle. The offset property determines the starting point on the circle, which is 0 degrees by default, i.e., the slider starts at the top of the circle.
The arc uses databinding to hook-in to the basic slider control, which in turn takes care of calculating what the new angle should be when the user slides the slider... relative to the offset. I'll cover the slider in more detail next.
I'll also cover the arc control later. For now, I'll mention the tension property, which is for alignment; for example, a tension of 0.5 means the arc is centred around its current angle, as in the picture. Now for some slider fun!
Around and Around...
It's always good to separate look from behaviour. Although ArcSlider overrides it completely, the slider control does have a default template, which is defined in the Generic theme file. But it's the behaviour I want to concentrate on...
protected override void OnApplyTemplate()
{
AddHandler(PointerPressedEvent, new PointerEventHandler(StealPointer), true);
AddHandler(PointerReleasedEvent, new PointerEventHandler(ReleasePointer), true);
AddHandler(PointerCanceledEvent, new PointerEventHandler(ReleasePointer), true);
AddHandler(PointerCaptureLostEvent, new PointerEventHandler(ReleasePointer), true);
AddHandler(PointerMovedEvent, new PointerEventHandler(UpdateValue), true);
}
The StealPointer
and ReleasePointer
event handlers are used to capture and release the pointer. Capturing the pointer means all pointer events are diverted to the slider while the pointer is pressed. Now for the tricky bit...
Given a PointerMovedEvent
, the control needs to calculate the new angle for the slider, and then adjust it based on the offset (e.g. for an offset of 30, an angle of 180 degrees becomes 150 degrees). To calculate the angle...
private double SliderAngle(PointerRoutedEventArgs e)
{
var centre = new Point(
ActualWidth / 2, ActualHeight / 2
);
var thumb = e.GetCurrentPoint(parent)
.Position.RelativeTo(centre);
var vertical = new Vector(0, -1);
return thumb.AngleTo(vertical);
}
This method first creates a Vector
using the RelativeTo
extension method on Point
. It may help to think of an arrow pointing from the centre of the circle to the pointer, like in the annotation below.
Once it has a vector to the pointer, the method then calculates the angle of that vector relative to one pointing directly upwards. The maths behind that is a bit tricky; have a look here if you'd like to know more.
So, the slider control uses event handlers to capture, track and release the pointer, updating the angle in the process. All that remains is for something to bind to the angle property - to make the angle visible.
<Style TargetType="control:Slider">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="control:Slider">
<control:HaloRing>
<Grid control:HaloRing.Angle="{Binding Angle, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
control:HaloRing.Offset="{Binding Offset, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}">
<ContentControl Template="{TemplateBinding Thumb}"/>
</Grid>
</control:HaloRing>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
You're looking at a cut-down version of the default slider template. The template isn't that important (remember ArcSlider overrides it completely), but introduces another control I want to cover... HaloRing
.
The Circle of Life...
I started RadialControls because I wanted an easy way to work with circles. One of the problems with circles is trying to arrange small controls, like buttons or ellipses (as in the default slider template) in a circle.
HaloRing
takes away that problem. It defines two attached properties: offset and angle, which, when applied to a child control, cause it to appear in a circle, together with any other children in the ring.
The way it works is like a panel: the ring control overrides the layout methods on the Panel
class to specify how its children should be arranged. I won't go into masses of detail, but here's the gist of it...
protected override Size ArrangeOverride(Size finalSize)
{
var thickness = RingThickness();
var size = RingSize(finalSize);
var radius = (
Math.Min(size.Width, size.Height) - thickness
)
foreach(var child in Children)
{
ArrangeChid(child, size);
TransformChild(child, radius);
}
return size;
}
ArrangeOverride
takes a size, which is the space available for the ring. It then gets the thickness of the ring, based on the max desired size of its children, and bumps the specified size so that it's at least equal to the thickness.
Now the interesting bit: the control will arrange each child in the centre of its area, and then transform the child to its position around the circle, which is specified using the offset and angle properties.
The transform has 2 parts: first translate the child control to where the offset is on the circle (0 degrees by default), using trigonometry to figure out the point. Then rotate the child around the circle to the specified angle.
There's lots more to say about the ring control, but I digress! So far, we've covered how the slider control is used as the basis for the ArcSlider example. I'll now cover the component that gives the ArcSlider its radiant good looks.
Who Built the Arc...
In case you didn't know, an arc is a section of the border around a circle. The HaloArc
draws an arc by extending the Path
class, and setting the path Data
property as shown below.
private PathFigure figure = new PathFigure();
private ArcSegment segment = new ArcSegment();
private PathGeometry path = new PathGeometry();
public HaloArc()
{
segment.SweepDirection = SweepDirection.Clockwise;
figure.Segments = new PathSegmentCollection { segment };
path.Figures = new PathFigureCollection { figure };
Data = path;
}
The Data
property is set to a PathGeometry
, which is composed of a single PathFigure
, which is in turn composed of an ArcSegment
(the bit that actually draws something!). Now to make the arrangements...
protected override Size ArrangeOverride(Size finalSize)
{
var circle = new Circle(finalSize);
circle.Radius -= StrokeThickness / 2;
ArrangePath(circle);
return finalSize;
}
A circle is a little utility I use to model (a triangle) a circle. I subtract half the path thickness so the stroke of the arc is within the area specified by finalSize
. I then use the resulting circle to setup the elements of the path...
private void ArrangePath(Circle circle)
{
var tension = Tension % 1;
var angle = Angle + Offset;
var startAngle = angle - tension * Spread;
var endAngle = angle + (1 - tension) * Spread;
figure.StartPoint = circle.PointAt(startAngle);
segment.Point = circle.PointAt(endAngle);
segment.Size = circle.Size();
segment.IsLargeArc = (Spread > 180);
}
The first 4 lines are just calculating the angles where the arc starts and ends. The start angle is set as a point on the figure (why Microsoft, why?!), and the end angle set as the (end) point of the arc segment.
And that's it! The PointAt
method uses some trigonometry, but it should be self explanatory (look under Utilities/Extensions). And so we reach the end of the tour: thanks for reading, and I hope you found it useful!
Conclusions
Time for dinner.