Building a Custom Inspector For Nested Serializable Classes

Hello, I’m implementing a “Condition” class in my project for which I have built a custom display UXML and controller script. I need many other classes to contain an instance of a Condition so each of those will require a custom editor to use my custom Condition display. However, I don’t want to write all of the boilerplate to display other data from the classes for which the default inspector view is fine. I’ve figured out how to use InspectorElement.FillDefaultInspector to avoid this problem on simple classes but when the Condition is buried within a list of serializable classes, that method breaks down. Here’s an example:

At the lowest level we have a serializable “NpcAction” class with some data. A ScritableObject “Activity” contains a list of these.

public class Activity : ScriptableObject
    {
        [SerializeField] public List<NpcAction> actionList = new List<NpcAction>();
        //...other basic type data
    }

    [System.Serializable]
    public class NpcAction
    {
        [SerializeField] string argument;
        //...other basic type data
    }

A serializable “ActivityWrapper” contains one Activity and a Condition (the one piece of data that I want a custom display for). An “ActivityList” MonoBehaviour contains a list of ActivityWrappers and other basic type data.

public class ActivityList : MonoBehaviour
    {
        [SerializeField] [HideInInspector] public List<ActivityWrapper> activities;
        //...other basic type data

        [System.Serializable]
        public class ActivityWrapper
        {
            [SerializeField] public Condition condition;
            [SerializeField] public Activity activity;
        }
    }

The CustomEditor of ActivityList builds a ListView to display the ActivityWrapper list. Then it builds the default inspector to display any other data.

[CustomEditor(typeof(ActivityList))]
    public class ActivityListEditor : Editor
    {
        [SerializeField] VisualTreeAsset _inspectorTemplate;
        [SerializeField] VisualTreeAsset _listEntryTemplate;
        [SerializeField] VisualTreeAsset _conditionTemplate;

        ListView _listView;
        ActivityList _selectedParentItem;
        VisualElement _rootVisualElement;
        VisualElement _defaultInspectorRoot;

        public override VisualElement CreateInspectorGUI()
        {
            _rootVisualElement = new VisualElement();
            _inspectorTemplate.CloneTree(_rootVisualElement);
            _selectedParentItem = Selection.activeObject as ActivityList;

            // Build a list view to display the ActivityWrappers contained within the selected ActivityList
            SetupListView();

            // Build the custom inspector to display the rest of the data in the ActivityList
            _defaultInspectorRoot = _rootVisualElement.Q<Foldout>("default-inspector-root");
            InspectorElement.FillDefaultInspector(_defaultInspectorRoot, serializedObject, this);
            return _rootVisualElement;
        }

        private void SetupListView()
        {
            _listView = _rootVisualElement.Q<ListView>("list-view");

            _listView.makeItem = () =>
            {
                VisualElement newListEntry = _listEntryTemplate.Instantiate();
                ActivityListEntryController newListEntryController = new();
                newListEntry.userData = newListEntryController;
                newListEntryController.SetVisualElement(newListEntry, _conditionTemplate);
                return newListEntry;
            };

            _listView.bindItem = (item, index) =>
            {
                if (item.userData is ActivityListEntryController newListEntryController)
                {
                    newListEntryController.SetData(_selectedParentItem.activities[index]);
                }
            };
           
            _listView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight;
            _listView.itemsSource = _selectedParentItem.activities;
        }
    }

The controller for each item in the ActivityWrapper list instantiates the custom Condition controller script (which handles building it’s own UI internally).

public class ActivityListEntryController
    {
        VisualElement _conditionRoot;
        VisualTreeAsset _conditionListTemplate;
        ConditonTreeController _conditionTreeController = new();

        public void SetVisualElement(VisualElement visualElement, VisualTreeAsset conditionListTemplate)
        {
            _conditionListTemplate = conditionListTemplate;
            _conditionRoot = visualElement.Q<VisualElement>("condition-root");
        }

        public void SetData(ActivityList.ActivityWrapper itemData)
        {
            // Build a custom UI element to display the ConditionTree of this ActivityWrapper
            _conditionTreeController.SetupRootVisualElement(_conditionRoot, _conditionListTemplate, itemData.condition);

            // FIXME: Still need to display other data in ActivityWrapper such as Activity

        }
    }

