Custom elements in a Custom Property Drawer

I have a Stat class that is something used in monobehaviours.
It has three members i want to draw. The stat class has a custom drawer.
Stat
{
float baseValue;
float TotalValue();
List modifiers;
}
Im using the override of CreatePropertyGUI. In this method i render “baseValue” like this:

 var baseValueProp = property.FindPropertyRelative("baseValue");
   baseValueField = new FloatField(name + "BaseValue: ");
   baseValueField.BindProperty(baseValueProp);

This works. Next one is trickier since its a method. I solved it by adding an extra varible called serialisedCurrentValue which i update in the class itself. This also works so i can display the return value from the TotalValue() method like this:

 var currentValueProp = property.FindPropertyRelative("serialisedCurrentValue");
   currentValueLabel.BindProperty(currentValueProp);

Im not sure this is correct though. Having to have an extra variable in the class is annoying and if i would have to have many of them the class would be cluttered with ui stuff. Is this the way its intended?
The last one: List modifiers, i havent figured out yet. For each modifier i would like to show a label and a graphical status bar. But since i have to declare labels and add them to the visual element container in the CreatePropertyGUI method i dont understand how to keep the list dynamic.
Shoul i perhaps make a custom drawer for List ? In that case do i need to call some method inside of CreatePropertyGUI to trigger the custom drawer for List ?
I am reasonably sure there is something fundamental that i dont understand about how to use UI Toolkit and or serialisation but i cant figure it out so i appreciate a push in the right direction.
Here is the whole custom PropertyDrawer so far:

[CustomPropertyDrawer(typeof(FloatStat), true)]
public class FloatStatPropertyDrawer : PropertyDrawer
{
    VisualElement Content;
    FloatField baseValueField;
    Label currentValueLabel = new Label();
    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        var container = new VisualElement();
        var name = property.name;
        var baseValueProp = property.FindPropertyRelative("baseValue");
        baseValueField = new FloatField(name + "BaseValue: ");
        baseValueField.BindProperty(baseValueProp);
        var currentValueProp = property.FindPropertyRelative("serialisedCurrentValue");
        currentValueLabel.BindProperty(currentValueProp);
        var mods = property.FindPropertyRelative("mods");
        var modis = property.FindPropertyRelative("modifiers");
        if (mods != null)
        {
            for (int i = 0; i < mods.arraySize; i++)
            {
                SerializedProperty modifierProp = mods.GetArrayElementAtIndex(i);
                var modValue = modifierProp.FindPropertyRelative("modValue").floatValue.ToString();
                var description = modifierProp.FindPropertyRelative("Description").stringValue;
                Debug.Log(modValue + description);
            }
        }
        container.Add(currentValueLabel);
        container.Add(baseValueField);
        return container;
    }
}

You don’t have to call BindProperty on every single field, it suffices to set the bindingPath like so:

baseValueField.bindingPath = "baseValue";

In a PropertyDrawer the GUI is bound to the SerializedObject automatically after it is created.
You only need to call BindProperty/Bind if you want to bind a Bindable to a different SerializedObject.

For the modifier list you could also create a PropertyDrawer for the Modifier class, and then bind the list property to a ListView.

Regarding the “serialisedCurrentValue”, I would remove that and register value change callbacks to the base value field and the modifiers fields instead and set the Label to the new “TotalValue()” result whenever they change.

Thank you! I will look into this. A have a bit of a hard time figuring out parts of your advice though.

“For the modifier list you could also create a PropertyDrawer for the Modifier class, and then bind the list property to a ListView.”

So i create a separate PropertyDrawer for Modifier. How do i bind it to a list view inside of the FloatStat PropertyDrawer?

“Regarding the “serialisedCurrentValue”, I would remove that and register value change callbacks to the base value field and the modifiers fields instead and set the Label to the new “TotalValue()” result whenever they change.”

Glad to hear i dont need the serialisedCurrentValue hack! Does this mean its possible to call TotalValue() and get the methods result from the property drawer? How do i do that?

This should be the functionality you’re looking for:

[Serializable]
public class Modifier
{
    public float modValue;

    public float Modify(float f)
    {
        return f * modValue;
    }
}

