Saturday, April 11, 2020

Property Drawers


Explanation

So you implemented the FrameChecker from the last post but your editor is looking pretty bad. If you managed to make the FrameChecker appear in the inspector, you are seeing 3 integer boxes. Besides not looking great, it's probably making it hard for you to quickly modify things and have an idea of what you are actually doing.

Let's refresh what that class does. We created the FrameChecker to give it an animation clip and a range of frames in which we want to do something. For example, an attack. The animation for a punch may have 12 frames, but we only want to do damage in frames 4 to 8.

 

Problems

With the standard inspector you can input any frame number. You could even input negative frame numbers, or one greater than the total number of frames. Also you could make the start of the range bigger than the end, like hitting in frames 9 to 2. All of those are invalid states for our class, they break the implicit contract we were thinking of when we made it.

 

What do we want?

We want to enforce the contract, so you (or whoever uses this class) can only use valid inputs and therefore get valid outputs. Also we want a clear input interface and a way to provide visual feedback for valid and invalid values.

 

The Contract

  • The minimum possible frame number is 1.
  • The maximum possible frame number is the total frame number that the animation has.
  • hitFrameEnd should be bigger than hitFrameStart (they can't be equal).
  • I want a clear indicator of how many frames every move stage has (Startup, Active and Recovery)
The first 3 we can enforce via code. You would have to use C# setters or a custom method because in this case FrameChecker is not a MonoBehaviour, so we can't use OnValidate(). I won't go into this because there's already a lot of answers all over the internet.
Let's focus on the inspector.

 

Custom Inspectors and Property Drawers

Custom Inspectors and Property Drawers are types of scripts that allow us to customize the Unity Editor. The differences is that Custom Editors are used for MonoBehaviours and PropertyDrawers for any Serializable class.

If you create a standard C# class (as opposed to a Unity script that inherits from MonoBehaviour) you won't be able to see it in the inspector unless you make it Serializable.
That's easy, you just need to add a line just before your class definition:

[System.Serializable]
public class FrameChecker...


 
Note that as this class is not a MonoBehaviour you can't add it directly to a GameObjectyou'll need to add it as a property in a script that inherits from MonoBehaviour.
If you made the class Serializable you can now see it in the inspector as a list of it's public properties. Making a Property Drawer will allow us to customize that.
You can make all sort of crazy things with Custom Editors and Property Drawers, but what we want today is this:




To create a basic Property Drawer we need to do the following:
  • Make a new class (I'll call it FrameCheckerDrawer)
  • Make that class inherit from PropertyDrawer 
  • Declare an Attribute to let Unity know which class this PropertyDrawer is for.
    • Attributes are the things we declare inside square brackets, in this case it would be
      [CustomPropertyDrawer(typeof(FrameChecker))]
  • Override two methods: OnGUI and GetPropertyHeight. The first one is where we draw the inspector, in the second one we let Unity know how much space it will occupy.
So let´s see how that would look before we start actually coding the thing:

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(FrameChecker))]
public class FrameCheckerDrawer : PropertyDrawer {
  public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { ... }

   public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { ... }

}



Let's determine the height first. If you check the image above you'll see that we have basically 4 rows: 
  • The name of the property (Frame Checker) 
  • The frame count and its distribution in every move stage
  • The active range in text, for easier reading.
  • The active range as a slidable range selector, for easier handling.

Each of these has the same height:16px, and the distance between them is 4px. so the total height of our property drawer would be 80px (but you can use whatever height works for you).
As we also need the particular height of every row to draw it, I'll create two constants for that and fill our GetPropertyHeight method.


public class FrameCheckerDrawer : PropertyDrawer {
  const int yDistance = 20;
  const int fieldHeight = 16;

  public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { ... }

   public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
        return yDistance * 4;
    }

}


Now let's look at the actual drawing. We'll need a couple of things so, as I seem to really like dots, here's a list:


First let get the serialized properties. If you are not familiar with serialization and why we need it, check this. To do this, we'll use the property parameter that comes in the OnGUI method, that's the actual serialized property we are drawing, so in this case it would be the FrameChecker.
To get the properties, you just use their name:

SerializedProperty hitFrameStart = property.FindPropertyRelative("hitFrameStart");
SerializedProperty hitFrameEnd = property.FindPropertyRelative("hitFrameEnd");
SerializedProperty totalFrames = property.FindPropertyRelative("totalFrames");


Using those we will be able to get the current value and make our inspector modify them.
Now let's make the Rects, we will need them to specify where every one of our rows is inside the inspector. For that we will use the position parameter (that's the position that the Editor will start rendering the property) and add to its y component the distance that precedes the row in question. For the height we just use fieldHeight.

Rect nameRect = new Rect(position.x, position.y, position.width, fieldHeight);
Rect framesRect = new Rect(position.x, position.y + yDistance, position.width, fieldHeight);
Rect hitFramesRect = new Rect(position.x, position.y + yDistance * 2, position.width, fieldHeight);
Rect sliderRect = new Rect(position.x, position.y + yDistance * 3, position.width, fieldHeight);


Now we'll use all of that to render. First we need to wrap all we do with BeginProperty and EndProperty as we are gonna use MinMaxSlider and it needs it to work properly. As we are on it, let's create a label to show the title of the property and then modify the indentLevel, so the rest of our property will render nicely. Also, we should remember to decrease the indent level when we finish with this.

label = EditorGUI.BeginProperty(position, label, property);
EditorGUI.LabelField(nameRect, property.displayName);
EditorGUI.indentLevel++;
frameRangeSlider(ref hitFrameStart, ref hitFrameEnd, totalFrames.intValue, framesRect, hitFramesRect, sliderRect);
EditorGUI.indentLevel--;
EditorGUI.EndProperty();


As you can see, we also added a new method for our slider so the code is cleaner. If you are wondering what ref means, check this.
At this point, our OnGUI method should look like this:

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
SerializedProperty hitFrameStart = property.FindPropertyRelative("hitFrameStart");
SerializedProperty hitFrameEnd = property.FindPropertyRelative("hitFrameEnd");
SerializedProperty totalFrames = property.FindPropertyRelative("totalFrames");

Rect nameRect = new Rect(position.x, position.y, position.width, fieldHeight);
Rect framesRect = new Rect(position.x, position.y + yDistance, position.width, fieldHeight);
Rect hitFramesRect = new Rect(position.x, position.y + yDistance * 2, position.width, fieldHeight);
Rect sliderRect = new Rect(position.x, position.y + yDistance * 3, position.width, fieldHeight);

label = EditorGUI.BeginProperty(position, label, property);
EditorGUI.LabelField(nameRect, property.displayName);
EditorGUI.indentLevel++;
frameRangeSlider(ref hitFrameStart, ref hitFrameEnd, totalFrames.intValue, framesRect, hitFramesRect, sliderRect);
EditorGUI.indentLevel--;
EditorGUI.EndProperty();
 
}


In that new method, first we'll be using hitFrameStart, hitFrameEnd and totalFrames to build our texts. We will be using rich text so we can use colors and formatting, create the LabelFields and give some basic style to them with GUIStyle.
Then we'll add the MinMaxSlider, using BeginChangeCheck() and EndChangeCheck() as the documentation from BeginProperty says.

public void frameRangeSlider(ref SerializedProperty hitFrameStart, ref SerializedProperty hitFrameEnd,  
int totalFrames, Rect framesRect, Rect hitRect, Rect sliderRect) {
// Must be a float to use the MinMaxSlider
float start = hitFrameStart.intValue;
float end = hitFrameEnd.intValue;

GUIStyle style = new GUIStyle(EditorStyles.helpBox);
style.richText = true;

string frames = "<b>" + totalFrames + "</b> frames";
string startUp = "<color=green><b>" + (start - 1) + "</b> startup</color>";
string active = "<color=red><b>" + (end - start + 1) + "</b> active</color>";
        string recovery = "<color=blue><b>" + (totalFrames - end) + "</b> recovery</color>";

EditorGUI.LabelField(framesRect, frames + ": " + startUp + "| " + active + "| " + recovery, style);
EditorGUI.LabelField(hitRect, "Hits in:  <b>" + start + "</b> to <b>" + end + "</b>", style);

// and here we add the MinMaxSlider with our values
EditorGUI.BeginChangeCheck();
    EditorGUI.MinMaxSlider(sliderRect, ref start, ref end, 1, totalFrames);
    if (EditorGUI.EndChangeCheck()) {
    hitFrameStart.intValue = Mathf.RoundToInt(start);
    hitFrameEnd.intValue = Mathf.RoundToInt(end);
    }


}


And we are done! Whenever you add a FrameChecker to a script, you should see it with our new inspector. Remember that you need to put Editor scripts inside a folder called Editor in your Assets folder for them to work.


So now what?


Any feedback or questions? Let me know in the comments!
If you enjoyed the post, remember to check the Patreon!

You can also follow Strangewire on Twitter