1
\$\begingroup\$

I have a class, EnumMap<TEnum,TValue>, for associating each member of an enum type with a value of a given type, since I find myself running into situations like that frequently. It acts similarly to a dictionary, but is stored as parallel Lists for serialization.

Since, during design time, I want to edit the contents of the value list but not its structure (and I don't want to edit the enum list at all), I wrote a custom property drawer that renders EnumMaps as if they were basic Serializable C# classes with a named member for each value of the enum. That is, if you have an enum defined like enum Example { A, B, C }, and a field with the type EnumMap<Example, int>, the field will render as if it's an instance of a class with three int fields named A, B, and C:

[Serializable]
public class EquivalentClass
{
    public int A, B, C;
}

The property drawer works great in every situation I've tested so far except for when an EnumMap maps to an EnumMap - ie, EnumMap<SomeEnumType, EnumMap<SomeOtherEnumType, SomeValueType>>. In that case, the child EnumMaps display the names of the parent enum type, and the property drawer errors out if the child enum has more name values than the parent.

I believe it has to do with how I'm getting the display names of the enums, but the documentation on SerializedProperty.enumDisplayNames and SerializedProperty.enumValueIndex isn't particularly helpful, and I haven't had any luck with Google. I've also tried SerializedProperty.enumNames but that didn't seem to change anything. (Side question - what exactly is the difference between those?) It could also be something to do with how I'm actually accessing the property in the first place, since I'm pretty new to custom editors.

Question: what's the idiomatic way to retrieve display names for a given enum value when writing custom editors? And how can you ensure that nested properties are retrieving the right relative values, instead of their parents'?


Here's the code for EnumMap:

[Serializable]
public class EnumMap<TEnum, TValue> : ISerializationCallbackReceiver
    where TEnum : Enum
{
    public TValue this[TEnum e]
    {
        get => m_values[getOrAddEnumPosition(e)];
        set => m_values[getOrAddEnumPosition(e)] = value;
    }

    [SerializeField] List<TEnum> m_enums;
    [SerializeField] List<TValue> m_values;

    public void OnBeforeSerialize ()
    {
        createAndResizeLists();
    }

    public void OnAfterDeserialize ()
    {
        createAndResizeLists();
    }

    void createAndResizeLists ()
    {
        if (m_enums == null) m_enums = new List<TEnum>();
        if (m_values == null) m_values = new List<TValue>();

        foreach (var e in EnumUtil.AllValues<TEnum>())
        {
            getOrAddEnumPosition(e);
        }
    }

    int getOrAddEnumPosition (TEnum e)
    {
        int position = m_enums.IndexOf(e);

        if (position != -1)
        {
            return position;
        }
        else
        {
            m_enums.Add(e);
            m_values.Add(default);
            return m_enums.Count - 1;
        }
    }
}

And here's its property drawer:

[CustomPropertyDrawer(typeof(EnumMap<,>))]
public class EnumMapDrawer : PropertyDrawer
{
    public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);

        Rect foldoutPosition = position;
        foldoutPosition.height = foldoutLabelHeight(label);
        property.isExpanded = EditorGUI.Foldout(foldoutPosition, property.isExpanded, label, true);

        if (property.isExpanded)
        {
            EditorGUI.indentLevel++;
            Rect fieldPosition = EditorGUI.IndentedRect(position);
            fieldPosition.y += foldoutLabelHeight(label) + EditorGUIUtility.standardVerticalSpacing; // ensure fields start below the label

            var enums = property.FindPropertyRelative("m_enums");
            var values = property.FindPropertyRelative("m_values");

            for (int i = 0; i < enums.arraySize; i++)
            {
                var value = values.GetArrayElementAtIndex(i);

                var currentEnum = enums.GetArrayElementAtIndex(i);
                string enumLabel = currentEnum.enumNames[currentEnum.enumValueIndex];

                fieldPosition.height = EditorGUI.GetPropertyHeight(value);
                EditorGUI.PropertyField(fieldPosition, value, new GUIContent(enumLabel), true);
                fieldPosition.y += fieldPosition.height + EditorGUIUtility.standardVerticalSpacing;
            }

            EditorGUI.indentLevel--;
        }

        EditorGUI.EndProperty();
    }

    public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
    {
        if (!property.isExpanded) return base.GetPropertyHeight(property, label);

        var values = property.FindPropertyRelative("m_values");

        float height = foldoutLabelHeight(label) + EditorGUIUtility.standardVerticalSpacing; // include the label and some space
        for (int i = 0; i < values.arraySize; i++)
        {
            height += EditorGUI.GetPropertyHeight(values.GetArrayElementAtIndex(i), true) + EditorGUIUtility.standardVerticalSpacing;
        }

        return height;
    }

    float foldoutLabelHeight (GUIContent label)
    {
        return EditorGUI.GetPropertyHeight(SerializedPropertyType.String, label);
    }
}

