Custom Property Drawers

Design your own GUI to modify Unity’s inspector

Download Files

Welcome! This tutorial is about the implementation of so called Custom Property Drawers in Unity. With them you can add functionality to the Unity editor. By specifying the appearance of your own classes in the Unity inspector you make it easier to configure your game objects. Therefore you can make the whole game development process with Unity more comfortable.

Thanks for reading and I hope you’ll enjoy the article!

Introduction

For the enemy selection we need to consider many different parameters: camera viewing angle, player position, rotation and more. Additionally these are continuous parameters which means that there are – philosophically speaking – infinite number of possible values. For example the camera viewing angle can be anything between 0° and 360° – even values like 156,468396820° are possible. So the question is how to consider all this endless number of values in a neat and comfortable way.

For this we’re using curves

Curves Example Explanation - Property Drawers

Unity’s Animation Curves

Unity already comes with curves – the so called Animation Curves. Here’s a short overview of their (default) properties:

  • Time

    Represented by the x-axis in a range between 0 and 1

  • Value

    Represented by the y-axis in a range between 0 and 1

  • Keys

    You can add multiple keys to the curve. A key contains a (time/value)-coordinate and tangent-information. Keys bring the curve into “shape”.

  • Presets

    Store your curves as presets in the Unity Editor. You can reuse them later.

1
2
3
Unity Animation Curve Example - Property Drawers
1

Key

is an XY-coordinate and defines a tangent

2

Presets

Choose from predefined curves

3

New

Store curve into presets-list

Then you’re able to evaluate the value (y) of the curve on a given time (x).

Curve Evaluation - Property Drawers

Activatable Interval Curves

Unity’s animation curves are really awesome, but we need some additional functionality:

  • Interval

    Unity’s standard animation curves have a time value (x-axis) in a range between 0 and 1 by default. Of course in the curve editor you can zoom out and change the range to an arbitrary value. For us this wasn’t handy enough, though. In our opinion zooming in and out in Unity’s curve editor isn’t very comfortable. That’s why we wanted the ability to change the range of the curve’s x-axis without the need of graphical editing. There should be simple text fields where you can type in the min and max boundaries of the x-axis.

  • Activatable

    Also we needed the possibility to activate and deactivate the curve. Deactivating the curve enables a default value. The value is returned for each given time (x). This can be pictured by a constant curve.

Actiavatable Interval Curve Illustration - Property Drawers concept

Let’s start coding!

We decided to seperate the interval’s code from the curve’s code. That’s why we firstly create a new script “Interval.cs” in the project view by right-clicking on your Scripts-directory and selecting “Create -> C# script” from the context menu.

Then copy the code from below and insert it into the new file. The comments in the snippet describe each code block in detail.

using UnityEngine;
using System.Collections;


namespace TFGames
{

    // Even though we'll write our own Property Drawer later, we make the class
    // serializable to temporarily use the default inspector for debug reasons
    //
    // Additionally the class doesn't need to inherit from MonoBehaviour since
    // it represents a property itself. It will be used by other scripts, but is
    // never attached to GameObjects directly
    //
    [System.Serializable]
    public class Interval
    {
        [Tooltip("Minimum value of the interval")]
        public float min;

        [Tooltip("Maximum value of the interval")]
        public float max;

        [Tooltip("Check if a value should be mirrored at the zero point")]
        public bool mirrored;

        [Tooltip("The value which schould be mirrored at the zero point")]
        public float mirrorValue;


        // The absolute size of the interval
        // Examples:
        //  - size(0;3) = 3;
        //  - size(-2;5) = 7;
        //  - size(5;10) = 5;
        //
        public float Size
        {
            get
            {
                return Max - Min;
            }
        }


        // Returns the minimum value of the interval considering whether the interval is mirrored or not
        //
        public float Min
        {
            get
            {
                if (!mirrored)
                {
                    return min;
                }
                else
                {
                    // In mirrored-mode the minimum is always a negative value
                    return mirrorValue >= 0 ? -mirrorValue : mirrorValue;
                }
            }
        }


        // Returns the maximum value of the interval considering whether the interval is mirrored or not
        //
        public float Max
        {
            get
            {
                if (!mirrored)
                {
                    return max;
                }
                else
                {
                    // In mirrored-mode the maximum is always a positive value
                    return mirrorValue >= 0 ? mirrorValue : -mirrorValue;
                }
            }
        }


        // Some different constructors
        //
        public Interval(float mirrorValue)
        {
            this.mirrored = true;
            this.mirrorValue = mirrorValue;
        }

        public Interval(float min, float max)
        {
            this.mirrored = false;
            this.min = min;
            this.max = max;
        }

        public Interval(float min, float max, bool mirrored)
        {
            this.mirrored = mirrored;
            this.mirrorValue = min;
            this.min = min;
            this.max = max;
        }


        // The method maps the given value onto a (0;1)-interval
        // with open ends
        //
        // Examples:
        //  - Interval ( 1; 3) -> normalize(2)   = 0.5;
        //  - Interval ( 0; 1) -> normalize(0.5) = 0.5;
        //  - Interval (-2; 2) -> normalize(-3)  = -0.25;
        //  - Interval ( 2; 4) -> normalize(5)   = 1.5;
        //
        public float Normalize(float value)
        {
            return (value - min) / Size;
        }


        // The method maps the given value onto a (0;1)-interval.
        // The interval's boundaries are considered and the result
        // is clamped between 0 and 1
        //
        // Examples:
        //  - Interval ( 1; 3) -> normClamped(2)   = 0.5;
        //  - Interval ( 0; 1) -> normClamped(0.5) = 0.5;
        //  - Interval (-2; 2) -> normClamped(-3)  = 0;
        //  - Interval ( 2; 4) -> normClamped(5)   = 1;
        //
        public float NormalizeClamped(float value)
        {
            return Mathf.Clamp01(Normalize(value));
        }
    }
}

Now we have to combine Unity’s animation curve with the currently created Interval-class. Add a new script named “ActivatableIntervalCurve.cs” by right-clicking onto your Scripts-directory in the project view and selecting “Create -> C# script” from the context menu.

Then copy the code below and insert it into the new file. The comments in the snippet describe each code block in detail.

using UnityEngine;
using System.Collections;


namespace TFGames
{

    // Even though we'll write our own Property Drawer later, we make the class
    // serializable to temporarily use the default inspector for debug reasons
    //
    // Additionally the class doesn't need to inherit from MonoBehaviour since
    // it represents a property itself which will be used by other scripts, but is
    // never attached to GameObjects directly
    //
    [System.Serializable]
    public class ActivatableRatioCurve
    {
        [Tooltip("Check if the curve should be evaluated \n" +
                 "Uncheck if the default value should be returned instead")]
        public bool curveEnabled;

        [Tooltip("The default value is returned when the curve is disabled")]
        public float defaultValue;

        [Tooltip("The curve is evaluated when 'Curve Enabled' is checked")]
        public AnimationCurve curve;

        [Tooltip("The interval to which the x-axis of the curve should be mapped \n" +
                 "    0 is mapped to the interval's min-value" +
                 "    1 is mapped to the interval's max-value")]
        public Interval interval;


        // The method evaluates the curve on the given time or - if the curve
        // is disabled - returns the default value
        //
        public float Evaluate(float time)
        {
            return curveEnabled ? EvaluateCurve(time) : defaultValue;
        }


        // The given time in interval-space is transformed into a value between
        // 0 and 1, which is used to evaluate Unity's animation curve.
        //
        private float EvaluateCurve(float time)
        {
            float normalizedValue = interval.NormalizeClamped(time);
            return curve.Evaluate(normalizedValue);
        }
    }
}

Custom Property Drawers

Currently the inspector of our activatable interval curve looks like this:

Default inspector - no property drawers used

This doesn’t look very convenient, right? The data isn’t well-structured, all information is shown at once, and the curve and interval don’t seem to belong together. Ok, let’s make some considerations about how this can be improved…

Property drawers concept

General Structure

Custom Property Drawers are editor scripts which are responsible for displaying custom data types (e.g. our interval class) in a handy way. The following snippet shows the general structure of such a script:

using UnityEngine;

// Since PropertyDrawer is an editor script, the UnityEditor-namespace is required
using UnityEditor;
using System.Collections;
using TFGames;

namespace TFGamesEditor
{
    // PropertyDrawers need to be labled with a CustomPropertyDrawer-attribute
    // The data type of the class for which the drawer is created for, needs to be
    // passed as a parameter. In our situation we want to create a drawer for our
    // Interval-class. That's why we pass 'typeof(Interval)'
    //
    [CustomPropertyDrawer(typeof(Interval))]

    // Then the IntervalDrawer must inherit from PropertyDrawer
    // It provides two functions called 'OnGUI (...)' which is responsible for
    // drawing the GUI and 'GetPropertyHeight (...)' which specifies the height of
    // the GUI in pixels.
    //
    public class IntervalDrawer : PropertyDrawer
    {
        
        // Override OnGUI to create your custom GUI for a data type like our
        // Interval-class in the Inspector
        //
        // PARAMETERS
        //   - position: The start position of the property. Place your
        //               GUI elements relatively to this position.
        //   - property: The property containing the data which should be displayed.
        //               In our case this parameter represents the Interval in a
        //               serialized form.
        //   - label:    The name of the property
        //
        public override void OnGUI(Rect position,
                                   SerializedProperty property,
                                   GUIContent label)
        {
            // Draw the GUI elements here
        }

        
        // Override GetPropertyHeight to specify the space needed to draw the
        // property. If not overwritten, the default height of a single line in
        // the inspector is used (which for instance is the height of a simple
        // float-field).
        //
        public override float GetPropertyHeight(SerializedProperty property,
                                                GUIContent label)
        {
            // return the height of the property
        }
    }
}

Editor scripts

Editor scripts extend the functionality of the Unity editor – that’s why they are called ‘editor scripts’ :O

You have to put all of those scripts into folders labled as ‘Editor’ (multiple editor-folders can exist). Then in editor mode Unity compiles the scripts inside the folders and their subfolders and ignores them when the game is built.

editor scripts

Also each script uses the namespace ‘UnityEditor’:

using namespace UnityEditor;

Our own property drawers

Interval drawer

Ok, you’ve learned the general structure of a property drawer. Now we try to adapt these concepts to a concrete implementation. The following code snippet is the drawer for our Interval-class. The images on the right illustrate the results of the different code blocks.

Property drawers in general
using UnityEngine;
using UnityEditor;           // PropertyDrawer requires to use the UnityEditor-namespace
using System.Collections;
using TFGames;

namespace TFGamesEditor
{
    [CustomPropertyDrawer(typeof(Interval))]
    public class IntervalDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position,
                                   SerializedProperty property,
                                   GUIContent label)
        {
            // Using BeginProperty / EndProperty on the parent property means that
            // prefab override logic works on the entire property.
            EditorGUI.BeginProperty(position, label, property);


            // Draw label and calculate new position
            position = EditorGUI.PrefixLabel(position,
                                             GUIUtility.GetControlID(FocusType.Passive),
                                             label);


            // Don't make child fields be indented
            int indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;


            // Calculate positions and dimensions for the GUI elements
            //  - min-field       ... X-pos:  0%, width: 40%  
            //  - mirror-checkbox ... X-pos: 40%, width: 20%   |> 100% of available width
            //  - max-field       ... X-pos: 60%, width: 40%  /
            Rect minRect = new Rect(position.x,
                                    position.y,
                                    position.width * 0.4f - 5,
                                    position.height);

            Rect mirroredRect = new Rect(position.x + position.width * 0.4f,
                                         position.y,
                                         position.width * 0.2f,
                                         position.height);

            Rect maxRect = new Rect(position.x + position.width * 0.6f + 5,
                                    position.y,
                                    position.width * 0.4f - 5,
                                    position.height);


            // Get properties by exactly passing the names of the interval's attributes
            SerializedProperty minProp = property.FindPropertyRelative("min");
            SerializedProperty maxProp = property.FindPropertyRelative("max");
            SerializedProperty mirroredProp = property.FindPropertyRelative("mirrored");
            SerializedProperty mirrorValueProp = property.FindPropertyRelative("mirrorValue");


            // Check if interval is mirrored
            if (!mirroredProp.boolValue)
            {
                // Draw minimum-field. Pass GUIContent.none to not draw the
                // label of the property
                EditorGUI.PropertyField(minRect, minProp, GUIContent.none);

                // Draw maximum-field
                EditorGUI.PropertyField(maxRect, maxProp, GUIContent.none);

                // If the user types in a heigher minimum value than the maximum
                // value, correct it
                if (minProp.floatValue > maxProp.floatValue)
                    maxProp.floatValue = minProp.floatValue;
            }
            else
            {
                // Draw mirrorValue-field
                EditorGUI.PropertyField(minRect, mirrorValueProp, GUIContent.none);

                // Make the field for the inverted mirror-value read-only
                //   GUI.enabled = false -> the field is read-only
                //   GUI.enabled = true  -> the field can be written
                GUI.enabled = false;
                EditorGUI.FloatField(maxRect,
                                     GUIContent.none,
                                     -mirrorValueProp.floatValue);
                GUI.enabled = true;

                // If the user types in a positive mirror value, then invert it to
                // make it negative again
                if (mirrorValueProp.floatValue > 0)
                    mirrorValueProp.floatValue = -mirrorValueProp.floatValue;
            }

            // Always draw the mirrored-checkbox
            EditorGUI.PropertyField(mirroredRect, mirroredProp, GUIContent.none);


            // Set indent back to what it was
            EditorGUI.indentLevel = indent;

            EditorGUI.EndProperty();
        }

        public override float GetPropertyHeight(SerializedProperty property,
                                                GUIContent label)
        {
            // Use Unity's default height, which is a single line
            // in the inspector
            return base.GetPropertyHeight(property, label);
        }
    }
}

Activatable Interval Curve drawer

Lastly we code the drawer for the activatable interval curve. The description below works in the same way as before. On the left side there’s the actual code with lots of comments, which describe each line in detail. On the right the images illustrate the results of the different code blocks.

Interval label - Property Drawers
Interval rects - Property Drawers
interval mirror disabled - Property Drawers
interval mirror enabled - Property Drawers
Interval height - Property Drawers
using UnityEngine;
using UnityEditor;
using System.Collections;
using TFGames;

namespace TFGamesEditor
{
    [CustomPropertyDrawer(typeof(ActivatableRatioCurve))]
    public class ActivatableRatioCurveDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position,
                                   SerializedProperty property,
                                   GUIContent label)
        {
            // Using BeginProperty / EndProperty on the parent property means that
            // prefab override logic works on the entire property.
            EditorGUI.BeginProperty(position, label, property);

            // Precalculate some height values
            float heightHalf = (position.height - 20) * 0.5f;
            float heightOneFourth = (position.height - 20) * 0.25f;


            // Draw the label and calculate the new position
            // This time the label should be placed a bit lower.
            // That's why you need to adjust the shift again after drawing the label.
            Rect labelRect = new Rect(position.x,
                                      position.y + heightOneFourth,
                                      position.width,
                                      position.height);

            position = EditorGUI.PrefixLabel(labelRect,
                                             GUIUtility.GetControlID(FocusType.Passive),
                                             label);

            position.y -= heightOneFourth;


            // Don't make child fields be indented
            var indent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;


            // Calculate positions and dimensions for the GUI elements
            Rect enableRect = new Rect(position.x,
                                       position.y + heightOneFourth,
                                       20,
                                       heightHalf);

            Rect curveRect = new Rect(position.x + 25,
                                      position.y,
                                      position.width - 25,
                                      heightHalf - 2.5f);

            Rect intervalRect = new Rect(position.x + 25,
                                         position.y + heightHalf + 2.5f,
                                         position.width - 25,
                                         heightHalf);

            Rect defaultRect = new Rect(position.x + 25,
                                        position.y + heightOneFourth,
                                        position.width - 25,
                                        heightHalf);

            // Get properties by exactly passing the names of the interval's attributes
            SerializedProperty enabled = property.FindPropertyRelative("curveEnabled");
            SerializedProperty interval = property.FindPropertyRelative("interval");
            SerializedProperty curve = property.FindPropertyRelative("curve");
            SerializedProperty defaultField = property.FindPropertyRelative("defaultValue");


            // Check if the curve is enabled
            if (enabled.boolValue)
            {
                // Draw the checkbox for enabling/disabling the curve
                // Also draw the curve and the interval below
                EditorGUI.PropertyField(enableRect, enabled, GUIContent.none);
                EditorGUI.PropertyField(intervalRect, interval, GUIContent.none);
                EditorGUI.PropertyField(curveRect, curve, GUIContent.none);
            }
            else
            {
                // Draw the checkbox for enabling/disabling the curve
                // Instead of the curve, draw a float field to input the
                // default value when the curve is disabled
                EditorGUI.PropertyField(enableRect, enabled, GUIContent.none);
                EditorGUI.PropertyField(defaultRect, defaultField, GUIContent.none);
            }

            // Set indent back to what it was
            EditorGUI.indentLevel = indent;

            EditorGUI.EndProperty();
        }

        public override float GetPropertyHeight(SerializedProperty property,
                                                GUIContent label)
        {
            // Height is two times the standard height plus 20 pixels
            return base.GetPropertyHeight(property, label) * 2 + 20;
        }
    }
}

Property stack

You may noticed that the drawer for the activatable interval curve makes use of the Interval-drawer. Also, the Interval-drawer makes use of other simple property drawers like a float field or a checkbox. So your own drawers can be reused in other drawers. This principle can be applied as often as you want to create more and more complex objects. For instance you can use our activatable interval curve drawer in another drawer, which may add a button next to the interval curve.

Property Drawers Stack

Thank you…

…for reading this article. We hope you enjoyed it 🙂

4 replies
  1. KnightOwl
    KnightOwl says:

    What is your policy on using your tutorials while developing my own game?  It seems the tutorial was made for general use, but the lawyer-people have me scared to use any tutorial without first checking to make sure there are no ‘strings attached’.

    Thanks,
    KnightOwl
    (aka: overly-cautious fellow game developer)

    Reply
    • 25games
      25games says:

      Hey KnightOwl,

      thanks for your advice that we have to add a policy-remark to our tutorials!
      The “Property-Drawer”-tutorial and all its attached images and project files are for private and commercial use. So don’t worry about that 🙂

      Thanks for the comment and have a nice day,
      Severin

      Reply
      • KnightOwl
        KnightOwl says:

        Now that I have more closely looked through the tutorial, it is actually pretty good!

        Drawers seem like a lesser used but very powerful feature of Unity.  Sadly it looks like there has not been much good documentation on how to implement this feature in a successful manner in the past. Thank you for covering this well, it has already sparked some ideas on how to better use Drawers while developing my own game.

        Good luck with all your endeavors and hope to see more insightful tutorials in the future. If you ever need insight implementing engineering simulations in the Unity platform, let me know 😛

        Reply

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *

*

Curve label - Property Drawers
Curve rects - Property Drawers
Curve fields - Property Drawers
Curve constant value - Property Drawers
Curve Height - Property Drawers