Loading...

UNITY 3D: Extending The Editor part 4 - Property Drawer and Custom IMGUI Controls

Intro


In this part, we will not only discuss how to create property drawers and what they're for but also how to create a custom IMGUI Control to use with the property drawer.

If you haven't followed along with the previous parts in this series, I highly recommend you to do so right now. I will be using the scripts built during the last parts to extend on further.

 

Fast Travel


If you are only interested in a specific part, you can skip to the section you'd like here. I do however use the same project and files during the entirety of this series. The project's source can be found in the last part of this series.

UNITY 3D: Extending The Editor part 1 - Intro

UNITY 3D: Extending The Editor part 2 - Setting up a data asset and a Custom Editor

UNITY 3D: Extending The Editor part 3 - Unity's Internal Reorderable List

UNITY 3D: Extending The Editor part 4 - Custom Property Drawers and Custom IMGUI Controls

 

End Result


 

What are Property Drawers?

Property Drawers are used to override the default drawing behavior of a specific control in the inspector window. It is especially useful when using custom serializable classes as we are using here today. It can completely change the way a data class looks in the inspector. According to the Unity Docs, Property Drawers have two primary uses:

  • Customize the GUI of every instance of a Serializable class.

  • Customize the GUI of script members with custom Property Attributes.

 

What are IMGUI Controls?

IMGUI Controls are used to visualize or modify displayed data in the inspector or any other editor window. Controls come in many forms, and you can even create your own.

The most commonly used ones are:

  • GUI.Label Which is a non-interactive string that can be used to display simple text.

  • GUI.Button Which displays an interactive button with text.

  • GUI.TextField which shows an editable text field

For more controls, please refer to "Further readings and references".

 

Implementation


First off, we need to create a new C# script and place it in the Editor/Scripts folder next to the TutorialShortcutCustomEditor.cs file. I will name it TutorialShortcutPropertyDrawer.cs, so it is instantly clear what we are dealing with.

This is how a basic Property Drawer looks like:

[CustomPropertyDrawer(typeof(TutorialShortcutData.Binding))]
public class TutorialShortcutPropertyDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);
        EditorGUI.PropertyField(position, property, label, true);
        EditorGUI.EndProperty();
    }
​
    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return EditorGUIUtility.singleLineHeight;
    }
}

Notice the [CustomPropertyDrawer(typeof(TutorialShortcutData.Binding))] above the class definition? This line defines a Custom Property Drawer for the serialized struct Binding. It is almost the same as defining a Custom Editor. The other main difference is, is that we are inheriting from PropertyDrawer instead of MonoBehaviour or Editor.

If you were to copy paste the line of code above, you'll notice that not much changes. The height calculations in the list might be a bit off, but that's it.

When looking at the current structure that we made in the previous parts, we see that it draws a name field and one data field, which in turn has two enum fields. In my opinion, it would be somewhat counter-intuitive to keep searching for a particular key in this enum list. If only there were a way to make this easier...

...but there is!

with a custom GUI control, we can override this behavior so that it makes choosing a new shortcut a breeze.

 

Creating a custom control


In my case, I will create a custom control for selecting a keycode and a modifier key. This control will function as a "scanner" and will detect keyboard events.

To start building our control, we will make a new method called ShorcutScanner()

private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
}

This method will draw the GUI control and process the keyboard events.

The first thing we need to define is a ControlID. A Control ID is used by the IMGUI system to determine which control currently is being used. When we set our Control ID, we prevent other controls from grabbing it, while we are the active control. This allows us to consume events like keyboard events.

We generate a control with the following line:

private static readonly int ShorcutScannerHash = "ShorcutScanner".GetHashCode();
​
private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
    //Generate a control ID
    int controlID = GUIUtility.GetControlID(hint: ShorcutScannerHash, focusType: FocusType.Keyboard, rect: position);
}

The static field ShorcutScannerHash acts as a unique identifier for our control. This is the same way Unity handles its controls internally.

Because we are working with Immediate Mode GUI (IMGUI), it means that everything gets redrawn and repainted every frame. So there is no such thing as persistent data within these methods.

 

Example:

private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
    //Generate a control ID
    int controlID = GUIUtility.GetControlID(hint: ShorcutScannerHash, focusType: FocusType.Keyboard, rect: position);
    
    bool isScanningForInput = false;
    
    if(GUI.Button(position, new GUIContent("Start Scanning")))
    {
        //This will only be true for one frame once we click on the button
        isScanningForInput = true;
    }
        
    Debug.Log($"Is scanning for input: {isScanningForInput}");
}

So we would need to define our variables as non-static and create an instance of each control? Thankfully not. Unity has our backs in this case in the form of a StateObject.

 

States

A StateObject is a small data class that you can define yourself that holds minimal data about the current state of the control. The data within this class will be persistent and thus stay alive longer than one frame.

To use a StateObject we need to define a class first:

private class KeyScannerInfoState
{
    public bool isScanning;
    public KeyCode keyCode;
    public EventModifiers modifiers;
​
    public override string ToString()
    {
        if (modifiers == EventModifiers.None)
            return keyCode.ToString();
​
        return string.Join("+", modifiers, keyCode);
    }
​
    public void ResetState()
    {
        keyCode = KeyCode.None;
        modifiers = EventModifiers.None;
    }
}

Note: Put this class in the same file as your custom control, to keep things organized.

This class only holds data about our custom control. It keeps track of whether we are currently scanning for input, knows which key is currently pressed and what modifiers are used with the key.

To use this class as a StateObject we can use the GUIUtility.GetStateObject() method.

private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
    //Generate a control ID
    int controlID = GUIUtility.GetControlID(hint: ShorcutScannerHash, focusType:            FocusType.Keyboard, rect: position);
    
    //Create a state that holds information about the current state of the control
    //that would otherwise be lost during a repaint event.
    KeyScannerInfoState state = (KeyScannerInfoState) GUIUtility.GetStateObject(
        typeof(KeyScannerInfoState),
        controlID);
}

Now that we have this state variable in our control's method, we can use it to save our data until we are ready to assign the registered input data to the SerializedProperty.

 

References & GUI

The property parameter that we are given represents the Binding struct that I made in the TutorialShortcutData (see part 2). We are, however, more interested in the two properties m_KeyCode and m_Modifiers inside the Binding struct. To get a reference to those fields, we can use property.FindPropertyRelative() to find it. These two fields are the actual values that we wish to set.

SerializedProperty keyCodeProp = property.FindPropertyRelative("m_KeyCode");
SerializedProperty modifierProp = property.FindPropertyRelative("m_Modifiers");

Add these lines just below the state variable declaration

private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
    //Generate a control ID
    int controlID = GUIUtility.GetControlID(ShorcutScannerHash, FocusType.Keyboard, position);
​
    //Create a state that holds information about the current state of the control
    //that would otherwise be lost during a repaint event.
    KeyScannerInfoState state = (KeyScannerInfoState) GUIUtility.GetStateObject(
        typeof(KeyScannerInfoState),
        controlID);
​
    //Get a reference to the properties we want to modify
    SerializedProperty keyCodeProp = property.FindPropertyRelative("m_KeyCode");
    SerializedProperty modifierProp = property.FindPropertyRelative("m_Modifiers");
}

Let's start with drawing something to the inspector window so we can see if we are still on the right track.

We will draw a prefix label first, so it looks just like a standard field in the inspector.

position = EditorGUI.PrefixLabel(position, controlID, label);

 

Now we have a cool looking label! Just like the default ones.

For the field, we will want to draw something that looks like a text field, but functions as a button.

The desired behavior should be: "Click on value field -> Enter or leave scanning state, depending on the current scanning state" or "clicked outside of value field while scanning -> Leave scanning state".

Because this behavior does not exist by default, we need to create it ourselves.

To get a button to look like a text field, we need to create our own GUIStyle. A GUIStyle has all the relevant information about background textures, fonts, margins, paddings, etc.. Essentially, all necessary rules to draw a control are present.

//Create the style for the value field.
//This way, we can tell if we are "recording" a keyboard event.
GUIStyle style = new GUIStyle(GUI.skin.box); //Create a copy of the default box style
style.padding = new RectOffset(); //Reset the padding to zero
style.margin = new RectOffset();  //Reset the margin to zero
​
//Create the content for our style
//This is what will be displayed in the value field
GUIContent content = new GUIContent(state.ToString());

Before we can draw this, it is vital to understand how events work within IMGUI and how we can use them to add functionality to our control.

 

IMGUI Events

IMGUI uses events to communicate the current state. Events can be triggered by users or by the system itself. Think of events as "a button has been pressed" or "Something has changed, so we need to repaint (redraw) again". This information is stored in Event.current.type. All controls act on these events. For example: If the current event is EventType.Repaint, all IMGUI elements are drawn to the screen.

The most commonly used events which we will also be using here are:

Event Type Description
EventType.MouseDown Set when the user has pressed a mouse button.
EventType.MouseUp Set when the user has released a mouse button.
EventType.KeyDown Set when the user has pressed a key.
EventType.KeyUp Set when the user has released a key.
EventType.Repaint Set when IMGUI needs to redraw the screen.
EventType.Layout Set before any other event. Used for initialization and auto-layout

See this for more Events.

 

Using The Events

We will use these events to set a base for our control.

//The current event being processed by the IMGUI system
var current = Event.current;
//Gets the current type for this specific control
var eventType = Event.current.GetTypeForControl(controlID);
​
switch (eventType)
{
    case EventType.MouseDown:
        //Decide what to down we have a mouse down event
        break;
    case EventType.MouseUp:
        //Decide what to do when we have a mouse up event
        break;
    case EventType.KeyDown:
        //Decide what to do when we have a key down event
        break;
    case EventType.Repaint:
        //Draw the style that we made earlier.
        style.Draw(position, content, controlID, state.isScanning);
        break;
}

The code above is the core structure of our control. Here we can create functionality depending on the Event.current. When we paste this in our ShorcutScanner() method we should see the style we defined earlier being drawn to the inspector window.

 

Our complete ShortcutScanner() so far:

private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
{
    //Generate a control ID
    int controlID = GUIUtility.GetControlID(ShorcutScannerHash, FocusType.Keyboard, position);
​
    //Create a state that holds information about the current state of the control
    //that would otherwise be lost during a repaint event.
    KeyScannerInfoState state = (KeyScannerInfoState) GUIUtility.GetStateObject(
        typeof(KeyScannerInfoState),
        controlID);
​
    //Get a reference to the properties we want to modify
    SerializedProperty keyCodeProp = property.FindPropertyRelative("m_KeyCode");
    SerializedProperty modifierProp = property.FindPropertyRelative("m_Modifiers");
    
    
    //Create the style for the value field.
    GUIStyle style = new GUIStyle(GUI.skin.box);
    style.padding = new RectOffset();
    style.margin = new RectOffset();
    
    //Create the content for our style
    //This is what will be displayed in the value field
    GUIContent content = new GUIContent(state.ToString());
​
    //Draw the prefix label and use it's position to draw the value box
    position = EditorGUI.PrefixLabel(position, controlID, label);
    
    //The current event being processed by the IMGUI system
    var current = Event.current;
    //Gets the current event type for this specific control
    var eventType = current.GetTypeForControl(controlID);
​
    switch (eventType)
    {
        case EventType.MouseDown:
            //Decide what to do when we have a mouse down event
            break;
        case EventType.MouseUp:
            //Decide what to do when we have a mouse up event
            break;
        case EventType.KeyDown:
            //Decide what to do when we have a key down event
            break;
        case EventType.Repaint:
            //Draw the style that we made earlier.
            style.Draw(position, content, controlID, state.isScanning);
            break;
    }
}

Now that we have our basic layout complete we can start adding the actual functionality to our custom control.

We will do so by adding code to the EventType.MouseDown, EventType.MouseUp and EventType.KeyDown cases in our switch statement.

 

Adding Functionality

Mouse Down

In our EventType.MouseDown event we want to say to the IMGUI system that we are now the active control and all other controls should ignore the events. Being the currently active control is called hotcontrol .

Because of the EventType.MouseDown event is sent whenever there was a mouse click; we have to make sure the mouse click happens when the mouse cursor is within the value field rect.

case EventType.MouseDown:
    //Is the cursor within the value rect?
    if (position.Contains(current.mousePosition))
    {
        //Check if no other controls have hotcontrol OR if we are scanning for input
        if (GUIUtility.hotControl == 0 || state.isScanning)
        {
            //Set hotcontrol to our ID, so other controls know not to listen to events
            GUIUtility.hotControl = controlID;
            //Do the same for keyboard control
            GUIUtility.keyboardControl = controlID;
            //Consume this event, causing other GUI elements to ignore it.
            current.Use();
        }
    }
​
    break;

 

Mouse Up

In the EventType.MouseUp event we want to toggle the scanning state of our control. So basically, if we are the currently active control (we have hotcontrol) and our cursor's position is still within the value field rect, we toggle the scanning state on or off.

If we are no longer in the scanning state (because we clicked on the value field again or outside of it), we give back the controls to the IMGUI system so other GUI elements can consume events again. This is also where we set our input value serializedProperties to the newly selected ones.

case EventType.MouseUp:
    if (GUIUtility.hotControl == controlID)
    {
        //Is the cursor within the value rect?
        if (position.Contains(current.mousePosition))
        {
            //Toggle the scan state
            state.isScanning = !state.isScanning;
        }
        else
        {
            //If we click somewhere else, disable recording
            state.isScanning = false;
        }
​
        //If we are no longer scanning, give back the controls
        if (!state.isScanning)
        {
            //release hot control so other controls can use it now
            GUIUtility.keyboardControl = 0;
            GUIUtility.hotControl = 0;
​
            //set the values to the properties
            modifierProp.enumValueIndex = (int) state.modifiers;
            keyCodeProp.enumValueIndex = (int) state.keyCode;
            //We changed the input values, so notify the IMGUI that something changed
            GUI.changed = true;
        }
        //Consume the event
        current.Use();
    }
​
    break;

 

Key Down

When this event is dispatched, we can consume keyboard events. Whenever we press a key, and we are scanning, we will be able to detect it in this case statement.

case EventType.KeyDown:
    //If we are not scanning, stop here
    if (!state.isScanning) return;
    //If the current key is not a keyboard key OR the current key is none, stop here
    if (!current.isKey || current.keyCode == KeyCode.None) return;
​
    //Set the state properties to the pressed key and optionally event modifiers
    state.modifiers = current.modifiers;
    state.keyCode = current.keyCode;
    //Consume the event
    current.Use();
​
    break;

 

One last step

One last step I like to do is to visualize when we are scanning for input. I will do this by changing the color of the text in the value field to red when we are recording and grey when we are not.

The only thing we need to do for this is to add an extra check when creating the GUIStyle

if (state.isScanning)
{
    style.normal.textColor = Color.red;
}

You can add this after we set the style's padding and margin.

 

The complete implementation 

using UnityEditor;
using UnityEngine;
​
[CustomPropertyDrawer(typeof(TutorialShortcutData.Binding))]
public class TutorialShortcutPropertyDrawer : PropertyDrawer
{
    private static readonly int ShorcutScannerHash = "ShorcutScanner".GetHashCode();
​
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);
        //EditorGUI.PropertyField(position, property, label, true);
        ShorcutScanner(position, property, label);
        EditorGUI.EndProperty();
    }
​
    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return EditorGUIUtility.singleLineHeight;
    }
