A Fully Customizable Knob Control Without Images

Updated on 2020-07-17

Add a flexible knob control to your .NET projects

0

Introduction

I didn't find a knob control that drew how I liked and most use images to display their value so I released a knob control as part of my MidiUI project. In the end, I decided to improve on it and make it standalone since MidiUI requires my Midi library. This code therefore, is something of a rerelease but with enough improvements that it warrants its own article.

MidiUI project Midi library

The issue with using images to draw the control is they almost never blend in with the regular Windows Forms controls. They're great if you're going to skin the entire UI, but otherwise they look woefully out of place on a form.

Futhermore, the other alternative - the TrackBar control - can be pretty useful, but it takes up a lot of screen real-estate and is not always appropriate for all needs. It's sometimes more appropriate to present a rotary knob for entering and displaying a value.

Unfortunately, WinForms does not include a knob control, but we can make one. This article aims to provide you with a knob control while we explore how it works so you can modify it if you like.

Conceptualizing this Mess

The control must be responsible for several things, including drawing, keyboard and mouse input and focus handling. It must also expose many properties used for customizing the appearance and behavior.

We'll cover the properties here and then dive into the code for the rest of it afterwards.

Appearance

Altering the appearance is done through and BorderColor, BorderWidth which control the appearance of the knob's border, BackColor which changes the background color of the control, and KnobColor which changes the knob's main color. There is also PointerWidth, PointerColor, PointerOffset, PointerStartCap and PointerEndCap which control the appearance of the pointer. In addition, there is MinimumAngle and MaximumAngle which control where the knob starts and ends. To control the appearance of the knob ticks, we have HasTicks, TickWidth, and TickHeight, and TickColor.

Behavior

Like with the appearance, Knob has a number of behavior settings as well, including LargeChange which changes the distance between the ticks (mirroring the TrackBar's property), Minimum and Maximum which change the range of allowable values, and Value itself for reporting or setting the current value.

Now on to the code!

Coding this Mess

We have several aspects of this control we have to take care, like I said before. We have painting, focus, keyboard and mouse control. We'll start with the painting.

Painting the Control

Painting the control involves some math for computing the pointer line and tick marks. On top of that, we have to make a number of adjustments to our painting rectangles like ClientRectangle in order to get them pixel perfect. Here's the code:

// call the base method
base.OnPaint(args);

var g = args.Graphics;

// we need to copy these so we can adjust them
float knobMinAngle = _minimumAngle;
float knobMaxAngle = _maximumAngle;
// adjust them to be within bounds
if (knobMinAngle < 0)
    knobMinAngle = 360 + knobMinAngle;
if (knobMaxAngle <= 0)
    knobMaxAngle = 360 + knobMaxAngle;

double offset = 0.0;
int min = Minimum, max = Maximum;
var knobRange = (knobMaxAngle - knobMinAngle);
double valueRange = max - min;
double valueRatio = knobRange / valueRange;
if (0 > min)
    offset = -min;
var knobRect = ClientRectangle;
// adjust the client rect so it doesn't overhang
knobRect.Inflate(-1, -1);
var orr = knobRect;
if(TicksVisible)
{
    // we have to make the knob smaller to make room
    // for the ticks
    knobRect.Inflate(new Size(-_tickHeight-2, -_tickHeight-2));
}
var size = (float)Math.Min(knobRect.Width-4, knobRect.Height-4);
// give it a bit of a margin:
knobRect.X += 2;
knobRect.Y += 2;

var radius = size / 2f;
var origin = new PointF(knobRect.Left +radius, knobRect.Top + radius);
var borderRect = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth / 2)));
var knobInnerRect = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth)));
// compute our angle
double q = ((Value + offset) * valueRatio) + knobMinAngle;
double angle = (q + 90d);
if (angle > 360.0)
    angle -= 360.0;
