Tools and Property Drawers

Hi :)

I think there 2 kinds of tools that help development. Tools that speed up processes and tools that help visualize data. For the first type, most of the time they specific to a given project and they are usually reused or adapted for similar projects. The second type is more generic, they are specific for some data but different projects deal with a common data format.

In Unity3D we have Custom Editors and Property Drawers. Custom Editors are more commonly used for the first type of tools. They include new windows, custom inspectors are custom Scene GUIs. Property drawers are more of the second type, they are just some fancy way to show certain data.

Well, I think visualization is power and that everyone should know how to use Property Drawers since they are easy, fast and helps you and others understand whats is going on in the game.

Let's start with a simple Interval struct. Most games make use of some kind of interval for many variables, usually referred as resources (e.g. health, bullets, wood, stone, food, armour, etc).

using System;
using UnityEngine;

[System.Serializable]
public class Interval where T : IComparable
{
    [SerializeField]
    private T minValue;
    [SerializeField]
    private T maxValue;

    public T MaxValue
    {
        get { return maxValue; }
        set
        {
            maxValue = value;
            minValue = Clamp(minValue, minValue, maxValue);
        }
    }
    public T MinValue
    {
        get { return minValue; }
        set { minValue = Clamp(minValue, minValue, maxValue); }
    }

    public T Clamp(T value) => Clamp(value, MinValue, MaxValue);

    public static T Clamp(T val, T min, T max)
    {
        if (val.CompareTo(min) < 0) return min;
        else if (val.CompareTo(max) > 0) return max;
        else return val;
    }
}

So, the Interval struct pretty much just define a minimum and maximum values and have a method to clamp a given value to the specified interval. Now, our resource variable does not contain only a minimum and maximum value, it also has a current value. So let's define it.

[System.Serializable]
public class ValueInterval : Interval where T : IComparable
{
    [SerializeField]
    private T value;

    public T Value {
        get => value;
        set => this.value = base.Clamp(value); }
}

Now, Unity does not serialize generics, so we have to create a concrete definition for it to be serialized. Lets do this and also define a class with a field of the concrete type.

[System.Serializable]
public class IntInterval : ValueInterval, IInteractible { }

public class PlayerResources : MonoBehaviour
{
    public IntInterval  health;
    public IntInterval  armor;
    public IntInterval  coins;
}

Ok, so now that we have our concrete serialized class, it should show up in the inspector as

That is not very handy to see. Unity has some pretty cool ways to show these values, such as progress bars and sliders. I'm gonna use it help us visualize these resource fields. To start, we need to create an Editor class that is a custom drawer for the kind of data we want to show.

// put it in an Editor folder
using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(IntInterval))] // the type of data
public class IntIntervalDrawer : PropertyDrawer // custom drawer
{

}

The PropertyDrawer has some overrides that help us draw the property in a different way, the first one is GetPropertyHeight. Now, this method is used to calculate the height of the property in the inspector UI. For the Interval property, I want it to have 1 line to visualize the data and 1 line to edit the data so the total height will be 2 lines. But I only want to edit when it is expanded, so the GetPropertyHeight will look like this.

public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        if (property.isExpanded)
            return EditorGUIUtility.singleLineHeight * 2.0f;
        else
            return EditorGUIUtility.singleLineHeight;
    }
When it is not expanded, we will use 1 line to draw a progress bar from min to max and when it is expanded we will use the additional line to edit the fields of the property. Now to draw the property itself, we need first to get the serialized fields inside the property. The drawing is done inside the OnGUI method. At first it will look like this.

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);

        var valueProperty = property.FindPropertyRelative("value");
        var minProperty = property.FindPropertyRelative("minValue");
        var maxProperty = property.FindPropertyRelative("maxValue");

//Draw the stuff here

        EditorGUI.EndProperty();
    }

To make the progress bar, we need to first calculate the progress and then use EditorGUI to draw the progress bar.

var currentProgress = (valueProperty.intValue - minProperty.intValue);
        var total = (maxProperty.intValue - minProperty.intValue);

        var progressRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);

        EditorGUI.ProgressBar(progressRect, currentProgress / (float)total, string.Format("{0} : [{1}..{2}..{3}]", label.text, minProperty.intValue, valueProperty.intValue, maxProperty.intValue));
Nwo we need to control the isExpanded value, so we will add:

property.isExpanded = EditorGUI.Foldout(progressRect, property.isExpanded, GUIContent.none, true);

This will make it expand when we click on the arrow or on the label region, which is the same as the progress bar since they use the same rect.
Now to draw the fields to edit when the property is expanded we will add:

if (property.isExpanded)
        {
            var indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;
            var minRect = new Rect(progressRect.x, progressRect.yMax, INPUT_WIDTH, EditorGUIUtility.singleLineHeight);
            var valueRect = new Rect(minRect.xMax, progressRect.yMax, progressRect.width - INPUT_WIDTH*2, EditorGUIUtility.singleLineHeight);
            var maxRect = new Rect(valueRect.xMax, progressRect.yMax, INPUT_WIDTH, EditorGUIUtility.singleLineHeight);

            EditorGUI.BeginChangeCheck();
            EditorGUI.PropertyField(minRect, minProperty, GUIContent.none, false);
            var newValue = EditorGUI.IntSlider(valueRect, GUIContent.none, valueProperty.intValue, minProperty.intValue, maxProperty.intValue);
            EditorGUI.PropertyField(maxRect, maxProperty, GUIContent.none, false);
            if (EditorGUI.EndChangeCheck())
            {
                valueProperty.intValue = newValue;
            }
            EditorGUI.indentLevel = indent;
        }

Note that I use .xMax and .yMax on the rects for the property in order to start another rect when the previous only finish. The EditorGUI.BeginChangeCheck() and EditorGUI.EndChangeCheck() is used in order to oly assign values when there is a change in the control UI, otherwise, it will always keep assigning the same value without needing. THe indentLevel is so that it show arrays without indenting the elements.

In the end the property will look like

The final code for the property drawer is


using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(IntInterval))]
public class IntIntervalDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);

        var valueProperty = property.FindPropertyRelative("value");
        var minProperty = property.FindPropertyRelative("minValue");
        var maxProperty = property.FindPropertyRelative("maxValue");

        var currentProgress = (valueProperty.intValue - minProperty.intValue);
        var total = (maxProperty.intValue - minProperty.intValue);

        var progressRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);

        EditorGUI.ProgressBar(progressRect, currentProgress / (float)total, string.Format("{0} : [{1}..{2}..{3}]", label.text, minProperty.intValue, valueProperty.intValue, maxProperty.intValue));

        property.isExpanded = EditorGUI.Foldout(progressRect, property.isExpanded, GUIContent.none, true);

        if (property.isExpanded)
        {
            var minRect = new Rect(progressRect.x, progressRect.yMax, 50, EditorGUIUtility.singleLineHeight);
            var valueRect = new Rect(minRect.xMax, progressRect.yMax, progressRect.width - 100, EditorGUIUtility.singleLineHeight);
            var maxRect = new Rect(valueRect.xMax, progressRect.yMax, 50, EditorGUIUtility.singleLineHeight);

            EditorGUI.PropertyField(minRect, minProperty, GUIContent.none, false);
            valueProperty.intValue = EditorGUI.IntSlider(valueRect, GUIContent.none, valueProperty.intValue, minProperty.intValue, maxProperty.intValue);
            EditorGUI.PropertyField(maxRect, maxProperty, GUIContent.none, false);
        }

        EditorGUI.EndProperty();
    }
    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        if (property.isExpanded)
            return EditorGUIUtility.singleLineHeight * 2.0f;
        else
            return EditorGUIUtility.singleLineHeight;
    }
}

There are some improvements to be made, primarily using a local bool instead of property.isExpanded, but I will review this later.



Comments

Popular posts from this blog

Unity3D/C# - Asynchronous Programming - Coroutines vs await/async

C# - Simple Graph Interfaces and a MeshGraph

Unity/C# - Grids - Simple Grid Segment struct