Skip to content

Instantly share code, notes, and snippets.

@tomkail
Last active July 1, 2024 03:14
Show Gist options
  • Save tomkail/ba4136e6aa990f4dc94e0d39ec6a058c to your computer and use it in GitHub Desktop.
Save tomkail/ba4136e6aa990f4dc94e0d39ec6a058c to your computer and use it in GitHub Desktop.
Displays the fields of a ScriptableObject in the inspector
// Developed by Tom Kail at Inkle
// Released under the MIT Licence as held at https://opensource.org/licenses/MIT
// Must be placed within a folder named "Editor"
using System;
using System.Reflection;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using Object = UnityEngine.Object;
/// <summary>
/// Extends how ScriptableObject object references are displayed in the inspector
/// Shows you all values under the object reference
/// Also provides a button to create a new ScriptableObject if property is null.
/// </summary>
[CustomPropertyDrawer(typeof(ScriptableObject), true)]
public class ExtendedScriptableObjectDrawer : PropertyDrawer {
public override float GetPropertyHeight (SerializedProperty property, GUIContent label) {
float totalHeight = EditorGUIUtility.singleLineHeight;
if(property.objectReferenceValue == null || !AreAnySubPropertiesVisible(property)){
return totalHeight;
}
if(property.isExpanded) {
var data = property.objectReferenceValue as ScriptableObject;
if( data == null ) return EditorGUIUtility.singleLineHeight;
SerializedObject serializedObject = new SerializedObject(data);
SerializedProperty prop = serializedObject.GetIterator();
if (prop.NextVisible(true)) {
do {
if(prop.name == "m_Script") continue;
var subProp = serializedObject.FindProperty(prop.name);
float height = EditorGUI.GetPropertyHeight(subProp, null, true) + EditorGUIUtility.standardVerticalSpacing;
totalHeight += height;
}
while (prop.NextVisible(false));
}
// Add a tiny bit of height if open for the background
totalHeight += EditorGUIUtility.standardVerticalSpacing;
serializedObject.Dispose();
}
return totalHeight;
}
const int buttonWidth = 66;
static readonly List<string> ignoreClassFullNames = new List<string>{ "TMPro.TMP_FontAsset" };
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) {
EditorGUI.BeginProperty (position, label, property);
var type = GetFieldType();
if(type == null || ignoreClassFullNames.Contains(type.FullName)) {
EditorGUI.PropertyField(position, property, label);
EditorGUI.EndProperty ();
return;
}
ScriptableObject propertySO = null;
if(!property.hasMultipleDifferentValues && property.serializedObject.targetObject != null && property.serializedObject.targetObject is ScriptableObject) {
propertySO = (ScriptableObject)property.serializedObject.targetObject;
}
var propertyRect = Rect.zero;
var guiContent = new GUIContent(property.displayName);
var foldoutRect = new Rect(position.x, position.y, EditorGUIUtility.labelWidth, EditorGUIUtility.singleLineHeight);
if(property.objectReferenceValue != null && AreAnySubPropertiesVisible(property)) {
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, guiContent, true);
} else {
// So yeah having a foldout look like a label is a weird hack
// but both code paths seem to need to be a foldout or
// the object field control goes weird when the codepath changes.
// I guess because foldout is an interactable control of its own and throws off the controlID?
foldoutRect.x += 12;
EditorGUI.Foldout(foldoutRect, property.isExpanded, guiContent, true, EditorStyles.label);
}
var indentedPosition = EditorGUI.IndentedRect(position);
var indentOffset = indentedPosition.x - position.x;
propertyRect = new Rect(position.x + (EditorGUIUtility.labelWidth - indentOffset), position.y, position.width - (EditorGUIUtility.labelWidth - indentOffset), EditorGUIUtility.singleLineHeight);
if(propertySO != null || property.objectReferenceValue == null) {
propertyRect.width -= buttonWidth;
}
EditorGUI.ObjectField(propertyRect, property, type, GUIContent.none);
if (GUI.changed) property.serializedObject.ApplyModifiedProperties();
var buttonRect = new Rect(position.x + position.width - buttonWidth, position.y, buttonWidth, EditorGUIUtility.singleLineHeight);
if(property.propertyType == SerializedPropertyType.ObjectReference && property.objectReferenceValue != null) {
var data = (ScriptableObject)property.objectReferenceValue;
if(property.isExpanded) {
// Draw a background that shows us clearly which fields are part of the ScriptableObject
GUI.Box(new Rect(0, position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing - 1, Screen.width, position.height - EditorGUIUtility.singleLineHeight - EditorGUIUtility.standardVerticalSpacing), "");
EditorGUI.indentLevel++;
SerializedObject serializedObject = new SerializedObject(data);
// Iterate over all the values and draw them
SerializedProperty prop = serializedObject.GetIterator();
float y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
if (prop.NextVisible(true)) {
do {
// Don't bother drawing the class file
if(prop.name == "m_Script") continue;
float height = EditorGUI.GetPropertyHeight(prop, new GUIContent(prop.displayName), true);
EditorGUI.PropertyField(new Rect(position.x, y, position.width-buttonWidth, height), prop, true);
y += height + EditorGUIUtility.standardVerticalSpacing;
}
while (prop.NextVisible(false));
}
if (GUI.changed)
serializedObject.ApplyModifiedProperties();
serializedObject.Dispose();
EditorGUI.indentLevel--;
}
} else {
if(GUI.Button(buttonRect, "Create")) {
string selectedAssetPath = "Assets";
if(property.serializedObject.targetObject is MonoBehaviour) {
MonoScript ms = MonoScript.FromMonoBehaviour((MonoBehaviour)property.serializedObject.targetObject);
selectedAssetPath = System.IO.Path.GetDirectoryName(AssetDatabase.GetAssetPath( ms ));
}
property.objectReferenceValue = CreateAssetWithSavePrompt(type, selectedAssetPath);
}
}
property.serializedObject.ApplyModifiedProperties();
EditorGUI.EndProperty ();
}
public static T _GUILayout<T> (string label, T objectReferenceValue, ref bool isExpanded) where T : ScriptableObject {
return _GUILayout<T>(new GUIContent(label), objectReferenceValue, ref isExpanded);
}
public static T _GUILayout<T> (GUIContent label, T objectReferenceValue, ref bool isExpanded) where T : ScriptableObject {
Rect position = EditorGUILayout.BeginVertical();
var propertyRect = Rect.zero;
var guiContent = label;
var foldoutRect = new Rect(position.x, position.y, EditorGUIUtility.labelWidth, EditorGUIUtility.singleLineHeight);
if(objectReferenceValue != null) {
isExpanded = EditorGUI.Foldout(foldoutRect, isExpanded, guiContent, true);
var indentedPosition = EditorGUI.IndentedRect(position);
var indentOffset = indentedPosition.x - position.x;
propertyRect = new Rect(position.x + EditorGUIUtility.labelWidth - indentOffset, position.y, position.width - EditorGUIUtility.labelWidth - indentOffset, EditorGUIUtility.singleLineHeight);
} else {
// So yeah having a foldout look like a label is a weird hack
// but both code paths seem to need to be a foldout or
// the object field control goes weird when the codepath changes.
// I guess because foldout is an interactable control of its own and throws off the controlID?
foldoutRect.x += 12;
EditorGUI.Foldout(foldoutRect, isExpanded, guiContent, true, EditorStyles.label);
var indentedPosition = EditorGUI.IndentedRect(position);
var indentOffset = indentedPosition.x - position.x;
propertyRect = new Rect(position.x + EditorGUIUtility.labelWidth - indentOffset, position.y, position.width - EditorGUIUtility.labelWidth - indentOffset-60, EditorGUIUtility.singleLineHeight);
}
EditorGUILayout.BeginHorizontal();
objectReferenceValue = EditorGUILayout.ObjectField(new GUIContent(" "), objectReferenceValue, typeof(T), false) as T;
if(objectReferenceValue != null) {
EditorGUILayout.EndHorizontal();
if(isExpanded) {
DrawScriptableObjectChildFields(objectReferenceValue);
}
} else {
if(GUILayout.Button("Create", GUILayout.Width(buttonWidth))) {
string selectedAssetPath = "Assets";
var newAsset = CreateAssetWithSavePrompt(typeof(T), selectedAssetPath);
if(newAsset != null) {
objectReferenceValue = (T)newAsset;
}
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndVertical();
return objectReferenceValue;
}
static void DrawScriptableObjectChildFields<T> (T objectReferenceValue) where T : ScriptableObject {
// Draw a background that shows us clearly which fields are part of the ScriptableObject
EditorGUI.indentLevel++;
EditorGUILayout.BeginVertical(GUI.skin.box);
var serializedObject = new SerializedObject(objectReferenceValue);
// Iterate over all the values and draw them
SerializedProperty prop = serializedObject.GetIterator();
if (prop.NextVisible(true)) {
do {
// Don't bother drawing the class file
if(prop.name == "m_Script") continue;
EditorGUILayout.PropertyField(prop, true);
}
while (prop.NextVisible(false));
}
if (GUI.changed)
serializedObject.ApplyModifiedProperties();
serializedObject.Dispose();
EditorGUILayout.EndVertical();
EditorGUI.indentLevel--;
}
public static T DrawScriptableObjectField<T> (GUIContent label, T objectReferenceValue, ref bool isExpanded) where T : ScriptableObject {
Rect position = EditorGUILayout.BeginVertical();
var propertyRect = Rect.zero;
var guiContent = label;
var foldoutRect = new Rect(position.x, position.y, EditorGUIUtility.labelWidth, EditorGUIUtility.singleLineHeight);
if(objectReferenceValue != null) {
isExpanded = EditorGUI.Foldout(foldoutRect, isExpanded, guiContent, true);
var indentedPosition = EditorGUI.IndentedRect(position);
var indentOffset = indentedPosition.x - position.x;
propertyRect = new Rect(position.x + EditorGUIUtility.labelWidth - indentOffset, position.y, position.width - EditorGUIUtility.labelWidth - indentOffset, EditorGUIUtility.singleLineHeight);
} else {
// So yeah having a foldout look like a label is a weird hack
// but both code paths seem to need to be a foldout or
// the object field control goes weird when the codepath changes.
// I guess because foldout is an interactable control of its own and throws off the controlID?
foldoutRect.x += 12;
EditorGUI.Foldout(foldoutRect, isExpanded, guiContent, true, EditorStyles.label);
var indentedPosition = EditorGUI.IndentedRect(position);
var indentOffset = indentedPosition.x - position.x;
propertyRect = new Rect(position.x + EditorGUIUtility.labelWidth - indentOffset, position.y, position.width - EditorGUIUtility.labelWidth - indentOffset-60, EditorGUIUtility.singleLineHeight);
}
EditorGUILayout.BeginHorizontal();
objectReferenceValue = EditorGUILayout.ObjectField(new GUIContent(" "), objectReferenceValue, typeof(T), false) as T;
if(objectReferenceValue != null) {
EditorGUILayout.EndHorizontal();
if(isExpanded) {
}
} else {
if(GUILayout.Button("Create", GUILayout.Width(buttonWidth))) {
string selectedAssetPath = "Assets";
var newAsset = CreateAssetWithSavePrompt(typeof(T), selectedAssetPath);
if(newAsset != null) {
objectReferenceValue = (T)newAsset;
}
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndVertical();
return objectReferenceValue;
}
// Creates a new ScriptableObject via the default Save File panel
static ScriptableObject CreateAssetWithSavePrompt (Type type, string path) {
path = EditorUtility.SaveFilePanelInProject("Save ScriptableObject", type.Name+".asset", "asset", "Enter a file name for the ScriptableObject.", path);
if (path == "") return null;
ScriptableObject asset = ScriptableObject.CreateInstance(type);
AssetDatabase.CreateAsset (asset, path);
AssetDatabase.SaveAssets ();
AssetDatabase.Refresh();
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
EditorGUIUtility.PingObject(asset);
return asset;
}
Type GetFieldType () {
Type type = fieldInfo.FieldType;
if(type.IsArray) type = type.GetElementType();
else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) type = type.GetGenericArguments()[0];
return type;
}
static bool AreAnySubPropertiesVisible(SerializedProperty property) {
var data = (ScriptableObject)property.objectReferenceValue;
SerializedObject serializedObject = new SerializedObject(data);
SerializedProperty prop = serializedObject.GetIterator();
while (prop.NextVisible(true)) {
if (prop.name == "m_Script") continue;
return true; //if theres any visible property other than m_script
}
serializedObject.Dispose();
return false;
}
}
@perrauo
Copy link

perrauo commented Dec 10, 2022

Same problem for Serializable classes used as members of the extended object. I think foldout in general is the issue.
image

@Manamongods
Copy link

That sounds to me on first impression to be caused by Unity's switch to UI toolkit default editors. See if the problem is gone when changing the setting "Project Settings/Editor/Use IMGUI Default Inspector" to true. I believe UI Toolkit might not properly set EditorGUIUtility.hierarchyMode to true in its PropertyField code.

@robrab2000
Copy link

Is there a way I can exclude a specific type of ScriptableObject from being drawn with this?

@tomkail
Copy link
Author

tomkail commented Feb 1, 2023

@robrab2000 Yup, see ignoreClassFullNames

@robrab2000
Copy link

Thanks! It doesn't seem to be fixing the issue I'm having however. I'm using OdinInspector to create window which has buttons and does stuff. I want to be able to assign a custom scriptable object in the window but it seems to be struggling to find the type. When it renders the editor window I get loads of errors: NullReferenceException: Object reference not set to an instance of an object ExtendedScriptableObjectDrawer.GetFieldType () (at Assets/Scripts/SO-Scripts/Editor/ExtendedScriptableObjectDrawer.cs:271)

Line 271 is:
Type GetFieldType () { Type type = fieldInfo.FieldType;

The GetFieldType() function necessarily gets called before checking against the ignoreClassFullNames list. Perhaps the issue is with Odin though...

@tomkail
Copy link
Author

tomkail commented Feb 1, 2023 via email

@robrab2000
Copy link

I'm drawing a custom scriptable object within an OdinEditorWindow

Screenshot 2023-02-01 at 14 56 04

Screenshot 2023-02-01 at 14 56 25

Here is the window shown with the ExtendedScriptableObjectDrawer disabled and then enabled
Screenshot 2023-02-01 at 16 37 43
Screenshot 2023-02-01 at 13 05 51

@larryPlayabl
Copy link

This is such a great tool. Thank you!

I've been getting Unity editor crashes in this case:

  • My ScriptableObject type (call it MySOType) contains a List
  • I create a cyclical reference using this list: A -> B -> A

I've done some digging through the code to try to prevent it by statically tracking some state but I haven't been able to prevent the crash. Any thoughts?

@Manamongods
Copy link

Manamongods commented Mar 1, 2023

This is such a great tool. Thank you!

I've been getting Unity editor crashes in this case:

  • My ScriptableObject type (call it MySOType) contains a List
  • I create a cyclical reference using this list: A -> B -> A

I've done some digging through the code to try to prevent it by statically tracking some state but I haven't been able to prevent the crash. Any thoughts?

I believe this can be fixed by adding lines similar to these, taken from my own modified script so some changes are probably necessary:

public static uint maxDepth = 5;

public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
    depth++;
    try
    {
        float totalHeight = EditorGUIUtility.singleLineHeight;
        var so = property.objectReferenceValue as ScriptableObject;
        if (depth > maxDepth || so == null)
            return totalHeight;

        //...
    }
    finally
    {
        depth--;
    }
}

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
    depth++;
    try
    {
        label = EditorGUI.BeginProperty(position, label, property);

        var type = GetFieldType();


        // Ignoring
        if (depth > maxDepth || type == null
            || ignoreClassFullNames.Contains(type.FullName)
            || Attribute.IsDefined(fieldInfo, typeof(NonExtendedScriptableObjectAttribute)))
        {
            property.isExpanded = false; // Just in case
            DoObjectField(position, property, label, type);
            //EditorGUI.PropertyField(position, property, label);
            EditorGUI.EndProperty();
            return;
        }

        //...
    }
    finally
    {
        depth--;
    }
}

@tomkail
Copy link
Author

tomkail commented Mar 3, 2023

This is such a great tool. Thank you!
I've been getting Unity editor crashes in this case:

  • My ScriptableObject type (call it MySOType) contains a List
  • I create a cyclical reference using this list: A -> B -> A

I've done some digging through the code to try to prevent it by statically tracking some state but I haven't been able to prevent the crash. Any thoughts?

I believe this can be fixed by adding lines similar to these, taken from my own modified script so some changes are probably necessary:

public static uint maxDepth = 5;

public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
    depth++;
    try
    {
        float totalHeight = EditorGUIUtility.singleLineHeight;
        var so = property.objectReferenceValue as ScriptableObject;
        if (depth > maxDepth || so == null)
            return totalHeight;

        //...
    }
    finally
    {
        depth--;
    }
}

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
    depth++;
    try
    {
        label = EditorGUI.BeginProperty(position, label, property);

        var type = GetFieldType();


        // Ignoring
        if (depth > maxDepth || type == null
            || ignoreClassFullNames.Contains(type.FullName)
            || Attribute.IsDefined(fieldInfo, typeof(NonExtendedScriptableObjectAttribute)))
        {
            property.isExpanded = false; // Just in case
            DoObjectField(position, property, label, type);
            //EditorGUI.PropertyField(position, property, label);
            EditorGUI.EndProperty();
            return;
        }

        //...
    }
    finally
    {
        depth--;
    }
}

Super helpful! Thanks! If anyone can confirm this works we can merge it in; I've not got the time to do it just now. Ta!

@J3ster1337
Copy link

Thank you for saving my day! (probably more than a day actually :D)

@restush
Copy link

restush commented Jun 12, 2023

I'm not sure about the codes, but I have move to UIToolkit. It's less codes to write editor and easy to implement.

@EliCDavis
Copy link

Added a small code change so it won't trype to create ScriptableObjects of abstract class

                if (!type.IsAbstract && GUI.Button(buttonRect, "Create"))

@EliCDavis
Copy link

Actually took it a step further,

Made a Property Attribute

    public class ExtendableScriptableObject : PropertyAttribute
    {
        public Type createType;


        public ExtendableScriptableObject()
        {
        }

        public ExtendableScriptableObject(Type createType)
        {
            this.createType = createType;
        }
    }

And then edited the create function

else if (!type.IsAbstract || propertyAttribute.createType != null)
           {
               if (GUI.Button(buttonRect, "Create"))
               {
                   string selectedAssetPath = "Assets";
                   if (property.serializedObject.targetObject is MonoBehaviour)
                   {
                       MonoScript ms =
                           MonoScript.FromMonoBehaviour((MonoBehaviour)property.serializedObject.targetObject);
                       selectedAssetPath = System.IO.Path.GetDirectoryName(AssetDatabase.GetAssetPath(ms));
                   }

                   var typeToCreate = type;
                   if (propertyAttribute.createType != null)
                   {
                       typeToCreate = propertyAttribute.createType;
                   }

                   property.objectReferenceValue = CreateAssetWithSavePrompt(typeToCreate, selectedAssetPath);
               }
           }

