简介:面向.NET开发人员的C#自定义属性编辑器(UITypeEditor)示例包,围绕在Visual Studio属性窗口中为控件、类或自定义类型提供定制编辑界面的场景,帮助开发者更直观、高效地完成属性赋值与校验,适合希望扩展设计时能力的WinForms/组件开发人员。压缩包共47个文件,以30个C#源文件为核心,配套8个resx资源文件、2个csproj工程文件以及settings、config、sln等配置类文件,整体仅47KB,目前已有3329人学习使用。内容以两个可运行示例分别演示UserControl与Component两种属性编辑器实现路线:包括继承UITypeEditor并重写EditValue与GetEditStyle、通过EditorAttribute将自定义编辑器关联到目标属性,以及结合DesignerSerializationVisibility让设计时修改自动持久化到代码中。整体结构清晰,便于直接对照练习,可在此基础上扩展出颜色选择、图片库选择、复杂集合编辑等实用编辑器。
1. 先搞清楚这玩意儿到底解决什么问题
如果你做过 WinForms 上位机开发,或者维护过自定义控件库,大概率遇到过这种尴尬:自己封装的类属性拖到 PropertyGrid 上,属性值那一栏显示的是类的全名,双击也只能修改内部基础属性,遇到 List、Dictionary 这种复杂类型,配置起来恨不得手写 JSON。C# 的 UITypeEditor 就是专门解决这个痛点的机制,它允许你在属性窗口上挂一个自定义属性编辑器,把原本只能靠手敲的属性变成下拉框、弹出对话框,甚至直接在值区域画一张预览图。
我第一次真正用到它,是在做一个海康相机采集参数面板的时候。几个核心参数在代码里就是几个字符串宽度、像素格式、触发方式,但呈现在属性窗口里特别难看,还得让现场调试人员对着文档敲。后来花大半天时间用 UITypeEditor 改成下拉选择、弹窗配置,体验立刻就不一样了,从那以后我自己的 WinForms 工具类项目里,凡是涉及自定义类型,基本优先考虑用 UITypeEditor 把交互做顺手。
这篇内容适合谁?正在写 WinForms、做上位机软件、做自定义控件和设计期配置界面的同学,都能用得上。不需要你有特别深的基础,但至少得会 C# 的基本语法和 WinForms 的基础操作。我尽量把原理、代码、坑都写出来。
2. 核心原理:搞懂四个方法,再多的编辑器都能写
2.1 GetEditStyle 决定编辑器长什么样子
UITypeEditor 不是个接口,是个抽象类,一般情况下你只需要关注几个可重写的方法。第一个是 GetEditStyle,它告诉 PropertyGrid:我这个属性用哪种交互方式。
返回值有三种:
- DropDown:点击值区域之后,在下方弹出一个面板,比如下拉列表、颜色选择器
- Modal:点击之后弹出一个模态对话框,适合配置一个复杂对象,比如串口参数、相机标定参数
- None:不做自定义编辑,相当于不启用编辑器
这个方法的调用时机是属性窗口每次要绘制该属性时,所以要保证这个方法足够轻量,别在里面做耗时操作。比如从数据库读配置这种活儿,绝对不要放这里。
2.2 EditValue 是真正的编辑逻辑入口
点击属性值旁边的省略号按钮或者下拉箭头,最终会触发 EditValue。这里才是你真正干活的地方。方法签名是:
public override object EditValue( ITypeDescriptorContext context, IServiceProvider provider, object value)三个参数分别是什么意思?
- context:上下文,能拿到当前正在编辑的对象实例、属性描述符等。想修改同一个控件上其他属性,可以从这里下手
- provider:服务提供者。这个是关键中的关键,你要弹下拉面板,必须通过它拿到 IWindowsFormsEditorService,否则面板弹不出来
- value:当前属性的值,也就是编辑前的初始内容
EditValue 的返回值就是最终写入属性的值。所以不管你怎么编辑,最后必须 return 一个新的值或者修改后的值。
2.3 PaintValue 让属性在窗口里直接“看得见”
这个不算必选,但对体验的提升非常明显。它可以让属性值区域不再显示单调的文本,而是画一张图。最典型的就是颜色属性,不用看一串 “Color [A=255, R=255...]”,直接显示一个色块。
使用 PaintValue 需要两个方法配合:
- GetPaintValueSupported:返回 true 表示支持自定义绘制
- PaintValue:在 e.Bounds 范围里用 Graphics 画东西,比如填充色块
2.4 TypeConverter 和 UITypeEditor 分工别搞混
很多初学者容易把 TypeConverter 和 UITypeEditor 混在一起,其实这俩负责完全不同的环节。
TypeConverter 负责“显示”和“字符串互转”。比如一个自定义对象要显示成一个字符串,或者一个字符串要转成对象,靠的是它。UITypeEditor 负责“交互编辑”。严格来说它们可以独立使用,但实际项目里经常搭配:TypeConverter 让对象在属性网格中可读,UITypeEditor 让对象可编辑得更顺畅。
举个例子。串口参数类 SerialPortOptions 里放五个字段,如果不挂 TypeConverter,属性区域显示的是类全名;挂了 ExpandableObjectConverter,属性展开后可以逐个编辑子属性。如果还想弹窗编辑整套参数,再加一个 Modal 风格的 UITypeEditor。分工清楚,代码才不容易纠结。
3. 实战:三种编辑器从零写一遍
3.1 下拉式编辑器:配置项用列表选,简单又直观
我先说最常用的下拉式。目标是把一个“触发模式”属性变成下拉列表,里面预置几个选项。
先写编辑器类。关键点:拿到 IWindowsFormsEditorService,创建一个 ListBox,塞进下拉面板里,最后通过 DropDownControl 显示。
using System; using System.ComponentModel; using System.Drawing.Design; using System.Windows.Forms; using System.Windows.Forms.Design; public class TriggerModeEditor : UITypeEditor { private ListBox _listBox; public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context) { return UITypeEditorEditStyle.DropDown; } public override object EditValue( ITypeDescriptorContext context, IServiceProvider provider, object value) { IWindowsFormsEditorService svc = provider?.GetService(typeof(IWindowsFormsEditorService)) as IWindowsFormsEditorService; if (svc == null) return value; _listBox = new ListBox(); _listBox.Items.AddRange(new object[] { "硬件触发", "软件触发", "连续采集" }); _listBox.SelectedItem = value?.ToString(); _listBox.SelectedIndexChanged += (s, e) => { svc.CloseDropDown(); }; svc.DropDownControl(_listBox); return _listBox.SelectedItem ?? value; } }编辑器写好后,挂在需要自定义的属性上。注意 EditorAttribute 的写法,第一个参数是编辑器类型,第二个参数是 UITypeEditor 类型本身:
[Editor(typeof(TriggerModeEditor), typeof(UITypeEditor))] public string TriggerMode { get; set; }写完跑起来,属性窗口里点这个属性,右侧出现展开按钮,点击后下拉一个列表,选中即关闭并写入新值。
这里有三个细节需要注意。
第一,ListBox 选中的时候会触发 SelectedIndexChanged,然后立即关闭下拉面板。如果你希望在关闭前做校验,就不要用事件关闭,而是等 DropDownControl 返回后,再通过 _listBox.SelectedItem 取结果。
第二,DropDownControl 是同步方法,面板显示期间代码会阻塞在这里,等面板关闭后才会继续往下走。
第三,ListBox 有默认高度,如果项目多,需要提前设置高度。DropDownControl 在显示时会自动调整面板宽度和高度适配控件,但我实测有时候对宽度的适应并不理想,建议给控件设置一个合理的 Size,别完全依赖自动调整。
3.2 模态对话框编辑器:配置复杂对象的正确姿势
下拉面板毕竟空间有限,如果属性是一个包含多个子项的对象,弹窗才是更合理的交互方式。我以“串口参数”为例写一个。
先定义一个可配置对象:
public class SerialPortOptions { public string PortName { get; set; } public int BaudRate { get; set; } public int DataBits { get; set; } public System.IO.Ports.Parity Parity { get; set; } public System.IO.Ports.StopBits StopBits { get; set; } public override string ToString() { return $"{PortName}, {BaudRate}, {DataBits}, {Parity}, {StopBits}"; } }然后是编辑器类:
public class SerialPortOptionsEditor : UITypeEditor { public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context) { return UITypeEditorEditStyle.Modal; } public override object EditValue( ITypeDescriptorContext context, IServiceProvider provider, object value) { IWindowsFormsEditorService svc = provider?.GetService(typeof(IWindowsFormsEditorService)) as IWindowsFormsEditorService; if (svc == null) return value; SerialPortOptions current = value as SerialPortOptions ?? new SerialPortOptions(); using (SerialPortConfigForm form = new SerialPortConfigForm(current)) { if (svc.ShowDialog(form) == DialogResult.OK) { return form.Options; } } return value; } }这里 SerialPortConfigForm 是需要单独做的 Form,内部放几个 ComboBox 和 TextBox,构造时接收初始值,点确定时把界面内容组装成新的 SerialPortOptions。这个窗体的代码我就不贴了,做 WinForms 的同学闭眼都能写。
一个大坑:弹窗里修改的一定是副本或者新创建的对象,绝对不要直接修改传入的那个 value。因为修改传入的对象,在点击“取消”时状态已经回不去了。比如打开弹窗,把波特率从 9600 改成 115200,再点取消,如果不做副本,界面没变化但对象内部已经被改了,后面所有用到该对象的地方都会读到一个“取消的修改”。这种 bug 查起来特别隐蔽。
绑定方式一样,属性上挂特性:
[Editor(typeof(SerialPortOptionsEditor), typeof(UITypeEditor))] public SerialPortOptions SerialPort { get; set; }额外提一句,弹窗模式最好也能让属性网格里看到可见字符串。此时可以给 SerialPortOptions 挂个 TypeConverter,继承 ExpandableObjectConverter 或者自定义一个,让 ToString 正常显示,否则属性区域那一栏始终显示类型全名,不太好看。
3.3 预览图绘制:在属性窗口里实时看到效果
这个功能对“颜色类属性”或者“带状态的枚举”特别有用。我拿颜色举例,让属性区域直接显示色块。
给颜色属性写一个带 PaintValue 的编辑器:
public class ColorPreviewEditor : UITypeEditor { public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context) { return UITypeEditorEditStyle.None; } public override bool GetPaintValueSupported(ITypeDescriptorContext context) { return true; } public override void PaintValue(PaintValueEventArgs e) { base.PaintValue(e); Color c = (e.Value is Color color) ? color : Color.Transparent; using (SolidBrush brush = new SolidBrush(c)) { e.Graphics.FillRectangle(brush, e.Bounds); } e.Graphics.DrawRectangle(Pens.Black, e.Bounds); } }这里 GetEditStyle 返回 None,表示不去做弹出式编辑,只做绘制。挂到属性上:
[Editor(typeof(ColorPreviewEditor), typeof(UITypeEditor))] public Color MarkColor { get; set; }运行后属性区域会显示一个填充了当前颜色的色块,视觉上特别直观。做区域标定、通道标记等功能时,这个效果很加分。
绘制预览图时注意两点:一是 e.Bounds 是属性值那一栏的矩形区域,尺寸不大,绘制逻辑要简单,别搞多重抗锯齿、渐变这类耗时操作,刷新时会卡;二是绘制超出边界的部分会被裁剪掉,不用太担心画笔会画出格子外。
4. 常见问题与排查技巧实录
4.1 编辑器根本不出现:先查这三个细节
第一种情况,属性窗口那一栏连展开按钮都没有。优先检查特性是否挂对了,EditorAttribute 必须挂在属性上,且第二个参数是 typeof(UITypeEditor)。我见过有人第二个参数写成 typeof(TriggerModeEditor),编译不报错但就是不生效。
第二种情况,属性是只读的,只有 getter 没有 setter。这种情况 PropertyGrid 默认不进入编辑模式,编辑器自然不出现。设置方法很简单:保证有公开的 setter。
第三种情况,在 .NET Core 或 .NET 5+ 的 WinForms 里,设计期支持这些年确实有反复。如果是在运行期配合 PropertyGrid 使用,一切照旧;如果要支持设计器的属性窗口,不同版本 SDK 对自定义 UITypeEditor 的支持程度不一致。我的经验是:核心项目如果有复杂的自定义属性编辑需求,还是以运行期 PropertyGrid 为主,设计期的兼容性需要分别拿目标框架实测。
4.2 下拉面板一闪就消失或者宽度不对
DropDown 样式下拉面板容易出现两个问题。
第一个,面板一闪就消失了。常见原因是面板内控件抢焦点,或者鼠标点选的时候事件冒泡导致面板关闭。比如 ListBox 里如果同时绑定了 SelectedIndexChanged 和 MouseDown 并且都去关闭面板,就会出现还没选稳就关闭。解决办法是只保留一个关闭入口。
第二个,面板太窄或太高。DropDownControl 会根据控件的 PreferredSize 和当前属性列的宽度来调整面板尺寸,但有时候不算特别准。对策是别直接用裸的 ListBox 往下塞,可以包一层 UserControl 或 Panel,手动控制尺寸。我自己的习惯是 ListBox 设置固定高度,比如 120,并设置 HorizontalScrollbar = true,防止选项文字长的被截断看不清。
4.3 弹窗修改返回值后,属性窗口不刷新
Modal 模式编辑完点确定之后,属性值确实改了,但 PropertyGrid 那一栏没变化。这种问题根源在于 PropertyGrid 不知道对象状态改变了,它拿到的还是一个旧显示。
解决办法是在属性上挂 RefreshPropertiesAttribute:
[Editor(typeof(SerialPortOptionsEditor), typeof(UITypeEditor))] [RefreshProperties(RefreshProperties.All)] public SerialPortOptions SerialPort { get; set; }RefreshProperties.All 的意思是属性值变化后,强制 PropertyGrid 刷新所有属性行。这样不仅当前属性会刷新,其他依赖它的属性也会同步刷新,在联动配置里特别实用。
如果刷新仍然不生效,再考虑让配置对象实现 INotifyPropertyChanged,属性名对应变化后再通知,这个方案适应面更广,尤其对象被多个界面绑定时。
4.4 设计器代码不生成赋值,运行时一切正常
运行期 PropertyGrid 上编辑得很好,但一打开设计器,发现控件属性的赋值根本写不进 InitializeComponent。这个问题其实和 UITypeEditor 关系不大,但容易连带出现。
原因是设计器序列化对象时,需要知道怎么把对象赋值转换成代码。自定义类型默认序列化不了,设计器会直接跳过。解决办法是给配置对象配一个 TypeConverter,或者暴露若干子属性让设计器逐个序列化。最简单的方式是挂上 ExpandableObjectConverter,并给每个子属性提供 setter,设计器发现子属性可写后,就能正常生成赋值代码。如果还是不行,再考虑实现 ShouldSerializeXxx 和 ResetXxx 方法来控制序列化行为。
4.5 性能问题:属性网格打开时卡顿
如果属性特别多,而且每个都有 UITypeEditor,打开属性网格时会在绘制阶段反复调用 GetEditStyle 和 GetPaintValueSupported。这些方法里面有耗时的逻辑,整个窗口就会卡到怀疑人生。
我之前踩过一次。当时编辑器里为了显示一些动态内容,每次 GetEditStyle 都去扫一遍串口列表,结果打开属性面板要等两秒。后来把串口扫描结果做了缓存,只在点击编辑时才刷新列表,卡顿问题立刻解决。
所以性能优化就是两条:编辑器创建要轻,服务获取要缓存,重活全部放到 EditValue 或弹窗初始化阶段去干。
5. 一些顺手的扩展思路
UITypeEditor 不只适用于属性窗口,本质上它是一个可以复用的“编辑策略”。我做上位机工具链的时候,会把常用的配置项编辑器单独打包成一个类库,诸如枚举下拉编辑器、文件路径选择器、颜色选择器、串口参数弹窗编辑器,全部放到一个公共程序集里。不同项目引用同一个类库,属性面板风格统一,维护成本降一大截。
还有个小技巧:如果想要一种编辑器适配多个属性,可以在编辑器里通过 context.Instance 判断当前编辑的是哪个对象、哪个属性,然后动态调整候选内容。比如同一个下拉编辑器,在不同属性上显示不同的选项列表,只要根据属性名判断一下即可,不需要写多个类。但这种写法可读性差,适合交互逻辑真的很接近的情况,不建议滥用。
另外,项目里如果用了第三方控件库,像 DevExpress 的 GridControl 也有类似的属性编辑扩展机制,接口名虽然不同,设计思路几乎一致。学会 UITypeEditor 的套路之后,再去看这些库的编辑接口,基本都是触类旁通。
以我个人的体会,UI 编辑器这种机制的难点不在 API,而在于想清楚“哪种类型适合下拉、哪种类型适合弹窗”。下拉适合选项有限且互斥的,弹窗适合内部有多个子属性的对象,预览图适合颜色、状态、缩略图这类视觉属性。想清楚这一点,代码写起来就顺了。最后分享一个实操小经验:给属性加自定义编辑器之前,先把属性在 PropertyGrid 里的默认表现看一遍,截个图,再对比加完编辑器之后的效果,这样优化前后差异一眼就能看见,也方便给同事展示改造后的价值。
本文还有配套的精品资源,点击获取