Personalization of IMGUI and Unity Editor. Part two

Original author: Richard Fine
  • Transfer
More than a year has passed since the release of the new Unity UI system, so Richard Fine decided to write about its predecessor, IMGUI. In the last part of the material, we looked at how to create MyCustomSlider. We got a simple IMGUI functional element that can be used in custom editors, PropertyDrawers, EditorWindows, etc. But that is not all. In the second part of the article we will talk about how you can expand its functionality, for example, add the ability to multi-edit.




Control functions

Another important point is the relationship of IMGUI with the Scene View component. You must be familiar with auxiliary UI elements, such as orthogonal arrows, rings, or lines, that let you move, rotate, and scale objects. These elements are called control functions. Interestingly, they are also supported in IMGUI.

The standard elements of the GUI and EditorGUI classes used in Unity Editor / EditorWindows are two-dimensional, but the basic concepts of IMGUI, such as control identifiers and event types, are not bound to either the Unity editor or 2D. The control functions for three-dimensional Scene View elements are represented by the Handles class , which replaces the GUI and EditorGUI. For example, instead of the EditorGUI.IntField function, creating an element for editing a single integer, you can use a function that allows you to edit the Vector3 value using the interactive arrows in Scene View:

Vector3 PositionHandle(Vector3 position, Quaternion rotation);


Control functions can also be used to create custom interface elements. In this case, the basic concepts remain the same as when creating the editor elements, although the interaction with the mouse is somewhat complicated: in a three-dimensional environment, simply checking for the coordinates of the cursor with the rectangle is no longer enough. The HandleUtility class may come in handy here.

Prescribing function OnSceneGUI in a custom class editor, you can use the control functions in the editors, and the functions of the GUI - in the Scene View. To do this, you will have to make additional efforts: install GL matrices or use Handles.BeginGUI () and Handles.EndGUI () to set the context.

State objects

In the case of MyCustomSlider, we needed to track 2 things: the floating value of the slider (which was transmitted by the user and returned to it) and the change in the slider at a specific point in time (for this we used the hotControl element). But what if the element contains a lot more information?

IMGUI provides a simple storage system for so-called state objects associated with interface elements. To do this, you need to define a new class that will be used to store data, and associate the new object with the identifier of the control. Each object can be assigned no more than one identifier, and IMGUI does this on its own using the built-in constructor. When loading the editor code, such objects are not serialized (even if the [Serializable] label is set), therefore they cannot be used for long-term data storage.

Suppose we need a button that returns TRUE each time it is pressed, but it lights up red if you hold it for more than two seconds. To track the time a button is clicked, we will use a state object. Declare a class:

public class FlashingButtonInfo
{
      private double mouseDownAt;
      public void MouseDownNow()
      {
      		mouseDownAt = EditorApplication.timeSinceStartup;
      }
      public bool IsFlashing(int controlID)
      {
            if (GUIUtility.hotControl != controlID)
                  return false;
            double elapsedTime = EditorApplication.timeSinceStartup - mouseDownAt;
            if (elapsedTime < 2f)
                  return false;
            return (int)((elapsedTime - 2f) / 0.1f) % 2 == 0;
      }
}


The time the button is pressed will be stored in the mouseDownAt property when MouseDownNow () is called, and the IsFlashing function will determine whether the button should be lit in red at the moment. Naturally, if hotControl is not involved or less than two seconds have passed since the button was pressed, the button will not light up. But otherwise, its color will change every 0.1 seconds.

Now write the code for the button itself:

public static bool FlashingButton(Rect rc, GUIContent content, GUIStyle style)
{
        int controlID = GUIUtility.GetControlID (FocusType.Native);
        // Get (or create) the state object
        var state = (FlashingButtonInfo)GUIUtility.GetStateObject(
                                             typeof(FlashingButtonInfo), 
                                             controlID);
        switch (Event.current.GetTypeForControl(controlID)) {
                case EventType.Repaint:
                {
                        GUI.color = state.IsFlashing (controlID) 
                            ? Color.red 
                            : Color.white;
                        style.Draw (rc, content, controlID);
                        break;
                }
                case EventType.MouseDown:
                {
                        if (rc.Contains (Event.current.mousePosition) 
                         && Event.current.button == 0
                         && GUIUtility.hotControl == 0) 
                        {
                                GUIUtility.hotControl = controlID;
                                state.MouseDownNow();
                        }
                        break;
                }
                case EventType.MouseUp:
                {
                        if (GUIUtility.hotControl == controlID)
                                GUIUtility.hotControl = 0;
                        break;
                }
        }
        return GUIUtility.hotControl == controlID;
}


Everything is extremely simple. Note that the code snippets for responding to mouseDown and mouseUp are very similar to the ones we used earlier to handle the capture of the slider in the scroll bar. The only differences are the call to state.MouseDownNow () when the mouse button is clicked, as well as the change in the value of the GUI.color when the button is redrawn.

You may have noticed another difference related to the redraw event, namely the call to style.Draw (). This is worth talking about in more detail.

GUI Styles

When creating our first element, we used a GUI.DrawTextureto draw the slider itself. But with the FlashingButton element, everything is not so simple - the button should include not only an image in the form of a rounded rectangle, but also an inscription. We could try to draw a button using the GUI.DrawTexture and place GUI.Label on top of it, but there is a better way. Let's try to use the GUI.Label image rendering technique without using GUI.Label itself.

The GUIStyle class contains information about the visual properties of an interface element: from the font and text color to the intervals between elements. In addition, GUIStyle stores functions used to determine the width and height of objects using the style, as well as to directly draw elements on the screen.

GUIStyle can include different styles of drawing an element: when the cursor is on it, when it received keyboard input focus, when it is disabled or when it is active (with the mouse button held down). For any state, you can define the color and background image, and the GUIStyle will substitute them when rendering the element based on its control ID.

There are 4 ways to use GUIStyles to draw interface elements:

• Write a new style (new GUIStyle ()), setting the required values.
• Use one of the built-in styles of the EditorStyles class (if you want your custom elements to look like standard ones).
• If you need to slightly modify an existing style, for example, align the button text to the right. You can copy the desired style of the EditorStyles class and change the desired property manually.
• Extract style from GUISkin .

GUISkin is a large collection of GUIStyle objects that can be created in the project itself as a separate resource and edited using the Unity Inspector. Having created a new GUISkin and opening it, you will see slots for all standard interface elements: buttons, text windows, switches, etc. But the section of user styles is of particular interest. Here you can put any number of GUIStyle objects with unique names that can be retrieved using the GUISkin.GetStyle (“style_name”) method. It remains to figure out how to load GUISkin objects from code. There are several ways to do this. If the object is in the Editor Default Resources folder, use the EditorGUIUtility.LoadRequired () function ; to load from another directory use AssetDatabase.LoadAssetAtPath () . The main thing is in no case to place resources intended only for the editor in the resource packages or in the Resources folder.

Now that we have a GUIStyle, we can draw a GUIContent containing the desired text, image, and tooltip using GUIStyle.Draw () . As arguments, the coordinates of the rectangle in which the drawing is performed, the GUIContent itself and the identifier of the control element are used.

IMGUI markup

You may have noticed that each of the interface elements we examined had a Rect parameter that determines its position on the screen. However, we just talked about how GUIStyle includes markup properties. The question begs: is it really necessary to manually calculate all the values ​​of Rect, taking into account the peculiarities of the markup? In principle, it is possible. But IMGUI offers a simpler solution - a markup mechanism that does this automatically.

There is a special type of events for this - EventType.Layout. After the IMGUI sends such an event to the interface, its elements call the markup functions: GUILayoutUtility.GetRect () , GUILayout.BeginHorizontal / Vertical , and GUILayout.EndHorizontal / Verticalother. IMGUI stores the results of these calls in the form of a tree that contains all the interface elements and the space required for them. After building the tree, its recursive traversal is performed, during which the sizes of the elements and their position relative to each other are calculated.

When any other event, such as EventType.Repaint, is fired, the elements call the markup functions again. But this time, IMGUI repeats the “recorded” calls and returns the finished rectangles. In other words, if the parameters of the rectangles were already calculated using the GUILayoutUtility.GetRect () function during the Layout event, it will simply substitute the result saved earlier when another event is triggered.

By analogy with the identifiers of control elements, when executing the Layout event and other events, it is important to observe the order of the layout functions calls so that the elements do not receive the data of other rectangles. It is also worth considering that the values ​​returned by calling GUILayoutUtility.GetRect () during the Layout event are useless, because IMGUI will not know which element each rectangle corresponds to until the event ends and the tree is processed.

So, let's add markup for our strip with a slider. This is not difficult: after receiving a square from IMGUI, we can call the ready-made code:

public static float MyCustomSlider(float value, GUIStyle style)
{
	Rect position = GUILayoutUtility.GetRect(GUIContent.none, style);
	return MyCustomSlider(position, value, style);
}


If you call GUILayoutUtility.GetRect during the Layout event, the IMGUI remembers that a certain style is needed for empty content (empty because no image or text is specified for it). During other events, GetRect returns an existing rectangle. It turns out that during the Layout event, our MyCustomSlider element will be called with the wrong rectangle, but this does not matter, because without it we still cannot call GetControlID ().

All the data on the basis of which IMGUI determines the size of the rectangle is contained in the style. But what if the user wants to set one or more parameters manually?

The GUILayoutOption class is used for this .. Objects of this class are a kind of instructions for the markup system, indicating how the rectangle should be calculated (for example, have a certain height / width value or fill the available space vertically / horizontally). To create such an object, you need to call the factory functions of the GUILayout class, such as GUILayout.ExpandWidth () or GUILayout.MinHeight () , and pass them to GUILayoutUtility.GetRect () as an array. Then they are saved in the markup tree and are taken into account when processing it.

Instead of creating our own arrays from GUILayoutOption objects, we use the C # params keyword, which allows you to call a method with any number of parameters from which the array is automatically composed. This is how the new function of our strip looks like:

public static float MyCustomSlider(float value, GUIStyle style, params GUILayoutOption[] opts)
{
	Rect position = GUILayoutUtility.GetRect(GUIContent.none, style, opts);
	return MyCustomSlider(position, value, style);
}


As you can see, all the data entered by the user is transmitted directly to GetRect.

A similar method of combining the function of an IMGUI element with a version of the same function that uses automatic layout by markup is applicable to any IMGUI element, including those built into the GUI class. It turns out that the GUILayout class provides hosted versions of elements from the GUI class (and we use the EditorGUILayout class corresponding to EditorGUI).

In addition, elements placed automatically and manually can be combined. The space is reserved using GetRect, after which it can be divided into separate sections for various elements. The markup system does not use identifiers of control elements; therefore, several elements can be placed on one rectangle (or vice versa). Sometimes this approach works much faster than with fully automatic placement.

Please note that markup is not recommended when writing PropertyDrawers; instead, it is better to use a rectangle passed to PropertyDrawer.OnGUI () overload. The fact is that the Editor class itself does not use markup, but calculates a simple rectangle that moves down for each of the following properties. Therefore, if markup is used for the PropertyDrawer, the Editor will not be aware of the previous properties and, therefore, will not position the rectangle correctly.

Using Serialized Properties

So you can already create your own IMGUI element. It remains to discuss a couple of points that will help bring it to the Unity quality standard.

The first is using SerializedProperty. We will talk about the serialization system in more detail in the next article, but for now we will generalize: the SerializedProperty interface allows you to access any property to which the Unity serialization (loading and saving) system is connected. Thus, we can use any variable from scripts or objects displayed in Unity Inspector.
SerializedProperty provides access not only to the value of a variable, but also to various kinds of information, for example, comparing the current and initial values ​​of a variable or the state of a variable with child fields in the Inspector window (collapsed / expanded). In addition, the interface integrates any user-defined changes to the value of the variable in Undo and scene-dirtying systems. It does not use a managed version of the object, which positively affects performance. Therefore, the use of SerializedProperty is necessary for the full functioning of any complex interface elements.

The signature of the EditorGUI class methods that receive SerializedProperty objects as arguments is slightly different than usual. Such methods return nothing, because changes are made directly to SerializedProperty. An improved version of our strip will look like this:

public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style)


Now we do not have the value parameter; instead, the prop parameter is passed to SerializedProperty. With prop.floatValue, we can retrieve the value of the floating number when drawing the strip and change it when dragging the slider.

There are other benefits to using SerializedProperty in IMGUI code. Suppose a prefabOverride value shows changes to a property value in a template object. By default, changed properties are shown in bold, but we can set a different display style using GUIStyle.

Another important opportunity is editing multiple objects, that is, displaying several values ​​at once with the help of one element. If for the value EditorGUI.showMixedValueset to TRUE, the element is used to display multiple values.
Using the prefabOverride and showMixedValue mechanisms requires setting the context for the property using EditorGUI.BeginProperty () and EditorGUI.EndProperty () . Typically, if an element method takes an argument of the SerializedProperty class, it must itself call BeginProperty and EndProperty. If it accepts “pure” values ​​(for example, the EditorGUI.IntField method, which accepts int and does not work with properties), the calls to BeginProperty and EndProperty must be contained in the code that calls this method.

public class MySliderDrawer : PropertyDrawer
{
    public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
    {
        return EditorGUIUtility.singleLineHeight;
    }
    private GUISkin _sliderSkin;
    public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
    {
        if (_sliderSkin == null)
            _sliderSkin = (GUISkin)EditorGUIUtility.LoadRequired ("MyCustomSlider Skin");
        MyCustomSlider (position, property, _sliderSkin.GetStyle ("MyCustomSlider"), label);
    }
}
// Then, the updated definition of MyCustomSlider:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style, GUIContent label)
{
    label = EditorGUI.BeginProperty (controlRect, label, prop);
    controlRect = EditorGUI.PrefixLabel (controlRect, label);
    // Use our previous definition of MyCustomSlider, which we’ve updated to do something
    // sensible if EditorGUI.showMixedValue is true
    EditorGUI.BeginChangeCheck();
    float newValue = MyCustomSlider(controlRect, prop.floatValue, style);
    if(EditorGUI.EndChangeCheck())
        prop.floatValue = newValue;
    EditorGUI.EndProperty ();
}


Conclusion

I hope this article helps you understand the basics of IMGUI. To become a true professional, you will have to master many other aspects: the SerializedObject / SerializedProperty system, the features of working with CustomEditor / EditorWindow / PropertyDrawer, the use of the Undo class, etc. In one way or another, IMGUI allows you to unlock Unity's vast potential for creating custom tools for sale on the Asset Store or personal use.

Also popular now: