Editor Scripting with Generics

I’m trying to create a custom property drawer for any class that is a child of this GenericVariableSO class.

public abstract class GenericVariableSO<T> : ScriptableObject
{
    [NonSerialized]
    protected T runtimeValue;
    [SerializeField] protected T initialValue;

    public void Set(T value)
    {
        runtimeValue = value;
        Updated();
    }

    public T Get()
    {
        return runtimeValue;
    }

}

All I want it to do is show the serialized object field and if that field is populated, also show the standard input field for whatever field is, (and if that value is changed, save it back to the scriptable object)

Firstly [CustomPropertyDrawer(typeof(GenericVariableSO))] does not work, it says
Using the generic type ‘GenericVariableSO’ requires 1 type arguments
But [CustomPropertyDrawer(typeof(GenericVariableSO))] also doesn’t work,
The type or namespace name ‘T’ could not be found (are you missing a using directive or an assembly reference?)

So I changed it to one of the child classes just to see if I could get it working

The child class is just empty

public class IntVariableSO : GenericVariableSO<int>
{
   
}

The scriptable object field gets drawn correctly but I can’t access the initialValue

SerializedObject soProperty = new(property.objectReferenceValue);
var SOInitialValueField = new PropertyField(soProperty.FindProperty("initalValue"));

That doesn’t work but no errors, so I tried to force this but

var SOInitialValueField = ((IntVariableSO)property.objectReferenceValue).initalValue;

throws the error: ‘IntVariableSO’ does not contain a definition for ‘initalValue’ and no accessible extension method ‘initalValue’ accepting a first argument of type ‘IntVariableSO’

Is there any way to resolve these issues cleanly? I’d like to keep the structure of my classes because it’s nice and clean and minimal effort to create new variables but I’d also like to be able to edit the values on the monobehavior as I would if it were a regular field or property

To make the generic property drawer work for all child classes you have to supply a second argument:

CustomPropertyDrawer(typeof(GenericVariableSO<>), true)]

Related thread that might help:

1 Like

Well, you just mistyped "initialValue". You wrote "initalValue" (so you’re missing the 3rd i).

Unfortunately you can not use “nameof” with protected or private fields. However you could provide a readonly property which returns the field name

public string InitialValueFieldName => nameof(initialValue);

That way you get actual type safety n your code as you can avoid using magic string values.
Though in order to access that value you either want to use a simple interface like

public interface IInitialValueField
{
    string InitialValueFieldName { get; }
}

That way you can easily access the field name inside the property drawer, even when you’re using the generic version. Since the actual property field uses reflection to shoe the proper UI, it should work fine. Though just typing the field name right would already solve the issue :slight_smile:

1 Like

Thank you for the syntax help with the generic and the typo (I swear I copied and pasted it at least once to make sure it was right haha)

I’m making progress, no more errors, however, I still can’t get the field rendering.

public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        // Create property container element.
        var container = new VisualElement();
        var SOField = new PropertyField(property);

        container.Add(SOField);

        if (property.objectReferenceValue!=null)
        {
            SerializedObject soProperty = new(property.objectReferenceValue);
            SerializedProperty prop = soProperty.GetIterator();
            if(prop.NextVisible(true) )
            {
                do
                {
                    Debug.Log(prop.name);
                } while (prop.NextVisible(false));
            }
            var SOInitialValueField = new PropertyField(soProperty.FindProperty("initialValue"));
            container.Add(SOInitialValueField);

        }
       
        return container;
    }

I’ve made initialValue public just for now so it can be accessed. and I’ve confirmed by looping that the initialValue does exist in soProperty but I’m still not getting a field to edit it.

This is my original implementation UGUI (initialValue is called storedValue in this version), this does work but I have to create a new drawer for each type. I want to achieve the same functionality but have it just draw the correct field type without me needing to specify, plus I do like the UIToolkit’s simpler syntax for adding fields too.

using UnityEditor;
using UnityEngine;
using HR.Utilities.Variables;


public abstract class CustomVariableDrawer<T> : PropertyDrawer
{
    protected abstract T GetValueToEdit(SerializedProperty property);
    protected abstract void ApplyValue(SerializedProperty property, T newValue);

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        // Adjust height as needed
        return (EditorGUIUtility.singleLineHeight*2) +EditorGUIUtility.standardVerticalSpacing;
    }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        // Display the ScriptableObject field
        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), property, label);

        // Move the position down for the value field
        position.y += EditorGUIUtility.singleLineHeight+EditorGUIUtility.standardVerticalSpacing;

        if(property.objectReferenceValue == null)
        {
            return;
        }

        // Get the value to edit
        T valueToEdit = GetValueToEdit(property);

        // Edit the value using the appropriate EditorGUI method
        T newValue = EditValue(position, valueToEdit);

        // Apply the new value if changed
        if (GUI.changed)
        {
            ApplyValue(property, newValue);
            property.serializedObject.ApplyModifiedProperties();
            Undo.RecordObject(property.objectReferenceValue, "Change Variable Value");
            EditorUtility.SetDirty(property.objectReferenceValue);
        }
    }

    protected abstract T EditValue(Rect position, T valueToEdit);
}

[CustomPropertyDrawer(typeof(FloatVariable))]
public class FloatVariableDrawer : CustomVariableDrawer<float>
{
    protected override float GetValueToEdit(SerializedProperty property)
    {
        return ((FloatVariable)property.objectReferenceValue).storedValue;
    }

    protected override void ApplyValue(SerializedProperty property, float newValue)
    {
        ((FloatVariable)property.objectReferenceValue).storedValue = newValue;
    }

    protected override float EditValue(Rect position, float valueToEdit)
    {
        return EditorGUI.FloatField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), " ", valueToEdit);
    }
}

[CustomPropertyDrawer(typeof(IntVariable))]
public class IntVariableDrawer : CustomVariableDrawer<int>
{
    protected override int GetValueToEdit(SerializedProperty property)
    {
        return ((IntVariable)property.objectReferenceValue).storedValue;
    }

    protected override void ApplyValue(SerializedProperty property, int newValue)
    {
        ((IntVariable)property.objectReferenceValue).storedValue = newValue;
    }

    protected override int EditValue(Rect position, int valueToEdit)
    {
        return EditorGUI.IntField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), " ", valueToEdit);
    }
}

[CustomPropertyDrawer(typeof(StringVariable))]
public class StringVariableDrawer : CustomVariableDrawer<string>
{
    protected override string GetValueToEdit(SerializedProperty property)
    {
        return ((StringVariable)property.objectReferenceValue).storedValue;
    }

    protected override void ApplyValue(SerializedProperty property, string newValue)
    {
        ((StringVariable)property.objectReferenceValue).storedValue = newValue;
    }

    protected override string EditValue(Rect position, string valueToEdit)
    {
        return EditorGUI.TextField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), " ", valueToEdit);
    }
}

[CustomPropertyDrawer(typeof(BoolVariable))]
public class BoolVariableDrawer : CustomVariableDrawer<bool>
{
    protected override bool GetValueToEdit(SerializedProperty property)
    {
        return ((BoolVariable)property.objectReferenceValue).storedValue;
    }

    protected override void ApplyValue(SerializedProperty property, bool newValue)
    {
        ((BoolVariable)property.objectReferenceValue).storedValue = newValue;
    }

    protected override bool EditValue(Rect position, bool valueToEdit)
    {
        return EditorGUI.Toggle(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), " ", valueToEdit);
    }
}

Well, the problem here is that CreatePropertyGUI is only invoked once when the editor is shown / created. So you can not have dynamic elements like you used to in the IMGUI system. That was and still is one of the main advantages of the IMGUI system. It can react to any changes dynamically. Currently you only create your edit field when a scriptable object is assigned when the UI is created. Of course when you select an object and then assign a scriptable object, the inspector is not recreated. There are ways to play with data bindings and callbacks to achieve things like that.

So the whole generation of the SerializedObject has to be done whenever you select / assign a new ScriptableObject instance in the inspector. Though that also means that the databinding of your edit field needs to be changed dynamically. There are probably ways with callbacks to pull that off. Though the result is probably more complicated than the IMGUI version ^^. Though maybe there’s a simpler solution I’m not aware of since I haven’t used the UI toolkit that often yet.

Yout IMGUI approach would still work without the need to create concrete subclasses of your editor. When you use a EditorGUI.PropertyField it works the same way as the PropertyField class in the UI toolkit. They are type unaware / work with any serializable data that can be represented by an SerializedProperty.

ps: This may help when you want to stick to UI Toolkit. Though note that examples are often badly designed or can be misleading.

Thanks for the info, the code itself is definitely running because I get the debug output but good to know that it wont work properly. I’ll see if the IMGUI version actually creates the field

Well, since your subfield essentially uses a different serializedobject as basis, you probably need to call Bind yourself. For the actual custom editor and inspected object, the inspector will do that for you once you have build your UI. Though since you essentially create a property field for a field that is not part of the inspected object, you most likely have to do the binding yourself.

I’ve gone back to the IMGUI and its almost there

[CustomPropertyDrawer(typeof(GenericVariableSO<>), true)]
public class CustomVariableDrawer : PropertyDrawer
{

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        // Adjust height as needed
        return (EditorGUIUtility.singleLineHeight * 2) + EditorGUIUtility.standardVerticalSpacing;
    }
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), property, label);
        position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
        if (property.objectReferenceValue == null )
        {
            return;
        }
        SerializedObject soProperty = new(property.objectReferenceValue);
        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), soProperty.FindProperty("initialValue"), false);

        if (GUI.changed)
        {
            property.serializedObject.ApplyModifiedProperties();
            Undo.RecordObject(property.objectReferenceValue, "Change Variable Value");
            EditorUtility.SetDirty(property.objectReferenceValue);
        }

    }

The field exists but changes don’t save. In my original version I had this just inside the if (GUI.changed) and that got the value from the field and set it on the scriptable object but I had to know the specific type to do that. I don’t know how to make that last part, generic

int newValue = EditorGUI.IntField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), " ", ((IntVariable)property.objectReferenceValue).storedValue);

((IntVariable)property.objectReferenceValue).storedValue = newValue;

Does this work?

((dynamic)property.objectReferenceValue).storedValue = newValue;

Well, you have a second SerializedObject where you need to call “ApplyModifiedProperties” on as well. Note that your “GUI.changed” block is a mix of very different techniques and most are used wrongly.

Undo.RecordObject must be called before the change is done. Otherwise Unity can not diff the result at the end of the UI callback.

Calling SetDirty is kinda outdated as it just informs Unity that the actual native object has changed data and needs to be saved. However the concept of SerializedObject / SerializedProperty is that the actual serialized data is abstracted from the underlying target(s) object(s). So when you work with SerializedProperties, all changes are tracked in this abstraction layer. When you call Update on the SerializedObject, Unity will actually load / copy the actual serialized data into that abstraction layer. Now you can modify it through the SerializedProperties. Though none of the underlying objects are affected yet. Only wnen you call ApplyModifiedProperties will it actually write the changes back to the actual object(s). That’s why calling SetDirty on that scriptable object instance does nothing since nothing has changed in the actual object.

You didn’T do yourself a favour of calling your sub SerializedObject “soProperty” as it’s not a property but a completely separate serialization unit.

I actually did get it working just by changing property.serializedObject.ApplyModifiedProperties(); to soProperty.ApplyModifiedProperties(); and the undo does seem to work in the order that I have it. If I swap the applymodifiedproperties and undo, it doesn’t use my undo line - it works just as if it isn’t there - in that it still does the undo just without the custom text as the undo label.

Happy to take suggestions on the name of soProperty, I’m bad at naming variables, worse when I don’t really know what they are.

This is what I’ve ended up with

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), property, label);
        position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
        if (property.objectReferenceValue == null )
        {
            return;
        }
        SerializedObject soProperty = new(property.objectReferenceValue);
        SerializedProperty initialValueProp = soProperty.FindProperty("initialValue");
        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), initialValueProp,new GUIContent(" ") );


        if (GUI.changed)
        {
            soProperty.ApplyModifiedProperties();
        }

    }

Is there a way to take any attributes like Range or Textarea and remove them from the scriptable object field (where they’re not valid) and filter them down to the initialValue field?

You would need to do a bunch of processing before anything is drawn, which Unity’s current inspector system doesn’t really support.

Honestly, I would just get Odin Inspector. It can do all this with just a few attributes.

Yes, but it would be a ton of work that isn’t handled for you since the property drawer is only able to see the fields that belong to the referenced type and not the field that the property drawer is rendering for. I tried playing around with ChatGPT to see how far I could get and it’s just too complex of a task.

However everything prior to this question it was able to handle just fine.

9642350--1371152--upload_2024-2-13_18-51-39.png

GenericVariableSO property drawer