[CustomPropertyDrawer(typeof(Modifier), true)]
public class ModifierPropertyDrawer : PropertyDrawer
{
    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        VisualElement root = new VisualElement();
        root.Add(new FloatField("Mod Value:") { bindingPath = "modValue" });
        return root;
    }
}

[Serializable]
public class FloatStat
{
    public float baseValue = 1;
    public List<Modifier> modifiers = new List<Modifier>();
  
    public float TotalValue()
    {
        float totalValue = baseValue;
        foreach (Modifier m in modifiers) totalValue = m.Modify(totalValue);
        return totalValue;
    }
}

[CustomPropertyDrawer(typeof(FloatStat), true)]
public class FloatStatPropertyDrawer : PropertyDrawer
{
    Label totalValueLabel;

    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        VisualElement root = new VisualElement();
        FloatField f = new FloatField("Base Value:") { bindingPath = "baseValue" };
        root.Add(f);
        ListView list = new ListView() {
            bindingPath = "modifiers",
            showBoundCollectionSize = false,
            virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight,
            showFoldoutHeader = true,
            headerTitle = "Modifiers",
            showAddRemoveFooter = true
        };
        root.Add(list);
        totalValueLabel = new Label();
        SetTotalValueLabel(property);
        totalValueLabel.TrackPropertyValue(property, SetTotalValueLabel);
        root.Add(totalValueLabel);
        return root;
    }

    void SetTotalValueLabel(SerializedProperty property)
    {
        FloatStat floatStat = property.GetUnderlyingValue() as FloatStat;
        totalValueLabel.text = "Total Value: " + floatStat.TotalValue();
    }
}
1 Like

Its working kind of. The thing is that it doesnt always update when the floatstat changes. When a modifier is added or removed it should redraw. To test this i added a modifier in the start method of a monobehaviour and also i can add a modifier by pressing space. I log when a modifier is added. I also added a debug.log after the totalValueLabel.TrackPropertyValue(property, SetTotalValueLabel) line in the property drawer. This to check if the problem is the order they execute in.

So when i run i get this in the log:
Tracking property speed
Tracking property speed
Adding mod: OnStart

When i look at the inspector the property drawer shows that the base value is 200, there is a modifier of -100 and the total value is : 200. So total value fails give the proper result.

Then i press space which adds a modifier of only 0.3. Then it shows me that there are 2 modifiers. One of -100, another of 0.3 and the total value is 100.3.
So at this point it does calculate with the first modifier.

Now i change the test and i make the start method that adds the first modifier a awake method instead. Then i get this log:

Adding mod: OnAwake
Tracking property speed
Tracking property speed

And the Total value shows 100 so when i add the first modifier in the awake it works. I then add a modifier with space and it still works as intended.

I cant figure this out. When i use the start method i can see that the property tracking is enabled before the modifier is added. So it should change. Especially since it changes correctly when i add a modifier with space. I really need the ui to always be correct and updated. What am i not understanding here?

Here is my updated Property Drawers. let me know if you want to see anything else.

CustomPropertyDrawer(typeof(FloatStat), true)]
public class FloatStatPropertyDrawer : PropertyDrawer
{
    Label totalValueLabel = new Label();
    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        var root = new VisualElement();
        var name = property.name;

        // BASE VALUE
        var baseValueField = new FloatField("BaseValue: ") {
            bindingPath = "baseValue"
        };
        root.Add(baseValueField);

        // MODIFIER LIST VIEW
        ListView modifierListView = new ListView()
        {
            bindingPath = "floatStatMods",
            showBoundCollectionSize = false,
            virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight,
            showFoldoutHeader = true,
            headerTitle = "Modifiers",
            showAddRemoveFooter = false
        };
        root.Add(modifierListView);


        // TOTAL VALUE
        totalValueLabel.TrackPropertyValue(property, SetTotalValueLabel);
        Debug.Log("Tracking property " + property.name);
        SetTotalValueLabel(property);
        root.Add(totalValueLabel);
        return root;
    }

    private void SetTotalValueLabel(SerializedProperty property)
    {
        FloatStat floatStat = property.GetUnderlyingValue() as FloatStat;
        totalValueLabel.text = "Total Value::: " + floatStat.CalculateCurrentValue();
    }
}
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
     VisualElement root = new();
     root.Add(new FloatField("Mod Value: ") { bindingPath = "modValue" });
     return root;
}

