Why Generate Custom Editors?
Writing custom inspectors for components and scriptable objects in Unity can be a powerful way to improve the design experience and iteration time of a project. Unfortunately, it can also entail writing a non-trivial amount of boilerplate code, especially for scripts with lots of exposed parameters. Here is one method for saving time by automating most of that work.
What This Post Will Explain
I’m writing this post in the form of a tutorial. My hope is that it is helpful for people who are trying to learn more about Unity editor scripting and tools. The topics covered are as follows:
- How to create a new script based on a custom template
- How Unity’s built-in C# script creation template works
- How to generate a new custom editor script that automatically populates all serialized fields from an existing MonoBehaviour or ScriptableObject source file
- Ideas for ways to expand upon this funcitonality or tailor it to suit your needs
A Unity package based on this tutorial is available on GitHub.
Custom Script Templates
Whenever you create a new script asset in the Unity editor project pane using the context menu (Create > C# Script), a built-in script template is used to help populate the default code you find when you open the file for the first time.
A script template is simply a text file. All of Unity’s default templates are stored in:
Windows:
- Program Files > Unity > Editors > (Editor Version) > Editor > Data > Resources > ScriptTemplates
macOS:
- Applications > Unity > Hub > Editor > (Editor Version) > Unity (Show Package Contents) > Contents > Resources > ScriptTemplates on macOS
This is the template for a new MonoBehaviour that is used when you right-click in the project pane and select Create > C# Script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#ROOTNAMESPACEBEGIN#
public class #SCRIPTNAME# : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
#NOTRIM#
}
// Update is called once per frame
void Update()
{
#NOTRIM#
}
}
#ROOTNAMESPACEEND#
You’ll recognize that this is just the default MonoBehaviour with a few unusual instances of all caps text wrapped in #’s. I’ll refer to these as preprocessor directives in this post. They aren’t really C# preprocessor directives, but they are being used in a similar way with a similar syntax, so I’m borrowing the name.
#ROOTNAMESPACEBEGIN#
and #ROOTNAMESPACEEND#
are replaced with
the namespace you can set in Edit > Project Settings… > Editor > Root namespace
(under C# Project Generation).
#SCRIPTNAME#
is replaced by the name you gave to the actual script asset.
#NOTRIM#
is used to prevent blank lines from being stripped out.
#NAME#
is the script name without spaces stripped out (if any)
#SCRIPTNAME_LOWER#
is replaced by either the script name with the first character made lowercase, or if the first character
is already lowercase, the script name with an uppercase first letter appended to “my” (e.g. “scriptName” to “myScriptName)
Knowing this, we can easily tailor a custom template to our liking. For example, I could add a copyright notice, strip out some using statements, remove comments, and only have the Awake and Start event functions appear by default:
/******************************************************************
* Copyright (C) 2023 Owen Magelssen. All rights reserved.
* https://owenmagelssen.com
******************************************************************/
using UnityEngine;
#ROOTNAMESPACEBEGIN#
public class #SCRIPTNAME# : MonoBehaviour
{
void Awake()
{
#NOTRIM#
}
void Start()
{
#NOTRIM#
}
}
#ROOTNAMESPACEEND#
This saves some copy/paste, deleting and renaming every time
we create a new MonoBehaviour. Finally, to actually generate the script, we can call the
UnityEditor.ProjectWindowUtil.CreateScriptAssetFromTemplateFile
function.
This function takes two strings as arguments - the path to the template and the default file name.
Your template should be a plain text file with a name and extension as the file name
(e.g. “MonoBehaviourTemplate.cs.txt”.) Let’s assume this template file is in the Assets
directory of our project at Assets/MonoBehaviourTemplate.cs.txt. The following function
will add a ‘MonoBehaviour’ option to the Create menu that uses our new template:
[MenuItem("Assets/Create/MonoBehaviour", priority = 40)]
public static void CreateMonoBehaviour()
{
string templatePath = Application.dataPath + "/MonoBehaviourTemplate.cs.txt";
ProjectWindowUtil.CreateScriptAssetFromTemplateFile(templatePath, "NewMonoBehaviour.cs");
}
How Scripts are Made from Templates
So how does CreateScriptAssetFromTemplateFile
generate the new script?
Simply put, it does a few things:
- Retrieving the correct icon for the type of script that is being made
- Inserting OS-correct line endings
- Creating the actual script asset
- Processing the contents of the template to populate the generated parts of the script
This is reductive, but those are the general steps. The generation part essentially boils down to a series of calls to the String.Replace method, where the contents of the template are copied and the preprocessor directives are replaced in the way that I described earlier.
Generating a Custom Editor
Now that we have learned how to create a custom template and how the important parts of the script
generation work, we will go over how to create a custom template for an editor script
(for a custom inspector written with Unity’s immediate mode editor GUI system) that is automatically populated with SerializedProperty
members
based on the serialized fields in any MonoBehaviour or ScriptableObject derived class.
The approach for generating custom editor code will go something like this:
- Write a custom template that contains all the unchanging custom editor code
- Place custom preprocessor directives in the areas where we want the generated serialized properties to be declared, assigned, and drawn
- Write a function that gets a reference to the current selected asset in the project pane of the Unity editor
- Verify that the selected asset is a MonoBehaviour or ScriptableObject source file
- If there is no MonoBehaviour or ScriptableObject selected, create a basic editor script without serialized properties and return
- Get the names of all serialized fields in the script
- Use the names of those fields to generate serialized property declarations, assignments, and drawing code as strings
- Copy the contents of the template file, and insert our serialized property generated code in place of our custom preprocessor directives
- Save the modified contents of the template into a new (temporary) template file
- Pass the new template into Unity’s built-in function for creating a new script asset
- Optionally, you can clean up by deleting the temporary template file. Otherwise, you can simply overwrite it each time you generate an editor
The nice thing about this approach is that we still leave most of the heavy lifting to the built-in Unity functions. This means there is less that we can mess up and less code for us to maintain.
First, let’s take care of writing a template for a custom editor and
placing custom preprocessor directives for the serialized property declarations,
assignments, and drawing. Again, this is using the immediate mode GUI system, but for UI Toolkit you could use the same principle
to generate a UXML file and assign the serialized field names to the binding-path
parameters
of your elements.
using UnityEngine;
using UnityEditor;
#ROOTNAMESPACEBEGIN#
[CanEditMultipleObjects]
[CustomEditor(typeof(#TARGETNAME#))]
public class #SCRIPTNAME# : Editor
{
#PROPERTIES#
private void OnEnable()
{
#PROPERTYINITIALIZERS#
}
public override void OnInspectorGUI()
{
serializedObject.Update();
#PROPERTYDRAWING#
serializedObject.ApplyModifiedProperties();
}
}
#ROOTNAMESPACEEND#
Next, we’ll get a reference to the currently selected script asset as a MonoScript variable. This will make getting the class type implemented in the script less complicated and verbose than other options (string parsing and reflection.)
string selectionPath = AssetDatabase.GetAssetPath(Selection.activeInstanceID);
FileAttributes attr = File.GetAttributes(selectionPath);
bool isDirectory = (attr & FileAttributes.Directory) == FileAttributes.Directory;
if (string.IsNullOrEmpty(selectionPath) || isDirectory)
{
// No asset is selected at all in the project window!
// So instead, we can just create a generic editor script
CreateGenericEditor();
return;
}
var monoScript = AssetDatabase.LoadAssetAtPath<MonoScript>(selectionPath);
if (monoScript != null)
{
// The selected asset is a C# script, so we get the class
var type = monoScript.GetClass();
if (type.IsSubclassOf(typeof(MonoBehaviour))
|| type.IsSubclassOf(typeof(ScriptableObject)))
{
// the selected script is a MonoBehaviour or ScriptableObject,
// so we generate a custom editor with all the serialized properties
CreateGeneratedCustomEditor(type);
}
}
As you can see, if no asset was selected a call to the yet-to-be written function
CreateGenericEditor
is made, otherwise we verify that the asset is a C# script
and that it inherits from either MonoBehaviour or ScriptableObject. Next, we need to
get the names of all fields from the script that are serialized. To do this, we will create
an instance of the class the script implements then create a SerializedObject
from that instance.
We will then enumerate through all serialized properties in the SerializedObject
, collecting
the serialized field names we need one by one.
We’ll have to take slightly different approaches depending on whether the script is a MonoBehaviour or a ScriptableObject because MonoBehaviours cannot be instantiated with the ’new’ keyword. Instead, we’ll have to create a temporary game object with a component of the MonoBehaviour type added to it. It is important to save a reference to the temporary game object so that we can destroy it once we’re done with it or else we’ll be leaking game objects into the open scene every time we generate a new custom editor script. I’ll proceed by modifying part of the code snippet from above.
if (monoScript != null)
{
var type = monoScript.GetClass();
if (type.IsSubclassOf(typeof(MonoBehaviour)))
{
var obj = new GameObject();
var component = obj.AddComponent(type);
if (component == null)
{
UnityEngine.Object.DestroyImmediate(obj);
CreateGenericCustomMonoBehaviourEditor();
return;
}
SerializedObject serializedObject = new SerializedObject(component);
CreateGeneratedEditor(type, serializedObject);
UnityEngine.Object.DestroyImmediate(obj);
}
else if (type.IsSubclassOf(typeof(ScriptableObject)))
{
var scriptableObject = ScriptableObject.CreateInstance(type);
SerializedObject serializedObject = new SerializedObject(scriptableObject);
CreateGeneratedEditor(type, serializedObject);
}
}
OPTIONAL: Adding Some Reusability
One possibility I wanted to allow for when I wrote this utility for myself was the ability to easily use the editor script generation methods from another class. Who knows? Maybe someday I'll need to generate a custom editor for a class as a part of a completely different tool. In order to do that cleanly, it made sense to use generics to enforce that the type being passed into the generator function inherits from MonoBehaviour or ScriptableObject. This makes it clear to the user of those functions when they are not passing a valid argument. It also avoids having to write exception handling since the error will occur at compile time (or more realistically, detected by static analysis features in your IDE.) The functions look like this:public static void CreateGeneratedMonoBehaviourEditor<T>() where T : MonoBehaviour { var obj = new GameObject(); var monoBehaviour = obj.AddComponent<T>(); if (monoBehaviour == null) { UnityEngine.Object.DestroyImmediate(obj); CreateGenericEditor(); return; } SerializedObject serializedObject = new SerializedObject(monoBehaviour); CreateGeneratedEditor<T>(serializedObject); UnityEngine.Object.DestroyImmediate(obj); } public static void CreateGeneratedScriptableObjectEditor<T>() where T : ScriptableObject { var scriptableObject = ScriptableObject.CreateInstance(typeof(T)); SerializedObject serializedObject = new SerializedObject(scriptableObject); CreateGeneratedEditor<T>(serializedObject); }
However, this comes with a bit of added complexity. In order to call one of these functions when all you have is a Type variable, you must use reflection to get a version of the method that you can invoke by passing the type as a plain old object argument. That looks like this (where
CreateScriptFromTemplate
is the name of the class where the methodCreateGeneratedMonoBehaviour
is implemented):var mi = typeof(CreateScriptFromTemplate).GetMethod("CreateGeneratedMonoBehaviourEditor"); var methodRef = mi.MakeGenericMethod(type); methodRef.Invoke(null, null);
Then, from within the
CreateGeneratedEditor
class, we can get the Type variable we need like so:Type type = typeof(T);
Now let’s take a look at the CreateGeneratedEditor
and CreateGenericEditor
methods. First, the
simpler case - a generic editor. For this method, we’re just stripping out our custom
preprocessor directives.
// this path is assuming that our source files are in the packages directory, not the assets directory
private static string TemplateDirectory => Path.GetDirectoryName(Application.dataPath) + "/Packages/com.oni.editortools/Editor/Templates/";
private const string EditorTemplate = "CustomEditor.cs.txt";
private static string TempDirPath => Path.GetDirectoryName(Application.dataPath) + "/Temp/CustomEditorGenerator.Temp";
private static string GeneratedTemplatePath
{
get
{
if (!Directory.Exists(TempDirPath))
Directory.CreateDirectory(TempDirPath);
return TempDirPath + "/CustomEditorGeneratedTemplate.cs.txt";
}
}
public static void CreateGenericEditor()
{
string templatePath = TemplateDirectory + EditorTemplate;
string templateContent = File.ReadAllText(templatePath);
string withDeclarations = templateContent.Replace("#PROPERTIES#", "\t\t");
string withInitializers = withDeclarations.Replace("#PROPERTYINITIALIZERS#", "\t\t\t");
string withDrawing = withInitializers.Replace("#PROPERTYDRAWING#", "\t\t\t");
string withTargetName = withDrawing.Replace("#TARGETNAME#", "#NAME#");
System.IO.File.WriteAllText(GeneratedTemplatePath, withTargetName);
ProjectWindowUtil.CreateScriptAssetFromTemplateFile(GeneratedTemplatePath, "NewCustomEditor.cs");
}
We could replace some of these preprocessor directives with empty strings, but I used
the escape sequence \t
to add the correct number of tabs for those lines instead.
The directive #TARGETNAME#
is replaced with another, #NAME#
, which Unity will handle
once CreateScriptAssetFromTemplateFile
is called by inserting the name the script file
was given. That will actually render the custom editor useless until it is replaced with
the class it is meant to draw an inspector for. Since that isn’t known in this case,
I decided on this as a compromise since it at least doesn’t cause compiler errors as soon as
the script file is created. This isn’t ideal because it may give the impression to the end user
that something unexpected has happened, which it hasn’t. Another approach could be to leave it
blank and just comment out the custom editor attribute.
Next, the custom editor with automatically generated serialized properties:
private static string TemplateDirectory => Path.GetDirectoryName(Application.dataPath) + "/Packages/com.oni.editortools/Editor/Templates/";
private const string EditorTemplate = "CustomEditor.cs.txt";
private static string TempDirPath => Path.GetDirectoryName(Application.dataPath) + "/Temp/CustomEditorGenerator.Temp";
private static string GeneratedTemplatePath
{
get
{
if (!Directory.Exists(TempDirPath))
Directory.CreateDirectory(TempDirPath);
return TempDirPath + "/CustomEditorGeneratedTemplate.cs.txt";
}
}
private static string Truncate(this string value, int maxLength)
{
return string.IsNullOrEmpty(value) ? value : value[..Math.Min(value.Length, maxLength)];
}
private static bool HasSerializeFieldAttribute(Type type, string fieldName)
{
FieldInfo info = type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (info == null) return false;
return Attribute.IsDefined(info, typeof(SerializeField));
}
private static void CreateGeneratedEditor(Type type, SerializedObject serializedObject)
{
SerializedProperty serializedProperty = serializedObject.GetIterator();
string propertyDeclarations = string.Empty;
string propertyInitializers = string.Empty;
string propertyDrawing = string.Empty;
while (serializedProperty.Next(true))
{
string name = serializedProperty.name;
if (type.GetField(name) != null
|| HasSerializeFieldAttribute(type, name))
{
propertyDeclarations = string.Concat(propertyDeclarations, $"\t\tprivate SerializedProperty _{name};\n");
propertyInitializers = string.Concat(propertyInitializers, $"\t\t\t_{name} = serializedObject.FindProperty(\"{name}\");\n");
propertyDrawing = string.Concat(propertyDrawing, $"\t\t\tEditorGUILayout.PropertyField(_{name});\n");
}
}
// remove trailing line breaks
propertyInitializers = propertyInitializers.Truncate(propertyInitializers.Length - 1);
propertyDrawing = propertyDrawing.Truncate(propertyDrawing.Length - 1);
string templatePath = TemplateDirectory + EditorTemplate;
string templateContent = File.ReadAllText(templatePath);
string withDeclarations = templateContent.Replace("#PROPERTIES#", propertyDeclarations);
string withInitializers = withDeclarations.Replace("#PROPERTYINITIALIZERS#", propertyInitializers);
string withDrawing = withInitializers.Replace("#PROPERTYDRAWING#", propertyDrawing);
string withTargetName = withDrawing.Replace("#TARGETNAME#", type.FullName);
System.IO.File.WriteAllText(GeneratedTemplatePath, withTargetName);
ProjectWindowUtil.CreateScriptAssetFromTemplateFile(GeneratedTemplatePath, type.Name + "Editor.cs");
}
The SerializedObject.GetIterator
method saves us some work. Without it, we
would have to use reflection to look through all fields in the target class and filter
them by access modifiers (private, public, etc.) and whether they are implemented
in our script or a parent class. Unfortunately, we still have to manually check
for the [SerializeField]
attribute, but it doesn’t add too much to the process.
At last, the basics of our system are complete. Here are examples of a simple MonoBehaviour and the custom editor that we can now generate for it by simply right-clicking it and selecting Create > Custom Editor:
using UnityEngine;
namespace MyNamespace
{
public class CharacterMovement : MonoBehaviour
{
public Transform characterRoot;
[SerializeField] private float moveSpeed = 1.0f;
[SerializeField] private float jumpHeight = 1.0f;
public bool IsGrounded { get; private set; }
private float _timeToJumpPeak = 0.75f;
private float _timeToReturnToGround = 0.25f;
private void Update()
{
// do character movement stuff
}
}
}
/******************************************************************
* Copyright (C) 2023 Owen Magelssen. All rights reserved.
* https://owenmagelssen.com
******************************************************************/
using UnityEngine;
using UnityEditor;
namespace MyNamespace
{
[CanEditMultipleObjects]
[CustomEditor(typeof(MyNamespace.CharacterMovement))]
public class CharacterMovementEditor : Editor
{
private SerializedProperty _characterRoot;
private SerializedProperty _moveSpeed;
private SerializedProperty _jumpHeight;
private void OnEnable()
{
_characterRoot = serializedObject.FindProperty("characterRoot");
_moveSpeed = serializedObject.FindProperty("moveSpeed");
_jumpHeight = serializedObject.FindProperty("jumpHeight");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(_characterRoot);
EditorGUILayout.PropertyField(_moveSpeed);
EditorGUILayout.PropertyField(_jumpHeight);
serializedObject.ApplyModifiedProperties();
}
}
}
Next Steps
There are many things you could do to expand on this system. Here are just a few ideas:
- Check if the generated editor script is in an editor directory. If not, find or make one and place the script there.
- Detect what type the serialized fields are and draw them with something other than
EditorGUILayout.PropertyField
- Get a reference to the serialized object’s target object and use it to create buttons in the inspector that call each of the public methods in the script
- Wrap each control in the inspector in a ChangeCheckScope or Begin/End ChangeCheck calls that invoke a custom callback method for when each parameter is changed in the inspector
- Add code that will handle correct indentation in cases where the user does or does not have a root namespace defined
- Automatically insert the current year into a copyright notice
Download the Code
That’s all for this post. A package based on this tutorial is available on GitHub. You can add it to your project using the git url via the Unity package manager, or you can use it as a starting point for your own custom package.