Inline Scriptable Objects
In this article, I will show how to inline ScriptableObjects in an Inspector component to modify global options in Unity. This is useful when you want a single source of truth for your game settings and you want to modify them easily from the Inspector.
Why do we need to inline ScriptableObjects?
One of the most useful features in Unity for fast testing and iteration is the ability to play the game in the Editor while changing loaded values in a non-destructive way. However, there are cases — such as polishing feature feel parameters — where you want your changes to persist after exiting Play Mode. Of course, it is possible to copy values from the component and paste them outside Play Mode, but this is not very convenient. Also, this trick only works if you change values on a single component.
Sometimes you also want a single source of truth for all objects of a certain type. Prefabs can help, but you have to apply values every time, and depending on the prefab hierarchy, you may still need to change values on multiple prefabs when many of them share the same settings.
To solve these problems, we can use ScriptableObjects. But it can be annoying to open them, change values, and then go back to the GameObject to tweak or inspect other settings — or even just to select the GameObject again. Logically, it would be preferable to have those values on the component itself, because they are part of the component’s behavior. For example, think of a component that controls a character’s jump height. It is more natural to adjust the jump height directly on that component instead of opening a ScriptableObject that stores the value.
Unity already provides everything needed, except for visualizing ScriptableObject values directly in the Inspector of the component that uses them. In this article, I will show how to write a custom property drawer that lets you inline ScriptableObjects in the Inspector by adding a single attribute to the ScriptableObject field.
So, all of this sounds useful, but how do we do it?
First, we need to create the attribute. Fortunately, this is quite simple:
using System;
using UnityEngine;
[AttributeUsage(AttributeTargets.Field)]
public class InlineAttribute : PropertyAttribute { }
Now we can create any ScriptableObject and use it as usual. To mark a field as inline, we just add the Inline attribute to it. For example:
[SerializeField, Inline] MyScriptableObject myScriptableObject;
Using the Inline attribute is the only thing we need to do to inline the ScriptableObject in the Inspector — well, other than writing the property drawer, of course.
IMGUI or UI Toolkit?
Before writing the property drawer, we need to decide whether to use IMGUI or UI Toolkit. I tried writing this drawer using both approaches, and these are my conclusions:
- In IMGUI, it is quite simple to assemble the foldout widget exactly as I want (see below), but everything else is harder to assemble. It is also slower.
- UI Toolkit generally produces simpler code, even though it requires some hacky tweaking with inline styles to look perfect.
In the end, I think UI Toolkit is the better choice for performance, future-proofing, and code simplicity — but it has drawbacks.
The foldout
What I want to achieve is simple to explain: I want a foldout, but instead of a label I want an object field, and inside the foldout I want to draw the properties of the ScriptableObject assigned to that object field (if any). You can see an example in the following image.
The basic idea is straightforward, but the naive approach does not work well by itself. We can create the fields we need, then add the PropertyField inside the Toggle contained in the Foldout:
PropertyField mainField = new(property);
Foldout foldout = new();
Toggle toggle = foldout.Q<Toggle>(className: Foldout.toggleUssClassName);
toggle.Add(mainField);
Notice that I did not set a foldout label, because there is already one in the PropertyField. And yes, it mostly works… but the layout is a mess.

The main problem is the foldout arrow, which takes up as much space as it can, while at the same time the foldout does not take as much space as it can. The best way to solve this kind of problem is to use a USS. I added one in “Assets/Editor Default Resources/InlineObject.uss”.
.unity-foldout__toggle .inline-foldout__field {
flex-grow: 1;
}
.unity-foldout__toggle.inline-foldout__toggle > .unity-toggle__input {
flex-grow: 0;
}
The specificity of these two rules is high enough to ensure they are selected over the default ones. Then I added the USS to the foldout, adding the relative classes.
toggle.AddToClassList("inline-foldout__toggle");
mainField.AddToClassList("inline-foldout__field");
newFoldout.styleSheets.Add(LoadUss());
StyleSheet LoadUss() =>
AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor Default Resources/InlineObject.uss");
Progress.

But we still have some misalignment, as shown in the previous image compared to another similar PropertyField.
How do we solve this? First, the foldout toggle root indents everything quite a lot on both sides with marginLeft and marginRight. Sadly, the correct margins are hardcoded in the USS Unity uses, so it is not possible to take them into account in our USS, unless we precalculate them and write the resulting numbers. Not the perfect solution, in my opinion. So I decided to calculate them at runtime, and then use inline styles to apply them.
float extraMarginRight = toggle.resolvedStyle.marginRight;
float extraMarginLeft = toggleArrow.layout.width + toggle.resolvedStyle.marginLeft;
mainField.style.marginRight = mainField.resolvedStyle.marginRight - extraMarginRight;
mainField.style.marginLeft = mainField.resolvedStyle.marginLeft - extraMarginLeft;
The problem with this code as-is is that it cannot run before the layout is calculated, so it must execute at the right time. To simplify the execution flow, I use schedule.Execute:
foldout.schedule.Execute(DelayedSetup);
This greatly improves the alignment, as you can see.