This seems to be a weird quirk of the TrackPropertyValue method, in the documentation up to version 2021.1 it says it tracks changes every frame. In the documentation for higher versions that phrase was changed to “regular intervales”, however the method doesn’t seem to have been smoothed out…

Since the Start function runs right before the first frame, the changes made within it seem to go unnoticed.
It works when adding the modifier in the Awake function because that function runs before the PropertyDrawer is created and the first TotalValue() call.

So if I where in your place I would just not make changes to the floatStat in the Start function lol.
If that isn’t an option you could instead use TrackSerializedObjectValue which works fine but unnecessarily tracks the whole Monobehavior the floatStat appears in.

Hey thank you so much for your help. This is truly valuable. Its not an option to have to restrict the use to only start or awake. The stat system is something i will use for several projects in the future. Also, this quirk makes me think there could be more cases where it wont update.
I have an idea for a solution that i want to ask you about. There is another complexity as well that i need to figure out as well. There should be several types of modifiers. My idea was that floatStatModifier would be a base class that other modifiers derive from. But im not sure that will work well with serialisation and UI Toolkit? The idea was that base class would have an overridable method called float GetModValue() instead of a modValue float variable. But then i would have the same issue in the Modifier property drawer since i cant get the value from the method.
Since i am already casting the underlying FloatStat value in the FloatStat property drawer im thinking what if i just use it to create the whole drawer for FloatStat and also go through all the modifiers and update a hierachy of controls. I love the idea of binding but it used doesnt seem very easy to get right unless there is a plain old class with just simple variables.
Also i “solved” the start/Awake issue but setting the root visualElement container to execute the SetTotalValueLabel method every 100ms. Like:

  root.schedule.Execute(() => {
      SetTotalValueLabel(property);
  }).Every(100);

Im thinking of just using this and skip the binding. Are there good reasons not to do UI like this? I can imagine its not as performant? Also, you seem to be very knowledgeable regarding serialisation and UI Toolkit. I would like to dig deep into this but i find that the documentation doesnt cover anyting but the low hanging fruit scenarions very well, or am i mistaken? I would appreaciate tips on where to learn more regarding this. Thanks again!

The documentation and the forum are good resources, its just about finding the right page and apply it to your specific usecase, and then it’s just trial and error and failing upwards.

Also, you shouldn’t abandon the serialization system altogether, it works perfectly fine.
The TrackPropertyValue method is only intended to update elements that can’t be bound.
The actual totalValue is calculated correctly it’s just not displayed at startup.

When whipping out the hacks I’d suggest one that hurts less and just wait for the first frame to add the modifier:

public class MyExample : MonoBehaviour
{
    public FloatStat floatStat = new FloatStat();

    void Start()
    {
        StartCoroutine(AddModAtStart());
    }

    IEnumerator AddModAtStart()
    {
        yield return new WaitForEndOfFrame();
        floatStat.modifiers.Add(new Modifier() {modValue = 3});
    }
}

Extending the Modifier class is absolutely fine and works with UI Toolkit and Serialization as well.
The implementation depends on how you want to modify exactly, but for mathematical modifications I’d leave the modValue as it is and override the modification method to do the different calculations.

Ok i am giving it another go and i am getting better results. How should i handle property drawers for polymorphic classes? I have a list of TestItemBase. I populate it with some TestItemBase instances and some TestItem instances that derive from the base class. I was hoping that UI Toolkit system would choose the property drawer for the derived type, but it always chooses the type the collection is of. Which is the base class. I solved it by using the TestItemBase property drawer to draw all of them. I get the type of the item being drawn and use a switch statement. Is that the way to do it or is there a way to get it to choose different drawers depending on the type? Oh another weird thing. In this test i am instantiating the test class in the start method. And now it works fine. So it seems the start method didnt work for the previous class. Which is super weird.

[Serializable]
public class TestClassBase
{
    public string TestClassBaseDescription;
    public List<TestItemBase> TestItems;
    public float progress = 0.5f;
}

[Serializable]
public class TestClass : TestClassBase
{
    [SerializeField] public string TextInfo;
    [SerializeField] public float NumberThing = 4;
}

[Serializable]
public class TestItemBase
{
    public string TestItemBaseString;
}

