// 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; | |
} | |
} |
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)
I'm not sure about the codes, but I have move to UIToolkit. It's less codes to write editor and easy to implement.
Added a small code change so it won't trype to create ScriptableObjects of abstract class
if (!type.IsAbstract && GUI.Button(buttonRect, "Create"))
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;
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
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