So what I have implemented above displays the basic data contained in ActivityList (using the default inspector) and manually builds a list of ActivityWrappers, each of which has a the custom Condition display. I still need to display the other ActivityWrapper field, the Activity, which consists of a List of NpcActions.

I could write all of the boilerplate to draw another list view for the NpcActions within each item of the ActivityWrapper list view but that is a lot of work just to draw something that is already well served by the default inspector. I have not been able to figure out how to call InspectorElement.FillDefaultInspector from within ActivityListEntryController to display the Activity list because I would need to somehow get the ActivityWrapper that is passed into SetData as a SerializedObject, which I have not been able to do.

I looked a little into CustomPropertyDrawer which I hoped would allow me to automatically replace the display of Condition instances without defining a custom editor for everything that uses a Condition but it does not seem that framework supports complicated UI style and behavior defined in C# scripting.

I am hoping there is some piece of UI toolkit that I am just not aware of that will help me solve this problem efficiently. Thank you very much for taking the time to read my post and for any feedback you can provide.

Hi. I have a couple of questions:

  • Why aren’t you using Unity’s serialization system to bind your list?
  • What problems did you have with CustomPropertyDrawer? Why didn’t it work for you?
1 Like

Hi Oscar, thanks for taking an interest in my post.

  1. You’re referring to this, right? I don’t use this system to populate the ListView of ActivityWrappers within the ActivityListEditor because I want to draw the Condition within each element of that list myself. However, I hadn’t thought to try using binding to automatically draw the nested ListView of Activity, so I gave that a shot.

I changed the SetData function within ActivityListEntryController to the following which converts the Activity ScriptableObject into a SerialzedObject then calls bind on its root VisualElement.

public void SetData(ActivityList.ActivityWrapper itemData)
        {
            // Build a custom UI element to display the ConditionTree of this ActivityWrapper
            _conditionTreeController.SetupRootVisualElement(_conditionRoot, _conditionListTemplate, itemData.condition);

            SerializedObject serializedObject = new(itemData.activity);
            _root.Bind(serializedObject);
        }

And the UXML template for the ActivityListEntry that the above controller is attached to looks like this:

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="True">
    <ui:VisualElement name="condition-root" style="flex-grow: 1;" />
    <uie:PropertyField binding-path="actionList" />
</ui:UXML>

And this works! The ActivityList custom inspector is rendered when a game object with that component is selected. Within it, a ListView of ActivityWrappers is manually generated. For each one we have the custom Condition display (just placeholder text in this example) then all of the data contained within the Activity is displayed automatically.

