Custom Editor Window strange List Behaviour

Hi there,
I’m working on a Custom Editor Window for a Rule-System. The Rule System itself is build from a bunch of classes (all Scriptable Objects) that are combined to make up a Rule. At the moment the whole System is build around 4 Classes and an Enum + 2 Editor-Classes, Code will be below.

I have the following Objects:
Ruleset:

[CreateAssetMenu(menuName = "Scriptable Objects/Ruleset")]
public class Ruleset : ScriptableObject
{
    #region Variable Serialization
    [SerializeField] List<Rule> rules = new List<Rule>();
    #endregion

    #region Properties
    public List<Rule> Rules { get { return rules; } set { rules = value; } }
    #endregion

    #region Methods

    #endregion
}

Rule:

public class Rule : ScriptableObject
{
    [SerializeField] string ruleName = "Rule Name";
    [SerializeField] List<Condition> conditions = new List<Condition>();
    [SerializeField] Tip tip = null;
    [SerializeField] List<TrackableAction> resetConditions = new List<TrackableAction>();

    public string RuleName { get { return ruleName; } set { ruleName = value; } }
    public List<Condition> Conditions { get { return conditions; } set { conditions = value; } }
    public Tip Tip { get { return tip; } set { tip = value; } }
    public List<TrackableAction> ResetConditions { get { return resetConditions; } set { resetConditions = value; } }
}

Condition:

[System.Serializable]
public class Condition : ScriptableObject
{
    [SerializeField, Tooltip("Either one of these counts towards the Condition-Trigger Threshold")] List<TrackableAction> actions;
    [SerializeField] bool trackAmount = true;
    [SerializeField] int amount;
    [SerializeField] float timeInSeconds;

    public List<TrackableAction> Actions { get { return actions; } set { actions = value; } }
    public bool TrackAmount { get { return trackAmount; } set { trackAmount = value; } }
    public float TimeInSeconds { get { return timeInSeconds; } set { timeInSeconds = value; } }
    public int Amount { get { return amount; } set { amount = value; } }
}

Tip:

public class Tip : ScriptableObject
{
    [SerializeField] Sprite tipImage = null;
    [SerializeField] LocalizedString tipText = null;

    public Sprite TipImage { get { return tipImage; } set { tipImage = value; } }
    public LocalizedString TipText { get { return tipText; } set { tipText = value; } }
}

And a simple enum with some testing values for now:

public enum TrackableAction
{
    test, test2, test3
}

Now to the Tricky part. Since all of these are different Objects it can get pretty complicated to actually work with this System as is. So I have created a Custom Window that is used to combine all of these Objects into one View. At the moment I’m just prototyping around, so non of this is necessarily final Code and sometimes it shows, sorry for that.
Now in order to actually work with this, I’ve created a simple Custom Editor for the Ruleset Object that just throws away everything and show the User a Button to open the Window. It will also Setup the Window with the SerializedObject of the Ruleset that was used to open the Window.

RulesetEditor:

[CustomEditor(typeof(Ruleset))]
public class RulesetEditor : Editor
{
    RulesetWindow window = null;

    public override void OnInspectorGUI()
    {

        if(window == null && GUILayout.Button("Open Editor Window", GUILayout.Height(50f)))
        {
            window = RulesetWindow.Init();
            window.Ruleset = serializedObject;
        }
    }
}

Now to my actual Problem, here is the long Code of the Window, I will get into details of what’s wrong with it below the Code:

public class RulesetWindow : EditorWindow
{
    // Ruleset Reference for this Window
    SerializedObject currentRuleset = null;
    // The Current Size of the Window
    Vector2 windowsSize = Vector2.zero;
    GUIStyle headerTextStyle;
    GUIStyle ruleNameStyle;
    GUIStyle horizontalLine;
    GUIStyle verticalLine;
    Color headerBG = new Color(.1f, .1f, .1f, 1f);
    Color rowEvenBG = new Color(.25f, .25f, .25f, 1f);
    Color rowOddBG = new Color(.375f, .375f, .375f, 1f);
    Color footerBG = new Color(.1f, .1f, .1f, 1f);
    Color lightGrey = new Color(.5f, .5f, .5f, 1f);
    Vector2 scrollPosition = Vector2.zero;
       
    // Setter for the Ruleset Editor to call to set the Reference to the Ruleset that can be edited by this Window
    public SerializedObject Ruleset { set { currentRuleset = value; } }

