前言
在 WinForm 应用程序开发中,原生控件虽然稳定可靠,但往往难以满足对界面美观和交互体验有更高要求的场景。
本文将介绍一个名为 RotatingSwitchButton 的自定义控件实现过程——它模拟了物理旋转开关的外观与行为,融合了动画、阴影、金属质感和精细刻度等现代 UI 元素,在保留 WinForm 轻量级特性的基础上,显著提升了视觉表现力。
正文
这个旋转开关控件的核心目标是:用纯 GDI+ 绘图实现一个既好看又实用的状态切换元件。它不像普通 CheckBox 那样简单,而是通过旋钮的位置(向上为"开",向下为"关")直观传达状态,并辅以平滑动画增强交互反馈。
状态与动画控制
控件内部使用 isOn 布尔值记录当前状态,currentAngle 表示旋钮的实时角度。
切换状态时,并非直接跳转,而是启动一个基于 Timer 的动画循环:
private bool isOn = false;
private float currentAngle = 0f;
private readonly float targetOnAngle = -90f; // 向上位置
private readonly float targetOffAngle = 90f; // 向下位置
动画采用缓动函数 EaseInOutQuad,使运动更自然,避免机械感:
private float EaseInOutQuad(float t)
{
return t < 0.5f ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
每帧根据目标角度逐步逼近,直到误差小于阈值后停止定时器,确保性能不受长期运行影响。
阴影与深度感
为提升立体感,控件支持可配置的阴影效果。通过 PathGradientBrush 创建径向渐变,模拟柔和投影:
private void DrawShadow(Graphics g, float centerX, float centerY, float radius)
{
using (var shadowPath = new GraphicsPath())
{
float shadowSize = radius * 2 + shadowBlur * 2;
float shadowX = centerX - radius - shadowBlur + shadowDepth * 0.5f;
float shadowY = centerY - radius - shadowBlur + shadowDepth;
shadowPath.AddEllipse(shadowX, shadowY, shadowSize, shadowSize);
using (var shadowBrush = new PathGradientBrush(shadowPath))
{
Color centerShadowColor = Color.FromArgb(
(int)(255 * shadowOpacity),
shadowColor.R,
shadowColor.G,
shadowColor.B);
shadowBrush.CenterColor = centerShadowColor;
shadowBrush.SurroundColors = new[] { Color.FromArgb(0, shadowColor) };
shadowBrush.FocusScales = new PointF(0.8f, 0.8f);
g.FillPath(shadowBrush, shadowPath);
}
}
}
用户可通过 EnableShadow、ShadowDepth、ShadowOpacity 等属性灵活调整阴影表现。
视觉细节打磨
-
外环:使用线性渐变模拟金属拉丝质感,并叠加高光路径增强光泽。
-
刻度线:每隔 6 度绘制短线,30 度处加长,90° 和 270°(即 ON/OFF 位置)进一步突出:
for (int i = 0; i < 360; i += 6)
{
float scaleLength = (i % 30 == 0) ? 0.12f : 0.08f;
if (i == 90 || i == 270)
scaleLength = 0.15f; // 主要刻度线
// 绘制逻辑...
}
-
旋钮:采用多层绘制——底层阴影、主体径向渐变、顶部高光、中心指示线,营造真实金属旋钮效果。
-
标签:在上下两侧分别标注 "ON" 和 "OFF",当前状态高亮显示,非激活状态置灰,语义清晰。
项目效果
完整控件代码
以下为控件完整实现(已保留原始格式):
namespace AppControls
{
publicclassRotatingSwitchButton : Control
{
privatebool isOn = false;
privatefloat currentAngle = 0f;
privatereadonlyfloat targetOnAngle = -90f; // 负角度表示向上
privatereadonlyfloat targetOffAngle = 90f; // 正角度表示向下
private Timer animationTimer;
privatereadonly Color onColor = Color.FromArgb(76, 217, 100);
privatereadonly Color offColor = Color.FromArgb(255, 59, 48);
privatereadonly Color knobColor = Color.White;
privatereadonly Color gradientStart = Color.FromArgb(240, 240, 240);
privatereadonly Color gradientEnd = Color.FromArgb(200, 200, 200);
privatebool enableShadow = true;
privatefloat shadowDepth = 5f;
privatefloat shadowOpacity = 0.3f;
private Color shadowColor = Color.FromArgb(76, 0, 0, 0);
privatefloat shadowBlur = 10f;
[Category("Appearance")]
[Description("启用或禁用控件阴影")]
publicbool EnableShadow
{
get => enableShadow;
set
{
if (enableShadow != value)
{
enableShadow = value;
Invalidate();
}
}
}
[Category("Appearance")]
[Description("设置阴影深度")]
publicfloat ShadowDepth
{
get => shadowDepth;
set
{
if (shadowDepth != value)
{
shadowDepth = value;
Invalidate();
}
}
}
[Category("Appearance")]
[Description("设置阴影透明度 (0.0 - 1.0)")]
publicfloat ShadowOpacity
{
get => shadowOpacity;
set
{
value = Math.Max(0, Math.Min(1, value));
if (shadowOpacity != value)
{
shadowOpacity = value;
Invalidate();
}
}
}
[Category("Appearance")]
[Description("设置阴影颜色")]
public Color ShadowColor
{
get => shadowColor;
set
{
if (shadowColor != value)
{
shadowColor = value;
Invalidate();
}
}
}
[Category("Appearance")]
[Description("设置阴影模糊程度")]
publicfloat ShadowBlur
{
get => shadowBlur;
set
{
if (shadowBlur != value)
{
shadowBlur = value;
Invalidate();
}
}
}
publicbool IsOn
{
get => isOn;
set
{
if (isOn != value)
{
isOn = value;
StartAnimation();
OnValueChanged(EventArgs.Empty);
}
}
}
publicevent EventHandler ValueChanged;
public RotatingSwitchButton()
{
SetStyle(ControlStyles.SupportsTransparentBackColor |
ControlStyles.OptimizedDoubleBuffer |
ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint, true);
Size = new Size(100, 100);
BackColor = Color.Transparent;
currentAngle = targetOffAngle;
animationTimer = new Timer();
animationTimer.Interval = 16;
animationTimer.Tick += AnimationTimer_Tick;
Padding = new Padding((int)(shadowDepth + shadowBlur));
}
private void StartAnimation()
{
animationTimer.Start();
}
private float EaseInOutQuad(float t)
{
return t < 0.5f ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
private void AnimationTimer_Tick(object sender, EventArgs e)
{
float targetAngle = isOn ? targetOnAngle : targetOffAngle;
if (Math.Abs(currentAngle - targetAngle) < 0.1f)
{
currentAngle = targetAngle;
animationTimer.Stop();
}
else
{
float totalDistance = Math.Abs(targetAngle - currentAngle);
float step = totalDistance * EaseInOutQuad(0.15f);
currentAngle += (currentAngle < targetAngle) ? step : -step;
}
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
e.Graphics.CompositingQuality = CompositingQuality.HighQuality;
float centerX = Width / 2f;
float centerY = Height / 2f;
float outerRadius = Math.Min(Width, Height) / 2f - (shadowDepth + shadowBlur);
float innerRadius = outerRadius * 0.7f;
if (enableShadow)
{
DrawShadow(e.Graphics, centerX, centerY, outerRadius);
}
DrawOuterRing(e.Graphics, centerX, centerY, outerRadius);
DrawLabels(e.Graphics, centerX, centerY, outerRadius);
DrawInnerRing(e.Graphics, centerX, centerY, innerRadius);
DrawKnob(e.Graphics, centerX, centerY, innerRadius);
}
private void DrawShadow(Graphics g, float centerX, float centerY, float radius)
{
using (var shadowPath = new GraphicsPath())
{
float shadowSize = radius * 2 + shadowBlur * 2;
float shadowX = centerX - radius - shadowBlur + shadowDepth * 0.5f;
float shadowY = centerY - radius - shadowBlur + shadowDepth;
shadowPath.AddEllipse(shadowX, shadowY, shadowSize, shadowSize);
using (var shadowBrush = new PathGradientBrush(shadowPath))
{
Color centerShadowColor = Color.FromArgb(
(int)(255 * shadowOpacity),
shadowColor.R,
shadowColor.G,
shadowColor.B);
shadowBrush.CenterColor = centerShadowColor;
shadowBrush.SurroundColors = new[] { Color.FromArgb(0, shadowColor) };
shadowBrush.FocusScales = new PointF(0.8f, 0.8f);
g.FillPath(shadowBrush, shadowPath);
}
}
}
private void DrawOuterRing(Graphics g, float centerX, float centerY, float radius)
{
using (var gradientBrush = new LinearGradientBrush(
new RectangleF(centerX - radius, centerY - radius, radius * 2, radius * 2),
gradientStart, gradientEnd, 45f))
{
g.FillEllipse(gradientBrush, centerX - radius, centerY - radius, radius * 2, radius * 2);
}
using (var pen = new Pen(Color.FromArgb(100, 255, 255, 255), 1.5f))
{
g.DrawEllipse(pen, centerX - radius, centerY - radius, radius * 2, radius * 2);
}
using (var highlightPath = new GraphicsPath())
{
highlightPath.AddEllipse(centerX - radius * 0.9f, centerY - radius * 0.9f,
radius * 1.8f, radius * 1.8f);
using (var highlightBrush = new PathGradientBrush(highlightPath))
{
highlightBrush.CenterColor = Color.FromArgb(30, 255, 255, 255);
highlightBrush.SurroundColors = new[] { Color.FromArgb(0, 255, 255, 255) };
g.FillPath(highlightBrush, highlightPath);
}
}
}
private void DrawLabels(Graphics g, float centerX, float centerY, float radius)
{
using (var font = new Font("Arial", 12f, FontStyle.Bold))
{
DrawRotatedText(g, "ON", font, isOn ? onColor : Color.Gray,
centerX, centerY, radius * 0.8f, -90);
DrawRotatedText(g, "OFF", font, !isOn ? offColor : Color.Gray,
centerX, centerY, radius * 0.8f, 90);
}
}
private void DrawRotatedText(Graphics g, string text, Font font, Color color,
float centerX, float centerY, float radius, float angle)
{
using (var brush = new SolidBrush(color))
{
var size = g.MeasureString(text, font);
float x = centerX + (float)(radius * Math.Cos(angle * Math.PI / 180)) - size.Width / 2;
float y = centerY + (float)(radius * Math.Sin(angle * Math.PI / 180)) - size.Height / 2;
g.TranslateTransform(x + size.Width / 2, y + size.Height / 2);
g.RotateTransform(0); // 保持文本水平
g.DrawString(text, font, brush, -size.Width / 2, -size.Height / 2);
g.ResetTransform();
}
}
private void DrawInnerRing(Graphics g, float centerX, float centerY, float radius)
{
using (var pen = new Pen(Color.FromArgb(100, 130, 130, 130), 1f))
{
g.DrawEllipse(pen, centerX - radius, centerY - radius, radius * 2, radius * 2);
for (int i = 0; i < 360; i += 6)
{
float scaleLength = (i % 30 == 0) ? 0.12f : 0.08f;
if (i == 90 || i == 270)
scaleLength = 0.15f;
float startX = centerX + (float)(radius * (1 - scaleLength) * Math.Cos(i * Math.PI / 180));
float startY = centerY + (float)(radius * (1 - scaleLength) * Math.Sin(i * Math.PI / 180));
float endX = centerX + (float)(radius * Math.Cos(i * Math.PI / 180));
float endY = centerY + (float)(radius * Math.Sin(i * Math.PI / 180));
using (var scalePen = new Pen(Color.FromArgb(
i % 30 == 0 ? 120 : 80,
130, 130, 130), i % 30 == 0 ? 1.5f : 0.8f))
{
g.DrawLine(scalePen, startX, startY, endX, endY);
}
}
}
}
private void DrawKnob(Graphics g, float centerX, float centerY, float radius)
{
float knobRadius = radius * 0.3f;
float knobX = centerX + (float)(radius * 0.6f * Math.Cos(currentAngle * Math.PI / 180));
float knobY = centerY + (float)(radius * 0.6f * Math.Sin(currentAngle * Math.PI / 180));
using (var shadowBrush = new SolidBrush(Color.FromArgb(30, 0, 0, 0)))
{
g.FillEllipse(shadowBrush, knobX - knobRadius + 2, knobY - knobRadius + 2,
knobRadius * 2, knobRadius * 2);
}
using (var knobPath = new GraphicsPath())
{
knobPath.AddEllipse(knobX - knobRadius, knobY - knobRadius, knobRadius * 2, knobRadius * 2);
using (var knobBrush = new PathGradientBrush(knobPath))
{
knobBrush.CenterColor = Color.White;
knobBrush.SurroundColors = new[] { Color.FromArgb(230, 230, 230) };
g.FillPath(knobBrush, knobPath);
}
}
using (var highlightBrush = new PathGradientBrush(new PointF[] {
new PointF(knobX - knobRadius * 0.5f, knobY - knobRadius * 0.5f),
new PointF(knobX + knobRadius * 0.5f, knobY - knobRadius * 0.5f),
new PointF(knobX, knobY + knobRadius * 0.5f)
}))
{
highlightBrush.CenterColor = Color.FromArgb(50, 255, 255, 255);
highlightBrush.SurroundColors = new[] { Color.Transparent };
g.FillPath(highlightBrush, new GraphicsPath());
}
using (var indicatorPen = new Pen(Color.FromArgb(100, 0, 0, 0), 2f))
{
float lineLength = knobRadius * 0.8f;
float endX = knobX + (float)(lineLength * Math.Cos(currentAngle * Math.PI / 180));
float endY = knobY + (float)(lineLength * Math.Sin(currentAngle * Math.PI / 180));
g.DrawLine(indicatorPen, knobX, knobY, endX, endY);
}
}
protected override void OnClick(EventArgs e)
{
base.OnClick(e);
IsOn = !IsOn;
}
protected virtual void OnValueChanged(EventArgs e)
{
ValueChanged?.Invoke(this, e);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
animationTimer?.Dispose();
}
base.Dispose(disposing);
}
}
}
控价展示了如何在 WinForm 这一"传统"框架下,通过精细的 GDI+ 绘图和合理的动画设计,打造出具有现代感的 UI 控件。它不依赖第三方库,完全基于 .NET 内置能力实现,部署简单、兼容性好。对于需要提升专业软件界面质感、又不愿引入 WPF 或 Electron 等重型方案的项目来说,这类自定义控件提供了一条务实而高效的路径。
该控件适用于工业监控面板、设备控制界面、系统设置模块等场景,既能清晰传达状态,又能提升整体视觉品质。更重要的是,它的实现思路——分层绘制、资源及时释放、动画与交互解耦——为其他自定义控件开发提供了可复用的经验。
关键词
#WinForm、#自定义控件、#旋转开关、#GDI+、#动画、#阴影效果、#金属质感、#刻度绘制、#状态切换
作者:技术老小子

.NET 8 + WPF 的 Modbus 智能温湿监控系统
.NET 8 微服务框架长什么样?集成 AI 智能体、自动调度与实时通信
WPF 轻量级插件框架:动态菜单、浮动窗口、热加载 DLL,开箱即用
WPF 智能仓储上位机系统,集成数据采集与轻量级 MES 功能
C# + FFmpeg 一键转码,轻松解决海康视频网页播放难题
觉得有收获?不妨分享让更多人受益
关注「DotNet技术匠」,共同提升技术实力