So this is great for this situation but this only works because Activity is a ScriptableObject. What it it was just a Serializable class instead? Would it be possible to use the same pattern?
SerializedObject serializedObject = new(<serializable class instance>);
Doesn’t work so how would I set the bindings in this case?

  1. The roadblock I ran into with CustomPropertyDrawer is that I need access to the Condition object (not just the SerializedProperty which is passed into the CustomPropertyDrawer which only represents the serialized data in the object) in order to display and change it. More specifically, the Condition class contains a tree of Inequality(s) (another custom class) but since it needs to be serialized, I have implemented the tree data structure as a simple list with each Inequality containing the tree structure data such as the number of children. I can’t just use the CustomPropertyDrawer to display the list of Inequalities, since that would display the whole tree on one level. Instead, I need to access methods in the Condition class to determine which Inequalities are children of the root and only display those, then recursively display the children of the children and so on.
    I looked into getting the Condition Object back out of the SerializedProperty but found from [this post]( Casting SerializedProperty to the desired type #:~:text=You%20can’t%20cast%20it,are%202%20distinctly%20different%20things.) that is easier said than done. lordofduct implemented a very complicated helper class to do this, which feels like overkill for what I am attempting to do.

Right. One good thing about using Unity’s binding system is that it uses SerializedProperties and SerializedObjects to do it’s thing. This in turn handles stuff like registering changes with Undo, marking the Object dirty so it can be saved, and handling prefab overriding. It’s possible to do all these things for yourself, but it’s usually much harder. I only handle these things by myself in rare cases where the UI is very complex and I need to squeeze every bit of performance I can.

Right, you can’t create SerializedObjects from types that aren’t UnityEngine Objects. Usually, you just use a PropertyField and leverage property drawers. Or sometimes you can just use custom VisualElement classes that receive the data’s SerializedProperty or are bound to it.

So there’s a boxedValue that you can use to access a plain C# object. I haven’t used it a lot, but I believe you can’t just modify the data you read from boxedValue; you need to assign the modified that to boxedValue again. It’s also not the best performance-wise, specially if you’re both reading and writing.

That said, one rarely needs to access the plain C# object. All the data you need is already there in the SerializedObject and it’s SerializedProperties. You can just read it and then arrange the fields you need however you want. You can use things like FindPropertyRelative or GetArrayElementAtIndex to find the properties you need. You can also use Next, NextVisible and GetEndProperty for a more performant way of checking multiple SerializedProperties without generating a different SerializedProperty instance for each one.

1 Like

Oscar, thank you for the tips! I ended up using the property access methods you linked to re-write my methods that mutate my Condition tree. That allowed me to fully implement the CustomPropertyDrawer which is a huge relief since now I don’t need to spin up a custom editor for everything that uses a Condition. Here’s an example of one of my methods which reduces the “depth” of a Condition tree node and all of it’s children (recursively).

public static void DecreaseDepth(SerializedProperty conditionTree, int conditionIndex)
        {
            Debug.Log("Decrease Depth Requested");
            // Save information before the data structure is changed;
            int recursiveChildrenCount = RecursiveChildrenCount(conditionTree, conditionIndex);
            int oldParentConditonIndex = GetParentIndex(conditionTree, conditionIndex);
            int newParentConditionIndex = GetParentIndex(conditionTree, oldParentConditonIndex);
            // Start making changes
            for (int i = conditionIndex; i <= conditionIndex + recursiveChildrenCount; i++)
            {
                SerializedProperty conditionProperty = conditionTree.GetArrayElementAtIndex(i);
                conditionProperty.FindPropertyRelative("depth").intValue--;
                // move the conditions just before the old parent
                conditionTree.MoveArrayElement(i, oldParentConditonIndex);
                oldParentConditonIndex++;
            }
            SerializedProperty newParentChildCountProperty =
                conditionTree.GetArrayElementAtIndex(newParentConditionIndex).FindPropertyRelative("childCount");
            newParentChildCountProperty.intValue++;
            SerializedProperty oldParentChildCountProperty =
                conditionTree.GetArrayElementAtIndex(oldParentConditonIndex).FindPropertyRelative("childCount");
            oldParentChildCountProperty.intValue--;
            if (oldParentChildCountProperty.intValue == 0)
            {
                conditionTree.DeleteArrayElementAtIndex(oldParentConditonIndex);
                newParentChildCountProperty.intValue--;
            }
            conditionTree.serializedObject.ApplyModifiedProperties();
        }

In the end, working on the conditionTree as a SerializedProperty Array wasn’t that different from the methods I had before that worked on the conditionTree as a List of Conditon class instances. The main hurdle was just finding and understanding the tools Unity has for that. In addition to the ones you mentioned (FindPropertyRelative or GetArrayElementAtIndex) I made use of MoveArrayElement, DeleteArrayElementAtIndex, InsertArrayElementAtIndex, and ApplyModifiedProperties.

I am glad I went through this exercise because I am already starting to see what you mean. For one, binding the data on the condition tree allowed me to drop all of the boilerplate for setting the Ui to display the initial value and handling the callbacks for when something changed. I haven’t looked into the undo/redo functionality much yet but I will in the future and I’m happy to know that using SerializedProperties will make that easier.

Thanks for the tip. Unfortunately, it says that you can’t box an array/list, which is what I would have needed to do if I didn’t want to re-write all of my tree modification functions. Still, that’s a useful thing to know about since it could save some boilerplate when binding a bunch of properties.

Thank you again very much for taking the time to respond to my post. Your feedback has been very helpful! Here’s an example of the final UI in case you’re curious.

Best,
-Dane

1 Like

SerializedProperties already take care of those things, so you shouldn’t need to do anything else. : )

Yeah, it has some limits. I think you’d have to wrap your lists in a struct or plain class.

No problem :). I’m happy to help. It looks nice.

A final tip: I recommend tagging or quoting people when replying to them in the forum. That way they get a notification and it’s harder for them to miss your reply.

2 Likes