Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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;
}
}
@SanielX
Copy link

SanielX commented Aug 20, 2022

Not 100% sure what is going on here, but it sure isn't happy. I can easily open and close the first two levels of foldouts, but after that, it seems to be a "no-go". I can click in fields and adjust sliders, but can't open or close inner nested foldouts. Also, the items below the top one keep randomly disappearing.

If anyone still happens to have this problem. It seems that the reason this happens is because when you use new SerializedObject you do not dispose it after. I'm assuming GC doesn't get a chance to collect them and they get broken or something. I'm talking about lines 102 and 29. You need to dispose those after usage and it should work

@tomkail
Copy link
Author

tomkail commented Aug 22, 2022

Not 100% sure what is going on here, but it sure isn't happy. I can easily open and close the first two levels of foldouts, but after that, it seems to be a "no-go". I can click in fields and adjust sliders, but can't open or close inner nested foldouts. Also, the items below the top one keep randomly disappearing.

If anyone still happens to have this problem. It seems that the reason this happens is because when you use new SerializedObject you do not dispose it after. I'm assuming GC doesn't get a chance to collect them and they get broken or something. I'm talking about lines 102 and 29. You need to dispose those after usage and it should work

Fantastic find, thank you! I've updated the gist. It compiles but I've not tested to see if it resolves the issue.

@rugelly
Copy link

rugelly commented Sep 28, 2022

I have a possibly unique problem? values, no matter how nested, update fine in the hierarchy window. My problem is in the inspector: nested values not updating during runtime except for a single time when moused over the inspection window.

Here I have a scriptableobject that contains a float, it updates during runtime in the inspector window so no problems here.
first

Now this one is a scriptableobject that references the previous one. The problem is that it only updates when I mouse over the inspector.
second

Is there a way to update the value without wildly swinging my mouse around?

@SanielX
Copy link

SanielX commented Sep 29, 2022

Is there a way to update the value without wildly swinging my mouse around?

Well first of all the idea of storing runtime values of a gameobject in asset is nonsensical
The only way you can make editor update values continuously is to create CustomEditor that has RequiresConstantRepaint method overriden which returns true. You can also switch inspector to debug mode but that will obviously disable inline editing attribute

@rugelly
Copy link

rugelly commented Sep 30, 2022

Is there a way to update the value without wildly swinging my mouse around?

Well first of all the idea of storing runtime values of a gameobject in asset is nonsensical The only way you can make editor update values continuously is to create CustomEditor that has RequiresConstantRepaint method overriden which returns true. You can also switch inspector to debug mode but that will obviously disable inline editing attribute

custom editor works like a charm thanks!
And thats not what im doing with the values so dont panic, thanks for the concern lol

@OliPerraul
Copy link

With Unity 2022.2 I am having a weird offset for ExtendObject properties. Any ideas?
image

@OliPerraul
Copy link

OliPerraul 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

@Steffenvy
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?

@Steffenvy
Copy link

Steffenvy 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)

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