[Serializable]
public class TestItem : TestItemBase
{
    public string TestItemName;
}

[Serializable]
public class TestController : MonoBehaviour
{
    public int Four = 4;
    [SerializeField] public TestClass testy1;

    // Start is called before the first frame update
    void Start()
    {
        testy1 = new TestClass()
        {
            TestClassBaseDescription = "This is base description",
            TextInfo = "This is textinfo",
            TestItems = new List<TestItemBase> {
                new TestItem()
            {
                TestItemBaseString = "TestItembaseString 1"

            }, new TestItem(){
                TestItemBaseString = "TestItemBaseString 2",
                TestItemName = "This is polymorphism",
            }, new TestItemBase(){
                TestItemBaseString = "Actual base class item"
            } }
        };
    }

    // Update is called once per frame
    void Update()
    {
        testy1.TextInfo = Time.time.ToString();
    }
}


[CustomPropertyDrawer(typeof(TestItemBase), true)]
public class TestItemBasePropertyDrawer : PropertyDrawer
{
    VisualElement Content;

    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        VisualElement inspector = new();
        var propertyType = property.GetUnderlyingValue().GetType();

        Label TestItemBaseString = new() { bindingPath = "TestItemBaseString" };

        if (propertyType == typeof(TestItemBase))
        {
            Label Text = new() { text = "BASECLASS" };
            inspector.Add(Text);
        }

       if (propertyType == typeof(TestItem))
        {
            Label TestItemName = new() { bindingPath = "TestItemName" };
            inspector.Add(TestItemName);
        }
       
      
        inspector.Add(TestItemBaseString);

        return inspector;
    }
}

It’s not that weird, the only problem in the initial case was the TrackPropertyValue callback not registering the change to the tracked field in the Start method and thus not updating the Label in your PropertyDrawer.

Unity does choose the derived classes PropertyDrawer to display the field, you just have to mark fields that could be of a derived class type with [SerializeReference] instead of [SerializeField], so they are serialized by reference instead of by value. Otherwise the field is deserialized as the parent class type and the subclass info is lost. You should read up on the Serialization of custom classes section in the documentation and SerializeReference.

Also note that fields with the SerializeReference attribute are not automatically displayed in the inspector and need a custom drawer of some sort.

1 Like

Thank you so much. I will study this for sure.

Ive been trying to use UXML assets for the visual design and now i am stuck again.
Here is a refresher of what i am trying to do:
FloatStat
contains FloatStatModifier collection.

FloatStatModifier
is the base class. There will be derived classes in the future that will need to have altered ui.

FloatStat UI
contains of two parts
Total Value
This part will be alot simpler if there are no FloatStatModifiers
If there are modifiers then this part will show both baseVale and total value
Modifiers
This is a list that shows all current active modifiers

PropertyDrawer
I am now using UXML assets for the UI.
I tried to make different drawers for FloatStat and modifiers but the listView dont seem to use the modifier property drawer. Ive changed to using makeItem and bindItem delegates. But the lists are never drawn. (The state of the code might be a bit weird cause ive been shootin blindly lately and i dont know which strategy is correct).
But i do know that when the modifiers change, either by removing or adding a modifier, or the modValue of one of them changes i need the whole UI to be redrawn. Since the modifier list should reflect this and also the total value of the FloatStat will also change.

[Serializable]
public class FloatStat : IUpdatable
{
    [SerializeField] public float baseValue;
    [SerializeField] public List<FloatStatModifier> floatStatMods = new List<FloatStatModifier>();

    public static implicit operator float(FloatStat floatStat)
    { 
        return floatStat.CalculateCurrentValue();
    }

    public float CalculateCurrentValue()
    {
        var currentValue = baseValue;
        var validMods = floatStatMods.Where(mod => !mod.FlaggedForRemoval).ToList();
      
        foreach (var modifier in validMods)
        { 
            currentValue += modifier.modValue;
        }

        return currentValue;
    }

    public void AddFloatStatModifier(FloatStatModifier modifier)
    {
        Debug.Log("Adding mod: " + modifier.description);
        floatStatMods.Add(modifier);
    }

    public void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            AddFloatStatModifier(new FloatStatModifier(3, "Space!"));
        }
    }
}

[Serializable]
public class FloatStatModifier
{
     public float modValue;

