资讯动态

Unity自定义Flags枚举绘制:从PropertyDrawer到全选按钮的完整指南

发布时间:2026/10/17 6:00:48 来源:尧图企业网站定制
开发Unity项目时只要涉及状态、Buff、权限这类多选配置大部分人第一时间会想到带[System.Flags]的位掩码枚举。用位运算可以把十几个开关塞进一个int里跑起来也快但编辑器的体验往往差一口气默认下拉框只能单选策划在Inspector里一个个填数字的日子我是真受够了。后来发现较新版本的Inspector已经能把Flags枚举自动画成可多选的掩码菜单但默认效果只保证“能选”离“好用”还差不少——选项全是英文枚举名、没有全选按钮、组合项处理也不够直观。这篇文章就把“在Inspector上显示自定义位掩码枚举”这件事彻底拆开从PropertyDrawer原理、两版可以直接抄的通用绘制代码到显示名、全选按钮这类进阶定制最后把几个容易忽略的坑一并列出来。适合准备给项目做编辑器优化或者刚接触编辑器扩展、想找一套顺手方案的开发者参考。1. 先从位掩码枚举和Unity默认控件说起1.1 一个正经的Flags枚举该怎么定义位掩码枚举的关键点是每个独立开关的状态位都必须是2的幂这样它们之间做按位或、按位与时才不会互相干扰。比如玩家的Buff状态可以这样定义[System.Flags] public enum BuffType { None 0, Stun 1 0, // 眩晕 Silence 1 1, // 沉默 Slow 1 2, // 减速 Dizzy 1 3, // 混乱 All Stun | Silence | Slow | Dizzy }这里None 0留下来表示“一个都没有”每个单项用1 n展开成2的幂组合项All用按位或拼出来。运行时判断某个状态是否包含某个Buff不要傻乎乎用HasFlag高频调用因为Enum.HasFlag内部有装箱和反射开销在战斗逻辑里会被打得很难看。手工位运算才是正规做法state | BuffType.Stun; // 添加Buff state ~BuffType.Slow; // 移除Buff bool hasStun (state BuffType.Stun) ! 0; // 判断Buff[System.Flags]这个Attribute本身不参与位运算它的主要价值在于两点一是让ToString()输出Stun, Silence而不是莫名其妙的数字调试日志和序列化可读性直接上一个档次二是告诉编辑器侧“这是一个可以多选的枚举”Inspector有机会把它渲染成多选控件。如果你只想要一份能跑的枚举写到这一步其实就够了。但放到公司项目里策划、关卡、运营配置经常会直接面对Inspector这时候枚举的UI呈现方式就成了影响工作效率的隐藏成本。我见过太多项目里策划靠数字猜状态位组合配置错一次就要翻代码对表效率非常低。所以推动我写这套自定义绘制的真正原因不是“技术做不到”而是“默认体验配不上业务需求”。1.2 默认Inspector到底做了什么又缺了什么以我手头Unity 2021系列的实际体验来看带上[System.Flags]的枚举字段Inspector默认会渲染成一个带勾选框的弹出菜单点击之后能在弹窗里多选。相比普通枚举那个只能选一项的下拉框这已经算良心了简单手调配个状态是够用的。但默认实现基本是“工程味”十足谈不上产品化。我用下来最难受的几个点选项显示名直接就是C#成员名英文缩写、下划线、首字母大写这些命名规范全部暴露给策划阅读门槛抬高。没有“全选/清空”这类快捷操作配置项一多策划要点好几下才能凑齐想要的组合。组合值比如前面写的All如果混在弹窗选项里会出现一颗老鼠屎坏一锅粥的情况勾了Stun又勾了All数据就会重复或相互矛盾。样式没法统一项目里想让Flags字段和其他自定义控件保持同样的观感默认绘制完全不给机会。有人会问既然新版Unity已经支持了为什么还要自己写很简单因为默认支持是“通用方案”它面向的是所有项目的平均值没法替你解决业务命名、组合过滤、快捷操作这些定制需求。而PropertyDrawer这套扩展机制恰好就是Unity留给开发者给Inspector“打补丁”的官方入口。这也是本文标题里“自定义”三个字真正着力的地方。2. 自定义绘制需要先搭好的基础骨架2.1 PropertyAttribute与PropertyDrawer的分工Inspector的绘制扩展体系里有两个类经常被绑在一起用职责一定要分清楚。PropertyAttribute本身只是挂在字段上的一个标记本身不干任何绘制工作真正画界面的是PropertyDrawer它负责在Inspector刷新时把你想要的控件画出来。要给Flags枚举做一个通用绘制器最干净的方式是先定义一个只有壳的Attributepublic class EnumFlagsAttribute : PropertyAttribute { }然后写对应的Drawer并用[CustomPropertyDrawer]把它注册到这个Attribute上[CustomPropertyDrawer(typeof(EnumFlagsAttribute))] public class EnumFlagsDrawer : PropertyDrawer { // 绘制逻辑写在这里 }字段上挂[EnumFlags]时Unity会在Inspector绘制流程里找到这个Drawer并把当前字段的序列化数据、绘制区域、标签等信息传进来。为什么非要用Attribute包一层而不是直接对枚举类型注册Drawer因为项目里可能有十几个枚举对每个枚举类型都单独写一个几乎一模一样的Drawer又蠢又难维护而用一个通用Attribute做一个入口哪个字段需要特殊绘制就贴一下可复用性高得多。这个模式在编辑扩展里非常常用后面所有定制都是在往这个壳子里填肉。2.2 Drawer的执行顺序和三个关键上下文PropertyDrawer里最核心的两个方法一个是OnGUI一个是GetPropertyHeight。GetPropertyHeight用来告诉Unity这个字段总共需要多高OnGUI则负责在给定的矩形区域里真正画出控件。Unity会先问高度再在Inspector对应位置给一块矩形让你画顺序不能乱。在方法里能拿到的三个关键上下文新手最容易忽略SerializedProperty property当前字段的序列化对象读取和写入枚举值都走它。枚举底层最终是整数所以常规情况下读写property.intValue即可。GUIContent labelInspector里显示在控件左边的字段标签可以拿到字段名也可以替换成自定义内容。fieldInfo.FieldType这才是真正帮你拿到“这个字段到底是哪个枚举类型”的关键。property.type在枚举字段上只返回enum拿不到具体类型名只有fieldInfo反射信息里才有完整的BuffType这样的类型对象后续所有Enum.GetValues、Enum.GetName都依赖它。还有一个小细节在OnGUI开头和结尾一定要成对调用EditorGUI.BeginProperty和EditorGUI.EndProperty。它负责处理Inspector的缩进层级、Prefab覆盖的高亮标识漏掉的话字段显示在数组、嵌套类里时会歪而且改动数据后Prefab的覆盖状态也会变得不可控。下面两版代码里我都会加上这两行这不是仪式感是真的有实际作用。3. 手写一个通用EnumFlagsDrawer两版代码直接抄3.1 方案A基于EditorGUI.MaskField的轻量实现EditorGUI.MaskField是Unity提供的一个原生多选控件外观和后端能力都很接近LayerMask那种弹窗效果。我们要做的就是把枚举的值翻译成MaskField需要的选项索引位再把用户勾选的结果翻译回枚举值。这个“双向翻译”是整个方案的核心坑也都在里面。先看完整代码using System; using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(EnumFlagsAttribute))] public class EnumFlagsDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginProperty(position, label, property); Type enumType fieldInfo.FieldType; if (enumType null || !enumType.IsEnum) { EditorGUI.LabelField(position, label, new GUIContent(字段不是枚举类型)); EditorGUI.EndProperty(); return; } Array values Enum.GetValues(enumType); int currentValue property.intValue; string[] optionNames new string[values.Length]; int[] optionValues new int[values.Length]; int optionCount 0; int currentMask 0; for (int i 0; i values.Length; i) { int v Convert.ToInt32(values.GetValue(i)); // 过滤掉 0 和组合值只保留单项 bit if (v 0 || (v (v - 1)) ! 0) continue; optionNames[optionCount] Enum.GetName(enumType, v) ?? v.ToString(); optionValues[optionCount] v; if ((currentValue v) v) currentMask | 1 optionCount; optionCount; } if (optionCount 0) { EditorGUI.LabelField(position, label, new GUIContent(没有可用的单项Flag)); EditorGUI.EndProperty(); return; } string[] displayNames new string[optionCount]; Array.Copy(optionNames, displayNames, optionCount); int newMask EditorGUI.MaskField(position, label, currentMask, displayNames); int newValue 0; for (int i 0; i optionCount; i) { if ((newMask (1 i)) ! 0) newValue | optionValues[i]; } if (newValue ! currentValue) property.intValue newValue; EditorGUI.EndProperty(); } }这段代码里最容易被误解的地方是“MaskField的mask是什么的掩码”。EditorGUI.MaskField返回的newMask表示的是“选项数组里第几项被选中”不是枚举值本身。比如第一个选项被选中newMask里第一位是1对应的是1 0而这个选项背后的枚举值可能是1 3。所以绝不要直接把newMask赋值给property.intValue必须再做一层翻译。另一个细节是过滤组合值。前面例子里All Stun | Silence | Slow | Dizzy如果把它也作为一个选项塞进MaskField用户一旦同时勾了Stun和All转回枚举值时按位或的结果反而会把状态加倍UI勾选状态和数据也对不上。稳妥的做法是只把每个独立开关位作为选项组合项通过后面的“全选”等快捷按钮体现下面会讲到。这个方案的优点是代码量小行高永远保持在EditorGUIUtility.singleLineHeight字段多了Inspector也不会变成一长条性能损耗可以忽略。缺点是选项数量受int掩码32位限制以及弹窗里的纯文本样式没法做太多文章。如果你的枚举就是十个八个开关这个方案足够支撑产品上线了。3.2 方案B自绘Toggle列表所见即所得有些时候策划反馈说“我不想点开弹窗再一个个勾”他们希望一进Inspector就看到所有开关平铺着像一列开关面板那样一目了然。这种需求就适合用方案B自己用Toggle控件把每个标志位画出来并且我一般会顺手加一排“全选/清空”按钮这对配置体验的提升非常明显。using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(EnumFlagsAttribute))] public class EnumFlagsToggleDrawer : PropertyDrawer { private struct FlagItem { public string DisplayName; public int Value; } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { int count GetSingleFlags().Length; float lineHeight EditorGUIUtility.singleLineHeight; return lineHeight count * (lineHeight 2) lineHeight 10; } public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginProperty(position, label, property); Type enumType fieldInfo.FieldType; if (enumType null || !enumType.IsEnum) { EditorGUI.LabelField(position, label, new GUIContent(字段不是枚举类型)); EditorGUI.EndProperty(); return; } FlagItem[] flags GetSingleFlags(); int currentValue property.intValue; Rect lineRect position; lineRect.height EditorGUIUtility.singleLineHeight; // 标题行 EditorGUI.LabelField(lineRect, label); lineRect.y lineRect.height 2; // 每个Flag一个Toggle foreach (FlagItem flag in flags) { bool oldOn (currentValue flag.Value) ! 0; bool newOn EditorGUI.ToggleLeft(lineRect, flag.DisplayName, oldOn); if (newOn ! oldOn) { currentValue newOn ? (currentValue | flag.Value) : (currentValue ~flag.Value); property.intValue currentValue; } lineRect.y lineRect.height 2; } // 快捷操作行 lineRect.y 6; float buttonWidth (lineRect.width - 4) * 0.5f; Rect allRect new Rect(lineRect.x, lineRect.y, buttonWidth, lineRect.height); Rect noneRect new Rect(lineRect.x buttonWidth 4, lineRect.y, buttonWidth, lineRect.height); int allValue 0; foreach (FlagItem flag in flags) allValue | flag.Value; if (GUI.Button(allRect, 全选)) property.intValue allValue; if (GUI.Button(noneRect, 清空)) property.intValue 0; EditorGUI.EndProperty(); } private FlagItem[] GetSingleFlags() { Type enumType fieldInfo.FieldType; Array values Enum.GetValues(enumType); ListFlagItem list new ListFlagItem(); for (int i 0; i values.Length; i) { int v Convert.ToInt32(values.GetValue(i)); if (v 0 || (v (v - 1)) ! 0) continue; list.Add(new FlagItem { DisplayName Enum.GetName(enumType, v) ?? v.ToString(), Value v }); } return list.ToArray(); } }这段代码的思路和方案A完全不同。方案A是在单个行内弹出一个多选菜单方案B是把面板垂直拉伸把每个选择项变成一行的Toggle开关。GetPropertyHeight必须根据枚举项数量动态计算高度所以每个Flag项都预留了“行高2像素”的间距防止Toggle文字挤在一起看不清。EditorGUI.ToggleLeft这个API很适合这种场景文字在左边、开关在右边各种长度的选项名放进去都比较顺眼。如果你希望排版更贴近传统Inspector也可以换成EditorGUI.Toggle然后自己写label排版但实测ToggleLeft的观感最像一个配置面板。这里要提醒一点OnGUI里修改currentValue之后要立刻把值写回property.intValue。因为接下来的Toggle读取的是本地变量如果不写回Inspector里其它联动逻辑可能拿到过期的数据。这个写回的时机也是两个方案通用的小细节。3.3 两个方案怎么选先回答这三个问题选方案之前可以先问自己三个问题第一选项数量多不多。如果枚举开关超过十个方案B会在Inspector里占掉大量纵向空间字段一多整个面板就像流水账一样长方案A不管选项多少都保持一排天然收敛。第二策划需要的交互习惯是什么。如果团队习惯“展开面板直接点”方案B更友好如果希望能和LayerMask那样点一下弹出再勾方案A更符合经典肌肉记忆。第三是否需要富文本、颜色、分组等定制样式。方案B自己画Toggle样式完全可控方案A的弹窗内容受到原生MaskField限制较多。根据这几个问题我自己的选择经验是开关数量在8个以内、且是角色、Buff这类高频配置的无脑上B开关数量多、或是系统开关这类低频配置的用A项目里同时有OLd和New两套Inspector体系时尽量用A保持兼容省心。两个方案代码量都不大也可以用Attribute参数化同一个绘制器根据字段需求切换平铺或弹窗模式这个留给有余力的读者自己扩展。4. 进阶定制显示名、全选按钮和UI Toolkit方向4.1 让选项显示中文或业务名第一步肯定是要解决显示名问题。枚举成员是代码资产命名要遵循C#规范往往全是英文。想让Inspector里显示“眩晕、沉默、减速”甚至“进入战斗后眩晕”就不能只拿Enum.GetName的结果当显示文本。Unity自带的[InspectorName]特性正好是为枚举成员定制显示名准备的在枚举成员上直接挂[System.Flags] public enum BuffType { None 0, [InspectorName(眩晕)] Stun 1 0, [InspectorName(沉默)] Silence 1 1, [InspectorName(减速)] Slow 1 2, [InspectorName(混乱)] Dizzy 1 3, }然后在Drawer里通过反射读取这个特性private static string GetDisplayName(Type enumType, string rawName) { if (enumType null || string.IsNullOrEmpty(rawName)) return rawName ?? ; FieldInfo field enumType.GetField(rawName); if (field null) return rawName; var attrs field.GetCustomAttributes(typeof(InspectorNameAttribute), false); InspectorNameAttribute attr attrs.Length 0 ? attrs[0] as InspectorNameAttribute : null; return attr ! null ? attr.name : rawName; }这个GetDisplayName可以做成工具函数放在公共类里方案A和方案B的Drawer都直接调。使用时把原本的Enum.GetName替换成GetDisplayName(enumType, rawName)即可。这样策划在Inspector里看到的是中文业务名而代码里该用枚举值的地方还是枚举值互不干扰。如果显示名规则太复杂比如需要按照策划配置表动态变化那再往后就得整一套名称表Excel或ScriptableObject映射了。但就项目前期而言[InspectorName]已经能解决九成痛点不必一开始就过度设计。4.2 在Drawer内增加全选/清空与数值反馈方案B里我已经加了全选和清空两个按钮如果想让方案A也有类似的快捷操作可以在EditorGUI.MaskField右侧留一小块区域画一个“全选”小按钮或者在属性区域下方多画一行。不过方案A的场景是“收敛”按钮加多了反而破坏简洁我更推荐用方案B来实现完整交互。除了按钮还可以在label旁边显示当前勾选了几个选项。代码很简单在方案B的GetSingleFlags()结果上遍历计数int selectedCount 0; foreach (FlagItem flag in flags) { if ((currentValue flag.Value) ! 0) selectedCount; } string headerText ${label.text}{selectedCount}/{flags.Length}; EditorGUI.LabelField(lineRect, new GUIContent(headerText));这个“3/8”式的计数反馈用下来效果非常好策划不用逐个去数开关一眼就能确认配置对不对。需要注意label.text是Inspector根据字段名生成的直接修改它会污染后面绘制流程中对原始label的引用所以这里一定要new GUIContent再传不要原地改label.text。这个细节我第一次写的时候踩过看起来是小问题实际会导致数组元素、嵌套类字段的label全部错乱。4.3 顺带了解一下UI Toolkit的EnumFlagsField如果你已经开始用Unity 2021版本之后的UI Toolkit重写项目自定义Inspector那可以试试官方提供的EnumFlagsField控件它本质上就是原生实现了一个Flags枚举的多选显示。典型用法是在自定义Editor的CreateInspectorGUI里创建一个VisualElement树using UnityEditor.UIElements; using UnityEngine.UIElements; public override VisualElement CreateInspectorGUI() { var root new VisualElement(); var flagsField new EnumFlagsField(生效Buff, BuffType.Stun); flagsField.InitEnumValue(BuffType.Stun | BuffType.Slow); root.Add(flagsField); return root; }UI Toolkit的方案和IMGUI的PropertyDrawer是两套完全不同的渲染体系不能互相冒充。这里提到它主要是让大家知道方向如果你的项目已经全面转向UI Toolkit就不用把本文的Drawer硬塞进去直接走EnumFlagsField或自己写绑定的UXML模板就好。如果项目还停留在传统IMGUI Inspector阶段那本文的方案A/B依然是性价比最高的选择。5. 埋坑实录这些限制不提前知道会很难受5.1 枚举底层类型不只是int[Flags]枚举虽然是int用得最多但C#底层枚举类型也可以是byte、long等。一旦遇到enum BuffType : long这种定义上面两版Drawer里的property.intValue就会出问题因为SerializedProperty.intValue只能访问底层int的整数long枚举的高位数据会丢失。正确的做法是读取时先判断底层类型private static long ReadEnumValue(SerializedProperty property) { Type underlying fieldInfo.FieldType.GetEnumUnderlyingType(); if (underlying typeof(long) || underlying typeof(ulong)) return property.longValue; return property.intValue; } private static void WriteEnumValue(SerializedProperty property, long value) { Type underlying fieldInfo.FieldType.GetEnumUnderlyingType(); if (underlying typeof(long) || underlying typeof(ulong)) property.longValue value; else property.intValue (int)value; }Enum.GetValues拿到的数组元素是object转换成数值也不能无脑Convert.ToInt32换成Convert.ToInt64最保险。我的建议是项目里除非真的有超过32个开关或需要复用高位的场景否则一律用int枚举因为编辑器扩展、序列化显示、第三方插件对int的支持最稳能大幅减少跨类型转换的隐患。5.2 MaskField的32位上限与组合项过滤EditorGUI.MaskField的mask参数是int所以它最多只能表达32个选项。而且第31个选项会触发1 31变成负数此时再做位运算极其容易出迷惑性bug。如果项目的Flags枚举选项超过二十个方案A就不能再硬撑要么切成方案B平铺要么给MaskField做分组折叠。组合项过滤在上面代码里已经做了但有个边界情况要补一句当某个枚举值本身是负数比如1 31这种最高位Flag我上面的v 0判断会把它误过滤掉。处理高位Flag时要么接受“最后一个选项不可见”的妥协要么用long底层枚举扩展位数。我个人倾向是“换个设计”因为让策划去配置一个最高位Bit情绪价值远低于它带来的理解成本和边界风险。5.3 fieldInfo与数组字段的边界情况fieldInfo.FieldType在普通字段上很靠谱但遇到数组字段需要警惕。假如你写的是[EnumFlags] public BuffType[] buffTypes;这个Attribute是挂在数组字段上的PropertyDrawer拿到的fieldInfo.FieldType是BuffType[]而不是BuffType于是代码里的enumType.IsEnum判断直接失败最终只会显示一行“字段不是枚举类型”。这其实是Unity PropertyDrawer机制的正常表现Attribute挂在哪Drawer就处理哪一层数据并不会自动下钻到数组元素。解决问题有几个常见路子一是放弃数组改成单个枚举字段组合成类再用自绘类来处理二是写一个专门针对数组容器的Drawer自己去遍历property.arraySize并逐项画子元素。后面这条路略复杂需要处理SerializedProperty.GetArrayElementAtIndex和缩进如果需求不强我建议为字段建一个[Serializable]的包装类由包装类自行管理内部的枚举数组既简单又在Unity序列化规则内。5.4 Flags对ToString、序列化和性能的连带影响最后聊几个不少人踩过的隐性坑。第一个是[System.Flags]对ToString()确实影响很大没有这个Attribute时位组合枚举ToString()打印的是数字加上后打印的是Stun, Silence。这看似只是日志友好度问题但当JSON或Excel导出工具拿枚举字符串当键时格式不一致会导致线上数据读不出所以枚举定义的决定一定要早做后期改起来牵连众多。第二个是Unity序列化枚举是按底层整数保存的不因为[Flags]特性而变成别的格式所以Prefab、ScriptableObject、JsonUtility处理的数字逻辑和普通枚举完全一致不用担心数据兼容。真正要在改枚举值时注意的是保证自己在Drawer里只通过property写值不要用反射直接改对象字段否则会绕过Undo记录和Prefab覆盖系统。第三个是性能。EditorDrawer里的反射调用本来就比正常代码慢Enum.GetValues和Enum.GetName还会创建临时对象虽然Inspector绘制频率不算高但字段一多还是会有肉眼能感知的卡顿。我的优化习惯是在Drawer类里用一个静态缓存字典以枚举类型为key缓存过滤后的FlagItem数组只在类型第一次出现时构建private static readonly DictionaryType, FlagItem[] Cache new();要注意编辑器开启“域重载禁用”时修改枚举定义后静态缓存可能残留旧数据需要在[DidReloadScripts]回调里清掉缓存。这个小技巧能让Inspector打开速度和翻页手感都顺滑不少。最后再分享一点个人体会。我最早写这套EnumFlagsDrawer是从一个Buff配置工具里长出来的当时策划对着英文枚举项一个个脑内翻译实在忍不了。可真正动手以后发现让生活变好的关键不只是把枚举画成中文而是把“数据到控件”的映射逻辑想清楚了——一组枚举项对应一组UI选项中间维护一张index到value的转换表之后加新枚举、加中文名、做分组都只是往表里填配置而已。写到这里这套代码足够让普通项目直接在Inspector里用起来。如果你准备改造项目里的枚举控件建议直接拿上面的方案B先跑通一个字段确认策划的交互反馈再逐步加全选按钮和显示名映射。等你手头的Flags字段铺开之后一个稳定可靠的转换函数能省下大把逼疯人的调试时间这也算是我踩过很多编辑器扩展坑后最想提醒后来者的一句话。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