using UnityEngine;
using UnityEditor;

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

        // Draw the object field
        var objectFieldHeight = EditorGUIUtility.singleLineHeight;
        var objectFieldRect = new Rect(position.x, position.y, position.width, objectFieldHeight);
        EditorGUI.PropertyField(objectFieldRect, property, label, true);

        if (property.objectReferenceValue != null)
        {
            SerializedObject serializedObject = new SerializedObject(property.objectReferenceValue);
            SerializedProperty valueProperty = serializedObject.FindProperty("Value");

            if (valueProperty != null)
            {
                EditorGUI.indentLevel++;

                var valueFieldHeight = EditorGUI.GetPropertyHeight(valueProperty, true);
                var spacing = EditorGUIUtility.standardVerticalSpacing;
                var valueFieldRect = new Rect(position.x, position.y + objectFieldHeight + spacing, position.width, valueFieldHeight);

                serializedObject.Update();
                EditorGUI.PropertyField(valueFieldRect, valueProperty, new GUIContent("Value"), true);
                serializedObject.ApplyModifiedProperties();

                EditorGUI.indentLevel--;
            }
        }

        EditorGUI.EndProperty();
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        if (property.objectReferenceValue != null)
        {
            SerializedObject serializedObject = new SerializedObject(property.objectReferenceValue);
            SerializedProperty valueProperty = serializedObject.FindProperty("Value");
            float valueFieldHeight = valueProperty != null ? EditorGUI.GetPropertyHeight(valueProperty, true) : 0;
            return EditorGUIUtility.singleLineHeight + valueFieldHeight + EditorGUIUtility.standardVerticalSpacing;
        }
        else
        {
            return EditorGUIUtility.singleLineHeight;
        }
    }
}

GenericVariableSO

using UnityEngine;

public abstract class GenericVariableSO<T> : ScriptableObject
{
    public T Value;
}

IntVariableSO

[CreateAssetMenu]
public class IntVariableSO : GenericVariableSO<int> {}

Step 1: Get property attributes from the scriptable object field.
Step 2: Figure out if any of the property drawers has a custom property drawer.
Step 3: If so, then create the property drawer.
Step 4: Use reflection to inject the property attribute and the FieldInfo for initialValueinto into the property drawer.
Step 5: Draw the field using PropertyDrawer.OnGUI instead of EditorGUI.PropertyField.

public static bool TryGetCustomPropertyDrawerForAnyAttribute(PropertyAttribute[] propertyAttributes, FieldInfo fieldToDraw, out PropertyDrawer propertyDrawer)
{
    foreach(var propertyAttribute in propertyAttributes)
    {
        if(TryGetPropertyDrawerForAttribute(propertyAttribute, fieldToDraw, out propertyDrawer))
        {
            return true;
        }
    }

    propertyDrawer = null;
    return false;
}

static bool TryGetCustomPropertyDrawerForAttribute(PropertyAttribute propertyAttribute, FieldInfo fieldToDraw, out PropertyDrawer propertyDrawer)
{
    if(!TryGetDrawerType(propertyAttribute, out Type drawerType))
    {
        propertyDrawer = null;
        return false;
    }

    propertyDrawer = CreateInstance(drawerType) as PropertyDrawer;

    if(propertyDrawer == null)
    {
        return false;
    }

    if(propertyAttribute != null)
    {
        var attributeField = typeof(PropertyDrawer).GetField("m_Attribute", BindingFlags.Instance | BindingFlags.NonPublic);
        attributeField.SetValue(propertyDrawer, propertyAttribute);
    }

    typeof(PropertyDrawer).GetField("m_FieldInfo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(propertyDrawer, fieldToDraw);
    return true;
 
    static object CreateInstance(Type type)
    {
        try
        {
            return Activator.CreateInstance(type);
        }
        catch
        {
            return FormatterServices.GetUninitializedObject(type);
        }
    }
}

static bool TryGetDrawerType(PropertyAttribute propertyAttribute, out Type drawerType)
{
    var propertyAttributeType = propertyAttribute.GetType();
    var typeField = typeof(CustomPropertyDrawer).GetField("m_Type", BindingFlags.NonPublic | BindingFlags.Instance);
    var useForChildrenField = typeof(CustomPropertyDrawer).GetField("m_UseForChildren", BindingFlags.NonPublic | BindingFlags.Instance);
    drawerType = null;

    foreach(var propertyDrawerType in TypeCache.GetTypesWithAttribute<CustomPropertyDrawer>())
    {
        foreach(var attribute in propertyDrawerType.GetCustomAttributes<CustomPropertyDrawer>())
        {
            var targetType = typeField.GetValue(attribute) as Type;
            if(targetType == propertyAttributeType)
            {
                drawerType = propertyDrawerType;
                return true;
            }

            if(targetType.IsAssignableFrom(propertyAttributeType) && (bool)useForChildrenField.GetValue(attribute))
            {
                drawerType = propertyDrawerType;
            }
        }
    }

    return drawerType != null;
}

(P.S. Suck it ChatGPT - you just got pwned!!)

2 Likes