๐ ๏ธ Lesson 7.1: Custom Inspectors & Property Drawers
Unity's default Inspector is a workhorse: point it at any component and it draws a field for every serialized value. But a workhorse is not a craftsman's tool. When a designer keeps entering an invalid range, when two related fields should sit side by side, or when a "Bake" button would save an hour of manual work, you stop bending your workflow to the Inspector and start bending the Inspector to your workflow. This lesson shows you the two levers: a custom Editor that redraws a whole component, and a custom Property Drawer that redraws one field everywhere it appears.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why teams customize the Inspector โ validation, workflow buttons, clarity, and safety
- Write a
[CustomEditor(typeof(X))]class that overridesOnInspectorGUI - Use
SerializedObject/SerializedPropertyinstead of touching the target directly โ and say what you get for free by doing so - Lay out fields, help boxes, and buttons with
EditorGUILayout - Build a reusable
[CustomPropertyDrawer]for your ownPropertyAttribute - Place all of this in an Editor assembly so it never ships in your build
Estimated Time: 65 minutes ยท Prerequisite: Lesson 1.1 (assembly definitions) โ Editor code must live in an Editor asmdef
In This Lesson
Why Customize the Inspector
The default Inspector is reflection over your fields: Unity walks every serialized member and draws a matching control. That is a fine default and, for most components, all you need. You reach past it when the default draws the data correctly but presents the work badly. Four recurring motivations cover almost every real case:
โ Validation
Catch bad data at edit time โ a negative radius, a missing prefab reference, a spawn count above the pool size โ and show a warning in the Inspector instead of a crash at runtime.
โก Workflow buttons
Add a Bake, Regenerate, or Reset to Defaults button that runs an editor action right where the data lives, instead of hunting through menus.
๐๏ธ Clarity
Group related fields, hide irrelevant ones based on a mode, add headers and help boxes, and turn a wall of floats into something a designer can actually read.
None of these change what your component is โ the serialized data is identical. They change how a human interacts with it. That distinction matters, because a custom Inspector that quietly corrupts undo history or breaks multi-object editing is worse than no custom Inspector at all. The trick, as we'll see, is to redraw the interface while letting Unity keep owning the data.
๐ฌ A note on the two UI systems. Custom Editors can be built with the classic IMGUI API (OnInspectorGUI,EditorGUILayout) or the newer UI Toolkit (CreateInspectorGUI, returning aVisualElement). This lesson teaches IMGUI because it is the most direct, most documented path and reads top-to-bottom like the code it is. You'll meet UI Toolkit properly in the next lesson when we build a full EditorWindow with it.
Your First Custom Editor
Suppose we have a simple spawner component. Nothing exotic โ a prefab, a count, a radius:
using UnityEngine;
// This is a normal runtime MonoBehaviour. It lives in a runtime assembly.
public class EnemySpawner : MonoBehaviour
{
public GameObject enemyPrefab;
public int count = 10;
public float radius = 5f;
}
To take over its Inspector, we write a separate Editor class, tag it with [CustomEditor(typeof(EnemySpawner))], and override OnInspectorGUI. Unity calls that method every time it needs to draw the Inspector for a selected EnemySpawner:
using UnityEngine;
using UnityEditor; // the whole Editor API lives here
[CustomEditor(typeof(EnemySpawner))]
public class EnemySpawnerEditor : Editor
{
public override void OnInspectorGUI()
{
// 'target' is the EnemySpawner being inspected (typed as Object).
var spawner = (EnemySpawner)target;
// Draw a heading, then the default fields for now.
EditorGUILayout.LabelField("Enemy Spawner", EditorStyles.boldLabel);
DrawDefaultInspector(); // Unity's reflection-based drawing
if (spawner.count > 100)
EditorGUILayout.HelpBox("That's a lot of enemies โ check your pool size.",
MessageType.Warning);
if (GUILayout.Button("Preview Spawn Points"))
Debug.Log($"Would spawn {spawner.count} enemies within {spawner.radius}m.");
}
}
That is a complete, working custom Inspector. DrawDefaultInspector() keeps the normal fields, and we bolt on a warning and a button. Notice the shape of the class: it derives from Editor (not MonoBehaviour), it has a target reference to the object being inspected, and the [CustomEditor] attribute wires the two together. This is enough for buttons and read-only feedback โ but the moment you want to write to fields, reading target directly is the wrong move. Here's why.
SerializedObject & SerializedProperty
If you set spawner.count = 5 directly inside OnInspectorGUI, three things silently break: the change won't register with Undo, it won't mark the scene or prefab dirty (so it may not save), and it won't behave correctly when multiple objects are selected or when the value is a prefab override. Unity's Inspector never edits objects directly โ it edits them through a SerializedObject wrapper, and you should too.
๐ Definition
A SerializedObject is Unity's editable, undo-aware view of one or more objects' serialized data. A SerializedProperty is a handle to one field inside it (found by its C# field name). Editing through them gives you Undo/Redo, dirty-flagging, multi-object editing, and correct prefab-override bolding โ all for free.
The pattern is always the same three-step ritual, and the SVG below shows what it protects you from. Read the property at the start of the frame, draw fields bound to properties, then write the buffer back at the end:
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(EnemySpawner))]
[CanEditMultipleObjects] // enable multi-select editing
public class EnemySpawnerEditor : Editor
{
SerializedProperty prefabProp;
SerializedProperty countProp;
SerializedProperty radiusProp;
void OnEnable()
{
// Cache the property handles once, by field name.
prefabProp = serializedObject.FindProperty("enemyPrefab");
countProp = serializedObject.FindProperty("count");
radiusProp = serializedObject.FindProperty("radius");
}
public override void OnInspectorGUI()
{
serializedObject.Update(); // 1. pull current values into the buffer
EditorGUILayout.PropertyField(prefabProp);
EditorGUILayout.PropertyField(countProp);
EditorGUILayout.PropertyField(radiusProp);
if (countProp.intValue > 100)
EditorGUILayout.HelpBox("That's a lot of enemies.", MessageType.Warning);
serializedObject.ApplyModifiedProperties(); // 3. write buffer back (records Undo)
}
}
serializedObject is a property Unity gives every Editor. PropertyField draws the right control for whatever type the property is โ an object field for the prefab, an int field for the count โ and because it is bound to a SerializedProperty, all the machinery just works. ApplyModifiedProperties() is what records the Undo step and marks things dirty; forget it and your edits appear to work but never persist.
target directly skips Unity's editing pipeline; going through serializedObject earns Undo, dirty-flagging, multi-object editing, and prefab-override handling automatically.โ ๏ธ The three lines that must bracket every edit
Almost every broken custom Inspector traces to a missing serializedObject.Update() at the top or a missing serializedObject.ApplyModifiedProperties() at the bottom. Update pulls fresh values in; Apply pushes edits out and records Undo. Treat them as the opening and closing brackets of OnInspectorGUI and you'll rarely be surprised.
Drawing with EditorGUILayout
EditorGUILayout is the toolbox of Inspector controls, laid out automatically top to bottom so you never compute rectangles by hand. You saw PropertyField, LabelField, and HelpBox already; a handful more cover the vast majority of custom Inspectors. Here is a richer OnInspectorGUI that groups fields, reacts to a mode, and detects changes:
public override void OnInspectorGUI()
{
serializedObject.Update();
// A bold section header.
EditorGUILayout.LabelField("Spawn Settings", EditorStyles.boldLabel);
// Indent a group of related fields.
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(prefabProp, new GUIContent("Enemy Prefab"));
EditorGUILayout.PropertyField(countProp, new GUIContent("Spawn Count"));
EditorGUILayout.PropertyField(radiusProp, new GUIContent("Radius (m)"));
EditorGUI.indentLevel--;
// Put two controls on one horizontal line.
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Reset")) countProp.intValue = 10;
if (GUILayout.Button("Double")) countProp.intValue *= 2;
EditorGUILayout.EndHorizontal();
// Detect whether the user changed anything in a block.
EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(radiusProp);
if (EditorGUI.EndChangeCheck())
Debug.Log("Radius edited โ you could revalidate here.");
serializedObject.ApplyModifiedProperties();
}
Every one of these is composable: BeginHorizontal/EndHorizontal and indentLevel nest, BeginChangeCheck/EndChangeCheck brackets any block you care about, and EditorStyles gives you the editor's own fonts and colors so your additions look native. Because the method runs every repaint, you can branch on the current data โ hide a field when a bool is off, swap a slider for a plain field โ and the Inspector reshapes itself live.
โ Prefer PropertyField over typed getters where you can
You can read countProp.intValue and draw an EditorGUILayout.IntField yourself, and sometimes you must. But PropertyField already knows the field's type, tooltip, range attributes, and prefab-override state. Reach for the typed accessors (.intValue, .floatValue, .objectReferenceValue) only when you need the raw value for logic โ not as your default way to draw.
Property Drawers & Custom Attributes
A custom Editor customizes one component type. A Property Drawer customizes one field type or one attribute โ everywhere it appears, in every Inspector, automatically. This is how Unity's own [Range], [Header], and [Tooltip] work, and you can add your own. It's the more reusable of the two tools: write it once, decorate any field, done.
The pattern has two halves. First, a small PropertyAttribute โ a marker with no logic โ that you can stick on fields:
using UnityEngine;
// A marker attribute. Note: this is RUNTIME code (it decorates runtime fields),
// so it lives in a normal assembly, not the Editor one.
public class RequiredAttribute : PropertyAttribute { }
Then a [CustomPropertyDrawer] that knows how to draw any field wearing that attribute. Property drawers use the rect-based API (EditorGUI, not EditorGUILayout) because Unity hands them a fixed position rectangle to paint into:
using UnityEngine;
using UnityEditor;
[CustomPropertyDrawer(typeof(RequiredAttribute))]
public class RequiredDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
// Draw the normal field first.
EditorGUI.PropertyField(position, property, label);
// If it's an empty object reference, tint it and warn.
bool missing = property.propertyType == SerializedPropertyType.ObjectReference
&& property.objectReferenceValue == null;
if (missing)
{
var warnRect = new Rect(position.x, position.yMax, position.width, 16f);
EditorGUI.LabelField(warnRect, " โ This reference is required.");
}
}
// Reserve extra vertical space when we draw the warning line.
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
bool missing = property.propertyType == SerializedPropertyType.ObjectReference
&& property.objectReferenceValue == null;
return EditorGUIUtility.singleLineHeight + (missing ? 18f : 0f);
}
}
Now any field in any component can opt in: [Required] public GameObject enemyPrefab; and it grows a warning line whenever it's empty โ no per-component Editor needed. The SVG below shows the reconstructed result: a custom Inspector combining bold headers, labelled fields, a [Required] warning, a help box, and an action button.
[Required] drawer flagging the empty prefab, a HelpBox, a horizontal button row, and a full-width action button โ all produced by EditorGUILayout plus one PropertyDrawer.๐ก Editor vs. Property Drawer, in one sentence. Use a custom Editor when you want to restructure a whole component's Inspector (headers, buttons, conditional layout); use a Property Drawer when you want one field or one attribute to look and behave the same wherever it shows up.
Where Editor Code Must Live
Everything in the UnityEditor namespace exists only in the Editor โ it is stripped from player builds. If a using UnityEditor; ends up compiled into a runtime assembly, your game won't build. Back in Lesson 1.1 you learned to carve a project into assemblies with .asmdef files; Editor code is the textbook case for that discipline.
There are two ways to keep Editor code out of your build, and Unity honors both:
- A folder named
Editor. Any script under a folder literally calledEditoris compiled into the special Editor-only assembly. Simple, and enough for small projects. - An Editor
.asmdef. In a properly modularized project, you make a dedicated assembly definition with its Platforms set to Editor only, and have it reference your runtime assembly. Your customEditorandPropertyDrawerclasses go there.
โ ๏ธ The attribute goes with the runtime field, the drawer goes with the Editor
This trips people up: your RequiredAttribute : PropertyAttribute is runtime code โ it decorates runtime fields, so it must live in a runtime assembly. But the RequiredDrawer : PropertyDrawer is Editor code (it uses EditorGUI), so it must live in the Editor assembly. Split them across the boundary or your build breaks. The custom Editor class is likewise always Editor-only.
This is exactly the layering from Lesson 1.1 paying off: a clean runtime assembly that ships, and a separate Editor assembly full of tools that never leaves your machine. As your tooling grows in the next three lessons โ an EditorWindow, an Addressables-aware level tool โ it all lands on this same Editor side of the line.
Hands-on Challenge
๐๏ธ Exercise 1: A validated custom Editor
Objective: Take over the Inspector for a component and validate it safely.
Write a WaveConfig MonoBehaviour with int waveCount, float spawnInterval, and GameObject[] enemyPrefabs. Then write a custom Editor that:
- Draws all three fields through
SerializedProperty(with theUpdate/Applybrackets). - Shows a
HelpBoxerror whenspawnInterval <= 0or the prefab array is empty. - Adds a "Sort Prefabs by Name" button that reorders the array (through the serialized property) and records Undo.
๐ก Hint
Cache your properties in OnEnable. For the array, serializedObject.FindProperty("enemyPrefabs") gives a property with .arraySize and .GetArrayElementAtIndex(i). Because you edit through the serialized property and call ApplyModifiedProperties(), Undo is automatic โ you don't call Undo.RecordObject yourself.
โ Solution sketch
[CustomEditor(typeof(WaveConfig))]
public class WaveConfigEditor : Editor
{
SerializedProperty waveCount, spawnInterval, prefabs;
void OnEnable()
{
waveCount = serializedObject.FindProperty("waveCount");
spawnInterval = serializedObject.FindProperty("spawnInterval");
prefabs = serializedObject.FindProperty("enemyPrefabs");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(waveCount);
EditorGUILayout.PropertyField(spawnInterval);
EditorGUILayout.PropertyField(prefabs, true); // true = draw children
if (spawnInterval.floatValue <= 0f)
EditorGUILayout.HelpBox("Spawn interval must be positive.", MessageType.Error);
if (prefabs.arraySize == 0)
EditorGUILayout.HelpBox("Add at least one enemy prefab.", MessageType.Error);
if (GUILayout.Button("Sort Prefabs by Name"))
{
// Simple insertion sort over the serialized array elements' names.
for (int i = 1; i < prefabs.arraySize; i++)
for (int j = i; j > 0; j--)
{
var a = prefabs.GetArrayElementAtIndex(j - 1).objectReferenceValue;
var b = prefabs.GetArrayElementAtIndex(j).objectReferenceValue;
if (a != null && b != null &&
string.CompareOrdinal(a.name, b.name) > 0)
prefabs.MoveArrayElement(j, j - 1);
}
}
serializedObject.ApplyModifiedProperties(); // records the sort as one Undo step
}
}
๐๏ธ Exercise 2: A reusable [MinValue] drawer
Objective: Write a property drawer once and reuse it on any numeric field.
Create a MinValueAttribute : PropertyAttribute that stores a float min in its constructor, and a matching [CustomPropertyDrawer(typeof(MinValueAttribute))] that clamps the field's value to at least min. Decorate a field with [MinValue(0f)] public float radius; and confirm you can't drag it negative.
โ Answer
The attribute stores the bound: public class MinValueAttribute : PropertyAttribute { public float min; public MinValueAttribute(float min){ this.min = min; } }. In the drawer's OnGUI, cast attribute to MinValueAttribute to read min, draw the field with EditorGUI.PropertyField, then clamp: if (property.propertyType == SerializedPropertyType.Float && property.floatValue < min) property.floatValue = min;. Because a drawer edits the SerializedProperty it's handed, the clamp participates in Undo automatically โ no serializedObject plumbing needed inside the drawer.
๐ฏ Quick Quiz
Question 1: Why edit a field through SerializedProperty instead of writing target.count = 5 directly?
Question 2: You edit properties in OnInspectorGUI but the changes never persist. What did you most likely forget?
Question 3: You want a [Required] marker that flags any empty reference field in every Inspector. Which tool fits?
Question 4: Your project fails to build with an error about the UnityEditor namespace. What's the likely cause?
Summary
๐ Key Takeaways
- Customize the Inspector for validation, workflow buttons, and clarity โ you change the interface, not the data.
- A custom Editor is a class deriving from
Editor, tagged[CustomEditor(typeof(X))], overridingOnInspectorGUI. - Always edit through
serializedObject:Update()at the top,PropertyFields in the middle,ApplyModifiedProperties()at the bottom โ that earns Undo, dirty-flagging, multi-object editing, and prefab overrides for free. EditorGUILayoutgives you headers, help boxes, horizontal groups, indenting, and change checks with automatic layout.- A Property Drawer (
[CustomPropertyDrawer]+ aPropertyAttribute) customizes one field or attribute everywhere it appears โ the reusable option. - Editor code must live in an Editor folder or Editor-only asmdef (Lesson 1.1); the runtime attribute and its Editor drawer sit on opposite sides of that line.
๐ What's Next?
You've reshaped the Inspector that Unity gives you. Next you'll build a window of your own from nothing. In Lesson 7.2: EditorWindows with UI Toolkit, you'll add a menu item, open a dockable EditorWindow, and construct its interface with modern UI Toolkit VisualElements โ labels, buttons, and a ListView โ the foundation for the level-layout tool at the end of this module.
๐งฐ The Editor is yours now
You can validate data, add buttons, and reshape any Inspector โ safely, through the serialization pipeline. That's the first real step from using Unity to extending it.