Building a Rotary Wheel Control

Why

HeavenFresh, a company based in Mississauga, Canada make a plethora of home products. Of significant note are their AllJoyn-connected humidifiers and air purifiers. In preparation for IFA Berlin, we've been working together to build a Windows UWP application to control their devices.

Wat

In doing so, we wanted to build out a cool rotary wheel/dial/spinner control to configure the various settings which herein shall be referred to the wheel-of-cool. There wasn't anything similar in existence, so we made one and it looks awesome!

This post will delve into how to use basic shapes and render transformations to draw the wheel-of-cool, handling of user interactions to manipulate the wheel-of-cool, usage of storyboards and animations, and XAML layering for styling. If you want to skip all that, the code is all on GitHub.

How

To be honest, once you break down the wheel-of-cool into it's basic component, it's pretty simple. It's a rotating pie chart of equally divided pie slices each having it's own label.

Pie Slice

The most basic component of the pie chart is each slice. Jerry Nixon has an awesome blog post about building a pie slice so I will defer to that.

Given his code sample, the properties that I need to draw one slice are:

  • StartAngle: start angle of the pie slice
  • Angle: total angle of the slice
  • Radius: the radius of the pie slice

Label

I want to center the label in the middle of the pie slice. I'm going to need two render transformations:

  1. rotate to align the label with the angle of the pie slice
  2. translate to move the label the center of the slice.
1. Rotate

The rotate is pretty simple. Given the StartAngle and the total Angle of the pie slice, I need to rotate the label half the total Angle of the slice which translates Mathematically to the complex formula of: StartAngle + Angle/2.

2. Translate

The translate transform requires just a teeny-tiny bit more math. Ok, I lied, it requires a bit more than that. Once the label is rotated, I want to move it about 4/5 the radius of the pie slice.

Regardless of the position of the slice, I can calculate the new X, Y coordinates using basic trigonometry. Expanding the diagram to focus on the triangle:

    // where quadrant is an enumeration
    // NE = 0, SE = 1, SW = 2, NW = 4
    var quadrantAngle = startAngle + angle/2 - 90*(int)quadrant;

    var adjacent = Math.Cos(Math.PI/180* quadrantAngle) *radius;
    var opposite = Math.Sin(Math.PI/180* quadrantAngle) *radius;

Given an angle, it's super easy to figure out what quadrant you are in:

    public static Quadrants GetQuadrant(double angle)
    {
        return (angle <= 90)
            ? Quadrants.NE
            : (angle <= 180)
                ? Quadrants.SE
                : (angle <= 270) ? Quadrants.SW : Quadrants.NW;
    }

This is important because depending on what quadrant we are in, adjacent can either mean the horizontal distance (if we are SE or NW) or it can mean the vertical distance (if we are NE or SW) and vice versa for opposite.

The coordinate system of a canvas originates at the top left corner. However, to make the math easier, let's move the origin to the center of the pie chart by setting the property RenderTransformOrigin="0.5, 0.5" which makes (0,0) of the coordinate system the center of the pie chart.

Ok, now we just put the two pieces together to calculate the final resting point.

    switch (quadrant)
    {
        case Quadrants.NE:
            return new Point(opposite, -1*adjacent);

        case Quadrants.SE:
            return new Point(adjacent, opposite);

        case Quadrants.SW:
            return new Point(-1*opposite, adjacent);

        case Quadrants.NW:
            return new Point(-1*adjacent, -1*opposite);

        default:
            throw new NotSupportedException();
    }

Pie Chart

A pie chart is simply a collection of pie slices. Pretty easy to generate once you have the basic building blocks.

IList<string> Slices = new[] { 'wheel', 'of', 'cool' };

foreach (var slice in Slices)
{
    var sliceSize = 360/Slices.Count();

    var pieSlice = new PieSlice
    {
        StartAngle = startAngle,
        Angle = sliceSize,
        Radius = Size/2,
        BackgroundColor = color,
        Label = slice,
        ForegroundColor = ForegroundColor,
        HideLabel = HideLabels,
    };

    // add pie slice to canvas
    _pieSlices.Add(pieSlice);

    startAngle += sliceSize;
    color = color.Lighten();
}

At this point, we have something looking like this:

User Manipulation

In order to allow manipulation of the wheel-of-cool, we setup a rotate transform and bind that to an Angle property we'll periodically update when a ManipulationDelta event occurs.

<StackPanel x:Name="layoutRoot" 
            ManipulationMode="All" 
            ManipulationDelta="layoutRoot_ManipulationDelta"
            ManipulationCompleted="layoutRoot_ManipulationCompleted">
    <Grid x:Name="layoutSpinner">
        <Grid.RenderTransform>
            <RotateTransform x:Name="gridRotateTransform" Angle="{Binding Angle}" />
        </Grid.RenderTransform>
    </Grid>
</StackPanel>

Now, how do we actually calculate the angle in which to rotate the wheel-of-cool? Given the touch point:

    public static double GetAngle(Point touchPoint, Size circleSize)
    {
        var x = touchPoint.X - (circleSize.Width / 2d);
        var y = circleSize.Height - touchPoint.Y - (circleSize.Height / 2d);
        var hypot = Math.Sqrt(x * x + y * y);
        var value = Math.Asin(y / hypot) * 180 / Math.PI;
        var quadrant = (x >= 0) ?
            (y >= 0) ? Quadrants.NE : Quadrants.SE :
            (y >= 0) ? Quadrants.NW : Quadrants.SW;

        switch (quadrant)
        {
            case Quadrants.NE:
                value = 090 - value;
                break;

            case Quadrants.NW:
                value = 270 + value;
                break;

            case Quadrants.SE:
                value = 090 - value;
                break;

            case Quadrants.SW:
                value = 270 + value;
                break;
        }

        return value;
    }

Animation

This is the bonus level, wouldn't it be neat if the wheel-of-cool went to the center of the selected selected pie slice once the user interaction completes? When the user finishes their interaction, a ManipulationCompleted event will fire. At which point, we need to figure out:

  1. The selected pie slice
  2. The angle of selected pie slice
  3. Start an animation to slowly rotate the pie chart to the center of the pie slice

We can calculate which pie slice is currently the selected one (e.g. the one at the top)
First, let's setup the animation:

<UserControl.Resources>
    <Storyboard x:Name="storyBoard">
        <DoubleAnimation
            x:Name="doubleAnimation"
            Storyboard.TargetName="gridRotateTransform"
            Storyboard.TargetProperty="(Angle)"
            Duration="0:0:0.5"/>
        </Storyboard>
</UserControl.Resources>

And now the callback for the ManipulationCompleted event:

    private void layoutRoot_ManipulationCompleted(object sender, ManipulationCompletedRoutedEventArgs e)
    {
        var angleFromYAxis = 360 - Angle;
        SelectedItem = _pieSlices
            .SingleOrDefault(p => p.StartAngle <= angleFromYAxis && (p.StartAngle + p.Angle) > angleFromYAxis);

        var finalAngle = SelectedItem.StartAngle + SelectedItem.Angle / 2;

        doubleAnimation.From = Angle;
        doubleAnimation.To = 360 - finalAngle;
        storyBoard.Begin();

        Angle = 360 - finalAngle;
    }