Skip to content

Instantly share code, notes, and snippets.

@tomkail
Last active April 2, 2024 18:56
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;
}
}
@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