Skip to content

Instantly share code, notes, and snippets.

@sliptrixx
Last active June 5, 2023 19:27
Show Gist options
  • Save sliptrixx/0d69d3b9408b67a3de6b54e6d4bab8d4 to your computer and use it in GitHub Desktop.
Save sliptrixx/0d69d3b9408b67a3de6b54e6d4bab8d4 to your computer and use it in GitHub Desktop.
Scriptable Lists - A property drawer for nested scriptable objects

Brief

The scriptable list framework allows developers to create a list of scriptable objects inside a scriptable object and provides a neat way to modify the values in the inspector. Have a look at this:

image

The source code to build that scriptable object is this simple:

using UnityEngine;
using Hibzz.Utility;

[CreateAssetMenu(fileName = "DefaultObject", menuName = "Custom/DemoObject")]
public class ScriptableListDemo : ScriptableObject
{
	public string AbilityName;
	public float Cooldown;

	public ScriptableList<ArcaneProperty> OnCast;
	public ScriptableList<ArcaneProperty> OnTargetHit;

	public ScriptableList<ProjectileModifier> Modifier;

	public void UsageExample()
	{
		foreach(ArcaneProperty property in OnCast.items)
		{
			// code goes here
		}
		
		foreach(ArcaneProperty property in OnTargetHit.items)
		{
			// code goes here
		}
		
		foreach(ProjectileModifier property in Modifier.items)
		{
			// code goes here
		}
	}
}

The ScriptableListProperty is a generic property and doesn’t know anything about your custom scriptable list. So you must add something like the following lines of code in a new script inside the editor folder so that the inspector knows how to display your data.

using UnityEditor;

namespace Hibzz.Utility
{
	[CustomPropertyDrawer(typeof(ScriptableList<ArcaneProperty>))]
	public class ArcanePropertyListDrawer : ScriptableListProperty<ArcaneProperty> 	{ }
	
	[CustomPropertyDrawer(typeof(ScriptableList<ProjectileModifier>))]
	public class ProjectileModifierListDrawer : ScriptableListProperty<ProjectileModifier> { }
}

There are certain limitations to this solution, for example you can only add classes that belong to the same assembly as the generic class T. I’m unsure how performant heavy this solution might be as the project size grows, but so far I’ve had no issues.