    // Code to Initialize the Window
    public static RulesetWindow Init()
    {
        RulesetWindow window = (RulesetWindow)GetWindow(typeof(RulesetWindow));
        window.Show();
        window.titleContent = new GUIContent("Edit Ruleset");
        window.minSize = new Vector2(800, 600);
        return window;
    }

    private void OnEnable()
    {
        // Setup HorizontalLine
        horizontalLine = new GUIStyle();
        horizontalLine.normal.background = EditorGUIUtility.whiteTexture;
        horizontalLine.margin = new RectOffset(0, 0, 1, 1);
        horizontalLine.fixedHeight = 2;

        // Setup VerticalLine
        verticalLine = new GUIStyle();
        verticalLine.normal.background = EditorGUIUtility.whiteTexture;
        verticalLine.margin = new RectOffset(1, 1, 0, 0);
        verticalLine.stretchHeight = true;
        verticalLine.fixedWidth = 2;

        // Setup Header Style
        headerTextStyle = new GUIStyle();
        headerTextStyle.fontSize = 25;
        headerTextStyle.fontStyle = FontStyle.Bold;
        headerTextStyle.alignment = TextAnchor.MiddleCenter;
        headerTextStyle.normal.textColor = Color.white;

        // Setup Custom Fontstyle for Rulename Headers
        ruleNameStyle = new GUIStyle();
        ruleNameStyle.fontSize = 18;
        ruleNameStyle.fontStyle = FontStyle.Bold;
        ruleNameStyle.normal.textColor = Color.white;
    }

    // Used to set some Background Colors of the Window
    GUIStyle SetBackground(Color c)
    {
        GUIStyle backgroundStyle = new GUIStyle();
        Texture2D backtex = new Texture2D(1, 1);

        backtex.SetPixel(0, 0, c);
        backtex.Apply();
        backgroundStyle.normal.background = backtex;
        return backgroundStyle;
    }

    // Used to draw a Horizontal Line through the whole Window
    void HorizontalLine(Color color)
    {
        Color c = GUI.color;
        GUI.color = color;
        GUILayout.Box(GUIContent.none, horizontalLine);
        GUI.color = c;
    }

    // Used to draw a Vertical Line streched to the next Element
    void VerticalLine(Color color)
    {
        Color c = GUI.color;
        GUI.color = color;
        GUILayout.Box(GUIContent.none, verticalLine);
        GUI.color = c;
    }