// now in radians
double angrad = angle * (Math.PI / 180d);
// pointer adjustment
double adj = 1;
// adjust for endcap
if (_pointerEndCap != LineCap.NoAnchor)
    adj += (_pointerWidth) / 2d;
// compute the pointer line coordinates
var x1 = (float)(origin.X + (_pointerOffset - adj) * (float)Math.Cos(angrad));
var y1 = (float)(origin.Y + (_pointerOffset - adj) * (float)Math.Sin(angrad));
var x2 = (float)(origin.X + (radius - adj) * (float)Math.Cos(angrad));
var y2 = (float)(origin.Y + (radius - adj) * (float)Math.Sin(angrad));

using (var backBrush = new SolidBrush(BackColor))
{
    using (var bgBrush = new SolidBrush(_knobColor))
    {
        using (var borderPen = new Pen(_borderColor, _borderWidth))
        {
            using (var pointerPen = new Pen(_pointerColor, _pointerWidth))
            {
                g.SmoothingMode = SmoothingMode.AntiAlias;

                pointerPen.StartCap = _pointerStartCap;
                pointerPen.EndCap = _pointerEndCap;


                // erase the background so it antialiases properly
                g.FillRectangle(backBrush, (float)orr.Left - 1,
                 (float)orr.Top - 1, (float)orr.Width + 2, (float)orr.Height + 2);
                // draw the border
                g.DrawEllipse(borderPen, borderRect);
                // draw the knob
                g.FillEllipse(bgBrush, knobInnerRect);
                // draw the pointer
                g.DrawLine(pointerPen, x1, y1, x2, y2);
            }
        }
    }
}

if (TicksVisible)
{
    // draw the ticks
    using (var pen = new Pen(_tickColor, _tickWidth))
    {
        // for each tick line, compute its coordinates
        // and then draw it
        for (var i = 0; i < _tickPositions.Length; ++i)
        {
            // get the angle from our tick position
            angle = ((_tickPositions[i] + offset) * valueRatio) + knobMinAngle + 90d;
            if (angle > 360.0)
                angle -= 360.0;
            angrad = angle * (Math.PI / 180d);
            x1 = origin.X + (radius +2) * (float)Math.Cos(angrad);
            y1 = origin.Y + (radius + 2) * (float)Math.Sin(angrad);
            x2 = origin.X + (radius + _tickHeight+2) * (float)Math.Cos(angrad);
            y2 = origin.Y + (radius + _tickHeight+2) * (float)Math.Sin(angrad);
            g.DrawLine(pen, x1, y1, x2, y2);
        }
    }
}
// draw the focus rectangle if needed
if (Focused)
    ControlPaint.DrawFocusRectangle(g, new Rectangle
          (0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));

Hopefully, the comments help clarify what it's doing, though the drawing is a bit complicated due to all the appearance customization features.

Handling Mouse Input

To handle mouse input, we have to respond to OnMouseDown(), OnMouseMove(), OnMouseUp() and OnMouseWheel().

In OnMouseDown(), we must do hit testing to make sure the pointer landed inside the knob's bounding circle, and then if it does, we must store the coordinates of the mouse cursor when that happens:

Focus();
if (MouseButtons.Left == (args.Button & MouseButtons.Left)) {
    var knobRect = ClientRectangle;
    // adjust the client rect so it doesn't overhang
    knobRect.Inflate(-1, -1);
    if (TicksVisible)
        knobRect.Inflate(-_tickHeight-2, -_tickHeight-2);
    var size = (float)Math.Min(knobRect.Width - 4, knobRect.Height - 4);
    knobRect.X += 2;
    knobRect.Y += 2;
    var radius = size / 2f;
    var origin = new PointF(knobRect.Left + radius, knobRect.Top + radius);
    if (radius > _GetLineDistance(origin, new PointF(args.X, args.Y)))
    {
        _dragHit = args.Location;
        _dragging = true;
    }
}
base.OnMouseDown(args);

The first line handles focus when we click although we'll cover focus handling later. Then we compute the hit test by looking at the coordinates and seeing if the distance of the coordinates from the origin are within the radius of the circle. If it is, we store the location where the mouse hit, and then set the dragging flag. Either way, finally we call the base method.

Let's move on to OnMouseMove(). Here, we're comparing the current position with the last position to get a delta, which we then add to the knob Value. We have special handling for the control key, wherein if it's held, we move the knob to the large tick positions that we've precomputed. This code isn't ideal as it makes the knob move too fast when control is held, but to do it better requires a redesign of the mouse handling logic since the delta method won't work in that case. Still, it works:

// TODO: Improve Ctrl+Drag
if (_dragging)
{
    int opos = Value;
    int pos = opos;
    var delta = _dragHit.Y - args.Location.Y;
    if (Keys.Control == (ModifierKeys & Keys.Control))
        delta *= LargeChange;
    pos += delta;
    int min = Minimum;
    int max = Maximum;
    if (pos < min) pos = min;
    if (pos > max) pos = max;
    if (pos != opos)
    {
        if(Keys.Control==( ModifierKeys & Keys.Control))
        {
            var t = _tickPositions[0];
            var setVal = false;
            for(var i = 1;i<_tickPositions.Length;i++)
            {
                var t2 = _tickPositions[i]-1;
                if(pos>=t && pos<=t2)
                {
                    var l = pos - t;
                    var l2 = t2 - pos;
                    if (l <= l2)
                        Value = t;
                    else
                        Value = t2;
                    setVal = true;
                    break;
                }
                t = _tickPositions[i];
            }
            if (!setVal)
                Value = Maximum;

        } else
            Value = pos;
        _dragHit = args.Location;
    }
}
base.OnMouseMove(args);

The complication here is when the control key is held. We must move through the tick positions looking for the nearest one to our coordinates and then setting it. Remember the tick positions are precomputed so it involves traversing their array. The reason they're precomputed is because they're not entirely regular. If you specify something like 3 for LargeChange, and you have a range of 100 it doesn't divide evenly by 3 so we have to account for that. It's simply easier to do it ahead of time so we call _RecomputeTicks() whevener a setting changes that would modify them.

Next we have OnMouseUp() where we simply clear the _dragging flag:

_dragging = false;
base.OnMouseUp(args);

Finally, we have to handle OnMouseWheel() where we get a delta already, so our computation looks a bit different than it does in OnMouseMove():

int pos;
int m;
var delta = args.Delta;
if (0 < delta)
{
    delta = 1;
    pos = Value;
    pos += delta;
    m = Maximum;
    if (pos > m)
        pos = m;
    Value = pos;
}
else if (0 > delta)
{
    delta = -1;
    pos = Value;
    pos += delta;
    m = Minimum;
    if (pos < m)
        pos = m;
    Value = pos;
}
base.OnMouseWheel(args);

We set the delta to 1 or -1 since otherwise, it would move too fast.

Handling Keyboard Input

Handling keyboard input is relatively straightforward, although we must override two methods to do it. In addition to OnKeyDown(), we must override ProcessCmdKey() in order to catch the arrow keys, since normally the form intercepts them.

OnKeyDown() deals with the page up, page down, home, and end keys. It moves the pointer to the next highest tick, the next lowest tick, the Minimum, and the Maximum, respectively. Since our ticks are precomputed, finding them involves traversing a small array:

Focus();
if(Keys.PageDown==(args.KeyCode & Keys.PageDown))
{
    var v = Value;
    var i = 0;
    for(;i<_tickPositions.Length;i++)
    {
        var t = _tickPositions[i];
        if (t >= v)
            break;
    }
    if (1 > i)
        i = 1;
    Value = _tickPositions[i - 1];
}
if (Keys.PageUp == (args.KeyCode & Keys.PageUp))
{
    var v = Value;
    var i = 0;
    for (; i < _tickPositions.Length; i++)
    {
        var t = _tickPositions[i];
        if (t > v)
            break;
    }
    if (_tickPositions.Length <= i)
        i = _tickPositions.Length - 1;
    Value = _tickPositions[i];
}

