Introduction
First of all, this is one of those things where people just can’t agree with each other. Should you use enum, or should you build an OOP system surrounding your types, or maybe it’s ScriptableObject that is used both as the thing that goes into the thing, and the wrapper thing held above the another ScriptableObject which enumerates mini ScriptableObjects… meh, you get the point. We can always weigh pros and cons of each approach and certainly there are numerous benefits to be had from a robust design solution, but I won’t go further into this discussion.
Let’s just assume there are legit reasons why someone would really want to use an enum inside their project, and not just use them as tiny human-readable tokens for some system settings within the code, never to be seen again, but throughout the game logic as well, sprinkled in critical places, as contextual lightweight atomic data.
“Okay so let’s just use enum” Uhuh, well, you see, there’s a problem with how C# enums work and how Unity treats them. THEY ARE TERRIBLE for any kind of iterative design: you set them up, you give them names, you give them values, you’re better not to touch them ever again, especially in a collaborative environment. If you don’t give them explicit values, then you must not reorder them as well. In my early days, I remember having just a few enum classes to describe my monster categories and certain damage types. By the end of that project these couple of classes had more commentary in them (DON’T MOVE THIS, DON’T TOUCH THAT) then any other meaningful C#.
The other, HUGE problem with enums in Unity is that they serialize as their values. Now imagine if you had four monsters: Duck, Beaver, Rabbit, and Possum. What should their values be? It doesn’t matter right?
So you’d write it as such
public enum TerribleMonsters {
Duck,
Beaver,
Rabbit,
Possum
}
But then you choose a monster from a drop-down in some inspector field, maybe you’d like some behavior to associate with Possum, for example. And this is what saves to YAML
_selectedMonster: 3
Does this mean anything to you? It’s supposed to mean something to you, if you ever need to repair (or merge) your files. Also, what do you imagine will happen to your project if you suddenly remove the Beaver because it, idk, doesn’t fit the theme or whatever, or if you add a Porcupine somewhere in between? Enums are just bad for health.
So the main goals of this exercise will be to properly serialize our data to concrete strings, as well as to provide the expected enum behavior, in code, as well as in Unity inspectors.
This is the approach: we’ll make a so-called custom enumeration class. It kind of behaves like a standard enum, but you now have to fully specify the behavior on your end, because (sadly) there is nothing else in C# to lean onto. This means we’ll have to be very creative (aka prescient) with how this is going to be used in code, but we’ll also have to create a custom property drawer to present the data in a friendly manner (i.e. keep the dropdown).
You can find more about this pattern all over the internet, even the official dot net documentation mentions it. However, I haven’t seen one for Unity because it’s quirky and difficult to get right.
Tutorial begins
That said, this is going to be an intermediate-to-heavy tutorial. It features reflection, generic programming, attributes, some newer C# syntax, and I can’t possibly explain every little detail to a rookie. If you’re more advanced than that (like me or better), maybe you’ll learn a thing or two, and if you’re an expert versed with editor-juggling and C# mastery I fully expect you to correct me if there is something you would do better.
First things first. Standard C# enum construct exists because it lets you enumerate the names quickly and conveniently without having to write too much boilerplate surrounding the basic logic of it. We can’t implement it as neatly (because we can’t redefine the language syntax), so some boilerplate is necessary, but the goal is to keep it at the minimum. For the identifiers themselves we want to have a readable listing of some kind, but you can’t just type words in C# and pretend it’ll be alright, so it must be something like const
or a regular class field to declare these values properly.
We’re gonna use static public readonly
fields because enum values already behave in a static read-only fashion (edit: for the remainder of this article, don’t mind me sticking static in front of public, that’s just my personal convention). Consts are compile-time things which are handled differently, so it’s better to keep it straight from the start. The aim is to create an abstract superclass that encapsulates the underlying logic, and then the user can derive from it, add his own fields, and call it a day.
So something like this at a minimum?
public class MyEnum : QuasiEnum {
static public readonly MyEnum duck = new MyEnum("Duck", 0);
static public readonly MyEnum beaver = new MyEnum("Beaver", 1);
static public readonly MyEnum rabbit = new MyEnum("Rabbit", 2);
}
“But we still have to keep track of the values? And we also have to type in the names?”
We don’t actually (edit: the final product is even leaner than this). But you do want the inspector values to line up? And surely you need some values to correlate natively with the elements, it’s easy to ignore them if you don’t. I think this is the bare bone minimum for something that pretends to resemble an enum. The boilerplate is just unavoidable. You can still very compactly access the fields from the outside, i.e. MyEnum.duck
and the identities are clearly defined and lined-up nicely.
We should also take care of situations where we compare MyEnum.duck == MyEnum.beaver
and also provide implicit conversions to value and/or string for debugging at least, and let’s not forget about serialization.
QuasiEnum
Lets address these things by writing the core QuasiEnum class. We want each enum value to be comparable and equatable so that the rest of the system can understand how to match and compare objects of our type.
using System;
public abstract class QuasiEnum : IComparable<QuasiEnum>, IEquatable<QuasiEnum> {
public string Name { get; private set; }
public int Value { get; private set; }
}
Allowing protected setters is not required (and is probably not a good idea), but we can polish this some other time. Let’s do the internal constructor next.
protected QuasiEnum(string name, int value) {
if(name is null) throw new ArgumentNullException(nameof(name));
if(value < 0) throw new ArgumentException("Value must be >= 0", nameof(value));
Name = name;
Value = value;
}
Notice the constrained value. We want the negative values to be signals for an improper deserialization, and especially later when we get to the property drawer. There are other, more flexible ways to do this, but who cares about values that much? In this design, the name is the king. The name is front and center, we’ll use the corresponding values only as a convenience, if we do need them, or literally just to be able to sort the list in value order. But negative values? Meh.
This also means the values can repeat, for example. That’s fine, two enum entries can legally have the same value. But in our scenario two names can repeat as well, and that’s illegal. But remember that we’re tying these identities to field names in C# and you can’t have more two fields with the same name, so we’re fine there.
“Ok, what’s next. Should we implement the interfaces?” Yes, let’s start with IEquatable
.
Obviously with IEquatable you also want to override object.Equals(object)
method, but that’s just one way to satisfy the system. We also must overload Equals with more specific matchings, in our case that’s another QuasiEnum subtype. In this way we circumvent having to unbox object
.
Another thing worth noting is that our type is reference type, so we must be aware that the incoming object may be null
. This is even more important later, when we’ll override the static equality operators ==
and !=
.
Regardless, here we have to decide, which one is better: a) to rely strictly on the actual identity of the object, or b) to rely only on its name? Well the actual C# enums rely on comparing their values, so I’m going to compare the names instead. But no hard reference match, I think that can’t work because of serialization anyway. Strictly speaking, enum values should behave like value types.
Let’s quickly introduce a method to reliably match names from wherever we need to
static public bool StringsMatch(string str1, string str2)
=> string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase);
And then
public bool Equals(QuasiEnum other)
=> other is not null && StringsMatch(this.Name, other.name);
Finally, we want the method Equals
to work with anything that makes sense, so matching against another QuasiEnum
, another name (string
), or another value (int
), are all valid. If the incoming object is of the unsupported type (or null
), then it’s an automatic mismatch. (The following code is known as switch expression btw.)
public override bool Equals(object obj)
=> obj switch {
QuasiEnum other => Equals(other),
int val => this == val,
string str => this == str,
{} => false
};
“Checking against these other values, how’s that supposed to work?”
Going there, we’re doing custom static equality operators next. With them we need to make sure neither of the objects are null
, and if they’re both null
, the check should return true
. We can test this null equality with bool ReferenceEquals(...)
inherited from object
. Within these operators it’s silly to attempt a == null
, that would probably just lead to stack overflow.
Basically, if left-hand-side is non-null, it’s safe to proceed with Equals test we made before. And if it’s null, return true only if the right-hand-side is null as well.
static public bool operator ==(QuasiEnum a, QuasiEnum b)
=> ReferenceEquals(a, null)? ReferenceEquals(b, null) : a.Equals(b);
// strings can be null as well (even though they count as value types)
static public bool operator ==(QuasiEnum qenum, string str)
=> ReferenceEquals(qenum, null)? ReferenceEquals(str, null)
: StringsMatch(qenum.Name, str);
// other value types can't be null, they have default values (i.e. 0 in this case)
static public bool operator ==(QuasiEnum qenum, int val)
=> ReferenceEquals(qenum, null)? false : qenum.Value.Equals(val);
And since ==
operators need to have their matching counterparts, we make sure they stay consistent
static public bool operator !=(QuasiEnum a, QuasiEnum b) => !(a == b);
static public bool operator !=(QuasiEnum qenum, string str) => !(qenum == str);
static public bool operator !=(QuasiEnum qenum, int val) => !(qenum == val);
Next up, IComparable
. That’s just
public int CompareTo(QuasiEnum other) => value.CompareTo(other.Value);
and while we’re at it, let’s do GetHashCode as well
public override int GetHashCode() => GetType().GetHashCode() ^ Value.GetHashCode();
Let’s now do more pressing things, things that have to do with our static readonly
item collection.
This is going to be the core method to scan the internals of our derived class and the only way to make this work is via reflection (add using System.Reflection; to the top).
static IEnumerable<FieldInfo> enumerableStaticFieldsOf<T>() where T : QuasiEnum {
const BindingFlags flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly;
var fields = typeof(T).GetFields(flags); // fetch the list of all such fields
if(fields is not null)
for(int i = 0; fields.Length; i++)
yield return fields[i];
}
I will constrain T to QuasiEnum in many such methods throughout the solution. This is so only derivations are accepted (QuasiEnum itself is abstract
so you can’t instantiate that directly).
The method above will help us foreach
through all static fields within the type T, when we need it. Now we can use this to create two different publicly-available methods that should work with all QuasiEnum derivations in general. The first one GetItemsOf<T>
provides another IEnumerable to let us foreach through the items, and the other GetNamesOf<T>
returns a fixed array of all their names. The exact order is undefined.
static public IEnumerable<T> GetItemsOf<T>() where T : QuasiEnum {
foreach(var info in enumerableStaticFieldsOf<T>()) {
var value = info.GetValue(null) as T;
if(value is not null) yield return value;
}
}
(using System.Collections.Generic;
)
static public string[] GetNamesOf<T>() where T : QuasiEnum {
var names = new List<string>();
foreach(var info in enumerableStaticFieldsOf<T>()) {
var value = info.GetValue(null) as T;
if(value is not null) names.Add(value.Name);
}
return names.ToArray();
}
It’s almost done. Now we have the means of traversing through all internally declared items, but they must satisfy two things: 1) the type, 2) static public
declaration. We should now finish the heart of the system, the way to find a specific object instance once we know its type and name (or value). I’ll write this in a LINQ-esque manner, so let’s start with the general predicate search.
static T firstMatchOrDefault<T>(IEnumerable<T> enumerable, Func<T, bool> predicate) where T : QuasiEnum {
T item = default;
foreach(var x in enumerable)
if(predicate(x)) { item = x; break; }
return item;
}
This allows us to define a robust selection method
static T selectWhere<T>(Func<T, bool> predicate) where T : QuasiEnum
=> firstMatchOrDefault(GetItemsOf<T>(), predicate);
Leading us naturally to our protected interface, to be used by user derivations
static protected T GetByName<T>(string name) where T : QuasiEnum
=> selectWhere<T>( item => StringsMatch(item.Name, name) );
static protected T GetByValue<T>(int value) where T : QuasiEnum
=> selectWhere<T>( item => item.Value == value );
Just a couple more utility functions to help us explicitly cast as well as implicitly pass our QuasiEnum objects as string arguments, and we can wrap this up.
static public T Cast<T>(QuasiEnum qenum) where T : QuasiEnum => GetByName<T>(qenum.Name);
static public T Cast<T>(string str) where T : QuasiEnum => GetByName<T>(str);
static public T Cast<T>(int value) where T : QuasiEnum => GetByValue<T>(value);
static public implicit operator string(QuasiEnum qenum) => qenum.Name;
// and let's not forget
public override string ToString() => this; // lol, now this is just showing off
Ok, so the core class is done! But there are some things which can be slightly improved. Let’s think about the user side. The user derives from QuasiEnum, declares the fields, sets the names etc. The user can write a private constructor from which it becomes very easy to build a list of all locally defined names. Thanks to the reflection code above, this isn’t mandatory, but IF the user sets this up, it would be nice if he could deliver this list, because that’s how we can completely avoid having to use reflection during run-time (edit: reflection can be slow and messy compared to regular means of searching and method invocation, especially when used in games).
So let’s change our selectWhere
to
static T selectWhere<T>(Func<T, bool> predicate, IList<T> list) where T : QuasiEnum
=> firstMatchOrDefault(list?? GetItemsOf<T>(), predicate);
And expand our protected interface like so
static protected T GetByName<T>(string name, IList<T> list = null) where T : QuasiEnum
=> selectWhere<T>( item => StringsMatch(item.Name, name) , list);
static protected T GetByValue<T>(int value, IList<T> list = null) where T : QuasiEnum
=> selectWhere<T>( item => item.Value == value , list);
Let’s check what this looks like on the MyEnum
side.
public class MyEnum : QuasiEnum {
// we can now put the tools we've built so far to good use
static public List<MyEnum> GetAll()
=> new List<MyEnum>(GetItemsOf<MyEnum>());
static public MyEnum GetByName(string name) => GetByName<MyEnum>(name);
static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value);
// to mimic standard enum behavior
static public explicit operator MyEnum(int value) => GetByValue(value);
static public readonly MyEnum zero = new MyEnum("zero", 0);
static public readonly MyEnum first = new MyEnum("one", 1);
static public readonly MyEnum second = new MyEnum("two", 2);
static public readonly MyEnum third = new MyEnum("three", 3);
static public readonly MyEnum fourth = new MyEnum("four", 4);
static public readonly MyEnum fifth = new MyEnum("five", 5);
// constructor doesn't do anything at this point
private MyEnum(string name, int value) : base(name, value) {}
}
So what has to change if MyEnum
decides to support the local list to avoid having to use reflection?
public class MyEnum : QuasiEnum {
// NOTE: this definition MUST be above the actual static readonly fields
static List<MyEnum> _enums = new List<MyEnum>();
// we can now change this
static public MyEnum[] GetAll() => _enums.ToArray(); // don't forget we need a defensive copy anyway
// and we can supply the local list where it's needed
static public MyEnum GetByName(string name) => GetByName<MyEnum>(name, _enums);
static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value, _enums);
// static public readonly ...
// static public readonly ...
// static public readonly ...
private MyEnum(string name, int value) : base(name, value) {
_enums.Add(this); // they all gather nicely here
}
}
That’s not too much work, right? Also you can derive from this class again (edit: and we will by the end of this exercise). If you’re wondering how can you inherit static fields, well, you can define the superclass, and then simply redirect all subclass queries toward its definition instead. But I haven’t played much with this (I did with the nested types, that part works as expected), the above solution was something I was happy with, and it’s perfectly maintainable the way it is.
Oh and wouldn’t it be nice if we could save some extra info, something that makes more sense than having to repeat the name of the field, like a tooltip, or a display name of sorts? Auto-generating names would also be nice to have. Because if you think about it, changing a field’s name is a huge pain in the back, because you can forget to match the two. Currently, the two are completely disjointed and MyEnum.fourth
has the string value of “four” which is actually cool in a serialization context, as you can change fourth to usedToBeFourth and it will still deserialize correctly. So there are benefits to be had on both sides of the coin.
I decided that the names should match at compile-time. But here’s the problem, I don’t have any control over how and when the compiler is going to swoop through our readonlys. So the poor man’s solution was to place nameof
in each instantiation.
static public readonly MyEnum second = new MyEnum(nameof(second), 2);
This way you could at least rename a field and the name would always match it, but I wasn’t really satisfied with this solution, that’s just crazy. So I played dirty instead and assumed that the most logical thing a compiler would do would be to process the whole readonly block in the most optimal order there is: top to bottom. There are no guarantees this is the case however, and this might break unexpectedly, but let’s play with it, so far it’s been very reliable.
First, we add AutoGenerateNames to QuasiEnum
class
static protected T AutoGenerateNames<T>() where T : QuasiEnum {
foreach(var info in enumerableStaticFieldsOf<T>()) {
var value = info.GetValue(null) as T;
if(value?.Name == "") value.Name = info.Name;
}
return default;
}
This way we only fill in the name if the name was already provided blank (“”). This allows the user class to define name overrides. Now we can cheese the compiler. Back to MyEnum
static public readonly MyEnum zero = new MyEnum("", 0);
static public readonly MyEnum first = new MyEnum("", 1);
static public readonly MyEnum second = new MyEnum("", 2);
static public readonly MyEnum third = new MyEnum("", 3);
static public readonly MyEnum fourth = new MyEnum("four", 4);
static public readonly MyEnum fifth = new MyEnum("", 5);
static private object _ = AutoGenerateNames<MyEnum>(); // BAM
Wholesome, isn’t it? Here I’ve deliberately set the fourth
’s name to “four”.
(Edit: the way this works is overhauled in the latest version available at the end of the article. This version can also auto-define the fields, letting you avoid having to manually create objects with new
)
Let’s introduce another constructor for this particular case, and then we can try and tackle extra data and future-proof some basic support for custom display names.
// back in QuasiEnum
protected QuasiEnum(int value) : this("", value) {}
This improves legibility of MyEnum
public class MyEnum : QuasiEnum {
// ...
static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
static public readonly MyEnum first = new MyEnum(1, "first is straight");
static public readonly MyEnum second = new MyEnum(2, "second is too late");
static public readonly MyEnum third = new MyEnum(3, "third is a charm");
static private object _ = AutoGenerateNames<MyEnum>();
string _description;
private MyEnum(int value, string description) : base(value) {
_enums.Add(this);
_description = description;
}
public string DisplayName => _description;
}
Ok so this is pretty extensible. The solution is now working in code and can be used as is. But we still want to support custom serialization and integrate the whole thing with Unity editors.
Serialization
Well, this part is fairly simple to implement.
Let’s add the following method to QuasiEnum class. This will allow us to examine what is being read from the file and to react accordingly.
(Also add using Debug = UnityEngine.Debug;
to top.)
protected void ParseDeserialized<T>(string str, List<T> list = null, bool allowInvalid = false, bool suppressLog = false) where T : QuasiEnum {
var parsed = GetByName<T>(str, list);
if(parsed is not null) {
(Name, Value) = ( parsed.Name, parsed.Value );
} else if(allowInvalid) {
if(!suppressLog) Debug.LogWarning($"Warning: {typeof(T).Name} had invalid deserialization data.");
(Name, Value) = ( null, -1 );
} else {
throw new InvalidOperationException($"Unrecognized data '{str}'.");
}
}
First, the user class itself has to be marked for serialization. Then it should implement ISerializationCallbackReceiver
. We then introduce a serialized field called name
which will be stored to YAML. This word is a reserved keyword in C# however, so we’ll have to use verbatim syntax ( (edit: this was a mistake on my part, no it’s not reserved, everything’s fine.)@
).
[Serializable]
public class MyEnum : QuasiEnum, ISerializationCallbackReceiver {
// static readonly fields go here ...
[SerializeField] string name; // must be called 'name' because the drawer relies on this
void ISerializationCallbackReceiver.OnBeforeSerialize()
=> name = this;
void ISerializationCallbackReceiver.OnAfterDeserialize()
=> ParseDeserialized<MyEnum>(name, list: _enums, allowInvalid: true);
...
SimpleSanitizer
Before we proceed with the custom property drawer, we’re going to need a utility to sanitize and beautify our auto-generated names, in a manner similar to what Unity does when it builds an auto-inspector. Namely it introduces spaces between camelCase words, capitalizes letters, and removes underscore characters. We can do that as well. I won’t explain this code too much, but in a nutshell we have a simple state tracker (ScannerState
) and a simple string scanner (Process
method) that runs through the string and produces a better copy of it in one go.
using System.Text;
namespace StringSanitizers {
static public class SimpleSanitizer {
static public string Process(string src) {
if(string.IsNullOrWhiteSpace(src)) return src?.Trim();
var scan = new Utils.ScannerState();
for(int i = 1; i <= src.Length; i++) {
scan.Sample(src[i-1], (i < src.Length)? src[i] : default);
if(scan.curr.IsUScor()) scan.curr = Utils.SPACE;
else scan.curr = scan.TryCaps(scan.curr);
if(scan.curr.IsSpace()) scan.TrySpace();
else scan.Append(scan.curr);
if(scan.next == default) break; // no look-ahead
if(lower_upper_pair() || any_one_digit())
scan.TrySpace();
}
return scan.sb.ToString();
bool lower_upper_pair() => scan.curr.IsLower() && scan.next.IsUpper();
bool any_one_digit() => scan.curr.IsDigit() && !scan.next.IsDigit() ||
!scan.curr.IsDigit() && scan.next.IsDigit();
}
}
static class Utils {
public const char SPACE = ' ';
static public bool IsUScor(this char chr) => chr == '_';
static public bool IsSpace(this char chr) => chr == Utils.SPACE;
static public bool IsDigit(this char chr) => chr >= '0' && chr <= '9';
static public bool IsLower(this char chr) => char.IsLetter(chr) && char.IsLower(chr);
static public bool IsUpper(this char chr) => char.IsLetter(chr) && char.IsUpper(chr);
static public bool IsPunct(this char chr) => char.IsPunctuation(chr);
public class ScannerState {
public char curr, next;
public readonly StringBuilder sb;
public bool shift = true;
public ScannerState() => sb = new StringBuilder();
public void Sample(char a, char b) => (curr, next) = (a, b);
public void Append(char x) => sb.Append(x);
public void TrySpace() {
if(!shift) Append(Utils.SPACE);
shift = true;
}
public char TryCaps(char x) {
if(shift) {
if(x != Utils.SPACE) shift = false;
if(x.IsLower()) return char.ToUpperInvariant(x);
}
return x;
}
}
}
}
We’ll also need these to chop some strings later, it’s just an extract from my custom extensions. In my environment they sit in their respective libraries, but I’ll have to think twice about where to put them when I dump the code in its entirety.
static public bool CropLeft(this string s, int index, out string result) {
result = s.CropLeft(index);
return index > 0 && index < s.Length;
}
static public string CropLeft(this string s, int index)
=> (s is null)? null : s.Substring(0, index.Clamp(s.Length + 1));
// and this is here only because I refuse to use any kind of Math lib in this solution
static int Clamp(this int v, int count) => Clamp(v, 0, count - 1);
static int Clamp(this int v, int min, int max) => (v <= max)? (v > min)? v : min : max;
(Edit: You can find all of this settled down in the latest version source code.)
Phew, now we’re ready to start untangling the property drawer.
QuasiEnumDrawer
I hate writing drawers, but this one is not that big, honestly, ~200 lines of code. I’ll go backwards, it’ll be easier to parse. First, let’s consider what features we want to implement. How about the ability to show values next to the name and/or the type of the custom enum? And what about optional support for that DisplayName getter we introduced above? I’ve also added the possibility of having a ‘default’ entry, like ‘undefined’ or ‘None’. Why should you have to add that to your enum, that’s not data, that’s a selector state? Oh oh, and sorting features, we can sort by value, or by name, or leave it unsorted.
Ok then, so let’s make a couple of enums to remind ourselves what to include. Let’s bother with how to supply them later. This is btw a legit use of standard enums in C# and Unity. You just shouldn’t describe game logic with them.
[Flags]
public enum QuasiEnumOptions {
None = 0,
ShowValue = 0x1, // Shows value, in format: name (value)
ShowTypeInLabel = 0x2, // Shows type in label
IncludeSelectableNone = 0x4, // Adds selectable zeroth "None" element if the list is non-empty
UseDisplayName = 0x8, // Uses the locally-defined DisplayName getter for display names
}
public enum QuasiEnumSorting {
Unsorted = 0,
ByName,
ByValue
}
Let’s begin with the foundation.
We need this to keep track of everything, just a simple immutable struct holding what we have right now.
readonly struct RecordRow {
public readonly string name;
public readonly int value;
public readonly string display;
public RecordRow(string name, int value, string display)
=> (this.name, this.value, this.display) = (name, value, display);
}
Some general utilities we’re gonna need
// this is just to shorten this monstrosity
bool isNullOrWhite(string s) => string.IsNullOrWhiteSpace(s);
// this one will be handy, array alloc with a lambda filler
T[] newArray<T>(int len, Func<int, T> lambda) {
var d = new T[len];
if(lambda is not null)
for(int i = 0; i < len; i++) d[i] = lambda(i);
return d;
}
// finally, because we're going to use a local dictionary to help us find an index in O(1)
int indexOfRecord(string name) {
if(!_lookup.TryGetValue(name, out var index)) index = -1;
return index;
}
At the beginning, we want to introduce some standard strings.
static readonly string _displayName_method = "DisplayName"; // prescribed name of the getter
static readonly string _none_string = "(None)"; // for the extra 'None' selection field
static readonly string[] _empty_array = new string[] { "(Empty)" }; // when there are no names at all
We can now build our records, one row at a time, where each row represents one item. Don’t worry about that dynamic
declaration, I’ll get back to it.
RecordRow buildRecordRow(dynamic item, Type type, QuasiEnumOptions options) {
const BindingFlags flags = BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.DeclaredOnly |
BindingFlags.FlattenHierarchy | BindingFlags.Instance; // this is to fetch that getter
var display = string.Empty;
// optionally consider the local DisplayName getter
if(options.HasFlag(QuasiEnumOptions.UseDisplayName))
display = type.GetProperty(_displayName_method, flags)?.GetValue(item);
// fall back to basic name sanitation
if(isNullOrWhite(display))
display = SimpleSanitizer.Process(item.Name);
// optionally append value
if(options.HasFlag(QuasiEnumOptions.ShowValue))
display = $"{display} ({item.Value})";
return new RecordRow(item.Name, item.Value, display);
}
Nice. Now, the trick is to somehow fetch the values from GetNamesOf<T>
method back in QuasiEnum. But this is easier said than done, because it’s a generic code that also implies covariance. Long story short, I’ve managed to properly address it, but to fully satisfy the compiler, I had to declare the result as dynamic
in the end. There is probably a better solution, but I’m not bothered too much, I find this relatively appropriate for this scenario.
Here we supply a target type, and fetch back the list of names (note that we can’t just scan for readonlys again, because the user had ample opportunity to format his entries in a way that is unknown to us here).
dynamic enumerableNamesViaReflection(Type type) {
const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly;
var method = typeof(QuasiEnum).GetMethod(nameof(QuasiEnum.GetItemsOf), flags).MakeGenericMethod(type);
return method.Invoke(null, null); // static context, parameterless
}
This was the biggest hurdle. Let’s now build the records table in its entirety.
// will return an empty array at the very least
RecordRow[] buildRecords(Type type, QuasiEnumOptions options, QuasiEnumSorting sorting) {
var table = new List<RecordRow>();
// add the rows
foreach(var item in enumerableNamesViaReflection(type))
table.Add(buildRecordRow(item, type, options));
// sort the rows
table.Sort( (a,b) => comparison(a, b, sorting) );
// insert 'None' field
if(options.HasFlag(QuasiEnumOptions.IncludeSelectableNone))
table.Insert(0, new RecordRow(null, -1, _none_name)); // insert a deliberately invalid entry
// build the index lookup after everything has settled down
_lookup = new Dictionary<string, int>();
for(int i = 0; i < table.Count; i++)
_lookup[table[i].Name] = i;
return table.ToArray(); // finished
// just a local function to aid us with sorting
int comparison(RecordRow a, RecordRow b, QuasiEnumSorting sorting)
=> sorting switch {
QuasiEnumSorting.ByName => string.CompareOrdinal(a.display, b.display),
QuasiEnumSorting.ByValue => a.value.CompareTo(b.value),
_ => 0
}
}
With all that done we can properly address the body of the drawer. Here I’m showing the script in full glory.
using System;
using System.Collections.Generic;
using System.Reflection;
using StringSanitizers;
using UnityEngine;
using UnityEditor;
// useForChildren is supposed to mean child classes
[CustomPropertyDrawer(typeof(QuasiEnum), useForChildren: true)]
public class QuasiEnumDrawer : PropertyDrawer {
// we have already defined static readonly strings here
SerializedProperty _targetProperty;
RecordRow[] _records; // never null (except on start)
Dictionary<string, int> _lookup; // used for fast index-of-record lookup
string[] _displayNames; // cached for EditorGUI.Popup
public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
// settings are manually tweakable for now
var (type, options, sorting) = ( fieldInfo.FieldType, QuasiEnumOptions.None, QuasiEnumSorting.ByValue );
_targetProperty = property.FindPropertyRelative("name"); // this hinges on that serialized field
if(_targetProperty is null) return; // and if that's absent we have no other option but to bail
// I could handle this with more grace, maybe show something else?
if(_records is null) // we check if this was initialized
_records = buildRecords(type, options, sorting)
var val = indexOfRecord(_targetProperty.stringValue);
if(options.HasFlag(QuasiEnumOptions.ShowTypeInLabel))
label.text = !isNullOrWhite(label.text)? $"{label.text} ({type.Name})" : type.Name;
var savedIndent = EditorGUI.indentLevel;
label = EditorGUI.BeginProperty(rect, label, property);
rect = EditorGUI.PrefixLabel(rect, GUIUtility.GetControlID(FocusType.Passive), label);
EditorGUI.indentLevel = 0; // after indenting label, cancel indentation entirely
if(nonEmptyRecords(options)) {
var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length));
if(val != newVal) _targetProperty.stringValue = _records[newVal].name;
} else { // make the drop down appear empty and disabled
disabledGUI( () => EditorGUI.Popup(rect, 0, _empty_array) );
targetProperty.stringValue = string.Empty;
}
EditorGUI.EndProperty();
EditorGUI.indentLevel = savedIndent;
// let's do the local functions after this line to reduce the clutter above
// this allows us to track states between two pieces of bread in a sandwich
// a useful pattern that ensures minimum mess when writing IMGUI code
void disabledGUI(Action action) {
var saved = GUI.enabled;
GUI.enabled = false;
action.Invoke();
GUI.enabled = saved;
}
int drawDropDownSelector(Rect rect, int selected)
=> EditorGUI.Popup(rect, selected, _displayNames??= extractDisplayColumn());
string[] extractDisplayColumn()
=> newArray(_records.Length, i => _records[i].display );
// if the list was empty, "None" that was optionally added shouldn't count
bool nonEmptyRecords(QuasiEnumOptions options)
=> _records.Length - (options.HasFlag(QuasiEnumOptions.IncludeSelectableNone)? 1 : 0) > 0;
}
// the rest of the code we did before
}
Inspecting arrays
The solution above does not play well with serialized arrays. Well crap.
Another round then. First, we want to detect whether this property belongs to an array property, and if so, which element index it was assigned to. Both of these are another of the local functions in OnGUI. I can’t help it, they don’t belong anywhere else.
With detectArrayProperty all I do here is splice up the actual property path, sniff out the parent object, double-check it’s an array, and return it. We do this only once.
SerializedProperty detectArrayProperty() {
if(property.name == "data") { // I.e. propertyPath = "myName.Array.data[3]"
var path = property.propertyPath;
if(path.CropLeft(path.LastIndexOf(".Array"), out var actualName)) { // "myName"
var p = property.serializedObject.FindProperty(actualName);
if(p.isArray) return p;
}
}
return null;
}
We also need to get the element index. This is just a brute force search, not a pretty sight. I’m still looking into ways to improve this. But this will only encumber the inspector if the array is huge (and unfolded).
int getArrayPropertyIndex(SerializedProperty arrayProperty) {
if(arrayProperty is null) return -1; // just a sanity check
for(int i = 0; i < arrayProperty.arraySize; i++)
if(SerializedProperty.EqualContents(arrayProperty.GetArrayElementAtIndex(i), property))
return i;
return -1;
}
Let’s also introduce mixed value display, for multi-value editing. I won’t make it work-work, but I cannot completely neglect it either. I’m not sure how Unity treats multi-value drop downs, but there is not much one can do anyway.
// yet another sandwich method
void mixedGUI(bool condition, Action action) {
var saved = EditorGUI.showMixedValue;
EditorGUI.showMixedValue = condition;
action.Invoke();
EditorGUI.showMixedValue = saved;
}
we add another SerializedProperty class field
SerializedProperty _targetProperty;
SerializedProperty _arrayProperty;
Next we go back to OnGUI
...
if(_records is null) {
_records = buildRecords(type, options, sorting);
_arrayProperty = detectArrayProperty();
}
var val = indexOfRecord(_targetProperty.stringValue);
if(_arrayProperty is not null) {
if(string.Equals(label.text, _targetProperty.stringValue, StringComparison.InvariantCulture))
label.text = $"Element {getArrayPropertyIndex(_arrayProperty)}";
} else {
if(options.HasFlag(QuasiEnumOptions.ShowTypeInLabel))
label.text = !isNullOrWhite(label.text)? $"{label.text} ({type.Name})" : type.Name;
}
var savedIndent = EditorGUI.indentLevel;
...
if(nonEmptyRecords(options)) {
mixedGUI(_targetProperty.hasMultipleDifferentValues, () => {
var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length));
if(val != newVal) _targetProperty.stringValue = _records[newVal].name;
});
} else { // make the drop down empty and disabled
disabledGUI( () => EditorGUI.Popup(rect, 0, _empty_array) );
targetProperty.stringValue = string.Empty;
}
...
Edit: The drawer was changed slightly in the latest version. Most of this still holds true however.
Custom Attribute
Lastly, we ought to introduce a custom attribute. This will allow us to supply the missing options (as metadata).
using System;
using UnityEngine;
[AttributeUsage(AttributeTargets.Field, Inherited = true, AllowMultiple = true)]
public class QuasiEnumAttribute : PropertyAttribute {
public readonly Type type;
public readonly QuasiEnumOptions options;
public readonly QuasiEnumSorting sorting;
public QuasiEnumAttribute(Type type)
: this(type, QuasiEnumOptions.None, QuasiEnumSorting.ByValue) {}
public QuasiEnumAttribute(Type type, QuasiEnumSorting sorting)
: this(type, QuasiEnumOptions.None, sorting) {}
public QuasiEnumAttribute(Type type, QuasiEnumOptions options, QuasiEnumSorting.sorting) {
if(!type.IsSubclassOf(typeof(QuasiEnum))
throw new ArgumentException($"{nameof(QuasiEnumAttribute)} works only with subtypes of {nameof(QuasiEnum)}.");
(this.type, this.options, this.sorting) = (type, options, sorting);
}
}
This lets us fully describe the MyEnum field in our production code.
For example
using System;
using UnityEngine;
public class QuasiEnumTest : MonoBehaviour {
[SerializeField]
[QuasiEnum(typeof(MyEnum), QuasiEnumOptions.ShowValue | QuasiEnumOptions.IncludeSelectableNone, QuasiEnumSorting.ByName)]
MyEnum _enum; // try also MyEnum[] _enum;
}
But we do have to register this data in our drawer to make this work.
[CustomPropertyDrawer(typeof(QuasiEnumAttribute))]
[CustomPropertyDrawer(typeof(QuasiEnum), useForChildren: true)]
public class QuasiEnumDrawer : PropertyDrawer {
...
public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
var attrib = this.attribute as QuasiEnumAttribute;
var (type, options, sorting) = (attrib is null)? ( fieldInfo.FieldType, QuasiEnumOptions.None, QuasiEnumSorting.ByValue )
: ( attrib.type, attrib.options, attrib.sorting );
...
}
...
}
Edit: In the latest version, the attribute does not work on the actual field anymore, but on the class instead. This is because I’ve added settings to how the type itself is treated (for example a setting to auto-define the fields etc). There is also a possibility I will make two separate attributes in the final version, because you might want to have two serialized fields for the same type, one that accepts ‘None’ and the other that doesn’t, as an example. I’m still thinking about it.
Tooltips
As a bonus let’s add support for tooltips as well. We can start by attacking the underlying data.
[Flags]
public enum QuasiEnumOptions {
None = 0,
ShowValue = 0x1, // Shows value, in format: name (value)
ShowTypeInLabel = 0x2, // Shows type in label
IncludeSelectableNone = 0x4, // Adds selectable zeroth "None" element if the list is non-empty
UseDisplayName = 0x8, // Uses the locally-defined DisplayName getter for display names
UseTooltipString = 0x10 // Uses the locally-defined TooltipString getter for item tooltips
}
RecordRow buildRecordRow(dynamic item, Type type, QuasiEnumOptions options) {
var display = string.Empty;
var tooltip = default(string);
...
// optionally consider the local DisplayName getter
if(options.HasFlag(QuasiEnumOptions.UseDisplayName))
display = type.GetProperty(_displayName_method, flags)?.GetValue(item);
// optionally consider the local TooltipString getter
if(options.HasFlag(QuasiEnumOptions.UseTooltipString)) // new flag
tooltip = type.GetProperty(_tooltipName_method, flags)?.GetValue(item); // new getter
...
return new RecordRow(item.Name, item.Value, display, tooltip); // modified c-tor
}
readonly struct RecordRow {
public readonly string name;
public readonly int value;
public readonly string display;
public readonly string tooltip; // new field
public RecordRow(string name, int value, string display, string tooltip = null)
=> (this.name, this.value, this.display, this.tooltip) = (name, value, display, tooltip);
}
Then,
...
public class QuasiEnumDrawer : PropertyDrawer {
static readonly string _displayName_method = "DisplayName";
static readonly string _tooltipName_method = "TooltipString";
...
RecordRow[] _records; // never null (except on start)
Dictionary<string, int> _lookup; // used for fast index-of-record lookup
string[] _displayNames; // cached for EditorGUI.Popup
GUIContent[] _guiContent; // same as _displayNames, but for tooltips
public override void OnGUI(Rect rect, SerializedProperty property, GUIContent label) {
...
var newVal = drawDropDownSelector(rect, val.Clamp(_records.Length), options.HasFlag(QuasiEnumOptions.UseTooltipString));
...
int drawDropDownSelector(Rect rect, int selected, bool usesTooltips)
=> !usesTooltips? EditorGUI.Popup(rect, selected, _displayNames??= extractDisplayColumn())
: EditorGUI.Popup(rect, GUIContent.none, selected, _guiContent??= extractGUIContent());
...
GUIContent[] extractGUIContent() // I told you newArray was handy
=> newArray(_records.Length, i => new GUIContent(_records[i].display, _records[i].tooltip) );
...
}
...
}
And then we can modify MyEnum slightly
[Serializable]
public class MyEnum : QuasiEnum, ISerializationCallbackReceiver {
// this definition MUST be above the actual static readonly fields
static List<MyEnum> _enums = new List<MyEnum>();
static public MyEnum[] GetAll() => _enums.ToArray();
static public MyEnum GetByName(string name) => GetByName<MyEnum>(name, _enums);
static public MyEnum GetByValue(int value) => GetByValue<MyEnum>(value, _enums);
static public explicit operator MyEnum(int value) => GetByValue(value);
static public readonly MyEnum zero = new MyEnum(0, "zero is naughty");
static public readonly MyEnum first = new MyEnum(1, "first is straight");
static public readonly MyEnum second = new MyEnum(2, "second is too late");
static public readonly MyEnum third = new MyEnum(3, "third is a charm");
static public readonly MyEnum fourth = new MyEnum(4, "fourth has quad damage");
static public readonly MyEnum fifth = new MyEnum(5, "fifth is a wheel");
static public readonly MyEnum sixth = new MyEnum(6, "sixth is a sense");
static public readonly MyEnum seventh = new MyEnum(7, "seventh is a seal");
static public readonly MyEnum eight = new MyEnum(8, "ate a pizza");
static public readonly MyEnum nine = new MyEnum(9, "nine is the captain of the bunch");
static private object _ = AutoGenerateNames<MyEnum>();
[SerializeField] string @name; // must be called @name
void ISerializationCallbackReceiver.OnBeforeSerialize()
=> @name = this;
void ISerializationCallbackReceiver.OnAfterDeserialize()
=> ParseDeserialized<MyEnum>(@name, list: _enums, allowInvalid: true);
string _description;
private MyEnum(int value, string description) : base(value) {
_enums.Add(this);
_description = StringSanitizers.SimpleSanitizer.Process(description);
}
public string DisplayName => _description;
public string TooltipString => $"Hint: {_description}";
}
Feedback and questions are welcome.
Edit:
I’ve redacted the text above to reflect the latest version, where it mattered, and fixed some typos etc. The solution has changed dramatically in the meantime, however not by changing what was already explained, but by promoting the user class into yet another type-wrapper. This allowed me to do a second round of features, such as field auto-define. Then I decided to rename the whole thing to ErsatzEnumeration.
You can download the latest version of ErsatzEnum (in source code) in post #12