A Unity Editor extension for customizing inspector layout with attributes.
- Create tabs, boxes, foldouts and other groups, hide/disable properties on condition, inline editors.
- Works in C# and in ShaderLab.
- The inspector allows to inject custom code into it before/after/instead of any property.
- MIT licensed, small, opt-in.
Anyone who wrote custom editors knows, that they are prone to boilerplate and often rely on hardcoded names of the properties, which adds unnecessary friction to development. One way to deal with it is to use C# Attributes, and the most prominent project to do so is Odin Inspector. It is great, but it can't be used in open source and on the Asset Store because it's paid and huge. Also, as far as know, it doesn't do anything for shader editors, which drag with them even more boilerplate and bookkeeping that the regular ones. So, here is my take on the problem.
Markup Attributes is MIT licensed and relatively small, focusing exclusively on editor layout. It works both in C# and in ShaderLab. Custom inspector provides hooks at any of the properties, which makes it possible to extend the inspector without loosing the layout functionality.
You can install MarkupAttributes with Unity Package Manager. Git URL:
https://github.com/gasgiant/Markup-Attributes.git#upm
For MonoBehaviour
s and ScriptableObject
s you need to create a custom editor that inherits form MarkedUpEditor
:
using UnityEditor;
using MarkupAttributes;
[CustomEditor(typeof(MyComponent)), CanEditMultipleObjects]
internal class MyComponentEditor : MarkedUpEditor
{
}
Alternatively, you can apply MarkedUpEditor
to all classes that inherit from MonoBehaviour
or ScriptableObject
and don't have their own custom editor:
[CustomEditor(typeof(MonoBehaviour), true), CanEditMultipleObjects]
internal class MarkedUpMonoBehaviourEditor : MarkedUpEditor
{
}
[CustomEditor(typeof(ScriptableObject), true), CanEditMultipleObjects]
internal class MarkedUpScriptableObjectEditor : MarkedUpEditor
{
}
To make the attributes work inside serialized classes or structs you can add MarkedUpType
to their definition or add MarkedUpField
to the fields representing them:
[MarkedUpType]
[System.Serializable]
struct MyStruct
{
...
}
[System.Serializable]
class MyClass
{
...
}
class MyComponent : MonoBehaviour
{
public MyStruct myStruct;
[MarkedUpField]
public MyClass myClass;
}
Note, that the attributes will work in marked up types only inside MarkedUpEditor
. Currently marked up types are not supported inside arrays and lists. You can nest marked up types inside other marked up types.
To apply attributes to the materials with a certain Shader
you should tell Unity to use MarkedUpShaderGUI
:
Shader "Unlit/MyShader"
{
Properties
{
...
}
CustomEditor "MarkupAttributes.Editor.MarkedUpShaderGUI"
...
}
Any group attribute requires a path in group hierarchy. The last entry in the path is the name of the group.
[Box("Group")]
public int one;
[TitleGroup("Group/Nested Group")]
public int two;
public int three;
Starting a group closes all groups untill a path match.
[Box("Group")]
public int one;
[TitleGroup("Group/Nested Group 1")]
public int two;
public int three;
[TitleGroup("Group/Nested Group 2")]
public int four;
public int five;
./
shortcut opens a group on top of the current one, ../
closes the topmost group and then opens a new one on top.
[Box("Group")]
public int one;
[TitleGroup("./Nested Group 1")]
public int two;
public int three;
[TitleGroup("../Nested Group 2")]
public int four;
public int five;
EndGroup
closes the topmost group, or, when provided with a name, closes the named group and all of its children.
[Box("Group")]
public int one;
[Box("./Nested Group")]
public int two;
public int three;
[EndGroup("Group")]
public int four;
public int five;
Unfortunately, ShaderLab does not allow any special symbols in property attributes. Because of that, we can't use /
to write paths and have to replace them with spaces. Underscores then mark were you want actual spaces to be. Also, unlike in C#, you should not use quotes around the strings. For example, instead of
[Box("Parent Group/My Box")]
you would write
[Box(Parent_Group My_Box)]
.
The same goes for shortcuts, so
[Box("./My Box")]
and [Box("../My Box")]
becomes
[Box(. MyBox)]
and [Box(.. My_Box)]
.
Closes the topmost group. If provided with a name, closes the named group and all its children.
Starts a vertical group in a box.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
bool labeled | Adds a label to the box. Default: true. |
float space | Adds space before the group. Default: 0. |
// C#
[Box("Labeled Box")]
public int one;
public int two;
public int three;
[Box("Unlabeled Box", labeled: false)]
public int four;
public int five;
public int six;
// ShaderLab
[Box(Labeled_Box)]
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[Box(Unlabeled_Box, false)]
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
Starts a vertical group with a title.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
bool contentBox | Adds a box around the group content. Default: false. |
bool underline | Underlines the title. Default: true. |
float space | Adds space before the group. Default: 3. |
// C#
[TitleGroup("Title Group")]
public int one;
public int two;
public int three;
[TitleGroup("Title Group With A Content Box", contentBox: true)]
public int four;
public int five;
public int six;
// ShaderLab
[TitleGroup(Title_Group)]
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[TitleGroup(Title_Group_With_A_Box, true)]
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
Starts a collapsible vertical group.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
bool box | Puts the foldout inside a box. Default: true. |
float space | Adds space before the group. Default: 0. |
// C#
[Foldout("Foldout In A Box")]
public int one;
public int two;
public int three;
[Foldout("Foldout", box: false)]
public int four;
public int five;
public int six;
// ShaderLab
[Foldout(Foldout_In_A_Box)]
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[Foldout(Foldout, false)]
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
TabScope
creates a control for switching tabs. Tab
starts a group placed on a specified page. Names of the pages must match the names defined in TabScope
.
TabScope
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
string tabs | Names of the tabs separated by | in C# and by space in ShaderLab. |
bool box | Puts the tabs inside a box. Default: false. |
float space | Adds space before the group. Default: 0. |
Tab
Parameter | Description |
---|---|
string path | Path to the group. Name of the group must match one of the names specified in TabScope . See Nesting Groups. |
// C#
[TabScope("Tab Scope", "Left|Middle|Right", box: true)]
[Tab("./Left")]
public int one;
public int two;
public int three;
[Tab("../Middle")]
public int four;
public int five;
public int six;
[Tab("../Right")]
public int seven;
public int eight;
public int nine;
// ShaderLab
[TabScope(Tab_Scope, Left Middle Right, true)]
[Tab(. Left)]
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[Tab(.. Middle)]
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
[Tab(.. Right)]
_Seven("Seven", Float) = 0
_Eight("Eight", Float) = 0
_Nine("Nine", Float) = 0
HorizontalGroup
and VerticalGroup
start horizontal and vertical groups, respectively.
VerticalGroup
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
float space | Adds space before the group. Default: 0. |
HorizontalGroup
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
float labelWidth | Label width inside the horizontal group. |
float space | Adds space before the group. Default: 0. |
// C#
[HorizontalGroup("Split", labelWidth: 50)]
[VerticalGroup("./Left")]
public int one;
public int two;
public int three;
[VerticalGroup("../Right")]
public int four;
public int five;
public int six;
// ShaderLab
[HorizontalGroup(Split, 50)]
[VerticalGroup(. Left)]
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[VerticalGroup(.. Right)]
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
ReadOnly
and ReadOnlyGroup
disable a property or a group of properties in the inspector.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
// C#
[ReadOnly]
public int one;
[ReadOnlyGroup("Read Only Group")]
public int two;
public int three;
public int four;
// ShaderLab
[ReadOnly]
_One("One", Float) = 0
[ReadOnlyGroup(Read_Only_Group)]
_Two("Two", Float) = 0
_Three("Three", Float) = 0
_Four("Four", Float) = 0
HideIf
, ShowIf
, HideIfGroup
and ShowIfGroup
hide/show properties or groups of properties depending on a condition.
DisableIf
, EnableIf
, DisableIfGroup
and EnableIfGroup
disable/enable properties or groups of properties depending on a condition.
Non-Group attributes work on the property they are applied to. Group attributes work like all other groups and require a path (see Nesting Groups).
HideIf
, ShowIf
, DisableIf
, EnableIf
Parameter | Description |
---|---|
string memberName | Member to check the value of. Can be instance or static. Can be a field, a property or a method, that takes no arguments. If no value is provided, must be of type bool . |
object value (optional) | Condition will check if member equals value using Equals method. |
HideIfGroup
, ShowIfGroup
, DisableIfGroup
, EnableIfGroup
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
string memberName | Member to check the value of. Can be instance or static. Can be a field, a property or a method, that takes no arguments. If no value is provided, must be of type bool . |
object value (optional) | Condition will check if member equals value using Equals method. |
private bool boolField;
private bool BoolProperty => one % 2 == 0;
private static bool BoolMethod() => true;
public enum SomeEnum { Foo, Bar }
public SomeEnum state;
// Hide If Field
[HideIf(nameof(boolField))]
public int one;
[HideIf(nameof(boolField))]
public int two;
// Enable If Property
[EnableIfGroup("Enable If Property", nameof(BoolProperty))]
public int three;
public int four;
[EndGroup]
// Disable If Method
[DisableIfGroup("Disable If Method", nameof(BoolMethod))]
public int five;
public int six;
[EndGroup]
// Show If Enum Value
[ShowIf(nameof(state), SomeEnum.Foo)]
public int seven;
[ShowIf(nameof(state), SomeEnum.Bar)]
public int eight;
HideIf
, ShowIf
, DisableIf
, EnableIf
Parameter | Description |
---|---|
string condition | Name of the condition. Can be a float property (true if greater than zero, false otherwise), material keyword, or a global keyword (to indicate that keyword is global add G before it). |
HideIfGroup
, ShowIfGroup
, DisableIfGroup
, EnableIfGroup
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
string condition | Name of the condition. Can be a float property (true if greater than zero, false otherwise), material keyword, or a global keyword (to indicate that keyword is global add G before it). |
// Float Property Condition
_Toggle("Toggle", Float) = 0
[ShowIf(_Toggle)]
_One("One", Float) = 0
[ShowIf(_Toggle)]
_Two("Two", Float) = 0
// Material Keyword Condition
[EnableIfGroup(Enable_If_Keyword, MY_KEYWORD)]
_Three("Three", Float) = 0
_Four("Four", Float) = 0
// Global Keyword Condition
[DisableIfGroup(Hide_If_Global_Keyword, G MY_KEYWORD)]
_Five("Five", Float) = 0
_Six("Six", Float) = 0
Starts a vertical group with a toggle, that can be hidden or disabled depending on the toggle value.
In C# ToggleGroup
should be used on a serialized bool
field.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
bool foldable | Makes the group collapsible. Default: false. |
bool box | Puts the group inside a box. Default: true. |
float space | Adds space before the group. Default: 0. |
[ToggleGroup("Toggle Group")]
public bool boolean;
public int one;
public int two;
public int three;
[ToggleGroup("Foldable Toggle Group", foldable: true)]
public bool anotherBoolean;
public int four;
public int five;
public int six;
In ShaderLab ToggleGroup
should be used on a float
property.
Parameter | Description |
---|---|
string path | Path to the group (see Nesting Groups). |
bool foldable | Makes the group collapsible. Default: false. |
bool box | Puts the group inside a box. Default: true. |
string keyword (optional) | Keyword to turn on and off (like the built-in Toggle drawer). |
[ToggleGroup(Toggle_Group)]
_Toggle("Toggle", Float) = 0
_One("One", Float) = 0
_Two("Two", Float) = 0
_Three("Three", Float) = 0
[ToggleGroup(Toggle_Group_With_Keyword, true, MY_KEYWORD)]
_AnotherToggle("Another Toggle", Float) = 0
_Four("Four", Float) = 0
_Five("Five", Float) = 0
_Six("Six", Float) = 0
C# only
Makes attributes work inside serializable classes and structs. See Usage: Serializable classes and structs. Can optionally hide the target's control (foldout) and remove indent from target's children.
[MarkedUpType]
class SomeClass
{
...
}
[MarkedUpType(indentChildren: false)]
struct SomeStruct
{
...
}
C# only
Makes attributes work inside fields of serializable classes and structs. See Usage: Serializable classes and structs. Can optionally hide the target's control (foldout) and remove indent from target's children.
[MarkedUpField]
public SomeClass one;
[MarkedUpField(indentChildren: false)]
public SomeClass two;
[MarkedUpField(indentChildren: false, showControl: false)]
public SomeClass three;
C# only
Shows the inspector of some Unity.Object
(MonoBehaviour
, ScripatableObject
and Material
are Unity.Object
s, for instance) "inline" — embeds it in the current inspector. Can be used on an object reference field. Works in MarkedUpEditor
s and MarkedUpField
s inside them.
Parameter | Description | |
---|---|---|
InlineEditorMode mode | Box | Shows object field and inspector body inside a foldable box. |
ContentBox | Shows object field with foldable inspector body. | |
Stripped | Shows only inspector body. |
[InlineEditor]
public SomeData someData;
[InlineEditor(InlineEditorMode.ContentBox)]
public SomeComponent someComponent;
[TitleGroup("Stripped")]
[InlineEditor(InlineEditorMode.Stripped)]
public SomeData stripped1;
ShaderLab only
Tells MarkedUpShaderGUI
to draw Render Queue, Enable Instancing (if applicable) and Double Sided Global Illumination properties below this property.
[DrawSystemProperties]
_SomeProperty("Some Property", Float) = 0
MarkedUpEditor
allows to inject custom code into itself before, after and instead of any property. Here are the methods used for extension:
-
protected bool DrawMarkedUpInspector()
Works like
Editor
'sDrawDefaultInspector
, but for the marked up editor. Call it if you have overridden theOnInspectorGUI
and want to drawMarkedUpEditor
as is. -
protected virtual void OnInitialize()
Is called after
MarkedUpEditor
have initialized itself inOnEnable
. -
protected void AddCallback(SerializedProperty property, CallbackEvent type, Action<SerializedProperty> callback)
Adds a callback to a specified
SerializedProperty
at a specifiedCallbackEvent
. Only one callback can be added for a given property at a given event. Should be used inOnInitialize
. -
protected virtual void OnCleanup()
Is called before
MarkedUpEditor
started cleanup inOnDisable
.
using UnityEditor;
using UnityEngine;
using MarkupAttributes.Editor;
[CustomEditor(typeof(MyComponent))]
public class MyComponentEditor : MarkedUpEditor
{
protected override void OnInitialize()
{
AddCallback(serializedObject.FindProperty("one"),
CallbackEvent.AfterProperty, ButtonAfterOne);
AddCallback(serializedObject.FindProperty("six"),
CallbackEvent.BeforeProperty, ButtonBeforeSix);
AddCallback(serializedObject.FindProperty("three"),
CallbackEvent.ReplaceProperty, ButtonReplaceThree);
}
private void ButtonAfterOne(SerializedProperty property)
{
GUILayout.Button("After One");
}
private void ButtonBeforeSix(SerializedProperty property)
{
GUILayout.Button("Before Six");
}
private void ButtonReplaceThree(SerializedProperty property)
{
GUILayout.Button("Replace Three");
}
}
MarkedUpShaderGUI
can be extended in a similar manner. It provides the following methods:
-
protected virtual void OnInitialize(MaterialEditor materialEditor, MaterialProperty[] properties)
Is called after
MarkedUpShaderGUI
have initialized itself.AddCallback
should be used here. -
protected void AddCallback(MaterialProperty property, CallbackEvent type, Action<MaterialEditor, MaterialProperty[], MaterialProperty> callback)
Adds a callback to a specified
MaterialProperty
at a specifiedCallbackEvent
. Only one callback can be added for a given property at a given event. Should be used inOnInitialize
.
using UnityEditor;
using UnityEngine;
using MarkupAttributes.Editor;
public class MyShaderEditor : MarkedUpShaderGUI
{
protected override void OnInitialize(MaterialEditor materialEditor, MaterialProperty[] properties)
{
AddCallback(FindProperty("_Color", properties), CallbackEvent.AfterProperty, ButtonAfterColor);
}
private void ButtonAfterColor(MaterialEditor materialEditor, MaterialProperty[] properties,
MaterialProperty property)
{
GUILayout.Button("Button After Color");
}
}
Don't forget to tell Unity to use the modified editor for your shader.
Shader "Unlit/MyShader"
{
Properties
{
...
}
CustomEditor "MyShaderEditor"
...
}
Class MarkupGUI
exposes methods for creating Markup Attributes styled groups. They can be useful for making EditorWindows
or custom inspectors, that don't use attributes themselves.
using UnityEditor;
using MarkupAttributes.Editor;
public class CustomWindow : EditorWindow
{
MarkupGUI.GroupsStack groupsStack = new MarkupGUI.GroupsStack();
int activeTab = 0;
bool foldout = true;
public static void Open()
{
CustomWindow window = (CustomWindow)GetWindow(typeof(CustomWindow),
false, "Custom Window Sample");
window.Show();
}
void OnGUI()
{
// Always clear the groups stack.
groupsStack.Clear();
groupsStack += MarkupGUI.BeginBoxGroup("Box");
// group contents ...
groupsStack.EndGroup();
groupsStack += MarkupGUI.BeginTitleGroup("TitleGroup", true);
// group contents ...
groupsStack.EndGroup();
groupsStack += MarkupGUI.BeginFoldoutGroup(ref foldout, "Foldout");
// group contents ...
groupsStack.EndGroup();
groupsStack += MarkupGUI.BeginTabsGroup(ref activeTab,
new string[] { "Left", "Middle", "Right" }, true);
// group contents ...
// Always end all groups at the end.
groupsStack.EndAll();
}
}