Here's some minimal code to reproduce the property drawer issue:

public enum LongerEnum
{
    A, B, C, D
}

public enum ShorterEnum
{
    One, Two, Three
}

public class EnumMapTester : MonoBehaviour
{
    public EnumMap<ShorterEnum, EnumMap<LongerEnum, int>> Test;
}
\$\endgroup\$

1 Answer 1

1
+100
\$\begingroup\$

Seem like a bug in Unity serialization, enumNames and enumValueIndex are unreliable for the inner enumeration type.

But there is a good news that Unity provides fieldInfo property for PropertyDrawer, so we can get the type information.

Here is the code and comments:

if (property.isExpanded)
{
    EditorGUI.indentLevel++;
    Rect fieldPosition = EditorGUI.IndentedRect(position);
    fieldPosition.y += foldoutLabelHeight(label) + EditorGUIUtility.standardVerticalSpacing; // ensure fields start below the label

    var enums = property.FindPropertyRelative("m_enums");
    var values = property.FindPropertyRelative("m_values");

    // Get the type information from fieldInfo
    var fieldType = fieldInfo.FieldType;
    var genericTypeDefinition = fieldType.GetGenericTypeDefinition();
    var enumNames = enums.enumNames;
    if (genericTypeDefinition == typeof(EnumMap<,>)) // EnumMap as a field
    {
        enumNames = System.Enum.GetNames(fieldType.GenericTypeArguments[0]);
    }
    else if(genericTypeDefinition == typeof(List<>)) // EnumMap inside another EnumMap
    {
        enumNames = System.Enum.GetNames(fieldType.GenericTypeArguments[0].GenericTypeArguments[0]);
    }

    for (int i = 0; i < enums.arraySize; i++)
    {
        var value = values.GetArrayElementAtIndex(i);

        // 'enumValueIndex' is unreliable.
        // but because you add all the values of an enum type into 'm_enums',
        // so we can use just 'i' instead.
        var enumLabel = enumNames[i]; 

        fieldPosition.height = EditorGUI.GetPropertyHeight(value);
        EditorGUI.PropertyField(fieldPosition, value, new GUIContent(enumLabel), true);
        fieldPosition.y += fieldPosition.height + EditorGUIUtility.standardVerticalSpacing;
    }

    EditorGUI.indentLevel--;
}
\$\endgroup\$
2
  • \$\begingroup\$ Didn't realize this was a bug, good to know. Does this solution work for any arbitrary level of nesting, ie EnumMap<Enum1, EnumMap<Enum2, EnumMap<Enum3. TValue>>>, EnumMap<Enum1, EnumMap<Enum2, EnumMap<Enum3. EnumMap<TEnum4, TValue>>>>, etc.? \$\endgroup\$ Jan 12, 2022 at 18:46
  • \$\begingroup\$ I haven't tested, but I think it does. \$\endgroup\$
    – shingo
    Jan 13, 2022 at 4:25

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .