In this tutorial, we’ll learn more of the basics of Unity’s new UIElements framework by creating a simple custom inspector for a hypothetical Monster component.
Prerequisites
This is a beginner-level tutorial that assumes you have gone through the previous parts of the Extending the Unity Editor with UIElements series: Part 1, Part 2 and Part 3.
The Unity version used throughout this tutorial is 2019.3.12f1, but any version 2019.1 or newer should work.
What We’re Building
We’re going to build a custom inspector that can view and set properties on a hypothetical Monster
component (MonoBehaviour
). We’ll learn how to:
- Override Unity’s default inspector for our component to show our UI instead
- Create custom controls with our own
VisualElement
subclasses - Notify Unity that we’ve changed a
GameObject
s in the Scene - Dynamically add and remove
VisualElement
s from our UI
… and more! Our goal is to end up with something that looks like this:
Getting Started
We’ll start with a new 3D project. Before we can work on a custom inspector, we need something to inspect. Let’s create a Monster
class to hold information about the baddies in our game. We’ll start with the default C# script template and pare it down:
Assets/Monster.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Monster : MonoBehaviour
{
public string kind;
public int maxHp;
public List<string> attacks;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
Unity automatically creates a default inspector for classes deriving from MonoBehaviour
. Before we build a custom inspector, let’s quickly explore the inspector Unity gives us for free.
The Default Inspector
Let’s check out the default inspector for our Monster
class. Create a new empty GameObject
in the Scene, add the Monster
component, and open the Inspector window. You should see something like this:
Expand the Attacks
field to see how Unity handles a List<string>
. You can add/remove elements at the end of the list by changing the Size
field – you must defocus the field by pressing Tab
, clicking somewhere else, etc. for the change to take effect; Esc
cancels.
Additionally, right clicking any of the item labels (like Element 1
) brings up a context menu to duplicate or the delete the list item, like this:
One important thing to note is that changing any of the fields causes the Scene containing our Monster GameObject to be marked as dirty, which just means “modified” or “edited”. This gives you a visual indicator that the Scene needs to be saved in the Hierarchy view, and also causes the editor to display a prompt if you try to quit before saving. Here’s an example of both of those in action:
Now that we’ve seen the default inspector, let’s write our own.
Overriding the Default Inspector
Three things are required to override the default inspector for a MonoBehaviour
subclass:
- Create a subclass of
UnityEditor.Editor
- Tell Unity that this class should be used instead of the default inspector via the
[CustomEditor(System.Type t)]
attribute - Override the
VisualElement CreateInspectorGUI()
function of the baseEditor
class.
Let’s try to create a minimal custom inspector. Since inspectors are used in the editor only and are not part of the game, we should create an Editor
folder for all the remaining files for this tutorial.
In the Editor
folder, create a new C# script named MonsterEditor
. Let’s clear out the body of the class and make the three changes mentioned above.
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
public class MonsterEditor : MonoBehaviour
[CustomEditor(typeof(Monster))]
public class MonsterEditor : Editor
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
public override VisualElement CreateInspectorGUI()
{
return new VisualElement();
}
}
A bare new VisualElement()
is kind of like an empty HTML <div>
, so if we take a look at our Monster GameObject in the Inspector window, we should see an empty inspector.
In the previous tutorials we were subclassing EditorWindow
, and in OnEnable
we would instantiate our UXML template and attach the resulting UI object to the rootVisualElement
of our EditorWindow
.
For Editor
subclasses, we instead instantiate and then return
the resulting UI object in CreateInspectorGUI
– Unity will attach insert it into the Inspector window where it belongs.
Other than this difference, using UIElements is the same for both Editor
and EditorWindow
subclasses. With that in mind, let’s start building our UI.
Beginning to Build the UI
We didn’t use the Create 🠚 UIElements 🠚 Editor Window
template this time, so we need to manually create our USS and UXML files. We’ll focus on structure now and worry about styling later. For now, let’s create a UXML template for the structure of our UI.
Right click in the Editor
folder, and choose Create 🠚 UIElements 🠚 UXML Template
. Name the file MonsterEditor
. Let’s temporarily add a <Label>
to the template so we can verify that the template has been added to the UI in a few moments.
<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:engine="UnityEngine.UIElements"
xmlns:editor="UnityEditor.UIElements"
xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
<engine:Label text="Custom Monster Inspector" />
</engine:UXML>
Now that we have a template, we need to build a UI from it in our C# file’s CreateInspectorGUI
function. Let’s reopen Assets/Editor/MonsterEditor.cs
and replace that VisualElement
with an instance of our template:
public override VisualElement CreateInspectorGUI()
{
return new VisualElement();
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterEditor.uxml");
var ui = uxmlTemplate.CloneTree();
return ui;
}
Let’s verify by selecting our Monster GameObject and checking out the Inspector window. We should see our new Label:
<PropertyField>
: Matching the Default Inspector
There is a very powerful VisualElement
subclass called <PropertyField>
that can take the string name of a field/property and generate a type-appropriate editor for that field. This is another time that an example is better than an explanation – let’s reopen the UXML file, add a <PropertyField>
for each of the fields in our class, and also get rid of the Label while we’re there.
xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
<engine:Label text="Custom Monster Inspector" />
<editor:PropertyField binding-path="kind" />
<editor:PropertyField binding-path="maxHp" />
<editor:PropertyField binding-path="attacks" />
</engine:UXML>
Since we changed the UXML file and UXML don’t trigger an auto-refresh, you’ll need to deselect the Monster GameObject in the Hierarchy and reselect it to apply the changes we just made. Three lines of code, and we have what looks and acts exactly like the default inspector for our class:
Adding Elements Dynamically
Before we begin work on customizing the list editor component of our UI, let’s figure out how to add and remove VisualElement
s from our UI dynamically. First, a quick modification to our UXML template for testing – we’ll swap out the <PropertyField>
for the attack list with a <Button>
and an empty <VisualElement>
container to hold the dynamic items:
<editor:PropertyField binding-path="maxHp" />
<editor:PropertyField binding-path="attacks" />
<engine:Button name="addAttackBtn" text="Add Attack" />
<engine:VisualElement name="attacksContainer" />
</engine:UXML>
Now that the UXML file is set up, let’s reopen our C# file and create a field for our container, as well as set up a click handler for the button that adds a new <Label>
to the container when clicked:
[CustomEditor(typeof(Monster))]
public class MonsterEditor : Editor
{
VisualElement attacksContainer;
public override VisualElement CreateInspectorGUI()
{
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterEditor.uxml");
var ui = uxmlTemplate.CloneTree();
attacksContainer = ui.Q<VisualElement>("attacksContainer");
var addAttackBtn = ui.Q<Button>("addAttackBtn");
addAttackBtn.clicked += AddAttack;
return ui;
}
void AddAttack()
{
var newAttack = new Label(System.Guid.NewGuid().ToString());
attacksContainer.Add(newAttack);
}
}
Reselect the Monster GameObject in your Scene, and each time you click the button, you should see a new <Label>
added to the UI.
Here we just created and added a Label, but any type of VisualElement
can by added in this manner. In fact, we can create our own type of VisualElement
– like an editor for a monster attack list item!
Subclassing VisualElement
for Custom Controls
UIElements doesn’t come with too many built-in controls, but it is easy to create your own custom controls by inheriting from VisualElement
.
It’s kind of like building a tiny custom editor window we’ll stuff inside of our inspector, and it’s another thing that is easier to explain with code than prose.
Create a C# script in your Editor
folder named MonsterAttackEditor
. Open it, change the parent class to VisualElement
, and delete everything else in the class:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
public class MonsterAttackEditor : MonoBehaviour
public class MonsterAttackEditor : VisualElement
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
Now we need a constructor for our class that builds the UI using the inherited .Add(VisualElement)
function. Let’s add a Label like we were doing in the AddAttack
function of our main UI, but this one’s text will be set to whatever is passed in:
public class MonsterAttackEditor : VisualElement
{
public MonsterAttackEditor(string labelText)
{
var label = new Label(labelText);
Add(label);
}
}
That’s it! We now have a custom VisualElement
subclass that we can create and add to any other VisualElement
. Let’s try replacing the Label
in MonsterEditor.cs
with our new class:
void AddAttack()
{
var newAttack = new Label(System.Guid.NewGuid().ToString());
var newAttack = new MonsterAttackEditor($"{Random.Range(0, 1000)}");
attacksContainer.Add(newAttack);
}
If you reselect the Monster GameObject and click the button a few times, you should see random number labels like this:
Now that we have editors for our main UI and the individual list items, we’re ready to finish the structure of our UI.
Finishing the UI Structure
Let’s take a look at what we want our inspector to look like again:
Finishing the Main UI
Knowing that we’ll be dealing with the attack list items separately, the only thing missing from our main template is the label that shows how many attacks there are and has a toggle to show/hide the attack list. Let’s add that to the main template, MonsterEditor.uxml
:
<editor:PropertyField binding-path="maxHp" />
<engine:Label name="attacksLbl" text="â–¼ Attacks (###)" />
<engine:Button name="addAttackBtn" text="Add Attack" />
Note that we can use Unicode in the strings. Support depends on the font you are using for the editor. We’ll use the right and down arrow symbols, â–º (U+25BA) and â–¼ (U+25BC), to indicate whether the list can be, or already is, expanded. I chose these because they work with Unity’s default font, and they look similar to Unity’s default expansion toggle icon.
Finishing the List Item UI
Like our main UI, we’ll use a UXML template for the structure of our attack editors. Right click in the Editor
folder and choose Create 🠚 UIElements 🠚 UXML Template
. Name the file MonsterAttackEditor
.
The attack list items each consist of:
- A label + text input (a
<TextField>
) - 2 buttons (
<Button>
)
Let’s add these to our new template file, with a placeholder label for the text field:
<?xml version="1.0" encoding="utf-8"?>
<engine:UXML
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:engine="UnityEngine.UIElements"
xmlns:editor="UnityEditor.UIElements"
xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
<engine:TextField name="attackTxt" label="###" />
<engine:Button name="duplicateBtn" text="+" />
<engine:Button name="deleteBtn" text="X" />
</engine:UXML>
We now want to instantiate this template instead of creating a label in our corresponding C# script, MonsterAttackEditor.cs
.
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
public class MonsterAttackEditor : VisualElement
{
public MonsterAttackEditor(string labelText)
{
var label = new Label(labelText);
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterAttackEditor.uxml");
var ui = uxmlTemplate.CloneTree();
Add(label);
Add(ui);
}
}
Refresh the inspector window and verify that the UXML template we created is being loaded:
While we’re here, we should set the <TextField>
label to the constructor’s labelText
argument.
var ui = uxmlTemplate.CloneTree();
var attackTxt = ui.Q<TextField>("attackTxt");
attackTxt.label = labelText;
Add(ui);
After refreshing again, the attack item labels should now be set to random numbers when you add items:
Our structure is nearly complete now, but the editor is in some serious need of styling.
Adding Style
Before we add functionality to our attack list editor, let’s make the UI look more like our sketch. In order to add style to our UI, we need to create a USS file to hold our custom styles and embed it in our main UXML template.
First, create a USS file in your Editor
folder (Create 🠚 UIElements 🠚 USS File
) named MonsterEditor.uss
. You can delete the empty rule included by default:
VisualElement {}
Like in Part 2 and Part 3, we’ll embed our USS file into our main UI template, MonsterEditor.uxml
, with a <Style>
element, and wrap the whole UI with a generic <VisualElement>
:
xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
<engine:VisualElement>
<engine:Style src="MonsterEditor.uss" />
<editor:PropertyField binding-path="kind" />
<editor:PropertyField binding-path="maxHp" />
<engine:Label name="attacksLbl" text="â–¼ Attacks (###)" />
<engine:Button name="addAttackBtn" text="Add Attack" />
<engine:VisualElement name="attacksContainer" />
</engine:VisualElement>
</engine:UXML>
Like its cousin CSS, USS rules flow down to child elements. Since we .Add()
our attack list items to the VisualElement
named attacksContainer
above, the style rules in MonsterEditor.uss
will apply to our elements in our sub-template, MonsterAttackEditor.uxml
, as well.
Now that our stylesheet is embedded, we need some sort of style rule to get the elements of our list items to line up in a row.
Arranging Elements Horizontally
All of our controls have been full width and arranged in column format so far. In our mock up though, the buttons are on the same row as the text fields. To achieve this, we need to learn about how Unity arranges VisualElement
s.
USS uses the open source Yoga layout engine, which implements a subset of Flexbox CSS layout system.
To get multiple elements into the same row, we need to change the flex-direction
property of their parent’s style to row
– column
is the default for UIElements. First though, we should wrap the elements in our attack item UXML file in a container – a plain VisualElement
will do, and we’ll give it a class of horizontalContainer
so we can target it in our USS file:
xsi:noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd"
>
<engine:VisualElement class="horizontalContainer">
<engine:TextField name="attackTxt" label="###" />
<engine:Button name="duplicateBtn" text="+" />
<engine:Button name="deleteBtn" text="X" />
</engine:VisualElement>
</engine:UXML>
Now that we have a good structure set up, let’s set flex-direction
property to row
for elements with the class horizontalContainer
in our USS file. Just like with CSS selectors, you prefix a name with a dot (.
) to match by class.
.horizontalContainer { flex-direction: row; }
If you reselect the Monster GameObject and click the Add Attack button a couple times, you should see something like this:
We now have rows, but the <TextField>
inputs are tiny! They do expand as you type in them, but they don’t look good. We need to use another Flexbox property, flex-grow
, to tell Unity we want our text fields to expand to take up whatever space they can:
.horizontalContainer { flex-direction: row; }
TextField { flex-grow: 1; }
Syncing: UI List ⟷ GameObject List
This is close enough to the whiteboard drawing we had for now, so let’s move on to making our attack list functional and synced to the underlying Monster GameObject – time to reopen MonsterEditor.cs
.
We’re going to be a bit lazy in how we keep our UI’s attack list in sync with our Monster’s attack list – everytime an item is added or removed from the Monster’s attack list, we’ll blow away the whole UI list and recreate it.
It’s a bit inefficient, but it allows us to write some very short and simple code to get syncing working – make it work now, optimize later!
The Sync Function
To sync our UI list to our list of GameObjects, we first need a reference to the GameObject that is being inspected. Unity provides this to subclasses of Editor
via the inherited target
property, which we can cast to our Monster
class. This is the object that we need to stay in sync with.
Let’s create a field in to hold our target monster and initialize it:
[CustomEditor(typeof(Monster))]
public class MonsterEditor : Editor
{
Monster targetMonster;
VisualElement attacksContainer;
public override VisualElement CreateInspectorGUI()
{
targetMonster = (Monster)target;
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterEditor.uxml");
Now that we have our target monster, we’re ready to write a sync function. Every VisualElement
has a Clear()
function that removes all children elements. We can use this to reset the list, then create a new list item for each of monster’s attacks.
We’ll use a for
loop instead of a foreach
loop because we want to label each of the list items with it’s list index. We should also call this new sync function at the end of CreateInspectorGUI()
to initialize the attacks UI:
addAttackBtn.clicked += AddAttack;
SyncAttacksUI();
return ui;
}
void SyncAttacksUI()
{
attacksContainer.Clear();
for (var i = 0; i < targetMonster.attacks.Count; i++)
{
var newAttack = new MonsterAttackEditor($"#{i}");
attacksContainer.Add(newAttack);
}
}
void AddAttack()
We should also set the label that displays the number of attacks during the sync. For this, we’ll add a field to hold the label, initialize it in CreateInspectorGUI()
, and set it in SyncAttacksUI()
. We’ll worry about the expanded/contracted icon later.
Monster targetMonster;
Label attacksLbl;
VisualElement attacksContainer;
attacksContainer = ui.Q<VisualElement>("attacksContainer");
attacksLbl = ui.Q<Label>("attacksLbl");
var addAttackBtn = ui.Q<Button>("addAttackBtn");
void SyncAttacksUI()
{
attacksLbl.text = $"â–¼ Attacks ({targetMonster.attacks.Count})";
attacksContainer.Clear();
Now the number of attacks should be shown in the list header, and the list items should be labeled with their index also:
Sync: Adding Elements and Undo/Redo
Now we can make the AddAttack
function actually add an attack to our monster! We just need to add an empty attack to the target monster’s attack list and call the sync function to rebuild our list UI.
We should also use Undo.RecordObject
to tell Unity that we are editing a GameObject in the Scene – that way the Scene gets marked as dirty and the user is reminded to save their changes if they try to quit before saving.
void AddAttack()
{
var newAttack = new MonsterAttackEditor($"{Random.Range(0, 1000)}");
attacksContainer.Add(newAttack);
Undo.RecordObject(targetMonster, "Add Monster Attack");
targetMonster.attacks.Add(string.Empty);
SyncAttacksUI();
}
Unfortunately, if you actually try to Undo, it doesn’t look like anything happened. The new attack does get removed from the underlying Monster object, but our UI doesn’t know that the undo occurred, so it stays the same.
To fix this issue, we can register with Unity to be notified everytime an Undo or Redo occurs by adding a callback to the built-in Undo.undoRedoPerformed
.
For our callback, we’ll use the SyncAttacksUI
function. It does exactly what we want – rebuild the UI to match the state of the Monster. Let’s register our function with the Undo system. Also, as good citizens, we should unregister our callback when our UI goes away via the OnDisable()
lifecycle method provided by the Editor
class.
addAttackBtn.clicked += AddAttack;
SyncAttacksUI();
Undo.undoRedoPerformed += SyncAttacksUI;
return ui;
}
void OnDisable()
{
Undo.undoRedoPerformed -= SyncAttacksUI;
}
void SyncAttacksUI()
Undo should now work as expected after clicking the Add Attack button. And for a bonus, Redo support comes for free with Undo.
Sync: Editing Items
It’s time to make the things we type in the text inputs persist. Our MonsterAttackEditor
elements need two things to sync with the underlying target monster:
- A reference to the target monster to edit.
- Their list index, so they initialize to and modify the correct item in the monster attack list.
Let’s pass these 2 things in to the constructor. Since we’re passing in the index now, we no longer need the labelText
argument – the label can figure out its own text. First, let’s change MonsterEditor.cs
, since after this we’ll be in MonsterAttackEditor.cs
for a bit.
for (var i = 0; i < targetMonster.attacks.Count; i++)
{
var newAttack = new MonsterAttackEditor($"#{i}");
var newAttack = new MonsterAttackEditor(targetMonster, i);
attacksContainer.Add(newAttack);
}
The solution won’t compile until we fix the constructor in MonsterAttackEditor.cs
. Let’s add some fields to store the monster and index, change the parameters, and change the way the label is set.
public class MonsterAttackEditor : VisualElement
{
Monster monster;
int attackIdx;
public MonsterAttackEditor(string labelText)
public MonsterAttackEditor(Monster monster_, int attackIdx_)
{
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterAttackEditor.uxml");
var ui = uxmlTemplate.CloneTree();
var attackTxt = ui.Q<TextField>("attackTxt");
attackTxt.label = labelText;
attackTxt.label = $"#{attackIdx}";
Add(ui);
}
}
Now we can properly initialize the text input value too:
attackTxt.label = $"#{attackIdx}";
attackTxt.value = monster.attacks[attackIdx];
Add(ui);
What we need next is a way to update the monster
object when the text input is changed. The TextField
class provides a function called RegisterCallback<EventType>
we can use for this – you tell it what event you want to handle and provide it a function, and it will call your function when that event occurs.
We’ll use the BlurEvent
, which is called when the text field lost focus (after the user hit Enter
, Tab
, clicked outside the field, etc.). We need to:
- Store a reference to our
TextField
so we can access it outside of the constructor. - Create a function that takes a
BlurEvent
, and…- Tells Unity we are about to edit a GameObject.
- Updates the appropriate attack on the target Monster GameObject.
- Register our new function with our
TextField
.
Let’s give it a shot:
public class MonsterAttackEditor : VisualElement
{
TextField attackTxt;
Monster monster;
…
var ui = uxmlTemplate.CloneTree();
var attackTxt = ui.Q<TextField>("attackTxt");
attackTxt = ui.Q<TextField>("attackTxt");
attackTxt.RegisterCallback<BlurEvent>(HandleAttackChanged);
attackTxt.label = $"#{attackIdx}";
attackTxt.value = monster.attacks[attackIdx];
Add(ui);
}
void HandleAttackChanged(BlurEvent evt)
{
Undo.RecordObject(monster, "Rename Monster Attack");
monster.attacks[attackIdx] = attackTxt.value;
}
}
At this point, the list item editor is starting to become useful. You can add new attacks and give them names, and your changes will apply to the GameObject and persist.
Sync: Deleting Items
Deleting from the list is similar to adding to the list. We tell Unity we’re changing the object (RecordObject
), delete the list item, and sync the UI. Let’s write a function in MonsterEditor.cs
to do that, given some list index:
SyncAttacksUI();
}
void DeleteAttack(int attackIdx)
{
Undo.RecordObject(targetMonster, $"Delete Monster Attack #{attackIdx}");
targetMonster.attacks.RemoveAt(attackIdx);
SyncAttacksUI();
}
}
Now the question is, how do we get the delete button to call this function? We could look up the button and set up a click handler right after we create the list items in SyncAttacksUI()
, but we’d like our custom VisualElement
subclasses to be self-contained – optimally, we just pass some data to the constructor, and it’s ready to go.
So, let’s leave the click handling up to the MonsterAttackEditor
internals, and instead hand it a callback (an Action
) for when the delete button is pressed.
for (var i = 0; i < targetMonster.attacks.Count; i++)
{
var newAttack = new MonsterAttackEditor(targetMonster, i);
var newAttack = new MonsterAttackEditor(targetMonster, i, DeleteAttack);
attacksContainer.Add(newAttack);
For this to compile, we need to jump over to MonsterAttackEditor.cs
and change the constructor declaration to match.
int attackIdx;
Action<int> deleteCallback;
public MonsterAttackEditor(Monster monster_, int attackIdx_)
public MonsterAttackEditor(Monster monster_,
int attackIdx_,
Action<int> deleteCallback_)
{
monster = monster_;
attackIdx = attackIdx_;
deleteCallback = deleteCallback_;
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterAttackEditor.uxml");
Finally, we create a click handler, find our button, and register it:
attackTxt.value = monster.attacks[attackIdx];
var deleteBtn = ui.Q<Button>("deleteBtn");
deleteBtn.clicked += DeleteAttack;
Add(ui);
…
monster.attacks[attackIdx] = attackTxt.value;
}
void DeleteAttack() { deleteCallback(attackIdx); }
}
Refresh the inspector and try adding, editing, and deleting a few items. The UI, GameObject, and undo menu should all stay in sync:
Sync: Duplicating Items
We’ll treat duplicating attacks much the same way we did deleting attacks. Here’s the plan:
MonsterEditor.cs
- Write a function to duplicate items.MonsterEditor.cs
- Pass the new function as a callback to the constructor forMonsterAttackEditor
items.MonsterAttackEditor.cs
- Update theMonsterAttackEditor
constructor to take a duplicate callback.MonsterAttackEditor.cs
- Set up the click handler. For this we need to:- Create a click handler that just calls the duplicate callback.
- Find the duplicate button.
- Register the click handler with the button.
Let’s take care of MonsterEditor.cs
first:
for (var i = 0; i < targetMonster.attacks.Count; i++)
{
var newAttack = new MonsterAttackEditor(targetMonster, i, DeleteAttack);
var newAttack = new MonsterAttackEditor(targetMonster, i, DeleteAttack, DuplicateAttack);
attacksContainer.Add(newAttack);
…
SyncAttacksUI();
}
void DuplicateAttack(int attackIdx)
{
Undo.RecordObject(targetMonster, $"Duplicating Monster Attack #{attackIdx}");
targetMonster.attacks.Insert(attackIdx, targetMonster.attacks[attackIdx]);
SyncAttacksUI();
}
}
And now, MonsterAttackEditor.cs
:
Action<int> deleteCallback;
Action<int> duplicateCallback;
public MonsterAttackEditor(Monster monster_,
int attackIdx_,
Action<int> deleteCallback_)
Action<int> deleteCallback_,
Action<int> duplicateCallback_)
{
monster = monster_;
attackIdx = attackIdx_;
deleteCallback = deleteCallback_;
duplicateCallback = duplicateCallback_;
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/MonsterAttackEditor.uxml");
…
deleteBtn.clicked += DeleteAttack;
var duplicateBtn = ui.Q<Button>("duplicateBtn");
duplicateBtn.clicked += DuplicateAttack;
Add(ui);
…
void DeleteAttack() { deleteCallback(attackIdx); }
void DuplicateAttack() { duplicateCallback(attackIdx); }
}
Refresh the inspector and go crazy – all the buttons should work now.
Finishing Touches
We have a fairly functional custom inspector now, but there are a couple more things that we can polish to make it even better. First off, we should implement the expand/contract feature that we’ve been teasing since we first put the â–¼
character in our UI. Reopen MonsterEditor.cs
.
Generic Click Handling & Showing/Hiding VisualElements
Like CSS, we can hide elements in USS by setting the display
style property to none
. The other option for display
is flex
, which refers to Flexbox that we mentioned earlier. A VisualElement
's styles can be manipulated programatically through its style
property.
Let’s keep track of whether our list is expanded or contracted with a bool
and add a click handler to the list header label to toggle the attack container’s display style. Unlike the Button
class, Label
doesn’t have a clicked
property for attaching handlers. Instead, we need to use the generic RegisterCallback
function that all VisualElement
subclasses have.
We’ll use the RegisterCallback<MouseDownEvent>
, which calls a function you pass in when the element is clicked. The function you pass in must take a parameter of type MouseDownEvent
, but you don’t have to use the parameter in your function body if you don’t need to.
public class MonsterEditor : Editor
{
bool attacksExpanded = true;
Monster targetMonster;
…
attacksLbl = ui.Q<Label>("attacksLbl");
attacksLbl.RegisterCallback<MouseDownEvent>(ToggleAttacksListDisplay);
var addAttackBtn = ui.Q<Button>("addAttackBtn");
…
SyncAttacksUI();
}
void ToggleAttacksListDisplay(MouseDownEvent evt)
{
DisplayStyle newDisplayStyle;
if (attacksExpanded)
{
newDisplayStyle = DisplayStyle.None;
}
else
{
newDisplayStyle = DisplayStyle.Flex;
}
attacksContainer.style.display = newDisplayStyle;
attacksExpanded = !attacksExpanded;
}
}
Now clicking the attack list header label hides and shows the list.
We should also keep the label itself in sync, changing the expanded/contracted symbol both here and when we sync the UI after a change to the list. Since we now are setting the label in 2 places, let’s also refactor quickly and extract a function to update the label:
void SyncAttacksUI()
{
attacksLbl.text = $"â–¼ Attacks ({targetMonster.attacks.Count})";
UpdateAttackListLabel();
…
attacksExpanded = !attacksExpanded;
UpdateAttackListLabel();
}
void UpdateAttackListLabel()
{
var icon = attacksExpanded ? "â–¼" : "â–º";
attacksLbl.text = $"{icon} Attacks ({targetMonster.attacks.Count})";
}
}
The icon should now match the expanded/contracted state of the list.
Some Final Style
Let’s take a final look at what we’re trying to build again.
In the default inspector, and in our drawing, the attack list header label is further to the left. Let’s reopen our style sheet file, MonsterEditor.uss
, and adjust the margin.
.horizontalContainer { flex-direction: row; }
TextField { flex-grow: 1; }
#attacksLbl { margin-left: -12px; }
Here is our final UI, with the list contracted:
And here it is with the list expanded:
Close enough!
Wrap Up
Final Code: https://github.com/exploringunity/monster-inspector
Yet again we started with just a whiteboard drawing and a dream, and now we have a custom inspector powered by UIElements that handles editing both the simple and dynamic fields of our class.
Here are some of the things we covered:
- Creating custom
VisualElement
subclasses - Dynamically adding/removing elements to/from our UI
- Binding UI elements to GameObject fields
- Arranging elements using
flex-direction
andflex-grow
What’s Next?
In Part 5 of this series, we’ll be building a custom editor window that can save and restore the position, rotation, and scale of groups of GameObjects. See you next time!