一、前言

技术没有先进落后之分,只有合不合适。

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 = ;
float fCapWidth = ;
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 = ;
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 = ;
float fCapWidth = ;
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的更多相关文章

  1. [C#] (原创)一步一步教你自定义控件——04,ProgressBar(进度条)

    一.前言 技术没有先进与落后,只有合适与不合适. 本篇的自定义控件是:进度条(ProgressBar). 进度条的实现方式多种多样,主流的方式有:使用多张图片去实现.使用1个或2个Panel放到Use ...

  2. Ace教你一步一步做Android新闻客户端(一)

    复制粘贴了那么多博文很不好意思没点自己原创的也说不出去,现在写一篇一步一步教你做安卓新闻客户端,借此机会也是让自己把相关的技术再复习一遍,大神莫笑,专门做给新手看. 手里存了两篇,一个包括软件视图 和 ...

  3. 一步一步教你如何在linux下配置apache+tomcat(转)

    一步一步教你如何在linux下配置apache+tomcat   一.安装前准备. 1.   所有组件都安装到/usr/local/e789目录下 2.   解压缩命令:tar —vxzf 文件名(. ...

  4. 一步一步教你将普通的wifi路由器变为智能广告路由器

    一步一步教你将普通的wifi路由器变为智能广告路由器 相信大家对WiFi智能广告路由器已经不再陌生了,现在很多公共WiFi上网,都需要登录并且验证,这也就是WiFi广告路由器的最重要的功能.大致就是下 ...

  5. 一步一步教你使用Git

    一步一步教你使用Git 互联网给我们带来方便的同时,也时常让我们感到困惑.随便搜搜就出一大堆结果,然而总是有大量的重复和错误.小妖发出的内容,都是自己实测过的,有问题请留言. 现在,你已经安装了Git ...

  6. 微凉大大,教你一步一步在linux中正确的安装Xcache加速php。

    首先,强烈吐槽,百度上的教程,都左复制右复制的,乱七八糟,缺东缺西的.借此微凉大大我提供我苦心整理好的教程.以便各位小菜能顺利的使用Xcache加速php,假设看完了,也操作了,还是失败了的话,请联系 ...

  7. 使用WPF教你一步一步实现连连看

    使用WPF教你一步一步实现连连看(一) 第一步: 问题,怎样动态的建立一个10*10的grid(布局) for (int i = 0; i < 10; i++){ RowDefinition r ...

  8. (原创)超详细一步一步在eclipse中配置Struts2环境,无基础也能看懂

    (原创)超详细一步一步在eclipse中配置Struts2环境,无基础也能看懂 1. 在官网https://struts.apache.org下载Struts2,建议下载2.3系列版本.从图中可以看出 ...

  9. 一步一步教你用 Vue.js + Vuex 制作专门收藏微信公众号的 app

    一步一步教你用 Vue.js + Vuex 制作专门收藏微信公众号的 app 转载 作者:jrainlau 链接:https://segmentfault.com/a/1190000005844155 ...

随机推荐

  1. NOI Online #3 提高组 T1水壶 题解

    题目描述 有 n 个容量无穷大的水壶,它们从 1∼n 编号,初始时 i 号水壶中装有 Ai 单位的水. 你可以进行不超过 k 次操作,每次操作需要选择一个满足 1≤x≤n−1 的编号 x,然后把 x ...

  2. VSCode下,项识别为 cmdlet、函数、脚本文件或可运行程序的名称。

    vscode下webpack错误:无法将“webpack”项识别为 cmdlet.函数.脚本文件或可运行程序的名称.请检查名称的拼写,如果包括路径,请确保路径正确,然后再试一次. 解决方法: 1.因为 ...

  3. java IO流 (九) Path、Paths、Files的使用

    1.NIO的使用说明:>Java NIO (New IO,Non-Blocking IO)是从Java 1.4版本开始引入的一套新的IO API,可以替代标准的Java IO AP.>NI ...

  4. conda install 失败 http404

    最近conda install keras出现各种问题,显示配置问你,配置了清华中科大的源,都不行 估计原因是:配置各种源太多,最后全部删除只留一个清华源,成功 暴力方法直接删除C:\Users\Ad ...

  5. db2数据库创建删除主键约束和创建删除唯一键约束

    创建.删除唯一约束: db2 "alter table tabname add unique(colname)" db2 "alter table tabname dro ...

  6. selenium自动化测试实战——12306铁路官网范例

    一.Selenium介绍 Selenium 是什么?一句话,自动化测试工具.它支持各种浏览器,包括 Chrome,Safari,Firefox 等主流界面式浏览器,如果你在这些浏览器里面安装一个 Se ...

  7. ASP.Net Core 3.1 With Autofac ConfigureServices returning an System.IServiceProvider isn't supported.

    ASP.Net Core 3.1 With Autofac ConfigureServices returning an System.IServiceProvider isn't supported ...

  8. 牛客练习赛 66C公因子 题解

    原题 原题 思路 考场想复杂了,搞到自闭-- 实际上,因为差值不变,我们可以先差分,求\(\gcd\),便得到答案(考场时想多了,想到了负数.正数各种复杂的处理,但是不需要),最后处理一下即可 代码 ...

  9. JS 原型与原型链终极详解(二)

    四. __proto__ JS 在创建对象(不论是普通对象还是函数对象)的时候,都有一个叫做__proto__ 的内置属性,用于指向创建它的构造函数的原型对象. 对象 person1 有一个 __pr ...

  10. 本周六 Apache DolphinScheduler & Doris 将联合线上 Meetup

    活动背景 2020年,大数据成为国家基建的一个重要组成,大数据在越来越多的领域展现威力.随着大数据的应用场景越来越多,大家对数据的响应速度和数据加工工作流的方便程度也提出了更高的要求.在这种背景下,相 ...