    private void OnGUI()
    {
        // If Reference to Ruleset is not set, do nothing
        if (currentRuleset == null)
            return;

        currentRuleset.Update();
        windowsSize = position.size;
        // Used to create the Names of new Scriptable Objects
        StringBuilder nameBuilder = new StringBuilder();
        // Used to delete a rule at the end of a frame so it can't break the for-loop
        Rule ruleToDelete = null;

        // Header for the Table
        EditorGUILayout.BeginVertical(GUILayout.Height(40));
        EditorGUILayout.BeginHorizontal(SetBackground(headerBG), GUILayout.Height(25));
        // Placeholder for up/down Buttons
        EditorGUILayout.BeginVertical(GUILayout.MinWidth(28));
        GUILayout.Label("");
        EditorGUILayout.EndVertical();
        VerticalLine(lightGrey);

        EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 4 * 2) - 4 - 12));
        GUILayout.Label("Condition", headerTextStyle);
        EditorGUILayout.EndVertical();
        VerticalLine(lightGrey);

        EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6) - 34 - 6));
        GUILayout.Label("Tip", headerTextStyle);
        EditorGUILayout.EndVertical();
        VerticalLine(lightGrey);

        EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 4));
        GUILayout.Label("Reset Condition", headerTextStyle);
        EditorGUILayout.EndVertical();
        EditorGUILayout.EndHorizontal();
        HorizontalLine(lightGrey);

        // ScrollView will only encompass the Rule List, not the Table Header or the Buttons below
        scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, false, true, GUILayout.Width(windowsSize.x), GUILayout.Height(windowsSize.y - 80f));

        // Reference to the List<Rule> Parameter of the Ruleset
        SerializedProperty ruleList = currentRuleset.FindProperty("rules");

        if (ruleList.isArray)
        {
            // Iteration through every Rule
            for (int i = 0; i < ruleList.arraySize; i++)
            {
                // Just to be sure, check if the List actually holds a Rule Object
                if (ruleList.GetArrayElementAtIndex(i).objectReferenceValue is Rule)
                {
                    Rule rule = ruleList.GetArrayElementAtIndex(i).objectReferenceValue as Rule;

                    // The Header of each Rule so they can be more easily distinguished
                    EditorGUILayout.BeginVertical();
                    GUILayout.Space(10);
                    EditorGUILayout.BeginHorizontal();
                    GUILayout.Space(5);
                    GUILayout.Label(rule.RuleName, ruleNameStyle, GUILayout.Height(20), GUILayout.MinWidth((windowsSize.x / 2) - 15));
                    rule.RuleName = EditorGUILayout.TextField(rule.RuleName, GUILayout.Height(20), GUILayout.MinWidth((windowsSize.x / 2) - 15));
                    EditorGUILayout.EndHorizontal();
                    GUILayout.Space(4);
                    HorizontalLine(lightGrey);
                    EditorGUILayout.EndVertical();

                    // Main Horizontal Group that holds all 3 parts of a Rule next to each other
                    // It will also change color, so each Rule can be distinguished further
        // THIS HORIZONTAL ALSO SEEMS TO BE THE TROUBLE MAKER
                    EditorGUILayout.BeginHorizontal(i % 2 == 0 ? SetBackground(rowEvenBG) : SetBackground(rowOddBG));
                       
                    // Create the Button Panel to the left of each Rule
                    EditorGUILayout.BeginVertical(GUILayout.MaxWidth(30), GUILayout.MinWidth(30));
                    // Move up Button
                    if (GUILayout.Button("\u2191"))
                    {
                        MoveListElementUp(i, (currentRuleset.targetObject as Ruleset).Rules);
                    }
                    // Delete Button
                    if (GUILayout.Button("X"))
                    {
                        ruleToDelete = rule;
                    }
                    // Move Down Button
                    if (GUILayout.Button("\u2193"))
                    {
                        MoveListElementDown(i, (currentRuleset.targetObject as Ruleset).Rules);
                    }
                    EditorGUILayout.EndVertical();
                       
                    VerticalLine(lightGrey);
                       
                    // Editor for Conditions
                    EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 4 * 2) - 4));
                    for (int j = 0; j < rule.Conditions.Count; j++)
                    {
                        EditorGUILayout.BeginVertical();
                        EditorGUILayout.BeginHorizontal();

                        // List of Trackable Actions
                        SerializedObject conditionObject = new SerializedObject(rule.Conditions[j]);
                        SerializedProperty actionList = conditionObject.FindProperty("actions");
                        EditorGUILayout.PropertyField(actionList, true, GUILayout.MaxWidth((windowsSize.x / 4) * 1.5f));
                        conditionObject.ApplyModifiedProperties();

                        EditorGUILayout.BeginVertical();
                        // Condition type (time, amount ratio button + field to set amount)
                        int selection = rule.Conditions[j].TrackAmount ? 0 : 1;
                        int currentSelection = GUILayout.SelectionGrid(selection, new string[] { "amount", "time" }, 2, "toggle");
                        rule.Conditions[j].TrackAmount = currentSelection == 0 ? true : false;

                        if (rule.Conditions[j].TrackAmount)
                            rule.Conditions[j].Amount = EditorGUILayout.IntField(GUIContent.none, rule.Conditions[j].Amount);
                        else
                            rule.Conditions[j].TimeInSeconds = EditorGUILayout.FloatField(GUIContent.none, rule.Conditions[j].TimeInSeconds);
                        EditorGUILayout.EndVertical();

                        EditorGUILayout.EndHorizontal();
                        EditorGUILayout.BeginHorizontal();
                        // Move Up Button
                        if (GUILayout.Button("\u2191"))
                        {
                            MoveListElementUp(j, rule.Conditions);
                        }
                        // Remove Button
                        // Should probably be done the same way Rules get deleted to not risk breaking the for-loop
                        if (GUILayout.Button("X"))
                        {
                            DeleteCondition(rule.Conditions[j], true);
                            rule.Conditions.RemoveAt(j);

                            if (rule.Conditions.Count == 0)
                                CreateCondition(rule, 0);

                            // Reimport Asset so changes show up in Project-Window
                            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
                        }
                        // Move Down Button
                        if (GUILayout.Button("\u2193"))
                        {
                            MoveListElementDown(j, rule.Conditions);
                        }
                        EditorGUILayout.EndHorizontal();
                        if (j < rule.Conditions.Count)
                            HorizontalLine(new Color(.7f, .7f, .7f, 1f));
                        EditorGUILayout.EndVertical();
                    }
                    if (GUILayout.Button("New Condition"))
                    {
                        CreateCondition(rule, i);
                    }
                    EditorGUILayout.EndVertical();

                    VerticalLine(lightGrey);
                       
                    // Tip
                    EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6) - 34));
                    // Because the Text is a LocalizedString from the I2.Loc package with a Custom PropertyDrawer
                    // this has to be rendered through a PropertyField
                    SerializedObject tipObject = new SerializedObject(rule.Tip);
                    SerializedProperty tipTextProp = tipObject.FindProperty("tipText");
                    GUILayout.Label("Tip Text");
                    EditorGUILayout.PropertyField(tipTextProp, GUIContent.none);
                    GUILayout.Space(5);
                    rule.Tip.TipImage = EditorGUILayout.ObjectField(rule.Tip.TipImage, typeof(Sprite), false, GUILayout.Width(100), GUILayout.Height(100)) as Sprite;
                    tipObject.ApplyModifiedProperties();
                    EditorGUILayout.EndVertical();

                    VerticalLine(lightGrey);

                    // Reset Condition
                    EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 4));
                    // This is the ResetCondition List
        // THIS LIST IS DISPLAYED WEIRD
                    SerializedObject ruleObject = new SerializedObject(rule);
                    SerializedProperty resetProp = ruleObject.FindProperty("resetConditions");
                    EditorGUILayout.PropertyField(resetProp, true, GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 10));
                    ruleObject.ApplyModifiedProperties();
                    EditorGUILayout.EndVertical();
        // END OF THE TROUBLE MAKER HORIZONTAL
                    EditorGUILayout.EndHorizontal();
                }
                HorizontalLine(lightGrey);
            }

            // Delete Rule if User pressed a delete Button
            if (ruleToDelete != null)
            {
                int i = (currentRuleset.targetObject as Ruleset).Rules.IndexOf(ruleToDelete);
                DeleteRule(ruleToDelete, true);
                ruleList.DeleteArrayElementAtIndex(i);
            }
            currentRuleset.ApplyModifiedProperties();
        }

        EditorGUILayout.EndScrollView();
        HorizontalLine(Color.white);

        // Footer
        EditorGUILayout.BeginVertical(SetBackground(footerBG));
        if (GUILayout.Button("Create new Element"))
        {
            ruleList.arraySize++;
            SerializedProperty newArrayElement = ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1);
            Rule newRule = CreateInstance<Rule>();

            nameBuilder.Clear();
            nameBuilder.Append("Rule ");
            nameBuilder.Append(ruleList.arraySize - 1);
            newRule.name = nameBuilder.ToString();

            AssetDatabase.AddObjectToAsset(newRule, currentRuleset.targetObject);
            CreateCondition(newRule, ruleList.arraySize - 1);

            newRule.Tip = CreateInstance<Tip>();
            nameBuilder.Append(" Tip");
            newRule.Tip.name = nameBuilder.ToString();

            AssetDatabase.AddObjectToAsset(newRule.Tip, currentRuleset.targetObject);
            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));

            newArrayElement.objectReferenceValue = newRule;
               
            currentRuleset.ApplyModifiedProperties();
            currentRuleset.Update();
        }

        if (GUILayout.Button("Delete last"))
        {
            if (ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1).objectReferenceValue is Rule)
            {
                Rule rule = ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1).objectReferenceValue as Rule;
                DeleteRule(rule, true);

                ruleList.DeleteArrayElementAtIndex(ruleList.arraySize - 1);
                currentRuleset.ApplyModifiedProperties();
            }
        }
        EditorGUILayout.EndVertical();
        EditorGUILayout.EndVertical();
    }

    // Used to move Objects up in their List
    private void MoveListElementUp<T>(int index, List<T> list)
    {
        if (index == 0 || index >= list.Count)
            return;

        T save = list[index - 1];
        list[index - 1] = list[index];
        list[index] = save;
    }

    // Used to move Objects down in their List
    private void MoveListElementDown<T>(int index, List<T> list)
    {
        if (index >= list.Count - 1)
            return;

        T save = list[index + 1];
        list[index + 1] = list[index];
        list[index] = save;
    }

    // Delete Rule and all children of it
    private void DeleteRule(Rule ruleToDelete, bool reimportAsset = false)
    {
        for (int i = ruleToDelete.Conditions.Count - 1; i >= 0; i--)
        {
            DeleteCondition(ruleToDelete.Conditions[i]);
            ruleToDelete.Conditions.RemoveAt(i);
        }

        if (ruleToDelete.Tip != null)
            DeleteTip(ruleToDelete.Tip);

        AssetDatabase.RemoveObjectFromAsset(ruleToDelete);
        DestroyImmediate(ruleToDelete, true);

        if (reimportAsset)
            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
    }
       
    // Delete Condition
    private void DeleteCondition(Condition conditionToDelete, bool reimportAsset = false)
    {
        AssetDatabase.RemoveObjectFromAsset(conditionToDelete);
        DestroyImmediate(conditionToDelete, true);

        if (reimportAsset)
            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
    }

    // Delete Tip
    private void DeleteTip(Tip tipToDelete, bool reimportAsset = false)
    {
        AssetDatabase.RemoveObjectFromAsset(tipToDelete);
        DestroyImmediate(tipToDelete, true);

        if (reimportAsset)
            AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
    }

    // Create new Condition, Name it and make it a SubAsset of the current Ruleset
    private void CreateCondition(Rule rule, int index)
    {
        StringBuilder nameBuilder = new StringBuilder();
        Condition newCondition = CreateInstance<Condition>();
        nameBuilder.Append("Rule ");
        nameBuilder.Append(index);
        nameBuilder.Append(" Condition ");
        nameBuilder.Append(rule.Conditions.Count);
        newCondition.name = nameBuilder.ToString();
        AssetDatabase.AddObjectToAsset(newCondition, currentRuleset.targetObject);
        rule.Conditions.Add(newCondition);
        AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
    }
}

