完全可定制的无图像旋钮控件






4.43/5 (8投票s)
为您的.NET项目添加一个灵活的旋钮控件
引言
我没有找到一个我喜欢绘制的旋钮控件,而且大多数都使用图像来显示它们的值,所以我发布了一个旋钮控件作为我的MidiUI项目的一部分。最终,我决定改进它并使其独立,因为MidiUI需要我的Midi库。因此,这段代码可以说是一次重新发布,但有了足够的改进,值得单独写一篇文章。
使用图像绘制控件的问题在于它们几乎从不与常规的Windows Forms控件融合。如果您要对整个UI进行皮肤处理,它们会很棒,否则它们在表单上会显得格格不入。
此外,另一个替代方案——`TrackBar`控件——可能非常有用,但它占用大量屏幕空间,并不总是适用于所有需求。有时更适合用旋转旋钮来输入和显示值。
不幸的是,WinForms 不包含旋钮控件,但我们可以制作一个。本文旨在为您提供一个旋钮控件,同时我们探索它的工作原理,以便您可以在需要时进行修改。
概念化这个混乱的局面
该控件必须负责多项事务,包括绘图、键盘和鼠标输入以及焦点处理。它还必须公开许多用于自定义外观和行为的属性。
我们将在本节中介绍这些属性,然后深入探讨其余代码。
外观
外观的改变通过`BorderColor`和`BorderWidth`完成,它们控制旋钮边框的外观;`BackColor`改变控件的背景颜色;`KnobColor`改变旋钮的主体颜色。还有`PointerWidth`、`PointerColor`、`PointerOffset`、`PointerStartCap`和`PointerEndCap`,它们控制指针的外观。此外,还有`MinimumAngle`和`MaximumAngle`,它们控制旋钮的起始和结束位置。为了控制旋钮刻度的外观,我们有`HasTicks`、`TickWidth`、`TickHeight`和`TickColor`。
行为
与外观一样,`Knob`也有一系列行为设置,包括改变刻度之间距离的`LargeChange`(与`TrackBar`的属性相似),改变允许值范围的`Minimum`和`Maximum`,以及用于报告或设置当前值的`Value`本身。
现在开始代码!
编写这个混乱的程序
正如我之前所说,我们必须处理这个控件的几个方面。我们有绘画、焦点、键盘和鼠标控制。我们将从绘画开始。
绘制控件
绘制控件涉及一些计算指针线和刻度标记的数学运算。除此之外,我们还必须对我们的绘图矩形(例如`ClientRectangle`)进行大量调整,以使其达到像素完美。这是代码:
// 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)));
希望注释有助于澄清它的作用,尽管由于所有的外观定制功能,绘图有点复杂。
处理鼠标输入
要处理鼠标输入,我们必须响应`OnMouseDown()`、`OnMouseMove()`、`OnMouseUp()`和`OnMouseWheel()`。
在 `OnMouseDown()` 中,我们必须进行命中测试,以确保指针落在旋钮的边界圆内,如果落在边界圆内,则必须存储鼠标光标发生时的坐标。
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);
第一行处理点击时焦点,尽管我们稍后会介绍焦点处理。然后我们通过查看坐标并判断坐标到原点的距离是否在圆的半径内来计算命中测试。如果是,我们存储鼠标点击的位置,然后设置拖动标志。无论哪种方式,最后我们都调用基方法。
让我们继续讨论`OnMouseMove()`。在这里,我们比较当前位置和上次位置以获得一个增量,然后将其添加到旋钮`Value`中。我们对Ctrl键有特殊处理,如果按住它,我们将旋钮移动到我们预先计算好的大刻度位置。这段代码并不理想,因为它在按住Ctrl键时使旋钮移动过快,但要做得更好需要重新设计鼠标处理逻辑,因为增量方法在这种情况下将不起作用。尽管如此,它仍然有效。
// 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);
这里的复杂之处在于按住 Control 键时。我们必须遍历刻度位置,寻找最接近我们坐标的刻度,然后设置它。请记住刻度位置是预先计算的,所以这涉及到遍历它们的数组。它们预先计算的原因是它们并不完全规则。如果您为 `LargeChange` 指定 3,并且范围为 100,它不能被 3 整除,因此我们必须考虑到这一点。提前完成它更简单,所以每当更改设置会修改刻度时,我们都会调用 `_RecomputeTicks()`。
接下来是 `OnMouseUp()`,我们在这里简单地清除 `_dragging` 标志。
_dragging = false;
base.OnMouseUp(args);
最后,我们必须处理`OnMouseWheel()`,在这里我们已经获得了增量,所以我们的计算看起来与`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);
我们将增量设置为`1`或`-1`,否则它会移动太快。
处理键盘输入
处理键盘输入相对简单,尽管我们必须重写两个方法来完成。除了`OnKeyDown()`之外,我们还必须重写`ProcessCmdKey()`才能捕获箭头键,因为通常表单会拦截它们。
`OnKeyDown()` 处理 Page Up、Page Down、Home 和 End 键。它分别将指针移动到下一个最高刻度、下一个最低刻度、`Minimum` 和 `Maximum`。由于我们的刻度是预先计算的,所以查找它们需要遍历一个小数组。
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);
现在是`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);
这里的代码应该相对简单,但请注意这个错误。如果有人知道为什么右箭头在上述代码中不起作用,我将不胜感激,请评论告诉我如何使其工作。
焦点处理
处理焦点涉及在控件创建时设置特定的窗口样式,将控件设置为 Tab 键停止点,在鼠标和按键事件中设置焦点,以及绘制焦点矩形。既然您已经看到了前面代码中对 `Focus()` 的调用和绘制,我们将介绍在控件构造函数中发生的最初两个任务。
SetStyle(ControlStyles.Selectable,true);
UpdateStyles();
TabStop = true;
除了你已经看到的,这就是全部了。
实现控件属性
该控件有很多属性,导致它们占据了`Knob.cs`的大部分。要正确实现一个控件属性,它需要遵循特定的模式,包括引发相关的属性更改事件。下面是一个例子:
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);
}
真是一团糟!不幸的是,这是添加控件属性的“最佳实践”。`ValueChanged`事件是必要的,以及提供一种从控件派生并通过重写`OnValueChanged()`直接挂钩事件的方法,至少如果您希望您的控件表现得像一个标准的WinForms控件。我们还必须使用属性和事件的特性来标记它们,这些特性告诉属性浏览器如何显示它们,以及默认值(如果有)。请注意,当值更改时,我们调用`Invalidate()`来重新绘制控件。这对于大多数属性来说是典型的,因为更改它们会影响外观。该控件上有很多自定义属性,因此也有很多更改事件。正因为如此,我们使用`Events`成员来存储我们的事件委托,而不是让C#自己提供事件委托存储,因为前者更高效,即使它需要更多的工作来实现。我们查找事件的键是静态的只读`object`类型。这保证了每个键都是唯一的。
就这些了。尽情享受吧!
历史
- 2020年7月17日 - 首次提交