一、前言
技术没有先进落后之分,只有合不合适。
WinForm有着非常多的优点,在使用WinForm久了之后,难免会觉得WinForm自带的某些控件外观上有些许朴素、或者功能上有些不如意,自然而然便想去美化这些控件,或者给控件添加一些额外功能,而这便是自定义控件的意义所在。
自定义控件的难度并不大,但是却处在一个比较尴尬的位置:
1,一般的教材不会讲——因为还是有难度的,而且一般用不上;
2,而网上或书上所找到的自定义控件相关知识教程里,大多都是给一个已完成的自定义控件,再附上源码,只有了了注释和说明。毕竟难度不大,懂的自然懂,而且对懂的人来说,看别人的自定义控件往往是为了看一下实现的思路或某个点的实现方法,因为很多都是一点就透。
对于初学者而言,要想掌握自定义控件,就需要花费不少的时间去学习那些源代码、去模仿、去练习、去摸索,最后一步步去归纳总结出适合自己的一条路。当掌握了之后,回头看去,会发现其实真的不难,耗费的时间与学习的难度并不成正比,这些额外的时间就花费在了摸索和总结上了。
我也是这样一步步走来的,所以不想让大家再花费这么多的时间去掌握一项并不太难的知识,便有了这篇文章。
在本文中,我会从零开始,带着大家一步一步去实现一个自定义控件,同时会分享一些我的经验之谈,相信看完的你,一定会有所收获。
本篇的自定义控件是:TrackBar
本文地址:https://www.cnblogs.com/lesliexin/p/13265707.html
二、前期分析
(一)为什么需要去自定义控件?
我们来分析一下为什么要去自定义控件。
以本文要实现的TrackBar为例,最主要的原因便 是系统自带的TrackBar太过朴素,所以需要一款比较好看的TrackBar控件。
系统自带的TrackBar:

预想的TrackBar样式:

(二)实现目标
在实现一个自定义控件前,我们要确定一下我们要实现的目标,比如外观、功能、特点等。
1,外观
个人经验之谈
在设计预想样式时,可以何用任何方式,只要自己可以看明白就行,但是还是推荐使用绘图软件去做一个示意图,主要是因为在自定义控件时,往往会需要用到一些坐标、宽、高等值,特别是和GDI+有关时。使用绘图软件则可以去准确和清晰的标注出来这些信息,并进行相关的计算。
我想实现的TrackBar的外观样式如下:

2,功能
参考系统的TrackBar,可以将所需要的功能归为下面几点:
(1)支持鼠标点击。

(2)支持鼠标拖动。

(3)支持修改颜色。

3,特点
既然全实现自己的TrackBar,肯定要有自己的特点。
(1)支持颜色调整,包括背景色和前景色。

(2)支持圆角显示,和直角显示。

(三)技术分析
在自定义控件的目标定好之后,接下来便是分析实现上述目标所需要的技术。
1,整体实现
自定义的TrackBar从逻辑上可以分为两层:背景条(Bar)和滑块(Slider)。

在具体实现时也是按照这两层的思路去分层实现。
2,主要技术
通过上面的分析的示意,我们发现GDI+可以实现上述目标,所以我们的主要技术便是——GDI+。
3,圆角和直角的实现
直角可以使用GDI+中的Graphics.DrawLine去实现。那么圆角怎么实现呢?
其实也很简单,仍然使用Graphics.DrawLine实现,不过在创建Pen时,需要设置一下LineCap,通过LineCap可以实现多种样式,除了圆角外,还有菱形、箭头等等。
具体的设置后文会讲解,此处不再赘述。
MSDN中关于LineCap的说明如下:
指定可用线帽样式,Pen 对象以该线帽结束一段直线。