So, I have multiple Problems with this:
First, the big one that actually drives me crazy: Items in the Reset Condition List are not rendered (Line 266 RulesetWindow). BUT, if I remove both Lines marked as trouble Makers (Lines 158 and 270) they are rendered and I have no Idea why that would be. Also note that the list of Condition Actions is always getting rendered. The code is nearly identical and both Lists have the same type.

Second: Items in a Condition List and the Reset Condition List cannot be changed (Lines 192 and 266 if they are rendered). This is probably not to bad, I may have forgotten something.

Some Screenshots so you know what it all looks like:
Window with all layout functions enabled, but without ResetCondition-Elements being rendered (you can see the Element of each, but there is no Enum-Dropdown)

Window with the Trouble Maker Lines removed. Marked Item is the same as aboth and the dropdown is displayed, Layout of course is completly off in this one

Also some weird behaviour I have observed: When I click the Title of either a Condition Action List or a Reset Condition List, all of them in each Rule will be collapsed or expanded:

I know this is a big one and very specific. I’ve put a bunch of time into this and so far have not spotted anything that could explain this weird behaviour. I would be thankful for any help.

Pushing this one, problem is still there

In my experience, doing this inside an imgui drawer is a big no-no:

SerializedObject ruleObject = new SerializedObject(rule);
SerializedProperty resetProp = ruleObject.FindProperty("resetConditions");
EditorGUILayout.PropertyField(resetProp, true, GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 10));

The reason for this is that some of the state of the drawing is tied to the SerializedObject. So when you make a new SO-wrapper for the object on every frame, that will cause behavior to be less than ideal.

I didn’t fine-read all your code, I just jumped to the part that said “problem here”, but the creation of a new SO on each update stood out to me as probably wrong.

Usually for things like this, I’d have a Dictionary<Rule, SerializedObject>, and then do:

if (!soForRule.TryGetValue(rule, out var so)) {
    so = new SerializedObject(rule);
    soForRule[rule] = so;
}

Whenever you need it.

Thanks for your Reply, I’ll change that, you are absolutly right, it is not ideal.
We actually found a semi okay solution to the problem now. We create all the fields of these Lists ourselfs. It’s not ideal but avoids all the weird behaviour, since there are no lists anymore. I’d still be curious where the problem is coming from and how to avoid it in the future, but at least we can use the system now since the Editor Window is fully functional. I’m sure my Code can be improved at several points and I’d be happy to see more tips like this, to make my Code better :slight_smile: