In this tutorial, we’ll learn more of the basics of Unity’s new UIElements framework by continuing work on The Renamerator from Part 1, adding support for regular expressions, confirmation before the rename, a preview of the changes, and more – The Renamerator 2.
Update: Check out Part 4 of the series once you’re finished with this one.
Prerequisites
This is a beginner-level tutorial that assumes you have gone through Part 1 and Part 2 of the Extending the Unity Editor with UIElements series.
It also assumes you know the basics of regular expressions. We won’t be doing anything wizardly, just simple character classes and capture groups. If you understand the gist of the following, you should be fine: Regex.Replace("abc 123", @"(\w+) (\d+)", @"$2_$1"); // returns "123_abc"
The Unity version used throughout this tutorial is 2019.3.12f1, but any version 2019.1 or newer should work.
A Quick Recap of Part 1
In Part 1, we used UIElements to build The Renamerator, a custom editor window to batch rename GameObjects. Here’s an image of The Renamerator in action:
What We’re Building
In this tutorial, we’ll be building a new custom editor window for renaming GameObjects, more powerful, stylish, and user-friendly than the original Renamerator. We’ll learn how to:
- Use the
<Toggle>
, UIElements’ checkbox component - Show a confirmation dialog to prevent accidental renames
- Disable a
VisualElement
to prevent user interaction - Add a scroll bar to support content larger than the window
… and more! Our goal is to end up with something that looks like this:
Getting Started
We’ll start with a new 3D project. Create a new folder named Editor
in your Assets folder. As in the previous tutorials, all of our files will reside in this folder.
Like we did in Part 2, we’ll be using Unity’s built-in UIElements editor window template to get started quickly.
Creating an Editor Window ASAP
Go into the Editor
folder you just created, right click, and select Create 🠚 UIElements 🠚 Editor Window
. I chose the name Renamerator2
, but you can pick anything you’d like.
After clicking Confirm
, three files should be created, and the new editor window should open.
Let’s quickly clean up the files that Unity created to leave ourselves a blank slate, very similar to what we did in the Cleaning Up the Defaults
section of Part 2.
Cleaning Up the Defaults
Renamerator2.cs
First, let’s rename the function that opens the window for clarity, change the menu path and add a hotkey:
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
public class Renamerator2 : EditorWindow
{
[MenuItem("Window/UIElements/Renamerator2")]
[MenuItem("Custom Tools/The Renamerator II %#t")]
public static void ShowExample()
public static void OpenWindow()
{
Renamerator2 wnd = GetWindow<Renamerator2>();
wnd.titleContent = new GUIContent("Renamerator2");
}
Next, let’s clean up OnEnable
by deleting everything but the part that loads the UXML template, and we’ll make a few cosmetic changes to what’s left for brevity and clarity:
public void OnEnable()
{
// Each editor window contains a root VisualElement object
VisualElement root = rootVisualElement;
// VisualElements objects can contain other VisualElement following a tree hierarchy.
VisualElement label = new Label("Hello World! From C#");
root.Add(label);
// Import UXML
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/Renamerator2.uxml");
var uxmlTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/Renamerator2.uxml");
VisualElement labelFromUXML = visualTree.CloneTree();
var ui = visualTree.CloneTree();
root.Add(labelFromUXML);
rootVisualElement.Add(ui);
// A stylesheet can be added to a VisualElement.
// The style will be applied to the VisualElement and all of its children.
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor/Renamerator2.uss");
VisualElement labelWithStyle = new Label("Hello World! With Style");
labelWithStyle.styleSheets.Add(styleSheet);
root.Add(labelWithStyle);
}
}
At this point, we should be able to refresh our editor window and see the following:
We’re done with the C# file for now – we’ll come back to it when we’re ready to start adding functionality to our window. Let’s move on to the USS file next.
Renamerator2.uss
We’ll leave styling things for later – for now, just delete everything and leave the file empty:
Label {
font-size: 20px;
-unity-font-style: bold;
color: rgb(68, 138, 255);
}
Let’s move on to the UXML file.
Renamerator2.uxml
Let’s delete the default content, the <Label>
. Then, like in Part 2, we’ll embed our USS file into our UI template with a <Style>
element, wrapped with a generic <VisualElement>
. 1
<?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="Hello World! From UXML" />
<engine:VisualElement>
<engine:Style src="Renamerator2.uss" />
</engine:VisualElement>
</engine:UXML>
At this point, if you refresh the editor window, you should be left with an empty window:
And with that, we’re ready to start working on our rename feature. We’ll begin by building the basic structure of our UI.
Building the UI
Let’s take a look at what we want our window to look like again:
From top to bottom, it looks like we need something like the following:
- 2 text fields
- 1 checkbox
- 1 label
- 1 button
- Several labels
We’ve already used the <VisualElement>
subclasses <TextField>
, <Label>
, and <Button>
when we were building the original Renamerator in Part 1. The only new thing we need to learn is how to create a checkbox. For that, we will use another built-in subclass, the <Toggle>
.
Let’s start fleshing out the UI from the top down. Open the UXML file and add the “Find” and “Replace” text fields:
<engine:Style src="Renamerator2.uss" />
<engine:TextField name="searchTxt" label="Find:" />
<engine:TextField name="replaceTxt" label="Replace:" />
</engine:VisualElement>
Refresh the window to verify.
Now for the checkbox. All we need to do is add a <Toggle>
:
<engine:TextField name="replaceTxt" label="Replace:" />
<engine:Toggle name="useRegex" label= "Regex" />
</engine:VisualElement>
Moving on to the label and button:
<engine:Toggle name="useRegex" label= "Regex" />
<engine:Label name="numSelectedLbl" text="# Selected: NNN" />
<engine:Button name="renameBtn" text="Rename Selected" />
</engine:VisualElement>
The final thing we had on our mock UI was a preview of the rename, with a line for each object to be renamed. Let’s just add a single <Label>
for now:
<engine:Button name="renameBtn" text="Rename Selected" />
<engine:Label name="previewLbl" text="PREVIEW" />
</engine:VisualElement>
Alright, we’re finished with the structure of our UI. Let’s refresh the window once more to double check our work, and then we’ll move on to adding functionality.
Basic Rename Functionality
In this section, we’ll add support for the same basic string.Replace
-type renaming that the original Renamerator had. We’ll approach this incrementally by:
- Adding a click handler to the rename button.
- Getting user input from the find and replace fields.
- Displaying information on the selected GameObjects.
- Modifying the click handler to rename the selected GameObjects.
Since these are all functional changes, we shouldn’t need to touch the UXML or USS files – all our changes will be in our editor window C# script. This section is basically the same as Part 1, so we’ll mostly be skipping the theory and just cranking out code.
A Click Handler
Let’s start by adding a click handler to the button. First we need a function to define what happens when the button is clicked. For now, we’ll just log to the debug console:
rootVisualElement.Add(ui);
}
void RenameSelected()
{
Debug.Log("[Renamerator2]");
}
}
Now we need to find the rename button using the query function, Q
, and then add our new function to its click handlers.
rootVisualElement.Add(ui);
var renameBtn = ui.Q<Button>("renameBtn");
renameBtn.clicked += RenameSelected;
}
void RenameSelected()
The rename button should now log a message when clicked.
Getting User Input - <TextField>
Next we need to be able to get user input from the text fields. First, we’ll add some fields to hold references to the TextField
s from our UI.
public class Renamerator2 : EditorWindow
{
TextField searchTxt;
TextField replaceTxt;
[MenuItem("Custom Tools/The Renamerator II %#t")]
Then we initialize those fields in OnEnable
.
rootVisualElement.Add(ui);
searchTxt = ui.Q<TextField>("searchTxt");
replaceTxt = ui.Q<TextField>("replaceTxt");
var renameBtn = ui.Q<Button>("renameBtn");
And finally, we can modify the click callback function to print the values from the text fields as a quick test.
void RenameSelected()
{
var search = searchTxt.value;
var replace = replaceTxt.value;
Debug.Log("[Renamerator2]");
Debug.Log($"[Renamerator2] Renaming: {search} -> {replace}");
}
If you type something into the find and replace fields and click the button, you should see a message logged to the console similar to this:
Counting Selected GameObjects
We covered using Selection
class in Part 1. We’ll use its gameObjects
and selectionChanged
properties to keep our label in sync with the count of selected GameObjects.
We will need to set the label both when the window opens, and when the selection is changed. First off, we need a reference to the label:
public class Renamerator2 : EditorWindow
{
Label numSelectedLbl;
TextField searchTxt;
Now let’s write a function to set the label based on the current selection:
renameBtn.clicked += RenameSelected;
}
void UpdateNumSelectedLabel()
{
numSelectedLbl.text = $"# Selected: {Selection.gameObjects.Length}";
}
void RenameSelected()
And now we’ll look up and initialize the label, and we’ll register the function with the Selection
class so that it’s called everytime the user changes what GameObjects are selected.
renameBtn.clicked += RenameSelected;
numSelectedLbl = ui.Q<Label>("numSelectedLbl");
UpdateNumSelectedLabel();
Selection.selectionChanged += UpdateNumSelectedLabel;
}
void UpdateNumSelectedLabel()
And as good citizens, we should clean up after ourselves – we’ll unregister our callback when our window is closed in the OnDisable
lifecycle method provided by EditorWindow
.
Selection.selectionChanged += UpdateNumSelectedLabel;
}
public void OnDisable()
{
Selection.selectionChanged -= UpdateNumSelectedLabel;
}
void UpdateNumSelectedLabel()
All that’s left is to modify our click handler to actually loop over and rename the selected GameObjects. We’ll also update the log message to give us a little more information on what happened.
void RenameSelected()
{
var search = searchTxt.value;
var replace = replaceTxt.value;
var numSelected = Selection.gameObjects.Length;
var logMsg = $"Renaming { numSelected } objs: { search } -> { replace }";
Debug.Log($"[Renamerator2] Renaming: {search} -> {replace}");
Debug.Log($"[Renamerator2] " + logMsg);
foreach (var gameObj in Selection.gameObjects)
{
gameObj.name = gameObj.name.Replace(search, replace);
}
}
Create several GameObjects in your scene, select a few, and try renaming them. You should see something like this:
Congratulations! The Renamerator 2 has now achieved feature parity with The Renamerator from Part 1. From here on, it just gets better!
Beyond the Basics
This section is all about functionality, so keep the C# script open.
Undo Support
Let’s quickly add Undo support for our feature, like we did in the “Adding Undo/Redo Support” section of Part 2. We’ll again use the Undo.RecordObjects(object[] objectsToUndo, string name)
function, but this time we can pass Selection.gameObjects
directly to the function, because we’re modifying the first-level (shallow) name
property of each GameObject.
Debug.Log($"[Renamerator2] " + logMsg);
Undo.RecordObjects(Selection.gameObjects, "Rename Selected GameObjects");
foreach (var gameObj in Selection.gameObjects)
That’s it! One line, and we’ve now got first-class undo and redo support, just like any built-in action in the Unity editor.
Confirmation Dialog
What’s better than being able to undo accidents? Preventing accidents from happening in the first place! In this section, we’ll add a confirmation pop-up to give the user a chance to confirm/cancel the rename.
To present the user with a confirmation dialog, we will use the DisplayDialog
function from the EditorUtility
class. The parameters are strings for the pop-up’s title, body, ok button, and cancel button. It returns true if the user selected the ok button, false if they chose the cancel button or closed the popup. We’ll exit the function with an early return if the user doesn’t confirm.
var numSelected = Selection.gameObjects.Length;
const string title = "Rename Selected GameObjects";
var msg = $"Are you sure you want to rename the {numSelected} selected GameObjects?";
var doIt = EditorUtility.DisplayDialog(title, msg, "Rename", "Cancel");
if (!doIt) { return; }
var logMsg = $"Renaming { numSelected } objs: { search} -> { replace }";
Save the changes above and test it out. Now when you click the rename button a dialog should pop up, and the rename should only occur if you confirm.
Disabling a VisualElement
Another sanity check we could perform to increase user-friendliness is to check that there is at least one GameObject selected. We could abort with an early return in RenameSelected
, like we did with the confirmation dialog above if the user cancels. However, another option we have is to disable the button whenever nothing is selected, preventing us from needing to abort in the first place.
First, we’ll need to hoist our renameBtn
variable in OnEnable
to a field, so that we can access it in other functions later.
public class Renamerator2 : EditorWindow
{
Button renameBtn;
Label numSelectedLbl;
We’re already looking up the button in OnEnable
, we just need to get rid of the variable and set the field instead.
replaceTxt = ui.Q<TextField>("replaceTxt");
var renameBtn = ui.Q<Button>("renameBtn");
renameBtn = ui.Q<Button>("renameBtn");
renameBtn.clicked += RenameSelected;
Now that we have a reference to our button, we need to keep it in sync with the user selection. Our label already stays in sync with the selection, so we’ll hijack that function. We’ll rename the function to be more generic and add logic to enable/disable the button, using the SetEnabled
function that VisualElement
and all of its subclasses have.
numSelectedLbl = ui.Q<Label>("numSelectedLbl");
UpdateNumSelectedLabel();
SyncUIWithSelection();
Selection.selectionChanged += UpdateNumSelectedLabel;
Selection.selectionChanged += SyncUIWithSelection;
}
public void OnDisable()
{
Selection.selectionChanged -= UpdateNumSelectedLabel;
Selection.selectionChanged -= SyncUIWithSelection;
}
void UpdateNumSelectedLabel()
void SyncUIWithSelection()
{
var numSelected = Selection.gameObjects.Length;
numSelectedLbl.text = $"# Selected: {Selection.gameObjects.Length}";
numSelectedLbl.text = $"# Selected: {numSelected}";
renameBtn.SetEnabled(numSelected > 0);
}
Hopefully you are using your editor’s refactoring tools to help with function rename operations like this! For example, Ctrl+R Ctrl+R
is the default shortcut for renaming in Visual Studio on Windows.
After you save these changes, the button should be disabled when you have no GameObjects selected in the Hierarchy/Scene windows.
Getting User Input - <Toggle>
We’re going to start working toward supporting regex, but first, let’s check whether or not the user wants to use regex. Getting the value of a <Toggle>
is about the same as how we got the value of our <TextField>
s earlier. We look it up with the UI’s Q
function and inspect the value
property. Checked is true
, and unchecked is false
.
First we add a field for the toggle.
TextField replaceTxt;
Toggle useRegex;
[MenuItem("Custom Tools/The Renamerator II %#t")]
Then we look it up in OnEnable
.
replaceTxt = ui.Q<TextField>("replaceTxt");
useRegex = ui.Q<Toggle>("useRegex");
var renameBtn = ui.Q<Button>("renameBtn");
And finally, we check its value in RenameSelected
. We’ll just log a message to the debug console for now.
foreach (var gameObj in Selection.gameObjects)
{
if (useRegex.value)
{
Debug.Log("useRegex is checked.");
}
else
{
gameObj.name = gameObj.name.Replace(search, replace);
}
}
If you leave the regex checkbox unchecked, the behavior should be the same as before. If you check the box before clicking the rename button, you should see something like this:
Now that we’re respecting the checkbox, let’s power up our search and replace capabilities with regular expressions.
Regular Expressions
This one is easy! All we need to do to support regular expressions is to pass the user’s input to Regex.Replace
in useRegex branch of the RenameSelected
method.
if (useRegex.value)
{
Debug.Log("useRegex is checked.");
gameObj.name = Regex.Replace(gameObj.name, search, replace);
}
And that’s it! Try it out! Here’s an example of switching “Game” and “Object” and adding a space between them in the names of several selected objects:
Basic Rename Preview
It’s nice to be able to see a preview of your rename operation, especially when working with regex. Let’s start with a preview for the non-regex case since it’s simpler. It may seem like we need more than the one <Label>
that we put in our UXML template for previews, but we can actually just stuff all of the previews into a single string. Let’s implement previews for the non-regex rename.
First off, we need a field for the label.
Label numSelectedLbl;
Label previewLbl;
TextField searchTxt;
Followed by setting the reference in OnEnable
:
numSelectedLbl = ui.Q<Label>("numSelectedLbl");
previewLbl = ui.Q<Label>("previewLbl");
SyncUIWithSelection();
Next we need a function that sets the preview label. We’ll add some local variables to keep the lines shorter for the rest of the function to come, as well as a sanity check to prevent errors.
renameBtn.SetEnabled(numSelected > 0);
}
void UpdatePreview()
{
var search = searchTxt.value;
var replace = replaceTxt.value;
var gameObjs = Selection.gameObjects;
if (search == "") { previewLbl.text = ""; return; }
}
void RenameSelected()
With that in place, there’s what we need to do:
- Get a list (
IEnumerable
) of all the unmodified names. - Get a list of all the names after replacement.
- Zip together the two lists above into one list of (unmodified -> modified) strings
- Join all the preview strings into a single, newline-separated preview string.
The implementation is basically a line-for-line translation of these steps:
if (search == "") { previewLbl.text = ""; return; }
var oldNames = gameObjs.Select(go => go.name);
var newNames = gameObjs.Select(go => go.name.Replace(search, replace));
var nameChanges = oldNames.Zip(newNames, (x, y) => $"{x} -> {y}");
previewLbl.text = string.Join("\n", nameChanges);
}
The last thing we need is to have this function be called when our window opens, when the selection changes, and everytime either of our text fields change. We already are hooked into the selection changing, so we’ll piggyback off that, which will also initialize the label, since SyncUIWithSelection
is called in OnEnable
.
void SyncUIWithSelection()
{
UpdatePreview();
var numSelected = Selection.gameObjects.Length;
Finally, the TextField
class has a function called RegisterValueChangedCallback
that takes another function as an argument and calls it everytime its value is changed. We’ll set up these event handlers at the end of OnEnable
:
Selection.selectionChanged += SyncUIWithSelection;
searchTxt.RegisterValueChangedCallback(x => UpdatePreview());
replaceTxt.RegisterValueChangedCallback(x => UpdatePreview());
}
Regex Rename Preview
Supporting a preview when the regex option is exactly the same as the basic preview, except for how the new names are generated. Instead of just guarding against an empty find string, we also need to worry about invalid search and replacement patterns. We’ll branch based on the regex option and use Regex.Replace
inside of a try/catch
to handle invalid inputs.
var oldNames = gameObjs.Select(go => go.name);
var newNames = gameObjs.Select(go => go.name.Replace(search, replace));
System.Collections.Generic.IEnumerable<string> newNames;
if (useRegex.value)
{
newNames = gameObjs.Select(go =>
{
try { return Regex.Replace(go.name, search, replace); }
catch { return "REGEX ERROR"; }
});
}
else
{
newNames = gameObjs.Select(go => go.name.Replace(search, replace));
}
var nameChanges = oldNames.Zip(newNames, (x, y) => $"{x} -> {y}");
Et voilà! Try entering a regex and you should see a live preview as you type:
If your regex is not valid, the preview will let you know:
Finishing Touches
Let’s take one last look at the sketch of our UI to compare to what we have so far:
A Nicer Window Tab
While we still have the C# file open, let’s change our menu’s title to look more like the sketch by giving it an icon, and we’ll change the 2 to a ² since the title supports Unicode. The GUIContent
constructor that we call in the OpenWindow
function has an overload that accepts a Texture
for the icon.
Drag a PNG format image that you want to be your icon into your Editor folder – I made my own 32x32 icon using GIMP and named it RenameratorIcon
:
You may need to change the texture type of the image to “Sprite (2D and UI)” in the Inspector window if it isn’t displaying properly.
To use the icon, we must load it and pass it to the GUIContent
constructor:
Renamerator2 wnd = GetWindow<Renamerator2>();
var icon = (Texture2D)EditorGUIUtility.Load("Assets/Editor/RenameratorIcon.png");
wnd.titleContent = new GUIContent("Renamerator2");
wnd.titleContent = new GUIContent("Renamerator\u00B2", icon);
If you save the changes above and refresh the editor window, you should have a fancy looking window tab like this:
A Bigger & Bolder Button
Let’s add some styling to our button to make it stand out. For this, we’ll need to work in our USS file that we cleared out earlier. Let’s make the following changes:
- Add some space around the button.
- Add some space around the contents of the button.
- Make the text bold.
We can use the standard CSS properties margin
and padding
for the spacing, but Unity handles text differently than browsers, so we’ll need to use the Unity vendor extension property -unity-font-style
to get bold text. Let’s try it out:
Button {
margin: 5px 20px;
padding: 5px;
-unity-font-style: bold;
}
You may not need to refresh the editor window, since saving changes to USS files usually triggers an automatic refresh. Check out the button’s new style:
More Spacing
The labels could use some breathing room on the left side too, so let’s give them a little margin-left
:
-unity-font-style: bold;
}
Label {
margin-left: 5px;
}
Save the changes and let’s look at the result.
Targeted Styling
The last change we made affected all the labels on our UI. We can look up a specific element by its name
attribute in USS like we can by id
in CSS – using #
as a prefix. To find an element with name="foo"
, you would use the selector #foo
. Let’s change the font style of our label that displays the number of selected GameObjects to be italicized:
margin-left: 5px;
}
#numSelectedLbl {
-unity-font-style: italic;
}
Scrollable Content
Our UI is now as good as the whiteboard drawing we started with, but there’s one thing the drawing didn’t capture – you might have a lot of GameObjects selected, so many that even if you maximize the editor window, you still can’t see the entire preview.
UIElements has a built-in solution for this, the VisualElement
subclass <ScrollView>
. This element provides a scrollbar that adjusts to the element’s contents. Let’s open our UXML file and use one to wrap our preview label:
<engine:Button name="renameBtn" text="Rename Selected" />
<engine:ScrollView>
<engine:Label name="previewLbl" text="PREVIEW" />
</engine:ScrollView>
</engine:VisualElement>
That’s it, refresh the editor window since we changed the UXML file, and we automagically2 have a scrollbar!
We could keep improving The Renamerator² forever, but we’ve accomplished our initial goal and more, so let’s stop here for now.
Wrap Up
Final Code: https://github.com/exploringunity/renamerator2
Again we started with just a whiteboard drawing and a dream, and now we have a first-class custom editor window driven by UIElements that can do a user-friendly, regex-powered, bulk rename of GameObjects with just the click of a button.
Here are some of the things we covered:
- Getting yes/no user input with the built-in
Toggle
control - More USS – How to target a specific
VisualElement
for styling - Displaying a confirmation pop-up dialog to the user
- Handling content larger than your window with a
ScrollView
What’s Next?
In Part 4 of this series, we’ll learn how to use UIElements to create a basic custom inspector for our classes. See you next time!
-
When I wrote this, the Unity documentation on UXML templates said that “Unity does not support
<Style>
elements under the root<UXML>
element.” That is true for 2019.3.12f1, but seems to have been fixed in Unity 2020.1.0b3 (beta version). ↩︎ -
“Automagically” is still one of my favorite words.. ↩︎