// 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; | |
} | |
} |
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.
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.
Now this one is a scriptableobject that references the previous one. The problem is that it only updates when I mouse over the inspector.
Is there a way to update the value without wildly swinging my mouse around?
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
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
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.
Is there a way I can exclude a specific type of ScriptableObject from being drawn with this?
@robrab2000 Yup, see ignoreClassFullNames
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...
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?
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--;
}
}
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!
Thank you for saving my day! (probably more than a day actually :D)
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