​
    private void ShorcutScanner(Rect position, SerializedProperty property, GUIContent label)
    {
        //Generate a control ID
        int controlID = GUIUtility.GetControlID(ShorcutScannerHash, FocusType.Keyboard, position);
​
        //Create a state that holds information about the current state of the control
        //that would otherwise be lost during a repaint event.
        KeyScannerInfoState state = (KeyScannerInfoState) GUIUtility.GetStateObject(
            typeof(KeyScannerInfoState),
            controlID);
​
        //Get a reference to the properties we want to modify
        SerializedProperty keyCodeProp = property.FindPropertyRelative("m_KeyCode");
        SerializedProperty modifierProp = property.FindPropertyRelative("m_Modifiers");
​
​
        //Create the style for the value field.
        //This way, we can tell if we are "recording" a keyboard event.
        GUIStyle style = new GUIStyle(GUI.skin.box);
        style.padding = new RectOffset();
        style.margin = new RectOffset();
​
        //Change the text color of the value field when we are scanning for input
        if (state.isScanning)
        {
            style.normal.textColor = Color.red;
        }
        
        //Create the content for our style
        //This is what will be displayed in the value field
        GUIContent content = new GUIContent(state.ToString());
​
        //Draw the prefix label and use it's position to draw the value box
        position = EditorGUI.PrefixLabel(position, controlID, label);
​
        //The current event being processed by the IMGUI system
        var current = Event.current;
        //Gets the current event type for this specific control
        var eventType = current.GetTypeForControl(controlID);
​
        switch (eventType)
        {
            case EventType.MouseDown:
                if (position.Contains(current.mousePosition))
                {
                    //Check if no other controls have hotcontrol OR if we are scanning for input
                    if (GUIUtility.hotControl == 0 || state.isScanning)
                    {
                        //Set hotcontrol to our ID, so other controls know not to listen to events
                        GUIUtility.hotControl = controlID;
                        //Do the same for keyboard control
                        GUIUtility.keyboardControl = controlID;
                        //Consume this event, causing other GUI elements to ignore it.
                        current.Use();
                    }
                }
​
                break;
            case EventType.MouseUp:
                if (GUIUtility.hotControl == controlID)
                {
                    //Is the cursor within the value rect?
                    if (position.Contains(current.mousePosition))
                    {
                        //Toggle the scan state
                        state.isScanning = !state.isScanning;
                    }
                    else
                    {
                        //If we click somewhere else, disable recording
                        state.isScanning = false;
                    }
​
                    //If we are no longer scanning, give back the controls
                    if (!state.isScanning)
                    {
                        //release hot control so other controls can use it now
                        GUIUtility.keyboardControl = 0;
                        GUIUtility.hotControl = 0;
​
                        //set the values to the properties
                        modifierProp.enumValueIndex = (int) state.modifiers;
                        keyCodeProp.enumValueIndex = (int) state.keyCode;
                        //We changed the input values, so notify the IMGUI that something changed
                        GUI.changed = true;
                    }
​
                    //Consume the event
                    current.Use();
                }
​
                break;
            case EventType.KeyDown:
                //If we are not scanning, stop here
                if (!state.isScanning) return;
                //If the current key is not a keyboard key OR the current key is none, stop here
                if (!current.isKey || current.keyCode == KeyCode.None) return;
​
                //Set the state properties to the pressed key and optionally event modifiers
                state.modifiers = current.modifiers;
                state.keyCode = current.keyCode;
                //Consume the event
                current.Use();
​
                break;
            case EventType.Repaint:
                //Draw the style that we made earlier.
                style.Draw(position, content, controlID, state.isScanning);
                break;
        }
    }
​
    /// <summary>
    /// This class holds on to our data every time the IMGUI gets repainted
    /// </summary>
    private class KeyScannerInfoState
    {
        public bool isScanning;
        public KeyCode keyCode;
        public EventModifiers modifiers;
​
        public override string ToString()
        {
            if (modifiers == EventModifiers.None)
                return keyCode.ToString();
​
            return string.Join("+", modifiers, keyCode);
        }
​
        public void ResetState()
        {
            keyCode = KeyCode.None;
            modifiers = EventModifiers.None;
        }
    }
}

 

Conclusion


This part concludes this tutorial series on How to Extend the Unity Editor. In this series, I explained how to

  • Use Custom Editors for inspector windows (or ScriptableObjects)

  • Create a Unity Internal Reorderable List

  • Create a custom Property Drawer for a serialized class

  • Create a custom control

  • Detect keyboard input in editor time.

We went from this To this

 

Without changing the actual logic of the ScriptableObject.

All techniques and IMGUI apply to all Unity Editor related windows, inspectors, etc.. IMGUI can be an extremely powerful tool for creating fast workflows and optimizations that will save time and money in a production environment. The only thing I did not cover in this tutorial is the use of editor windows. But, that might be an exciting topic for the near future.

I hope this has been helpful in any way. If you have any questions or feedback, please feel free to email me or post a comment in the comment section down below.

 

Further steps


If you'd like a challenge, there are several things you could do to improve on this custom control.

  • Add a dedicated "Empty" key when scanning for keys, to clear the current entered values from the value field.

  • Add a dedicated "Cancel" key, to stop scanning for input and revert to the values of the serialized property before scanning started.

  • Cache the instance of the GUIStyle created for the value field.

 

Source code


The complete source code can be found on Github via the link below:

https://github.com/Link-SD/Unity3D-Extending-The-Editor

 

Further readings and references


Be the first to comment

Post Comment

This website uses cookies to ensure you get the best experience on my website