if (Keys.Home == (args.KeyCode & Keys.Home))
{
    Value = Minimum;
}
if (Keys.End== (args.KeyCode & Keys.End))
{
    Value = Maximum;
}
base.OnKeyDown(args);

Now here is ProcessCmdKey():

Focus();
int pos;
var handled = false;

// BUG: Right arrow doesn't seem to be working!
if (Keys.Up == (keyData & Keys.Up) || Keys.Right == (keyData & Keys.Right))
{
    pos = Value+1;
    if (pos < Maximum)
    {
        Value = pos;
    }
    else
        Value = Maximum;
    handled = true;
}

if (Keys.Down == (keyData & Keys.Down) || Keys.Left == (keyData & Keys.Left))
{
    pos = Value-1;
    if (pos > Minimum)
    {
        Value = pos;
    }
    else
        Value = Minimum;
    handled = true;
}
if (handled)
    return true;
return base.ProcessCmdKey(ref msg, keyData);

The code here should be relatively straightforward, though note the bug. If anyone knows why the right arrow doesn't work with the above code, I'd appreciate a comment letting me know how to make it work.

Focus Handling

Handling focus involves setting a particular window style upon control creation, setting the control as a tab stop, setting focus in mouse and key down events, and painting the focus rectangle. Since you've seen the calls to Focus() and the painting in the code earlier, we'll cover the first two tasks which happen in the control's constructor:

SetStyle(ControlStyles.Selectable,true);
UpdateStyles();
TabStop = true;

Other than what you've already seen, that's all there is to it.

Implementing Control Properties

There are a lot of properties on this control leading them to take up the lion's share of Knob.cs. To properly implement a control property, it needs to follow a particular pattern, including raising the associated property changed event. Here's one example:

static readonly object _ValueChangedKey = new object();
...
int _value = 0;
...
/// <summary>
/// Indicates the value of the control
/// </summary>
[Description("Indicates the value of the control")]
[Category("Behavior")]
[DefaultValue(0)]
public int Value {
    get { return _value; }
    set {
        if (_value != value)
        {
            _value = value;
            Invalidate();
            OnValueChanged(EventArgs.Empty);
        }
    }
}
/// <summary>
/// Raised with the value of Value changes
/// </summary>
[Description("Raised with the value of Value changes")]
[Category("Behavior")]
public event EventHandler ValueChanged {
    add { Events.AddHandler(_ValueChangedKey, value); }
    remove { Events.RemoveHandler(_ValueChangedKey, value); }
}
/// <summary>
/// Called when the value of Value changes
/// </summary>
/// <param name="args">The event args to use</param>
protected virtual void OnValueChanged(EventArgs args)
{
    (Events[_ValueChangedKey] as EventHandler)?.Invoke(this, args);
}

What a mess! Unfortunately, this is the "best practice" in terms of adding a control property. The ValueChanged event is necessary, as well as providing a way to derive from the control and hook the event directly by overriding OnValueChanged(), at least if you want your control to behave like a standard WinForms control. We must also mark up the property and event with attributes that tell the property browser how to display the it, and what the default value is, if any. Note that we call Invalidate() to repaint the control when the value changes. This is typical of most of the properties, since changing them impacts the appearance. There are a lot of custom properties on this control, and consequently a lot of changed events. Because of this, we use the Events member to store our event delegates instead of letting C# provide the event delegate storage itself because the former way is more efficient, even as it requires more work to implement. Our keys for looking up the event are static read only object types. This guarantees each key is unique.

That's all she wrote. Enjoy!

History

  • 17th July, 2020 - Initial submission