How to show list properties separately from the list itself in a custom editor window?

So, I’d like to build a custom editor window to help plan out the various missions and objectives in my game. My current layout has a lot of lists within lists within lists, which is a nightmare to work with in the default inspector. To avoid creating hundreds of unique scriptable objects to contain the data I need, I figured I could potentially create a custom editor window to serve as my Mission Editor. However, I’ve run into a snag with displaying some of the list information.

My code architecture is as follows:

  1. A Scriptable Object of type MissionsDataObject that contains a list of type Mission. These are the missions that show up on the left-most side of the editor, and their properties are what appear in the center pane of the editor window.

  2. Each Mission has a list of type Objective, which contains details about a specific objective for the mission, much like a quest.

  3. Each Objective has its own lists with more properties, though I’m not concerned with separating these lists in the editor window, just the Objectives List.

The code for the custom window, as well as the aforementioned scripts can be found here, plus a couple other scripts to ensure it all compiles.

-> CLICK TO SEE ALL THE CODE <-

Condition.cs

using System;
using System.Collections.Generic;

[Serializable]
public class Condition {

    public ConditionType conditionType;
    
    public class DurationParams {
        public Range range;
    }

    public class OccupyParams {
        public List<GridPosition> gridPositionList;
    }

    public class WeaponParams {
        public List<WeaponSO> weaponSOList;
    }

    public class AmmoParams {
        public Range range;
    }

}

public enum ConditionType {
    Duration,
    Ammo,
    Weapon,
    Occupy,
}

ExtendedEditorWindow.cs

using UnityEditor;
using UnityEngine;

public class ExtendedEditorWindow : EditorWindow {

    protected SerializedObject serializedObject;
    protected SerializedProperty currentProperty;

    private string selectedPropertyPath;
    protected SerializedProperty selectedProperty;

    protected void DrawProperties(SerializedProperty prop, bool drawChildren) {

        string lastPropPath = string.Empty;

        foreach (SerializedProperty p in prop)
        {
            if (p.isArray && p.propertyType == SerializedPropertyType.Generic) {
                EditorGUILayout.BeginHorizontal();
                p.isExpanded = EditorGUILayout.Foldout(p.isExpanded, p.displayName);
                EditorGUILayout.EndHorizontal();

                if (p.isExpanded) {
                    EditorGUI.indentLevel++;
                    DrawProperties(p, drawChildren);
                    EditorGUI.indentLevel--;
                }

            } else {

                if (!string.IsNullOrEmpty(lastPropPath) && p.propertyPath.Contains(lastPropPath)) {
                    continue;
                }

                lastPropPath = p.propertyPath;
                EditorGUILayout.PropertyField(p, drawChildren);
            }
        }
    }

    protected void DrawButtons(SerializedProperty prop) {
        foreach (SerializedProperty p in prop) {
            if (GUILayout.Button(p.displayName)) {
                selectedPropertyPath = p.propertyPath;
            }
        }

        if (!string.IsNullOrEmpty(selectedPropertyPath)) {
            selectedProperty = serializedObject.FindProperty(selectedPropertyPath);
        }
    }

    protected void DrawField(string propName, bool relative) {

        if (relative && currentProperty != null) {
            EditorGUILayout.PropertyField(currentProperty.FindPropertyRelative(propName), true);
        
        } else if (serializedObject != null) {
            EditorGUILayout.PropertyField(serializedObject.FindProperty(propName), true);
        }

    }

    protected void ApplyChanges() {
        serializedObject.ApplyModifiedProperties();
    }

}

``

### Goal.cs