     public readonly string description;
     public bool IsPaused;
     protected bool remove = false;
     public bool FlaggedForRemoval => remove;

     public void Remove()
     {
         remove = true;
     }
     public virtual float ModValue()
     { return modValue; }
     public FloatStatModifier(float _modValue, string description = "")
     {
         this.description = description;
         modValue = _modValue;
     }
}
[CustomPropertyDrawer(typeof(FloatStatModifier), true)]
public class ModifierPropertyDrawer : PropertyDrawer
{
    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        var floatStatModifier = property.GetUnderlyingValue() as FloatStatModifier;
        var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Stats/UI/FloatStatModifier.uxml");
        var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Stats/UI/FloatStatModifier.uss");
        var root = visualTree.CloneTree();
      
        var modifierLabel = root.Q<Label>("modifierLabel");
        var description = root.Q<Label>("descriptionLabel");

        description.text = floatStatModifier.description+ " >> ";

        modifierLabel.bindingPath = "modValue";
      
        return root;
    }
}

[CustomPropertyDrawer(typeof(FloatStat), true)]
public class FloatStatPropertyDrawer : PropertyDrawer
{
    FloatStat floatStat;
    Label totalValueLabel;
    FloatField baseValueField;
    ListView modifierListView;
    VisualTreeAsset modifierUXML;
    string propertyName;
    public override VisualElement CreatePropertyGUI(SerializedProperty property)
    {
        var floatStatUXML = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Stats/UI/FloatStat.uxml");
        modifierUXML = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Stats/UI/FloatStatModifier.uxml");
        var root = floatStatUXML.CloneTree();
        root.styleSheets.Add(AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Stats/UI/FloatStat.uss"));

        totalValueLabel = root.Q<Label>("totalValueLabel");
        baseValueField = root.Q<FloatField>("baseValueField");
        modifierListView = root.Q<ListView>("modifierListView");

        propertyName = property.name;

        floatStat = property.GetUnderlyingValue() as FloatStat;

        var modifierListProperty = property.FindPropertyRelative("floatStatMods");
        totalValueLabel.TrackPropertyValue(modifierListProperty, UpdateDrawer);
        modifierListView.TrackPropertyValue(modifierListProperty, (property) =>
        {
            modifierListView.Rebuild();
        });

        modifierListView = new ListView()
        {
            bindingPath = "floatStatMods",
            showBoundCollectionSize = false,
            virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight,
            showFoldoutHeader = true,
            itemsSource = floatStat.floatStatMods,
            makeItem = () => new VisualElement(),
            bindItem = (element, index) =>
            {
                var itemData = floatStat.floatStatMods[index];
                BindListViewItem(element, itemData);
            },
            headerTitle = "Modifiers",
            showAddRemoveFooter = true
        };


        UpdateDrawer(property);
        return root;
    }

    private void BindListViewItem(VisualElement element, FloatStatModifier modifier)
    {
        element.Clear();

        if (modifier is FloatStatModifier)
        {
            element = modifierUXML.CloneTree();

            var descriptionLabel = element.Q<Label>("descriptionLabel");
            var modifierLabel = element.Q<Label>("modifierLabel");

            descriptionLabel.text = modifier.description + " ¤> ";
            modifierLabel.text = modifier.modValue.ToString();

            element.Add(descriptionLabel);
            element.Add(modifierLabel);
        }
    }
 
    private void UpdateDrawer(SerializedProperty property)
    {
        if (floatStat.floatStatMods == null || floatStat.floatStatMods.Count == 0)
        {
            baseValueField.label = propertyName;

            totalValueLabel.style.display = DisplayStyle.None;
            modifierListView.style.display = DisplayStyle.None;
        }
        else
        {
            totalValueLabel.text = "Total Value: " + floatStat.CalculateCurrentValue();
            baseValueField.label = "Base Value:";
            totalValueLabel.style.display = DisplayStyle.Flex;
            modifierListView.style.display = DisplayStyle.Flex;
        }
    }
}

I just figured out what i think is the biggest problem.
On row 114 i instantiate a new ListView for the variable that i get from the UXML. This causes the listview to not draw at all.

I solved it by setting the properties of the instance i get from the UXML like modifierListView.bindingPath =“floatStatMods”; instead.