三、开始实现
(一)前期准备
1,创建自定义控件类库项目
个人经验之谈
建议创建自定义控件时,将自定义控件写在一个单独的类库里。主要的目的是提高复用性,同时也方便管理,以及方便控件间的相互调用。
关于控件间的相互调用:
因为控件除了单个的自定义控件外,还有用户控件(UserControl)——实现某些复杂功能的时候,往往就需要用到用户控件。用户控件往往是多个控件的组合,所以将控件放到一个类库中可以方便的调用,修改也方便。
启动VS(本文使用的VS2019),添加新的 类库(.NET Framework)项目,起好项目名称并选好位置,点击创建。
个人经验之谈
关于框架的选择。
在实际应用当中,框架版本要根据自定义控件所服务的项目去选择。因为是自定义控件,所以兼容性很高,往往.Net 2.0就可以实现绝大部分效果。所以,可以根据具体的项目去选择框架的版本,当然也可以选一个.Net 2.0,然后在实现完成之后编译成不同框架版本。
2,添加类
在项目名称上右击,选择添加-类,输入类名:LTrackBar.cs,确定。
个人经验之谈
关于类名
在起自定义控件的名称时,最好不要和系统控件名称一样,那样会导致二义性,平白增加代码量。
所以可以统一加一个前缀或后缀,如:TextBoxEx,PanelPlus。本文便是统一加上前缀”L“——LTrackBar
3,添加继承
在添加继承时,根据具体的需要去选择不同的继承。比如要对ComboBox的一拉选项添加不同的颜色,就继承ComboBox并进行重绘;比如要让TextBox支持透明,就继承TextBox进行重写等等。
在本例的LTrackBar中,通过前文的分析发现很简单,所以可以继承基础的Control类。
(1)添加继承
在类名后输入”:Control“

(2)添加引用
上一步里会发现”Control“显示代表错误的波浪线,我们将鼠标悬浮在上面,在弹出的提示按钮上点击,选择”将引用添加到System.Windows.Forms.dll",然后"Control"下面的波浪线将会消失,并变为浅蓝色。

↓

(3)修改可访问性。
由于是一个单独的类库,并且LTrackBar是一个独立的控件,所以我们需要将类的可访问性修改为Public。

4,添加自定义属性
个人经验之谈
关于参数命名
对于公共参数,个人建议添加一个统一的前缀。主要原因有两点:
1,在视图设计界面中的属性窗口中,无论是“按分类排序”还是“按字母排序”,都可以使控件所公开的自定义属性集中在一起。
按分类排序:

按字母排序:

2,在代码编辑界面,可以在输入统一的前缀后,将该控件的所以自定义属性都在代码提示窗口中显示在一起,方便选择。

(1)颜色相关
通过前文可知,我们涉及到的颜色有两个——背景条颜色和滑块颜色。所以我们添加两个属性,其中的“Invalidate()”是为了在修改该属性值后立刻使控件重绘。

(2)圆角相关

(3)最大值与最小值
如TrackBar一样,我们也需要有最大值和最小值,由于我的需要很简单,所以只支持整型(int)。
首先,最小值应该大于0,然后最小值要小于最大值,所以最小值如下:

其次,最大值也应该大于最小值。

(4)当前值
用来获取或设置当前LTrackBar所代表的值。
当前值需要在最大值和最小值之间,同时我们需要知道值发生了变化,所以添加了一个委托事件LValueChanged,关于委托和事件此处不展开讲,因为不懂也不影响使用,就像固定公式一样往上套就行了。只需要知道其作用是让调用本控件的人知道当前的值发生了变化。

(5)方向
LTrackBar支持横向显示,也支持竖向显示。
在横向显示时,分为两种情况:1,左端为最小值(L_Minimum),右端为最大值(L_Maximum);2,左端为最大值(L_Maximum),右端为最小值(L_Minimum)。
在竖向显示时,分为两种情况:1,顶部为最小值(L_Minimum),底部为最大值(L_Maximum);2,顶部为最大值(L_Maximum),底部为最小值(L_Minimum)。
综上,共有4种情况,所以我们先创建一个枚举。
同样为了方便统一管理,新建一个类专门存放枚举信息。

之后,创建一个Orientation枚举类型的属性:

上面的那两个if语句的作用是为了实现在改变方向后,自动交换控件的宽和高。
(6)宽度/高度
像TrackBar只能在设计器中调整宽度一样,LTrackBar也只能调整宽度(横向显示时)或高度(竖向显示),所以需要一个属性来控制。

为了实现只能调整宽度/高度,需要重写SetBoundsCore方法,MSDN上关于SetBoundsCore的说明如下:

我们需要对其进行重写,以限制只能调整宽度或高度:

由于VS的强大,所以在重写时非常方便:

(7)增加描述信息
在公开属性上加入Catagory(分组),Description(描述)。之后便可以在属性窗口看到相应的分类和说明。


5,添加事件
为了获取LTrackBar的当前值,以及在值改变时执行某些操作,所以需要增加一个事件。事件数据则为当前值(L_Value)。
(1)新建类,继承自EventArgs。

(2)新建委托和事件

6,重写方法
通过前文的分析,我们知道主要用到了GDI+,同时支持鼠标点击、拖动。所以我们需要重写以下这些方法。

其中,OnPaint事件是用来画显示界面的。Mouse相关的事件是与实现鼠标操作相关的。
为了知道当前鼠标的状态(进入、离开、按下、松开),需要定义一个枚举:

下面是每个重写方法的具体说明:
(1)OnMouseEnter方法
标识着鼠标进入,只需要设置一下鼠标状态即可。

(2)OnMouseLeave方法
同上

(3)OnMouseUp方法
同上

(4)OnMouseDown方法
当鼠标点击了控件时会触发本事件。在鼠标点击后,控件应该重绘界面,主要是滑块(Slider)的变化,同时滑块(Slider)所代表的值也应该发生变化,同时引发LValueChanged事件。

(5)OnMouseMove方法
当鼠标在控件上移动时触发本事件,在实际操作时都是在在按着鼠标左键并拖动,所以要判断鼠标的状态(mouseStatus)是否是按下(Down)。其他同上。

在OnMouseDown和OnMouseMove中,有一个方法:pPointToValue(),其作用便是将鼠标的坐标值转换为对应代表的值。其代码如下:

其代码很简单,就是计算鼠标落点占控件宽度/高度的比例,再乘以值的范围就得到了代表的值。在下文中有示意图讲解,本处不再赘述。
(6)OnPaint方法
本方法是控件实现的核心。几乎只要涉及控件重绘和自定义控件,都兔不了要重写OnPaint方法。
在OnPaint方法中,我们主要完成两部分的操作:
1)画背景条(Bar)
2)画滑块(Slider)
这便是OnPaint方法的完整代码:


protected override void OnPaint(PaintEventArgs e){ base.OnPaint(e); pValueToPoint(); e.Graphics.SmoothingMode = SmoothingMode.HighQuality; Pen penBarBack = new Pen(_BarColor, _BarSize); Pen penBarFore = new Pen(_SliderColor, _BarSize); float fCapHalfWidth = 0; float fCapWidth = 0; if (_IsRound) { fCapWidth = _BarSize; fCapHalfWidth = _BarSize / 2.0f; penBarBack.StartCap = LineCap.Round; penBarBack.EndCap = LineCap.Round; penBarFore.StartCap = LineCap.Round; penBarFore.EndCap = LineCap.Round; } float fPointValue = 0; if (_Orientation == Orientation.Horizontal_LR || _Orientation == Orientation.Horizontal_RL) { e.Graphics.DrawLine(penBarBack, fCapHalfWidth, Height / 2f, Width - fCapHalfWidth, Height / 2f); fPointValue = mousePoint.X; if (fPointValue < fCapHalfWidth) fPointValue = fCapHalfWidth; if (fPointValue > Width - fCapHalfWidth) fPointValue = Width - fCapHalfWidth; } else { e.Graphics.DrawLine(penBarBack, Width / 2f, fCapHalfWidth, Width / 2f, Height - fCapHalfWidth); fPointValue = mousePoint.Y; if (fPointValue < fCapHalfWidth) fPointValue = fCapHalfWidth; if (fPointValue > Height - fCapHalfWidth) fPointValue = Height - fCapHalfWidth; } if (_Orientation == Orientation.Horizontal_LR) { e.Graphics.DrawLine(penBarFore, fCapHalfWidth, Height / 2f, fPointValue, Height / 2f); } else if (_Orientation == Orientation.Horizontal_RL) { e.Graphics.DrawLine(penBarFore, fPointValue, Height / 2f, Width - fCapHalfWidth, Height / 2f); } else if (_Orientation == Orientation.Vertical_TB) { e.Graphics.DrawLine(penBarFore, Width / 2f, fCapHalfWidth, Width / 2f, fPointValue); } else { e.Graphics.DrawLine(penBarFore, Width / 2f, fPointValue, Width / 2f, Height - fCapHalfWidth); }}
OnPaint
在OnPain方法用到了一个方法:pValueToPoint(),其作用是将值转换为相应坐标。代码如下:


private void pValueToPoint(){ float fCapHalfWidth = 0; float fCapWidth = 0; if (_IsRound) { fCapWidth = _BarSize; fCapHalfWidth = _BarSize / 2.0f; } float fRatio = Convert.ToSingle(_Value-_Minimum) / (_Maximum - _Minimum); if (_Orientation == Orientation.Horizontal_LR) { float fPointValue = fRatio * (Width - fCapWidth) + fCapHalfWidth; mousePoint = new PointF(fPointValue, fCapHalfWidth); } else if (_Orientation == Orientation.Horizontal_RL) { float fPointValue = Width - fCapHalfWidth - fRatio * (Width - fCapWidth); mousePoint = new PointF(fPointValue, fCapHalfWidth); } else if (_Orientation == Orientation.Vertical_TB) { float fPointValue = fRatio * (Height - fCapWidth) + fCapHalfWidth; mousePoint = new PointF(fCapHalfWidth, fPointValue); } else { float fPointValue = Height - fCapHalfWidth - fRatio * (Height - fCapWidth); mousePoint = new PointF(fCapHalfWidth, fPointValue); }}
pValueToPoint
之所以没有注释,实在是太过浅显无可注释,单纯的看代码很难理解,下面我将通过示意图的方法讲解,其实只要看了示意图,就会恍然大悟,会发现其实很简单。
7,示意图解
对于LTrackBar而言,有两种样式:直角和圆角。这两种的实现并没有太大不同,主要是Pen的LineCap属性不同,LineCap说明见前文。
(以下将以横向、从左到右的样式(_Orientation = Orientation.Horizontal_LR)进行讲解,其他类同,不多赘述。)
示意图1:

我在图中标注了一些点,主要用来详解。
上图中的B点(Rect.B、Round.B)即是当前鼠标点击的点,也是代表当前值的点,也是蓝色条的宽度。
示意图2:

在LineCap=Round时,其在绘制的线条两端会各绘制一个半圆,如上图中紫色所示。其半圆直径等于线条宽度。
下面我会讲解一下上面那些代码中的那些算式是怎么来的。
(1)直角
1)计算
已知:
起始点:Rect.A;
结束点:Rect.C;
点Rect.A 对应的值为: L_Minimum;
点Rect.C 对应的值为: L_Maximum;
鼠标可点击范围=控件宽度 = Bar.Width;
实际取值范围 = (L_Maximum-L_Minimum);
鼠标点击处的X值=点Rect.B = Slider.Width;
鼠标点击处的X值与鼠标可点击范围的比值=该点击处对应的实际值与取值范围的比值,即:
对应值/取值范围=Slider.Width/Bar.Width;
所以:
对应值(_Value)=Slider.Width/Bar.Width*(L_Maximum-L_Minimum);
由于最左侧的点Rect.A并不是0,而是对应着L_Minimum,所以,最后得到的真实值(L_Value)=_Value+L_Minimum;
2)绘制
设置Pen的宽度=Bar.Height
所以要从控件高度的中间开始绘制,其起终坐标如下:
起点:(Rect.A)=(0,Bar.Height/2);
终点:(Rect.C)=(Bar.Width,Bar.Height/2);
(2)圆角
1)计算
已知:
因为设置了圆角(LineCap=Round),所以线条两端会各绘制一个半圆(示意图中紫色半圆所示),其半圆直径等于线条宽度。
那么其开始点便不再是点Round.A,而是点Round.D,同理,其结束点也不是点Round.C,而是点Round.E。
点Round.D 对应的值为: L_Minimum;
点Round.E 对应的值为: L_Maximum;
鼠标可点击范围=控件宽度减去两个半圆的宽度 = (Bar.Width-Bar.Height);
实际取值范围 = (L_Maximum-L_Minimum);
鼠标点击处的X值 (点Round.B) = (Slider.Width-Bar.Height/2);(注意:此时鼠标点击处所产生的视觉效果范围是(Round.A~Round.F),但其真正移动的范围是(Round.D~Round.B)。)
鼠标点击处的X值与鼠标可点击范围的比值=该点击处对应的实际值与取值范围的比值,即:
对应值/取值范围= (Slider.Width-Bar.Height/2)/ (Bar.Width-Bar.Height);
所以:
对应值(_Value)= (Slider.Width-Bar.Height/2)/ (Bar.Width-Bar.Height)*(L_Maximum-L_Minimum);
由于可点击的最左侧的点Round.D对应着L_Minimum,所以,最后得到的真实值(L_Value)=_Value+L_Minimum;
2)绘制
设置Pen的宽度=Bar.Height,所以要从控件高度的中间开始绘制。
又因为设置LineCap=Round,导致两端各绘制了一个半圆,所以其起点和终点的坐标也应减去相应的值:
起点:(Round.D)=(Bar.Height/2,Bar.Height/2);
终点:(Round.E)=(Bar.Width-Bar.Height/2,Bar.Height/2);
四,效果演示及调整优化
1,演示
我们在项目上右键,选择生成,之后在同一解决方案下新建一WinForm项目,此时在工具箱的最上层会有我们的自定义控件——LTrackBar。
如图:

我们选中并添加到主界面上,并设置相应的属性。
同时添加一个label,用来显示当前的值。
其实效果如下:

在实际运行时,我们会发现在点击和拖动时,控件会有闪烁(由于GIF录制帧率,所以上面的动图不看不闪烁)。
为了解决闪烁的问题,我们在LTrackBar的构造函数上添加对双缓冲的支持。

个人经验之谈
关于双缓冲
一般而言,只要涉及到了GDI+,都会使用双缓冲技术去减少闪烁,而且使用也很简单,就两行代码而已:
SetStyle(ControlStyles.AllPaintingInWmPaint, true);
SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
当然,ControlStyles还有很多属性,其作用也各有作用,在以后的文章中如果有用到我会再说明的。
2,默认事件
默认事件,顾名思义,就是双击控件时自动生成的事件,像双击Button时的Click事件,双击TextBox时的TextChanged事件等。
要实现这种效果,需要在代码的最上面加上DefaultEvent事件,如下:

其中“LValueChanged”就是我们要设置的默认事件。这样在我们双击LTrackBar时,便会自动生成该事件。
五、结束语
通篇下来,其实可以发现并没有用到多深的知识,更多的是想像力,解放你的思想,不要被常规所束缚。
六、源代码及工程下载
https://files.cnblogs.com/files/lesliexin/LTrackBar.7z
[C#] (原创)一步一步教你自定义控件——01,TrackBar贝恩、 eBay全品类备战旺季高峰会、 crowd、 什么是dropshipping?跨境电商Dropshipping订单模式详解!、 Shopee订单退货退款、 这些Q4旺季大杀招,你可能永远想不到!、 2017年中秋节去西冲海滩玩好不好?、 2017深圳西冲三门岛有什么超级好玩的娱乐项目?、 2017深圳欢乐谷狂欢节什么时候开幕?、