```cs
using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class Goal {

    public GoalType goalType;
    public Status goalStatus;

    [Space]
    public bool isRequired;
    public string rewardIfOptional;

    [Space]
    public int requiredAmount;
    public int progressAmount;

    public List<Condition> conditionList;

}

public enum GoalType {
    Defeat,
    Rescue,
    Collect,
    Reach,
    Survive,
    Prevent,
    Protect
}

Mission.cs

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.Search;

[Serializable]
public class Mission {

    public string missionName;
    [TextArea(2,4)]
    public string missionDescription;
    public int missionIndex;
    
    [SearchContext("t:sprite MissionPreview", SearchViewFlags.GridView)]
    public Sprite missionSelectPreview;

    public List<NewObjective> objectiveList;

}

MissionsDataEditor.cs

using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;

public class AssetHandler {

    [OnOpenAsset()]
    public static bool OpenEditor(int instanceId, int line) {
    
        MissionsDataObject missionsDataObject = EditorUtility.InstanceIDToObject(instanceId) as MissionsDataObject;

        if (missionsDataObject != null) {
            MissionsDataEditorWindow.Open(missionsDataObject);
            return true;
        }

        return false;
    }
}

[CustomEditor(typeof(MissionsDataObject))]
public class MissionsDataEditor : Editor {

    public override void OnInspectorGUI() {

        if (GUILayout.Button("Open Editor")) {
            MissionsDataEditorWindow.Open((MissionsDataObject)target);
        }
        
    }

}

MissionsDataEditorWindow.cs

using UnityEditor;
using UnityEngine;

public class MissionsDataEditorWindow : ExtendedEditorWindow {

    public static void Open(MissionsDataObject missionsDataObject) {
        MissionsDataEditorWindow window = GetWindow<MissionsDataEditorWindow>("Missions Data Editor");
        window.serializedObject = new SerializedObject(missionsDataObject);
    }

    private void OnGUI() {
        
        currentProperty = serializedObject.FindProperty("missionList");

        EditorGUILayout.BeginHorizontal();
        
            EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(150), GUILayout.ExpandHeight(true));
                DrawButtons(currentProperty);
            EditorGUILayout.EndVertical();

            EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(300), GUILayout.ExpandHeight(true));
                if (selectedProperty != null) {
                    DrawMissionPropertiesPanel();
                    
                } else {
                    
                    EditorGUILayout.LabelField("Select a mission");
                }
            EditorGUILayout.EndVertical();

            EditorGUILayout.BeginVertical("box", GUILayout.ExpandHeight(true));

                if (selectedProperty != null) {
                
                } else {
                    EditorGUILayout.LabelField("Select a mission");
                }

            EditorGUILayout.EndVertical();

        EditorGUILayout.EndHorizontal();

        ApplyChanges();
    }
    
    void DrawMissionPropertiesPanel() {
        currentProperty = selectedProperty;

        DrawField("missionSelectPreview", true);
        DrawField("missionName", true);
        DrawField("missionIndex", true);

        EditorGUILayout.Space(10);

        DrawField("missionDescription", true);
        
        EditorGUILayout.Space(20);

        DrawField("objectiveList", true);
    }

}

MissionsDataObject.cs

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "NewMissionsDataObject", menuName = "ScriptableObjects/MissionsData")]
public class MissionsDataObject : ScriptableObject {

    public List<Mission> missionList;

}

Objective.cs

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class Objective {
    
    [TextArea(1,3)]
    public string objectiveName;
    public Sprite objectivePreviewImage;
    public Status status = Status.NotStarted;

    public List<Goal> goalList;

    public bool IsObjectiveRequired() {
        foreach (Goal goal in goalList) {
            
            if (goal.isRequired) {
                return true;
            }

            continue;   
        }

        return false;
    }

    public override string ToString() {
        return objectiveName;
    }
}  

My goal is to split up the Objective List so that, in the center pane, it only shows a button with the element’s name. When pressed, that button would open all the details of the element in the right pane, but ONLY the details of the one pressed. For reference, this is very similar to what’s already happening with the Mission0, Mission1, etc. buttons on the right, but I can’t figure out a way to get it to work for the “second” tier of lists.

If possible, it would be nice to also the + and - buttons to add or remove elements from the list within the editor window, but if not possible, I could potentially switch to scriptable objects for the Missions to be able to edit the list outside of the window. Also, I know if I can get it working here, I can probably get it to work for the other levels of lists that I have within the Objective class, which would be nice.

Is this possible, or do I need to come at this problem from a different angle?

It’s totally possible!

The right hand panel should be working on the SerializedProperty for the mission you have selected using the central panel. So send it that SerializedProperty, and let it draw the data.

Your + and - buttons needs to do InsertArrayElementAtIndex and DeleteArrayElementAtIndex on the SerializedProperty for the missionList field. Note that InsertArrayElementAtIndex copies the previous element to create the new element, so you’ll want to set all of the fields manually.

Nothing you have asked for is very hard to do, the problem is just getting used to the clunkiness of working with arrays in the SerializedObject world.

@Baste Okay good to know. That first part is where I’m getting stuck though. I’m trying to use similar logic compared to the Mission List for the Objective List, but I get a GUI error and NullRefExceptions like crazy. I tried creating a separate function for drawing the Objective List, but with no results (other than errors).

ExtendedEditorWindow.cs

private string selectedMissionPropertyPath;
protected SerializedProperty currentMissionProperty;
protected SerializedProperty selectedMissionProperty;

private string selectedObjectivePropertyPath;
protected SerializedProperty currentObjectiveProperty;
protected SerializedProperty selectedObjectiveProperty;


protected void DrawMissionButtons(SerializedProperty prop) {
    foreach (SerializedProperty p in prop) {
        if (GUILayout.Button(p.displayName)) {
            selectedMissionPropertyPath = p.propertyPath;
        }
      

     if (!string.IsNullOrEmpty(selectedMissionPropertyPath)) {
        selectedMissionProperty = serializedObject.FindProperty(selectedMissionPropertyPath);
     }
 }
    
protected void DrawObjectiveButtons(SerializedProperty prop) {
    foreach (SerializedProperty p in prop) {
        if (GUILayout.Button(p.displayName)) {
            selectedObjectivePropertyPath = p.propertyPath;
        }
    }

    if (!string.IsNullOrEmpty(selectedObjectivePropertyPath)) {
        selectedObjectiveProperty = serializedObject.FindProperty(selectedObjectivePropertyPath);
    }
}

MissionsDataEditorWindow


private void OnGUI() {

    currentMissionProperty = serializedObject.FindProperty("missionList");
    currentObjectiveProperty = currentMissionProperty.serializedObject.FindProperty("objectiveList");

     // Rest of the function is the same
}

void DrawMissionPropertiesPanel() {

    // Rest hasn't changed, but added:

    DrawObjectiveButtons(currentObjectiveProperty);
}

I’m guessing there’s a different way to get the Objective List property to draw the list elements as buttons, but I have frighteningly little experience with building custom editors, so I’m at a loss.

So I don’t think this makes any sense:

currentMissionProperty = serializedObject.FindProperty("missionList");
currentObjectiveProperty = currentMissionProperty.serializedObject.FindProperty("objectiveList");

currentMissionProperty.serializedObject is just the same object as serializedObject. And that’s a MissionsDataObject, which doesn’t have any field named objectiveList!

So your errors are just because you’re calling DrawObjectiveButtons with a null reference.

Working with SerializedObject/Property gets tricky like this, since there’s no type info around. So you’re trying to work with an object that doesn’t exist and get errors much later!

@Baste you were correct. I worked it out last night. This line was incorrect, as it needed to find a relative property as opposed to a direct one:

// Wrong Version:
currentObjectiveProperty = currentMissionProperty.serializedObject.FindProperty("objectiveList");

// Corrected Version
currentObjectiveProperty = currentMissionProperty.FindPropertyRelative("objectiveList);

Then, I moved some code around to call it in the correct locations:

void DrawMissionPropertiesPanel() {
    // Top is the same
    // Removed "DrawField("objectiveList", true);"
    // Added at the end:
    currentObjectiveProperty = currentMissionProperty.FindPropertyRelative("objectiveList");
    DrawObjectiveButtons(currentObjectiveProperty); 
}

Next, I had to edit the DrawField method in ExtendedEditorWindow:

protected enum PropertyType {
    Mission,
    Objective,
}

protected void DrawField(string propName, bool relative, PropertyType propertyType) {
    var currentProperty = propertyType switch {
            PropertyType.Objective => currentObjectiveProperty,
            PropertyType.Goal => currentGoalProperty,
            _ => currentMissionProperty,
        };

        if (relative && currentProperty != null) {
            EditorGUILayout.PropertyField(currentProperty.FindPropertyRelative(propName), true);
        
        } else if (serializedObject != null) {
            EditorGUILayout.PropertyField(serializedObject.FindProperty(propName), true);
        }

Finally, I added a method to MissionsDataEditorWindow to draw the Objective properties and called that in OnGUI.

void DrawObjectivePropertiesPanel() {
    currentObjectiveProperty = selectedObjectiveProperty;

    // Repeated for all the other properties to be drawn:
    DrawField("objectiveName", true, PropertyType.Objective);
}

Thanks for the help! I could tell I was close since I already had the Mission buttons working, but I just needed to see things from a different angle for it to finally click.