2013-12-02

Non trivial Property Drawers in Unity

Another programming related post... I promise I'll publish something nicer next time.
Also, because Google insist on making their products consistently worse, I am unable to upload pictures. This is going to be even drier than usual.

Recently, Unity added a new tool to its arsenal, in the form of Property Drawers. They allow specifying how to display your own types inside Unity's editor interface, when contained as a variable of a MonoBehaviour. Previously we were limited to specifying how to draw a whole component, which had its limitations in terms of reuse.

So, now that you know they exist, you might start looking around for examples... only to find the most trivial one repeated over and over again: a Property Drawer for a class with one simple variable. But you want to display an array, or an array of colours, or a visual angle selector (which is used in an example, but its code is curiously missing). Something actually useful when editing complex games in Unity.

So, I'll save you an hour of reading through documentation and debugging weird errors. Property Drawers are very easy to use when you know what to pay attention to.

Basic case: a class with a Sprite picker

Let's create a simple class LayeredElement.cs, to organize Sprites.

LayeredElement.cs:

    [Serializable]
    public class LayeredElement {
        /// <summary>
        /// Order in which this element is painted,
        /// 0 being the background.
        /// </summary>
        public int layer;
        public int Layer { get { return this.layer; } }
    }

    [Serializable]
    public class NonConfigurablePart : LayeredElement {
        public Sprite sprite;
        public Sprite Sprite { get { return sprite; } }
    }


To display NonConfigurablePart we need to expose the layer and sprite variables. Currently I don't know how to display a texture/sprite selector using EditorGUI, so I'll use a simple selector and display the sprite independently.

NonConfigurablePartDrawer.cs:

    using UnityEditor;
    using UnityEngine;

    [CustomPropertyDrawer( typeof( NonConfigurablePart ) )]
    public class NonConfigurablePartDrawer : PropertyDrawer {
        // Default Unity line height
        private int textHeight = 16;
        // All sprites wiil take the same area.
        private int spriteDim = 60;

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

            // Store the original indentation.
            // Since we are being drawn inside a component,
            // changing it could modify how later elements are displayed.
            int origIndent = EditorGUI.indentLevel;

            // Obtain the properties we are interested in. Accessing variables by name is error prone, so be careful.
            SerializedProperty layerProp = property.FindPropertyRelative( "layer" );
            SerializedProperty spriteProp = property.FindPropertyRelative( "sprite" );

            // Name of the variable this NonConfigurablePart has in the current MonoBehaviour.
            EditorGUI.LabelField( new Rect( position.xMin, position.yMin, position.width, textHeight ), label );

            // We don't use indentation, actually, so just get rid of it
            EditorGUI.indentLevel = 0;
            // Calculate the area to use for the layer field.
            // 130 is the number of pixels Unity usually leaves before showing variable setters.
            // Found via trial and error =)
            Rect layerRect = new Rect( 130, position.yMin, position.width - 130, textHeight );

            // Check if it was modified this frame, to avoid overwriting the property constantly
            EditorGUI.BeginChangeCheck();
            int layer = EditorGUI.IntField( layerRect, "Layer", layerProp.intValue );
            if (layer < 0) layer = 0;
            if (EditorGUI.EndChangeCheck()) {
                layerProp.intValue = layer;
            }

            // Calculate where to draw the selector (right below the layer field).
            Rect selectorRect = new Rect( layerRect.xMin, layerRect.yMax, position.width - 130, textHeight );
            // PropertyField updates the property automatically, so no need to BeginChangeCheck
            EditorGUI.PropertyField( selectorRect, spriteProp, new GUIContent( "" ), false );

            // Calculate where to draw the sprite
            Rect spriteRect = new Rect( 130, selectorRect.yMax + 5, spriteDim, spriteDim );
            this.DrawSprite( (Sprite)spriteProp.objectReferenceValue, spriteRect );

            // That's it. Some little house cleaning and leave.
            EditorGUI.indentLevel = origIndent;
            EditorGUI.EndProperty();
        }

        // Calculate height required by the component.
        // Notice the absence of position; we cannot rely on a controlled width...
        public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
            // Layer (and label), selector and the sprite itself.
            return this.textHeight * 2 + this.spriteDim + 5;
        }

        private void DrawSprite(Sprite sprite, Rect spriteRect) {
            if (sprite != null) {
                EditorGUI.DrawTextureTransparent( spriteRect, sprite.texture, ScaleMode.ScaleToFit );
            } else {
                // Draw a visibly unconfigured rectangle
                EditorGUI.DrawRect( spriteRect, Color.magenta );
            }
        }
    }


There is nothing extraordinarily complex here, but some very specific calls and behaviours.
Keep reading for something a little bit more complex.


Complex case: dynamic size with an array of sprites

Let's add another LayeredElement:

    [Serializable]
    public class ConfigurablePart : LayeredElement {
        /// <summary>
        /// Possible configurations of this part.
        /// </summary>
        public Sprite[] sprites = new Sprite[3];

        public Sprite GetSprite(int spriteIndex) {
            return sprites[spriteIndex];
        }
    }


Here we will need to expose layer and an array of sprites. For extra points, we will allow changing the array size from the editor.

ConfigurablePartDrawer.cs:

    using UnityEditor;
    using UnityEngine;

    [CustomPropertyDrawer( typeof( ConfigurablePart ) )]
    public class ConfigurablePartDrawer : PropertyDrawer {

        private int textHeight = 16;
        private int spriteDim = 60;

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

            int origIndent = EditorGUI.indentLevel;

            EditorGUI.LabelField( new Rect( position.xMin, position.yMin, position.width, textHeight ), label );

            SerializedProperty layerProp = property.FindPropertyRelative( "layer" );
            SerializedProperty spritesProp = property.FindPropertyRelative( "sprites" );

            EditorGUI.indentLevel = 0;
            Rect layerRect = new Rect( 130, position.yMin, position.width - 130, textHeight );

            EditorGUI.BeginChangeCheck();
            int layer = EditorGUI.IntField( layerRect, "Layer", layerProp.intValue );
            if (layer < 0) layer = 0;
            if (EditorGUI.EndChangeCheck()) {
                layerProp.intValue = layer;
            }

            int arraySize = spritesProp.arraySize;

            layerRect = new Rect( 130, layerRect.yMax, position.width - 130, textHeight );

            // Array size. Beware of Unity's behaviour when resizing arrays.
            // If longer, the last element is repeated; if shorter, you lose the data.
            EditorGUI.BeginChangeCheck();
            int newSize = EditorGUI.IntField( layerRect, "Sprites", arraySize );
            if (newSize < 1) newSize = 1;
            if (EditorGUI.EndChangeCheck()) {
                spritesProp.arraySize = newSize;
                arraySize = newSize;
            }

            // Take a reference position for the sprite selectors, and draw below
            Rect selectorRect = layerRect;
            for (int i = 0; i < arraySize; ++i) {
                selectorRect = new Rect( selectorRect.xMin, selectorRect.yMax, position.width - 130, textHeight );
                // Accessing the array through serialized properties
                SerializedProperty spriteProp = spritesProp.GetArrayElementAtIndex( i );
                EditorGUI.PropertyField( selectorRect, spriteProp, new GUIContent( "" ), false );
            }

            // First reference
            int spriteColumn = 0;
            int spriteRow = 0;
            for (int i = 0; i < arraySize; ++i) {
                // Use the last selector as reference and increase columns and rows accordingly
                Rect spriteRect = new Rect( selectorRect.xMin + spriteColumn * (spriteDim + 5),
                                            selectorRect.yMax + 5 + spriteRow * (5 + spriteDim),
                                            spriteDim,
                                            spriteDim );

                this.DrawSprite( (Sprite)spritesProp.GetArrayElementAtIndex( i ).objectReferenceValue, spriteRect );

                ++spriteColumn;
                if (spriteColumn >= 3) {
                    spriteColumn = 0;
                    ++spriteRow;
                }
            }

            EditorGUI.indentLevel = origIndent;
            EditorGUI.EndProperty();
        }

        public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
            // layer + n * sprites + n / (sprites in a row) * sprite height
            int spriteCount = property.FindPropertyRelative( "sprites" ).arraySize;
            int spriteColumns = 1 + (spriteCount - 1)/ 3;
            return this.textHeight * 2 + this.textHeight * spriteCount + (this.spriteDim + 5) * spriteColumns;
        }

        private void DrawSprite(Sprite sprite, Rect spriteRect) {
            if (sprite != null) {
                EditorGUI.DrawTextureTransparent( spriteRect, sprite.texture, ScaleMode.ScaleToFit );
            } else {
                EditorGUI.DrawRect( spriteRect, Color.magenta );
            }
        }
    }


And that's it. We could add support for dynamic columns sizes, or made things prettier, but this is functional and educational enough.
I hope you'll use this new tool to make your artists' and designers' lives much better. Or even yours.

Limitations

Let's end stating the obvious limitations, and a strange issue I have with this demo.

First, since this draws independent properties, there is little you can do to configure how drawers for a common type behave. For example, there is no way I can configure all my NonConfigurablePropertyDrawers to display bigger sprites from Unity. Well, sure, there might be a way to hack some static information in there and expose it through the interface, but you'd display it every time- or hack a first-of-the-frame detector. But that's not usually required.

Second is the issue that Unity does not share the width of the Inspector area when calling GetPropertyHeight. With that simple data we could make menus more fluid, or change the way we display information on compressed configurations.

And the issue. If you try this demo (verified in Unity 3.1, with Texture2d instead of Sprite), you'll find that scrolling the inspector window results in the sprite drawers (and the magenta rectangles) appearing and dissappearing pretty much at random. I haven't found the cause, or whether that's caused by an error in my code, a bug in Unity, or some kind of render time limit I'm hitting. I've tested with just one rectangle, and since that failed, I'm guessing Unity does something it shouldn't.
Should you find a workaround, please let me know (comment here or tweet @elorahranma). I'll file a bug report one of these days, but being such a minor issue, I don't see it being fixed in the short term.

1 comment:

  1. Thanks for posting this - there are very few property drawer examples floating about on t'interweb.

    One thing that might help you avoid one hard coded value is when I call base.GetPropertyHeight() it returns a value that's one line of text high and which I am presuming is slightly more future proof than the existing textHeight constant ;)

    Incidentally, I found some property drawer repos on github:
    https://github.com/tenpn/ChestOfPropertyDrawers
    https://github.com/anchan828/property-drawer-collection

    Cheers, Alex

    ReplyDelete