And now I can use it like this: (RecoludeAPIRecordingResolver extends RecordingResolverConfig)

        [ExtendableScriptableObject(typeof(RecoludeAPIRecordingResolver))] [SerializeField]
        private RecordingResolverConfig resolverConfig;

@WatchaGames
Copy link

WatchaGames commented Nov 13, 2023

Hi, love this. Not a specialist of prop drawer in unity.
Updating to latest Odin in my main project started to have errors resulting in wrangled layout in inspector of a gameobject with Scriptables.

After a bit of digging (and again I don't know what Im doing in Prop Drawers ;-)
I had to add a test in this function so that it does not break/stops when encountering refs to scriptables objects that are not referencing any (basically null pointers to SO in the MonoBehaviour)

Type GetFieldType () {
		// FIX NICO ? IF NULL then dont try to get type
		if (fieldInfo == null)
		{
			return null;
		}
		Type type = fieldInfo.FieldType;
		if(type.IsArray) type = type.GetElementType();
		else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) type = type.GetGenericArguments()[0];
		return type;
	}


Does it make sense ?
Thanks a lot for this.
Nick

@sandsalamand
Copy link

If you end up with a Type Mismatch in your inspector, this script will spam invalid cast warnings at this line in AreAnySubPropertiesVisible:
var data = (ScriptableObject)property.objectReferenceValue;

Does anyone know of a simple way to guard against this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment