Skip to content

Instantly share code, notes, and snippets.

@tomkail
Last active October 19, 2024 04:37
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.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
/// <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) {
var totalHeight = EditorGUIUtility.singleLineHeight;
if (property.objectReferenceValue == null || !(property.objectReferenceValue is ScriptableObject) || !AreAnySubPropertiesVisible(property)) return totalHeight;
if (property.isExpanded) {
var data = property.objectReferenceValue as ScriptableObject;
if (data == null) return EditorGUIUtility.singleLineHeight;
using var serializedObject = new SerializedObject(data);
var prop = serializedObject.GetIterator();
if (prop.NextVisible(true))
do {
if (prop.name == "m_Script") continue;
var subProp = serializedObject.FindProperty(prop.name);
var 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;
}
return totalHeight;
}
const int buttonWidth = 66;
static readonly List<string> ignoreClassFullNames = new() {"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++;
using var serializedObject = new SerializedObject(data);
// Iterate over all the values and draw them
var prop = serializedObject.GetIterator();
var 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;
var 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();
EditorGUI.indentLevel--;
}
} else {
if (GUI.Button(buttonRect, "Create")) {
var selectedAssetPath = "Assets";
if (property.serializedObject.targetObject is MonoBehaviour) {
var ms = MonoScript.FromMonoBehaviour((MonoBehaviour) property.serializedObject.targetObject);
selectedAssetPath = Path.GetDirectoryName(AssetDatabase.GetAssetPath(ms));
}
property.objectReferenceValue = CreateAssetWithSavePrompt(type, selectedAssetPath);
}
}
property.serializedObject.ApplyModifiedProperties();
EditorGUI.EndProperty();
}
// Allows calling this drawer from GUILayout rather than as a property drawer, which can be useful for custom inspectors
public static T DrawScriptableObjectField<T>(GUIContent label, T objectReferenceValue, ref bool isExpanded) where T : ScriptableObject {
var position = EditorGUILayout.BeginVertical();
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);
} 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);
}
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))) {
var 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);
using var serializedObject = new SerializedObject(objectReferenceValue);
// Iterate over all the values and draw them
var 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();
EditorGUILayout.EndVertical();
EditorGUI.indentLevel--;
}
// 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;
var asset = ScriptableObject.CreateInstance(type);
AssetDatabase.CreateAsset(asset, path);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
EditorGUIUtility.PingObject(asset);
return asset;
}
Type GetFieldType() {
if (fieldInfo == null) return null;
var 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;
if (null != data) {
using var serializedObject = new SerializedObject(data);
var prop = serializedObject.GetIterator();
// Check for any visible property excluding m_script
while (prop.NextVisible(true)) {
if (prop.name == "m_Script")
continue;
return true;
}
}
return false;
}
}
@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?

@noisecrime
Copy link

noisecrime commented Sep 3, 2024

Isn't there a bug in AreAnySubPropertiesVisible(), as if it returns early it fails to dispose the serializedObject?

This could be solved by replacing the 'return true' statement with setting a found property and break, but I figured going with a 'using' would be better. Additionally I add a guard around data being null.
Anyone see anything wrong with this?

   static bool AreAnySubPropertiesVisible(SerializedProperty property)
    {
        var data = ( ScriptableObject )property.objectReferenceValue;

        if ( null != data )
        {
            using ( SerializedObject serializedObject = new SerializedObject(data) )
            {
                SerializedProperty prop = serializedObject.GetIterator();

                // Check for any visible property excluding m_script
                while ( prop.NextVisible(true) )
                {
                    if ( prop.name == "m_Script" )
                        continue;

                    return true;
                }
            }
        }

        return false;
    }

Going further I'm wondering if all instances of getting a SerializedObject in the script should be wrapped in 'using' to ensure dispose is automatically called even if an exception occurs.

Finally i'm not entirely convinced for the need for 'AreAnySubPropertiesVisible', as in best case it returns quickly as most properties are going to be visible, but by the same token i'm not sure there is a worse case as how could a scriptableObject have every property non-visible.

At this point I realized I don't fully understand what the 'visible' property explicitly denotes as I've never given it much thought. The documentation isn't much help, but appears to suggest that HideInInspector, isExpanded and hasVisibileChildren can only affect this value. However the only one of those that truly doesn't draw anything at all would be HideInInspector,. This suggests to me the possibility that there is nothing to draw at all ( once past the explicit isExpanded check for our scriptableObject reference we already have ) is low to non-existent. Therefore is this method pointless?

Edit: Just wondering what the point is of the '_GUILayout' methods and associated 'DrawScriptableObjectField/DrawScriptableObjectFields'? They aren't currently used as far as I can tell and its not exactly clear where one might have used it. Checked the previous revisions and found where they were added, though again they don't appeared to be used, nor is there any comments as to why they were added. Would it not be better to remove them?

@tomkail
Copy link
Author

tomkail commented Sep 11, 2024

Isn't there a bug in AreAnySubPropertiesVisible(), as if it returns early it fails to dispose the serializedObject?

This could be solved by replacing the 'return true' statement with setting a found property and break, but I figured going with a 'using' would be better. Additionally I add a guard around data being null. Anyone see anything wrong with this?

   static bool AreAnySubPropertiesVisible(SerializedProperty property)
    {
        var data = ( ScriptableObject )property.objectReferenceValue;

        if ( null != data )
        {
            using ( SerializedObject serializedObject = new SerializedObject(data) )
            {
                SerializedProperty prop = serializedObject.GetIterator();

                // Check for any visible property excluding m_script
                while ( prop.NextVisible(true) )
                {
                    if ( prop.name == "m_Script" )
                        continue;

                    return true;
                }
            }
        }

        return false;
    }

Going further I'm wondering if all instances of getting a SerializedObject in the script should be wrapped in 'using' to ensure dispose is automatically called even if an exception occurs.

Finally i'm not entirely convinced for the need for 'AreAnySubPropertiesVisible', as in best case it returns quickly as most properties are going to be visible, but by the same token i'm not sure there is a worse case as how could a scriptableObject have every property non-visible.

At this point I realized I don't fully understand what the 'visible' property explicitly denotes as I've never given it much thought. The documentation isn't much help, but appears to suggest that HideInInspector, isExpanded and hasVisibileChildren can only affect this value. However the only one of those that truly doesn't draw anything at all would be HideInInspector,. This suggests to me the possibility that there is nothing to draw at all ( once past the explicit isExpanded check for our scriptableObject reference we already have ) is low to non-existent. Therefore is this method pointless?

Edit: Just wondering what the point is of the '_GUILayout' methods and associated 'DrawScriptableObjectField/DrawScriptableObjectFields'? They aren't currently used as far as I can tell and its not exactly clear where one might have used it. Checked the previous revisions and found where they were added, though again they don't appeared to be used, nor is there any comments as to why they were added. Would it not be better to remove them?

Good spot, sounds sensible. I'll swap to using statements locally and update this if I don't run into any problems.
On the visible property - I can't really recall how this works and I don't have time to run tests. Let me know if you work it out!
The GUILayout functions were duplicates of DrawScriptableObjectField. Oops! Fixed.

@tomkail
Copy link
Author

tomkail commented Sep 11, 2024

if (fieldInfo == null)

Totally does, I've added this to the script :)

@artemklieptsovsett
Copy link

artemklieptsovsett commented Sep 17, 2024

Hello.

I have a problem with your script and https://github.com/ayellowpaper/SerializedDictionary
If there is a SerializedDictionary in the SO, then after Expand I get the following error in this method (their SerializedDictionaryInstanceDrawer class):
NullReferenceException: SerializedObject of SerializedProperty has been Disposed.

public float GetPropertyHeight(GUIContent label)
{
    if (!ListProperty.isExpanded) // error here 
        return SerializedDictionaryDrawer.TopHeaderClipHeight;

    return ReorderableList.GetHeight();
}

Do you have any ideas? Thanks

@tomkail
Copy link
Author

tomkail commented Sep 17, 2024 via email

@artemklieptsovsett
Copy link

Hm I just changed how objects are disposed last week. If you try an earlier version of this gist and it works could you let me know?

On Tue, Sep 17, 2024 at 07:33 artemklieptsovsett @.> wrote: @.* commented on this gist. ------------------------------ Hello. I have a problem with your script and https://github.com/ayellowpaper/SerializedDictionary If there is a SerializedDictionary in the SO, then after Expand I get the following error: NullReferenceException: SerializedObject of SerializedProperty has been Disposed. in this method public float GetPropertyHeight(GUIContent label) { if (!ListProperty.isExpanded) // error here return SerializedDictionaryDrawer.TopHeaderClipHeight; return ReorderableList.GetHeight(); } Do you have any ideas? Thanks — Reply to this email directly, view it on GitHub https://gist.github.com/tomkail/ba4136e6aa990f4dc94e0d39ec6a058c#gistcomment-5194281 or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJR3UHCKYUXZ2D5F2JVOZ3ZW7EJ7BFKMF2HI4TJMJ2XIZLTSKBKK5TBNR2WLJDUOJ2WLJDOMFWWLO3UNBZGKYLEL5YGC4TUNFRWS4DBNZ2F6YLDORUXM2LUPGBKK5TBNR2WLJDHNFZXJJDOMFWWLK3UNBZGKYLEL52HS4DFVRZXKYTKMVRXIX3UPFYGLK2HNFZXIQ3PNVWWK3TUUZ2G64DJMNZZDAVEOR4XAZNEM5UXG5FFOZQWY5LFVA2DKNRSHE2DGNFHORZGSZ3HMVZKMY3SMVQXIZI . You are receiving this email because you authored the thread. Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub .

Thank you for the quick response. I’ve already tried both older and newer versions. Unfortunately, it didn’t help.
Could you possibly check this on your side?

The reproduction steps are very simple:

  1. Import SerializedDictionary.
  2. Create a ScriptableObject with the following field:
    [SerializedDictionary("Id", "Description")]
    public SerializedDictionary<int, string> ElementDescriptions;
  3. Create a field with this ScriptableObject in another MonoBehaviour script and click "Expand" for this SO.

@tomkail
Copy link
Author

tomkail commented Sep 17, 2024

Hm I don't have time to dig into this, sorry! I would try not discarding the serialized objects in this class and seeing if that makes any difference, but if not it sounds like it could be a problem with how the SerializiedDictionary drawer works?

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