/* MIT License
Copyright (c) 2021 hibzz.games
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Author: Hibnu Hishath (sliptixx)
Notes: Crediting in derived work is not necessary, but is greatly appreciated.
Notes: Feel free to use it in your games/projects as is or in modified form. You
may sell modified versions of this code, but try not to sell the code as is in the
form of developer packages. This is licensed under MIT, so I can't stop you from
doing that, but that's just a dick move.
*/
using System.Collections.Generic;
using UnityEngine;
namespace Hibzz.Utility
{
/// <summary>
/// An empty interface that ensures that the generic Type T inherits from Scriptable objects
/// </summary>
public interface IScriptableList {}
/// <summary>
/// Marks a list of scriptable objects to be drawn so
/// </summary>
[System.Serializable]
public class ScriptableList<T> : ISerializationCallbackReceiver, IScriptableList where T : ScriptableObject
{
public List<T> items;
public ScriptableList()
{
}
public void OnAfterDeserialize()
{
if(items == null)
{
items = new List<T>();
}
}
public void OnBeforeSerialize()
{
}
}
}
/* MIT License
Copyright (c) 2021 hibzz.games
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Author: Hibnu Hishath (sliptixx)
Notes: Crediting is not necessary, but is greatly appreciated.
Notes: Feel free to use it in your games/projects as is or in modified form. You
may sell modified versions of this code, but try not to sell the code as is in the
form of developer packages. This is licensed under MIT, so I can't stop you from
doing that, but that's just a dick move.
Notes: This script is a heavily modified version of the response found in
https://stackoverflow.com/a/62845768/8182289. Thanks to u/ack.
*/
using UnityEngine;
using UnityEditor;
using UnityEditorInternal;
using System.IO;
using System.Reflection;
namespace Hibzz.Utility
{
public class ScriptableListProperty<T> : PropertyDrawer
{
/// <summary>
/// Is this scriptable list drawer initialized?
/// </summary>
private bool initialized = false;
/// <summary>
/// Reorderable list that can be viewed in the inspector
/// </summary>
private ReorderableList list;
private float listHeight = 0;
/// <summary>
/// A private property
/// </summary>
private SerializedProperty listProperty;
private struct PropertyCreationParams
{
public string Path;
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
if(!initialized) { Initialize(property); }
EditorGUI.BeginProperty(position, label, property);
list.DoList(position);
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
if (list != null)
{
return list.GetHeight() + EditorGUIUtility.standardVerticalSpacing * 3;
}
return base.GetPropertyHeight(property, label);
}
private void Initialize(SerializedProperty property)
{
// mark the item as initialized
initialized = true;
// find list property and add it to the list
listProperty = property.FindPropertyRelative("items");
// using theat generate the reorderable list
list = new ReorderableList(
property.serializedObject,
listProperty,
draggable: true,
displayHeader: true,
displayAddButton: true,
displayRemoveButton: true);
// how to draw the header?
list.drawHeaderCallback =
(Rect rect) =>
{
EditorGUI.LabelField(rect, property.displayName);
};
// what to do when the remove button is pressed in the list?
list.onRemoveCallback =
(ReorderableList list) =>
{
SerializedProperty element = list.serializedProperty.GetArrayElementAtIndex(list.index);
Object obj = element.objectReferenceValue;
AssetDatabase.RemoveObjectFromAsset(obj);
Object.DestroyImmediate(obj, true);
list.serializedProperty.DeleteArrayElementAtIndex(list.index);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
};
list.drawElementCallback =
(Rect rect, int index, bool isActive, bool isFocused) =>
{
SerializedProperty element = listProperty.GetArrayElementAtIndex(index);
rect.y += 2;
rect.width -= 10;
rect.height = EditorGUIUtility.singleLineHeight;
if (element.objectReferenceValue == null) { return; }
string label = element.objectReferenceValue.name;
EditorGUI.LabelField(rect, label, EditorStyles.boldLabel);
// Convert this element's data to a SerializedObject so we can iterate
// through each SerializedProperty and render a PropertyField.
SerializedObject nestedObject = new SerializedObject(element.objectReferenceValue);
// loop over all properties and render them
SerializedProperty prop = nestedObject.GetIterator();
float y = rect.y;
while (prop.NextVisible(true))
{
if (prop.name == "m_Script") { continue; }
rect.y += EditorGUIUtility.singleLineHeight;
EditorGUI.PropertyField(rect, prop);
}
nestedObject.ApplyModifiedProperties();
// Mark edits for saving
if (GUI.changed)
{
EditorUtility.SetDirty(property.serializedObject.targetObject);
}
};
// this callback helps calculate the height of the list
list.elementHeightCallback =
(int index) =>
{
float baseProp = EditorGUI.GetPropertyHeight(
list.serializedProperty.GetArrayElementAtIndex(index),
true);
float additionalProps = 0;
SerializedProperty element = listProperty.GetArrayElementAtIndex(index);
if (element.objectReferenceValue != null)
{
SerializedObject property = new SerializedObject(element.objectReferenceValue);
SerializedProperty prop = property.GetIterator();
while (prop.NextVisible(true))
{
if (prop.name == "m_Script") { continue; }
additionalProps += EditorGUIUtility.singleLineHeight;
}
}
float spaceBetweenElements = EditorGUIUtility.singleLineHeight / 2;
listHeight = baseProp + additionalProps + spaceBetweenElements;
return listHeight;
};
list.onAddDropdownCallback =
(Rect buttonRect, ReorderableList list) =>
{
GenericMenu menu = new GenericMenu();
var guids = AssetDatabase.FindAssets("t:script");
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var type = AssetDatabase.LoadAssetAtPath(path, typeof(Object));
// get the current assembly and look through all classes to find the fullname of the asset
bool typeFound = false;
Assembly assembly = typeof(T).Assembly;
// make sure that incoming guid type inherits the type of the list
foreach(System.Type t in assembly.GetTypes())
{
if (t.IsSubclassOf(typeof(T)) && t.Name == type.name)
{
typeFound = true;
break;
}
}
// if the type isn't found then skip
if(!typeFound) { continue; }
menu.AddItem(
new GUIContent(Path.GetFileNameWithoutExtension(path)),
false,
(object dataobj) =>
{
// make room in list
var data = (PropertyCreationParams)dataobj;
var index = list.serializedProperty.arraySize;
list.serializedProperty.arraySize++;
list.index = index;
var element = list.serializedProperty.GetArrayElementAtIndex(index);
// create new sub property
var type = AssetDatabase.LoadAssetAtPath(data.Path, typeof(Object));
var newProperty = ScriptableObject.CreateInstance(type.name);
newProperty.name = type.name;
AssetDatabase.AddObjectToAsset(newProperty, property.serializedObject.targetObject);
AssetDatabase.SaveAssets();
element.objectReferenceValue = newProperty;
property.serializedObject.ApplyModifiedProperties();
},
new PropertyCreationParams() { Path = path });
}
menu.ShowAsContext();
};
}
}
}
@sblack
Copy link

sblack commented Apr 25, 2023

can't seem to get this to work (including testing in a new project to be certain it wasn't other property drawers or the like interfering); nothing happens when I click the add button. Using other means to add an element, the remove button IS working.
EDIT: SubSO derives from ScriptableObject, SubSOChild derives from SubSO. SubSOListDrawer defined as per example, and placed in ScriptableListProperty.cs because it didn't seem like it needed its own file to work; no list drawer for SubSOChild defined. When I click the add button on a ScriptableList, I get an option for SubSOChild, but not SubSO.

also, getting these error messages in Visual Studio. Unity doesn't seem to care, and changing the names doesn't fix the issue, so possibly unconnected.

A local or parameter named 'property' cannot be declared in this scope because that name is used in an enclosing local scope to define a local or parameter	Assembly-CSharp-Editor	D:\My Documents\Unity 5 projects\ScriptableListTest\Assets\Editor\ScriptableListProperty.cs	183	
A local or parameter named 'type' cannot be declared in this scope because that name is used in an enclosing local scope to define a local or parameter	Assembly-CSharp-Editor	D:\My Documents\Unity 5 projects\ScriptableListTest\Assets\Editor\ScriptableListProperty.cs	239

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