This article demonstrates my standalone knob control for Windows Forms. It has a myriad of customization options, and supports full keyboard and focus control. Unlike many, if not most knob controls, this one is rendered with circles and lines, not constructed out of images, leading to easier blending in with existing Windows Forms controls.
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.
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:
base.OnPaint(args);
var g = args.Graphics;
float knobMinAngle = _minimumAngle;
float knobMaxAngle = _maximumAngle;
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;
knobRect.Inflate(-1, -1);
var orr = knobRect;
if(TicksVisible)
{
knobRect.Inflate(new Size(-_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);
var borderRect = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth / 2)));
var knobInnerRect = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth)));
double q = ((Value + offset) * valueRatio) + knobMinAngle;
double angle = (q + 90d);
if (angle > 360.0)
angle -= 360.0;
double angrad = angle * (Math.PI / 180d);
double adj = 1;
if (_pointerEndCap != LineCap.NoAnchor)
adj += (_pointerWidth) / 2d;
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;
g.FillRectangle(backBrush, (float)orr.Left - 1,
(float)orr.Top - 1, (float)orr.Width + 2, (float)orr.Height + 2);
g.DrawEllipse(borderPen, borderRect);
g.FillEllipse(bgBrush, knobInnerRect);
g.DrawLine(pointerPen, x1, y1, x2, y2);
}
}
}
}
if (TicksVisible)
{
using (var pen = new Pen(_tickColor, _tickWidth))
{
for (var i = 0; i < _tickPositions.Length; ++i)
{
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);
}
}
}
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;
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:
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;
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;
...
[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);
}
}
}
[Description("Raised with the value of Value changes")]
[Category("Behavior")]
public event EventHandler ValueChanged {
add { Events.AddHandler(_ValueChangedKey, value); }
remove { Events.RemoveHandler(_ValueChangedKey, value); }
}
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