But unfortunately, there is still a label alignment issue. Labels have different margins under .unity-foldout. There are two ways to fix it: reset the label margin, or remove the .unity-foldout class from the foldout. Both approaches have drawbacks, and I honestly do not know which one is better. I chose to reset the label margin because class-based queries are useful and because many of those classes also affect the visualization of the widget. The downside is that if Unity classes change in the future, alignment may break, but honestly, this can happen with the other solution as well. The code is:
.unity-foldout__toggle .inline-foldout__field .unity-text-element {
margin-left: 0;
}
.unity-foldout__toggle .inline-foldout__field .unity-object-field-display__label {
margin-left: 2px;
}
Notice that I set 2px of margin on the label contained in the ObjectField. This is because there is another rule in the Unity USS that sets a 2px margin on labels used this way.

Now, if you look closely at the image, you can notice that the object field labels still differ by one pixel. I had very similar issues in other parts of the widgets, too, depending on the width of my Inspector. I investigated this more than I would like to admit, and the issue appears to be rounding. UI Toolkit uses Yoga behind the scenes, and Yoga aligns everything to the pixel grid. Here, rounding seems to happen in different directions for the two object fields, possibly because the labels do not start at exactly the same position. While the positions are very close, the result lands near the center of a virtual pixel. I do not think perfect alignment is possible in similar cases unless you want to spend a lot of time on it. If you have any clues, or if you think I am wrong, please do not hesitate to email me — I would be happy to learn something new.
Showing the properties
This part is simple, and the structure is common enough that I will show the code first, then explain it:
mainField.RegisterValueChangeCallback(_ => RebuildProperties());
void RebuildProperties()
{
Object referencedObject = property.objectReferenceValue;
foldout.Clear();
toggleArrow.visible = referencedObject;
foldout.UnregisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
if (!referencedObject)
return;
foldout.RegisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
SerializedObject serializedObject = new(referencedObject);
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren)) {
enterChildren = false;
if (iterator.propertyPath == "m_Script")
continue;
var childField = new PropertyField(iterator);
childField.Bind(serializedObject);
foldout.Add(childField);
}
}
First, I clear anything previously created in the foldout. Then I set foldout arrow visibility based on whether a ScriptableObject is assigned to the object field. If there is no ScriptableObject (or it is invalid), I do not want to show the arrow. And of course I do not want to show anything in the foldout either, so I return early.
If properties should be shown, the code iterates over all visible properties, skips m_Script as usual (I do not want to show the script reference), creates a PropertyField for each property, binds it to the correct serialized object, and adds it to the foldout. As usual, we only enter visible children on the first iteration, controlled by the enterChildren variable.

Polishing
There are four things I wanted to finalize.
First, I want the foldout to expand/collapse when I click the object field label. This is simple:
var label = mainField.Q<Label>(className: PropertyField.labelUssClassName);
label.RegisterCallback<ClickEvent>(_ => foldout.value = !foldout.value);
This goes inside DelayedSetup.
Second, I want to preserve the foldout state during the current editor session. More persistent solutions, like EditorPrefs, could also preserve the state after closing the editor, but I do not think that is necessary in many cases. If you want, feel free to add an EditorPrefs approach — it is a relatively simple extension. Here is what I used:
Foldout foldout = new() { value = IsExpanded() };
bool IsExpanded()
{
string foldoutKey = GetFoldoutSessionKey(property.propertyPath, property.serializedObject.targetObject);
return SessionState.GetBool(foldoutKey, defaultValue: true);
}
static string GetFoldoutSessionKey(string path, Object obj)
{
const string baseKey = "InlineObjectFoldout";
if (obj == null)
return baseKey;
#if UNITY_6000_4_OR_NEWER
string id = obj.GetEntityId().ToString();
#else
string id = obj.GetInstanceID().ToString();
#endif
return $"{baseKey}_{path}_{id}";
}
Notice that I use GetEntityId when available, as GetInstanceID has been deprecated. I use SessionState to store the foldout state for the current editor session, and I generate a key unique to each property/object pair to keep states independent.
Third, I want to update the foldout state of every selected object when the user expands/collapses the foldout. In the RebuildProperties method, I first unregister the callback that handles this:
foldout.UnregisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
Then I register it again, but only when the object is not null:
foldout.RegisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
The implementation is:
void UpdateFoldoutStateForSelectedObjects(ChangeEvent<bool> evt) {
foreach (Object obj in property.serializedObject.targetObjects)
SessionState.SetBool(GetFoldoutSessionKey(property.propertyPath, obj), foldout.value);
}
There is nothing overly complex here — it’s worth noting that this enumerates every single selected object and stores the foldout state.
Finally, I want this to support multi-object editing, but only when the referenced ScriptableObject is the same across all selected objects. To do that, I added a method that checks whether selected objects reference different values:
bool HasDifferentTargetObjects()
{
var objects = property.serializedObject.targetObjects;
Object firstObject = objects.FirstOrDefault();
return objects.Any(obj => obj != firstObject);
}
And that’s it. Below is the complete property drawer and USS code, with a few additional simple guards I did not mention explicitly, and some simple refactoring.
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
[CustomPropertyDrawer(typeof(InlineAttribute))]
public class InlineAttributeDrawer : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
Object[] targetObjects = property.serializedObject.targetObjects;
string propertyPath = property.propertyPath;
PropertyField mainField = new(property);
if (HasIncompatibleValues())
return mainField;
Foldout foldout = CreateFoldout();
Toggle toggle = foldout.Q<Toggle>(className: Foldout.toggleUssClassName);
VisualElement toggleArrow = toggle.Q(className: Foldout.inputUssClassName);
foldout.schedule.Execute(DelayedSetup);
AddCustomClasses();
RegisterCallbacks();
toggle.Add(mainField);
return foldout;
bool HasIncompatibleValues() =>
property.propertyType != SerializedPropertyType.ObjectReference || HasDifferentTargetObjects();
bool HasDifferentTargetObjects() {
Object firstObject = targetObjects.FirstOrDefault();
return targetObjects.Skip(1).Any(obj => obj != firstObject);
}
StyleSheet LoadUss() =>
AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor Default Resources/InlineObject.uss");
bool IsExpanded()
{
string foldoutKey = GetFoldoutSessionKey(propertyPath, property.serializedObject.targetObject);
return SessionState.GetBool(foldoutKey, defaultValue: true);
}
Foldout CreateFoldout()
{
var newFoldout = new Foldout { value = IsExpanded() };
newFoldout.styleSheets.Add(LoadUss());
return newFoldout;
}
void RegisterCallbacks() =>
mainField.RegisterValueChangeCallback(_ => RebuildProperties());
void AddCustomClasses()
{
toggle.AddToClassList("inline-foldout__toggle");
mainField.AddToClassList("inline-foldout__field");
}
void DelayedSetup()
{
RegisterCallbacksDelayed();
SetupLayoutDelayed();
return;
void RegisterCallbacksDelayed() {
var label = mainField.Q<Label>(className: PropertyField.labelUssClassName);
label.RegisterCallback<ClickEvent>(_ => foldout.value = !foldout.value);
}
void SetupLayoutDelayed()
{
float extraMarginLeft = toggleArrow.layout.width + toggle.resolvedStyle.marginLeft;
float extraMarginRight = toggle.resolvedStyle.marginRight;
mainField.style.marginLeft = mainField.resolvedStyle.marginLeft - extraMarginLeft;
mainField.style.marginRight = mainField.resolvedStyle.marginRight - extraMarginRight;
}
}
void RebuildProperties()
{
Object referencedObject = property.objectReferenceValue;
foldout.Clear();
toggleArrow.visible = referencedObject;
foldout.UnregisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
if (!referencedObject)
return;
foldout.RegisterValueChangedCallback(UpdateFoldoutStateForSelectedObjects);
SerializedObject serializedObject = new(referencedObject);
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren)) {
enterChildren = false;
if (iterator.propertyPath == "m_Script")
continue;
var childField = new PropertyField(iterator);
childField.Bind(serializedObject);
foldout.Add(childField);
}
}
void UpdateFoldoutStateForSelectedObjects(ChangeEvent<bool> evt)
{
foreach (Object obj in targetObjects)
SessionState.SetBool(GetFoldoutSessionKey(propertyPath, obj), foldout.value);
}
}
static string GetFoldoutSessionKey(string path, Object obj)
{
const string baseKey = "InlineObjectFoldout";
if (obj == null)
return baseKey;
#if UNITY_6000_4_OR_NEWER
string id = obj.GetEntityId().ToString();
#else
string id = obj.GetInstanceID().ToString();
#endif
return $"{baseKey}_{path}_{id}";
}
}
.unity-foldout__toggle .inline-foldout__field {
flex-grow: 1;
}
.unity-foldout__toggle.inline-foldout__toggle > .unity-toggle__input {
flex-grow: 0;
}
.unity-foldout__toggle .inline-foldout__field .unity-text-element {
margin-left: 0;
}
.unity-foldout__toggle .inline-foldout__field .unity-object-field-display__label {
margin-left: 2px;
}