简单的用户控件






4.90/5 (18投票s)
一个简单的用户控件,用于选择形状和输入尺寸。
引言
用户控件是包装了一组标准控件的对象,例如Label
、TextBox
和RadioButton
,这些控件可以供多个应用程序重复使用。这种类型的控件在管理或打印地址标签的程序中很有用,例如,或者在电子邮件中创建签名块。当开发人员预计需要将同一组用户数据用于不同目的时,它们也很有用。除了标准控件之外,用户控件还可以显示自定义图形,这是此处利用的一个功能。
本文演示了创建可重用用户控件的基本原理,该控件包含图形、数据输入和验证以及事件。此处介绍的用户控件是一个较大项目的一部分,该项目稍后将提交,并可能成为一些未来项目的核心功能。我相信,更有经验的程序员在审查代码后一定会发现很多可以补充我知识的地方,我期待着从中学习。不过,在此期间,我希望这对那些仍在努力学习如何制作真正、功能性用户控件的人有所帮助。
背景
尽管我以电气工程师的身份接受培训和工作,但在我的工作中,我被要求扮演多种角色。最近,我被要求开始设计供水和污水处理系统,这对我来说是一个全新的知识领域。我在此角色中遇到的第一个挑战是,在给定管道或渠道的坡度和尺寸的情况下,确定重力流线的流量。感谢 Google,我找到了大量信息来帮助我学习,亚马逊也为我推荐了一本优秀的关于该主题的书,《水和废水计算手册》,作者是 Shun Dar Lin。
事实证明,这个东西的基本计算称为曼宁方程,它基于观察河流和溪流的明渠流的经验数据。该公式已被推广到包括封闭管道,对此我不同意,但目前还无法纠正。数学很简单,但乏味且重复,所以我决定编写一个程序来简化变量的迭代。由于我无法预测我可能需要的渠道形状,我使用 GIMP 创建了几张图表图像供用户在我的程序的输入部分选择各种尺寸时进行选择。我从未在 Windows 环境中画画取得过好成绩,所以我走了捷径,但结果看起来很糟糕。这让我不得不痛苦地承认,是时候学习在 Windows 中画画了,而我努力的理想载体似乎是用户控件,我可以在需要相同类型数据输入的未来项目中包含它。
接下来,我将展示我创建 Windows 窗体用户控件的第一个尝试。它允许用户从三种渠道形状中进行选择——圆形、矩形和梯形——然后允许用户输入方程所需的尺寸。选择是通过组合框中的单选按钮完成的;这会导致控件重绘自身以显示所选形状。然后,用户会看到一组文本框需要填写,这些文本框对应于显示的尺寸。每次选择或值更改时,都会生成一个事件,允许宿主窗体响应更改。在控件内部,有一个无操作的虚拟处理程序,以防宿主窗体未提供处理程序。这会生成一个警告,但它仍然可以成功编译和运行。也许有一天,我会找到一个更优雅的解决方案……
解释代码
该控件设计用于 Windows 窗体,并公开了许多值
Shape
是一个enum
类型,值为Circ
、Rect
和Trap
;Circ
是默认值。Depth
是一个double
,表示管道或渠道中流体的深度。Diameter
是一个double
,仅用于圆形管道。RectWidth
是一个double
,仅用于矩形渠道。BotWidth
是一个double
,用于梯形渠道的底部宽度。TopWidth
是一个double
,用于梯形渠道在水流高度处的宽度。
它还会引发一个事件 ValueChanged
,该事件可以由宿主窗体处理,也可以忽略。在内部,它会检查输入的尺寸值是否为数字,并在输入值不符合要求时阻止任何 ValueChanged
事件的发生。
由于这是一篇面向初学者的文章,让我们详细看看代码。我花了很长时间才学会这些东西,并且对大多数文章中缺乏详细解释感到沮丧,所以很可能会让大多数读者感到厌烦。不过,希望我能帮助到其他像我一样渴望清晰解释代码作用的人。不过,我必须提前警告您,有几个部分我无法解释——我只是尝试各种方法,直到 IDE 不再抱怨错误,并且当代码成功编译并按预期工作时,我将其视为“Good™”。
首先,让我们看看控件的构造函数,以及它在加载时执行的代码。本节还涵盖了使用的变量和公开的属性。
using System;
using System.Windows;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Data;
using System.Text;
using System.Windows.Forms;
namespace MyPanel
{
public partial class FlowPanel : UserControl
{
//Member variables and types
public enum myShape { Circ, Rect, Trap }; //Channel shape enumeration
private myShape shape = new myShape();
private double dim1; //User data - depth of flow
private double dim2; //User data - diameter or width value
private double dim3; //User data - width value
private double prev; //Temporary data to suppress excess events
private Font TextFont = new Font("Ariel", 9);
public MyEventArgs MyArgs = new MyEventArgs();
//Properties
public myShape Shape //Used by all
{
get { return shape; }
set { shape = value; }
}
public double Depth //Used by all
{
get { return dim1; }
set { dim1 = value; }
}
public double Diameter //Used for circular shape only
{
get { return dim2; }
set { dim2 = value; }
}
public double RectWidth //Used for rectangular shape only
{
get { return dim2; }
set { dim2 = value; }
}
public double BotWidth //Used for trapezoidal shape only
{
get { return dim2; }
set { dim2 = value; }
}
public double TopWidth //Used for trapezoidal shape only
{
get { return dim3; }
set { dim3 = value; }
}
//Constructor
public FlowPanel()
{
InitializeComponent();
this.Shape = myShape.Circ;
//Initialize default shape as Circular
}
//Panel Events
private void FlowPanel_Load(object sender, EventArgs e)
{
if (this.FindForm() != null)
{
Form MyForm = this.FindForm();
this.BackColor = MyForm.BackColor;
}
}
第一部分由 Visual Studio 用户控件模板创建,包含几个不需要的条目;我没 bother 去删除它们。FlowPanel
类继承自 UserControl
,并在一个组合框中包含三个单选按钮和三个文本框。这些可以在 FlowPanel.Designer.cs 文件中找到。成员变量用于保存用户选择和输入的数据,可以通过为每个变量公开的属性供父窗体访问。此处还声明了另外两个变量——TextFont
和 MyArgs
。这些用于在控件的图形部分绘制文本,以及在 FlowPanel
的事件处理程序中传递自定义信息。我稍后会介绍这些。
属性部分使用 get
和 set
运算符来提供对 FlowPanel
类内部使用的私有全局变量的访问。这些对于允许控件和宿主窗体之间的通信是必需的。通常,为了增强封装和信息隐藏,将所有成员变量设为私有符合面向对象编程的理念。理论是,最终用户不需要知道内部是如何工作的,并且如果使用一个代码块不需要了解其实现,那么它就更具可重用性。这允许在不破坏依赖于它的其他代码的情况下完全重写原始代码,只要公开的变量没有改变。在一个更复杂的工作中,例如执行许多计算的工作,这会更有意义。在本例中,则不然,因为几乎每个成员变量都需要暴露给外部世界,但我认为保持一致是个好习惯,即使只是为了帮助我养成好习惯。
由于 C# 是区分大小写的,所以使用相同的名称来命名公共属性和私有成员变量(仅首字母大小写不同)很方便。不必包含 get
和 set
操作——省略 set
部分将使属性变为只读——但我可能想稍后从宿主窗体更改这些值,所以我在代码中保留了它们。这里有一个有趣的点(至少我觉得有趣)是,私有变量 dim2
有三个公共属性。我使用 FlowPanel
上的同一个文本框来存储三个不同的值。对于圆形,它是直径,但对于其他形状,它存储一个宽度值。在宿主窗体中,如果我正在处理一个圆,我想直接访问直径,如果我正在处理一个矩形渠道,我想获取宽度 (RectWidth
)。通过使用多个 getter,我可以使用这些不同的名称使我的窗体代码更具可读性。控件并不关心它们实际上都在访问同一个文本框,而且这可以省去我以后记住直径实际上意味着宽度的麻烦,当选定的形状是矩形时。
接下来,我们来看 FlowPanel
的初始化代码,它由一个构造函数和一个控件加载到父窗体时触发的事件组成。构造函数首先调用 InitializeComponent()
,它实例化 FlowPanel.Designer.cs 文件中包含的各种标签、文本框、单选按钮等。这是在使用向导创建项目时自动生成的,无需修改。由于用户控件的默认颜色是一种异常丑陋的灰色(可能是因为微软没有人能找到更难看的颜色),我认为让控件通过继承父窗体的背景色来融入其中会更令人愉悦。FlowPanel_Load()
函数负责这一点。if()
语句用于确保控件确实包含在窗体中,然后将控件的背景色设置为与宿主匹配。关键字 this
引用当前类,FindForm()
返回宿主容器的引用。使用一个新的 Form
实例 MyForm
来获取宿主窗体背景色,并将其设置为当前对象的颜色以匹配。
在继续讲解代码之前,让我们看一下实际产品
请注意,当选择的形状是梯形时,会出现一个新的文本框。这是通过使用 TextBox
的 Visible
属性来实现的,该属性通过测试 Shape
值来设置。另请注意,标签已更改。所有这些效果都是通过 FlowPanel
上各个控件生成的事件实现的,并且在本地处理,无需宿主窗体执行任何操作。现在介绍这些处理程序似乎是个好时机,所以
//Control Event Handlers
private void rbCirc_CheckedChanged(object sender, EventArgs e)
{
if (rbCirc.Checked == true)
{
Shape = myShape.Circ;
MyArgs.MyControl = "rbCirc";
RaiseEvent(sender, MyArgs);
lblDim1.Text = "Depth, d";
lblDim2.Text = "Diameter, D";
lblDim3.Visible = false;
txtDim3.Visible = false;
Invalidate();
}
}
private void rbRect_CheckedChanged(object sender, EventArgs e)
{
if (rbRect.Checked == true)
{
Shape = myShape.Rect;
MyArgs.MyControl = "rbRect";
RaiseEvent(sender, MyArgs);
lblDim1.Text = "Depth, d";
lblDim2.Text = "Width, W";
lblDim3.Visible = false;
txtDim3.Visible = false;
Invalidate();
}
}
private void rbTrap_CheckedChanged(object sender, EventArgs e)
{
if (rbTrap.Checked == true)
{
Shape = myShape.Trap;
MyArgs.MyControl = "rbTrap";
RaiseEvent(rbTrap, MyArgs);
lblDim1.Text = "Depth, d";
lblDim2.Text = "Bottom Width, W1";
lblDim3.Text = "Top Width, W2";
lblDim3.Visible = true;
txtDim3.Visible = true;
Invalidate();
}
}
这三个事件处理程序都响应其各自单选按钮的 Checked
属性的变化。每个处理程序都将 Shape
的值设置为新选定的形状值,然后它们会为父窗体生成一个事件以供处理,该事件仅将新选定形状的名称传递给窗体。RaiseEvent()
函数和 MyArgs
将稍后讨论。之后,它们各自调用 Invalidate()
,这会强制 Windows 通过调用 OnPaint()
方法重绘控件。这可能是介绍 OnPaint()
方法的好时机,我发现这是整个练习中最乏味的部分。为了简洁起见,我将只展示绘制圆形形状的部分,但对于所有形状,原理都是一样的。
//Graphics
protected override void OnPaint(PaintEventArgs e)
{
using (Pen blackPen = new Pen(Color.Black, 1))
{
e.Graphics.DrawRectangle(blackPen, 200, 18, 195, 195);
switch (Shape)
{
case myShape.Circ: //Circular pipe
{
//Draw the pipe
e.Graphics.DrawEllipse(blackPen, 265, 50, 120, 120);
//Add the dimension Diameter, D
e.Graphics.DrawLine(blackPen, 220, 50, 300, 50);
e.Graphics.DrawLine(blackPen, 220, 170, 300, 170);
e.Graphics.DrawLine(blackPen, 220, 60, 230, 50);
e.Graphics.DrawLine(blackPen, 240, 60, 230, 50);
e.Graphics.DrawLine(blackPen, 220, 160, 230, 170);
e.Graphics.DrawLine(blackPen, 240, 160, 230, 170);
e.Graphics.DrawLine(blackPen, 230, 100, 230, 50);
e.Graphics.DrawLine(blackPen, 230, 120, 230, 170);
using (SolidBrush blackBrush = new SolidBrush(Color.Black))
{
e.Graphics.DrawString("D", TextFont, blackBrush, 225, 105);
}
using (Pen bluePen = new Pen(Color.Blue, 1))
{
//Draw the water level
e.Graphics.DrawLine(bluePen, 265, 110, 385, 110);
//Add the dimension Depth, d
e.Graphics.DrawLine(bluePen, 325, 160, 325, 110);
e.Graphics.DrawLine(bluePen, 315, 120, 325, 110);
e.Graphics.DrawLine(bluePen, 335, 120, 325, 110);
using (SolidBrush blueBrush = new SolidBrush(Color.Blue))
{
e.Graphics.DrawString("d",TextFont,blueBrush, 335,120);
}
}
break;
}
您可以看到,我已经将 OnPaint()
实现分解为 switch
块,它们测试 Shape
的当前值以确定要绘制哪个形状。进入方法后,会启动一个 using
块,以免每次都必须指定要使用的 Pen
。这还有一个额外的好处,即确保笔资源将在方法退出时被销毁。Pen
对象需要两个参数——颜色和像素宽度。令我惊讶的是,Windows 将圆形绘制为椭圆。实际上,这不应令人惊讶,因为圆是椭圆的一种特殊情况,两个焦点都位于同一点。以下几行绘制了尺寸线和箭头。每个线段都必须单独绘制,这就是为什么我多年来一直努力避免学习这一点。
我应该在此提到,尽管我读过的所有书籍都告诉我创建一个设备上下文,然后在其上绘图,但 PaintEventArgs
对象 e
为我处理了这个问题,并提供了对所有这些方便功能的易于访问。我仍然没有牢固掌握这些概念,但总有一天会掌握的。现在,我将与您分享我从许多乐于助人的 CodeProject 成员那里学到的技巧。
使用 Pen
对象加上起点和终点来绘制尺寸线并不难——这就是 e.Graphics.DrawLine()
调用中参数的指定方式。第一个参数是选定的 Pen
,接下来的两个是起点,最后两个是终点,采用 X,Y 格式。这些值以像素为单位,原点位于控件的左上角。X 值从左到右测量,Y 值从上到下增加。
绘制简单文本需要一个新对象,一个 Brush
。虽然我可能在创建 Pen
的同时创建了 Brush
对象,但不知为何我没有这样做,现在我也不想改变它。另一个 using
块用于绘制文本,以一两个字符的标签标识每个尺寸线。事后看来,我想到了显示管道中的水并用不同颜色表示,以及用相同的颜色显示水深可能会很酷。这导致了最后一部分,它被包含在另一个 using
块中,与前一个非常相似,但使用了蓝色的 Pen
和 Brush
。矩形和梯形情况下的代码大致相同,只是参数不同,所以没有必要在此包含。请注意,e.Graphics.DrawString()
方法需要一个字体选择 TextFont
。这在第一部分中定义了,但回想起来,在 OnPaint()
方法中定义它可能更好。这样可以在方法完成后释放该资源。
接下来,我想讨论文本框的行为。我打算将此控件用于的应用程序期望用户输入的值是 double
类型,并且它不应该担心用户输入中的意外错误。控件必须实现某种验证,以确保仅输入实际数字作为所考虑管道和渠道的尺寸。我的第一个想法是使用文本框的 TextChanged
事件来为宿主窗体引发一个事件以供处理,并触发验证。这产生了一些意想不到的副作用。首先,它导致了一个事件被触发,告知宿主窗体值已更改,即使该值无效。这是不可接受的。我找到的解决方案如下:
private double ValidateEntry(TextBox MyTextBox)
{
try
{
return Double.Parse(MyTextBox.Text);
}
catch (FormatException ex)
{
MessageBox.Show("Enter a valid numeric value\n" + ex.Message);
MyTextBox.Focus();
return 0.0;
}
}
//txtDim1
private void txtDim1_Enter(object sender, EventArgs e)
{
txtDim1.SelectAll();
}
private void txtDim1_Leave(object sender, EventArgs e)
{
prev = dim1; //Save the current value
dim1 = ValidateEntry(txtDim1); //Get the new value
if (dim1 != 0.0 & prev != dim1)
//Raise an event if Validation passed AND value changed
{
MyArgs.MyControl = "txtDim1";
RaiseEvent(txtDim1, MyArgs);
}
}
private void txtDim1_KeyPress(object sender, KeyPressEventArgs e)
{
if (e.KeyChar == 13)
{
txtDim2.Focus();
}
}
//txtDim2
private void txtDim2_Enter(object sender, EventArgs e)
{
txtDim2.SelectAll();
}
private void txtDim2_Leave(object sender, EventArgs e)
{
prev = dim2;
dim2 = ValidateEntry(txtDim2);
if (dim2 != 0.0 & prev != dim2)
{
MyArgs.MyControl = "txtDim2";
RaiseEvent(txtDim2, MyArgs);
}
}
private void txtDim2_KeyPress(object sender, KeyPressEventArgs e)
{
if (e.KeyChar == 13)
{
if (txtDim3.Visible == true)
{
txtDim3.Focus();
}
else
{
txtDim1.Focus();
}
}
}
//txtDim3
private void txtDim3_Enter(object sender, EventArgs e)
{
txtDim3.SelectAll();
}
private void txtDim3_Leave(object sender, EventArgs e)
{
prev = dim3;
dim3 = ValidateEntry(txtDim3);
if (dim3 != 0.0 & prev != dim3)
{
MyArgs.MyControl = "txtDim3";
RaiseEvent(txtDim3, MyArgs);
}
}
private void txtDim3_KeyPress(object sender, KeyPressEventArgs e)
{
if (e.KeyChar == 13)
{
txtDim1.Focus();
}
}
这里有很多事情正在进行,所以请注意。首先,每次用户尝试离开一个文本框并进入下一个文本框时,都会调用 ValidateEntry()
方法。Double.Parse()
方法尝试将传递给它的文本转换为 double
类型;如果成功,它将返回 double
值,但如果失败,它将引发 FormatException
。在 try
/catch
块中,会捕获此异常,并生成一个 MessageBox
来告知用户已检测到无效条目。将 0.0 值返回给调用方法,以抑制向父窗体引发事件。由于在此上下文中,输入值 0.0 相当无意义,我用它来检测无效条目。
由于我打算最终使其成为一个交互式控件,其行为类似于用于设计污水管道的电子表格组件,因此能够更改或接受先前输入的数据很重要。我最初为每个文本框使用 Enter
事件来清除框以供新输入,但后来发现 SelectAll()
方法是更好的选择,因为它允许用户选择保留输入,或者轻松删除并重新开始。它也不会立即引发 TextChanged
事件,而我最初就是用它来捕获用户输入的。当前的解决方案效果更好。
我没有预料到的一个怪癖是,当用户在文本框中输入值后按 Enter 键时,光标不会自动移到下一个文本框。我使用过的每个程序都会这样做,所以我期望它是默认行为。错误假设!感谢我的 CP 同事们,我了解到我必须为每个文本框实现一个 KeyPress
处理程序,并测试 Enter 键(e.KeyChar == 13
)才能将光标移动到新文本框。这可以通过目标文本框的 Focus()
方法来实现。这反过来又因为并非所有文本框都可见而变得复杂,具体取决于 Shape
的当前设置。您可以在 txtDim2
的 KeyPress()
处理程序中看到这一点;它会测试 txtDim3
文本框当前是否可见;如果可见,它会将焦点移至 txtDim3
,如果不可见,则将其移至控件上的第一个文本框。
我把最不理解的部分留到了最后,尽管我正在努力理解它——事件和委托。非常感谢 DaveyM69、Luc Pattyn、Henry Minute 和其他几个人帮助我增加了对该主题的理解,但教育我的工作尚未完成。此控件实现了一个事件来通知父窗体值何时发生更改;窗体是否利用该信息无关紧要。标准的 EventArgs
只将事件在 FlowPanel
对象中发生的这一事实传递给订阅它的元素,所以我不得不创建一个新的 MyArgs
结构,其中包含一个成员——MyControl
——它包含值发生更改的控件的名称。创建和处理事件的代码是:
//Events
public event EventHandler ValueChanged;
public class MyEventArgs : EventArgs
{
private string myControl;
public string MyControl
{
get { return myControl; }
set { myControl = value; }
}
}
protected virtual void OnValueChanged(object sender, MyEventArgs e)
{
EventHandler eh = ValueChanged;
if (eh != null)
eh(this, e);
}
public void RaiseEvent(object sender, MyEventArgs e)
{
OnValueChanged(sender, e);
}
这段代码创建了一个名为 ValueChanged
的事件,定义了一个名为 MyEventArgs
的新 EventArgs
类,并声明了一个名为 OnValueChanged
的函数来本地处理该事件。它还定义了一个名为 RaiseEvent()
的公共方法,该方法调用受保护成员 OnValueChanged()
。MyEventArgs
是必需的,因为默认的 EventArgs
只将 FlowPanel
控件的名称返回给父窗体,而不是 FlowPanel
内控件的名称。虽然这也能工作,但这需要窗体去获取每个控件的当前值,然后进行测试以确定哪个发生了变化,然后做出相应的响应;太麻烦了!相反,我实现了一个自定义的 EventArgs
类,其中包含一个名为 MyControl
的单文本值。在每个调用 RaiseEvent()
的控件处理程序中,将调用文本框或单选按钮的名称传递给 MyControl
,并将其正确地返回给父窗体。此时,我的理解就到此为止了。我对这为何以及如何工作只有模糊的概念,但它确实有效。我接下来将展示的测试窗体成功显示了此处描述的 FlowPanel
,并且正确响应了生成的事件。
使用代码
namespace FlowTestForm
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
flowPanel1.ValueChanged += new EventHandler(flowPanel1_ValueChanged);
}
private void flowPanel1_ValueChanged(object sender, EventArgs e)
{
MessageBox.Show(flowPanel1.MyArgs.MyControl + "changed");
}
}
}
使用向导创建 Windows 窗体应用程序生成了常规代码,并且没有显示样板部分。使用“工具”菜单将我的 FlowPanel
类添加到“工具箱”中,我在列表中找到它,然后将一个副本拖到窗体上。单击“事件”按钮,我找到事件 ValueChanged
,然后双击它。这会自动为事件创建一个处理程序 flowPanel1_ValueChanged()
,我只需向其中添加一个 MessageBox
来显示发生更改的控件的名称。为了使窗体响应事件,有必要在 InitializeComponent()
之后添加以下行来连接起来。这一行实际上使窗体订阅了该事件,使其能够接收事件已引发的通知。
目前,这个窗体只显示一条消息,但这为我扩展功能提供了一个抓手。我将首次使用此用户控件来彻底改造我丑陋的“私生子”,构建一个更好看的简单流量计算器。在那之后,谁知道呢?
关注点
我发现一个成员变量可以拥有多个名称不同的属性,这非常有趣且有用,它将使使用此控件变得容易得多。发现如何使控件采用父窗体的属性也很有趣,我猜测有一天我想重新访问它,使其可调整大小,具体取决于父字体的尺寸。这应该会使外观更一致。令我惊讶的是,控件一旦嵌入窗体,就暴露了 ValueChanged
事件,而不是公共的 RaiseEvent()
函数,但我猜我对事件的理解会随着时间的推移而提高。
欢迎评论和建议——我在做这个项目时学到了很多东西,我非常感谢我们社区里的许多 CodeProject 成员。我知道这对我们社区里的许多人来说都是小事一桩,但我希望它对其他像我一样仍在努力理解基础知识的人有所启发。
历史
- 版本 1.1.0.0 - 2010 年 6 月 15 日。