[Перевод] Персонализация IMGUI и редактора Unity. Часть вторая

С момента выхода новой системы Unity UI прошло больше года, поэтому Ричард Файн решил написать о ее предшественнице — IMGUI. В прошлой части материала мы разобрали как создать MyCustomSlider. У нас получился простой функциональный элемент IMGUI, который можно использовать в пользовательских редакторах, PropertyDrawers, EditorWindows и т. д. Но это еще не всё. Во второй части статьи мы поговорим о том, как можно расширить его функционал, например добавить возможность мультиредактирования.

59a333b26acd4d06a62c3f0a21e28c77.jpg

Управляющие функции

Еще один немаловажный момент — взаимосвязь IMGUI с компонентом Scene View. Вы, должно быть, знакомы со вспомогательными элементами UI, такими как ортогональные стрелки, кольца или линии, которые позволяют перемещать, вращать и масштабировать объекты. Эти элементы называются управляющими функциями. Что интересно, они также поддерживаются в IMGUI.

Стандартные элементы классов GUI и EditorGUI, используемые в Unity Editor / EditorWindows, двухмерны, но основные концепции IMGUI, такие как идентификаторы управляющих элементов и типы событий, не привязаны ни к редактору Unity, ни к 2D. Управляющие функции для трехмерных элементов Scene View представлены классом Handles, который заменяет собой GUI и EditorGUI. Например, вместо функции EditorGUI.IntField, создающей элемент для редактирования одного целого числа, можно использовать функцию, позволяющую редактировать значение Vector3 при помощи интерактивных стрелок в Scene View:

Vector3 PositionHandle(Vector3 position, Quaternion rotation);

Управляющие функции также можно использовать для создания собственных элементов интерфейса. В этом случае основные концепции остаются такими же, как при создании элементов редактора, хотя взаимодействие с мышью несколько усложнено: в трехмерной среде уже недостаточно простой проверки на соответствие координат курсора с прямоугольником. Здесь вам может пригодиться класс HandleUtility.

Прописав функцию OnSceneGUI в пользовательском классе редактора, вы сможете использовать управляющие функции в редакторах, а функции из GUI — в Scene View. Для этого придется приложить дополнительные усилия: установить GL-матрицы или применить Handles.BeginGUI () и Handles.EndGUI () для установки контекста.

Объекты состояния

В случае с MyCustomSlider нам нужно было отслеживать 2 вещи: плавающее значение ползунка (которое передавалось пользователем и возвращалось к нему) и изменение ползунка в конкретный момент времени (для этого мы использовали элемент hotControl). Но что, если в элементе содержится намного больше информации?

IMGUI предоставляет простую систему хранения так называемых объектов состояния, связанных с элементами интерфейса. Для этого нужно определить новый класс, который будет использоваться для хранения данных, и связать новый объект с идентификатором управляющего элемента. Каждому объекту можно присвоить не более одного идентификатора, причем IMGUI делает это сам — с помощью встроенного конструктора. При загрузке кода редактора такие объекты не сериализуются (даже если поставлена метка [Serializable]), поэтому их нельзя использовать для долговременного хранения данных.

Предположим, нам нужна кнопка, которая возвращает значение TRUE при каждом нажатии, но при этом загорается красным, если удерживать ее дольше двух секунд. Для отслеживания времени нажатия кнопки мы воспользуемся объектом состояния. Объявим класс:

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;
      }
}


Время нажатия кнопки будет храниться в свойстве mouseDownAt при вызове MouseDownNow (), а функция IsFlashing будет определять, должна ли кнопка гореть красным в данный момент. Естественно, если не задействован hotControl или с момента нажатия кнопки прошло менее двух секунд, кнопка не загорится. Но в противном случае ее цвет будет меняться каждые 0,1 секунды.

Теперь напишем код для самой кнопки:

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;
}

Всё предельно просто. Обратите внимание, что фрагменты кода для ответа на mouseDown и mouseUp очень похожи на те, которые мы использовали ранее для обработки захвата ползунка в полосе прокрутки. Единственные различия — вызов state.MouseDownNow () при нажатии кнопки мыши, а также изменение значения GUI.color при перерисовке кнопки.

Возможно, вы заметили еще одно различие, связанное с событием перерисовки, а именно вызов style.Draw (). Об этом стоит поговорить подробнее.

Стили графического интерфейса

При создании нашего первого элемента мы использовали GUI.DrawTexture для отрисовки самого слайдера. Но с элементом FlashingButton всё не так просто — кнопка должна включать в себя не только изображение в виде закругленного прямоугольника, но и надпись. Мы могли бы попытаться нарисовать кнопку с помощью GUI.DrawTexture и поместить поверх нее GUI.Label, но есть способ получше. Попробуем воспользоваться техникой отрисовки изображения GUI.Label, не используя сам GUI.Label.

Класс GUIStyle содержит данные о визуальных свойствах элемента интерфейса: от шрифта и цвета текста до интервалов между элементами. Кроме того, в GUIStyle хранятся функции, используемые для определения ширины и высоты объектов с помощью стиля, а также для непосредственной отрисовки элементов на экране.

GUIStyle может включать в себя разные стили отрисовки элемента: когда на него наведен курсор, когда он получил фокус ввода с клавиатуры, когда он отключен или когда активен (при зажатой кнопке мыши). Для любого состояния можно определить цвет и фоновое изображение, и GUIStyle подставит их при отрисовке элемента, исходя из его control ID.

Существует 4 способа применения GUIStyles для отрисовки элементов интерфейса:

• Написать новый стиль (new GUIStyle ()), задав необходимые значения.
• Использовать один из встроенных стилей класса EditorStyles (если вы хотите, чтобы ваши пользовательские элементы выглядели как стандартные).
• Если вам нужно слегка изменить существующий стиль, например выровнять текст кнопки по правому краю. Вы можете скопировать нужный стиль класса EditorStyles и изменить нужное свойство вручную.
• Извлечь стиль из GUISkin.

GUISkin — это большая коллекция объектов GUIStyle, которую можно создать в самом проекте в виде отдельного ресурса и редактировать с помощью Unity Inspector. Создав новый GUISkin и открыв его, вы увидите слоты для всех стандартных элементов интерфейса: кнопок, текстовых окон, переключателей и т. д. Но особенный интерес представляет раздел пользовательских стилей. Сюда можно поместить любое количество объектов GUIStyle с уникальными именами, которые можно будет извлекать с помощью метода GUISkin.GetStyle («имя_стиля»). Осталось выяснить, как загружать объекты GUISkin из кода. Для этого есть несколько способов. Если объект лежит в папке Editor Default Resources, используйте функцию EditorGUIUtility.LoadRequired (); для загрузки из другой директории используйте AssetDatabase.LoadAssetAtPath (). Главное — ни в коем случае не помещайте ресурсы, предназначенные только для редактора, в пакеты ресурсов или в папку Resources.

Теперь, когда у нас есть GUIStyle, можно отрисовать GUIContent, содержащий нужный текст, изображение и подсказку, с помощью GUIStyle.Draw (). В качестве аргументов используются координаты прямоугольника, в котором выполняется отрисовка, сам GUIContent и идентификатор управляющего элемента.

Разметка IMGUI

Возможно, вы заметили, что каждый из рассмотренных нами элементов интерфейса имел параметр Rect, определяющий его положение на экране. Вместе с тем, мы только что говорили о том, что GUIStyle включает в себя свойства разметки. Напрашивается вопрос: неужели нужно вручную высчитывать все значения Rect с учетом особенностей разметки? В принципе, можно. Но IMGUI предлагает более простое решение — механизм разметки, который делает это автоматически.

Для этого существует особый тип событий — EventType.Layout. После того, как IMGUI посылает такое событие интерфейсу, его элементы вызывают функции разметки: GUILayoutUtility.GetRect (), GUILayout.BeginHorizontal / Vertical, и GUILayout.EndHorizontal / Vertical и другие. IMGUI запоминает результаты этих вызовов в виде дерева, которое содержит все элементы интерфейса и необходимое для них пространство. После построения дерева проводится его рекурсивный обход, во время которого вычисляются размеры элементов и их положение относительно друг друга.

При запуске любого другого события, например EventType.Repaint, элементы снова вызывают функции разметки. Но на этот раз IMGUI повторяет «записанные» вызовы и возвращает готовые прямоугольники. Другими словами, если во время события Layout параметры прямоугольников уже были вычислены с помощью функции GUILayoutUtility.GetRect (), при запуске другого события она просто подставит сохраненный ранее результат.

По аналогии с идентификаторами управляющих элементов при выполнении события Layout и других событий важно соблюдать порядок вызовов функций разметки, чтобы элементы не получили данные чужих прямоугольников. Также стоит учитывать, что значения, возвращаемые вызовом GUILayoutUtility.GetRect () во время события Layout, бесполезны, так как IMGUI не будет знать, какому элементу соответствует каждый прямоугольник вплоть до окончания события и обработки дерева.

Итак, давайте добавим разметку для нашей полосы с ползунком. Это несложно: получив квадрат от IMGUI, мы можем вызвать уже готовый код:

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

В случае вызова GUILayoutUtility.GetRect во время события Layout IMGUI запоминает, что определенный стиль нужен для пустого контента (пустого, потому что для него не указаны изображение или текст). Во время других событий GetRect возвращает существующий прямоугольник. Получается, что во время события Layout наш элемент MyCustomSlider будет вызываться с неправильным прямоугольником, но это неважно, поскольку без него мы все равно не сможем вызывать GetControlID ().

Все данные, на основе которых IMGUI определяет размер прямоугольника, содержатся в стиле. Но что если пользователь захочет задать один или несколько параметров вручную?

Для этого используется класс GUILayoutOption. Объекты этого класса — своего рода инструкции для системы разметок, указывающие, как именно должен рассчитываться прямоугольник (например, иметь определенное значение высоты/ширины или заполнять доступное пространство по вертикали/горизонтали). Для создания такого объекта нужно вызвать фабричные функции класса GUILayout, такие как GUILayout.ExpandWidth () или GUILayout.MinHeight (), и передать их в GUILayoutUtility.GetRect () в виде массива. Затем они сохраняются в дереве разметок и учитываются при его обработке.

Вместо того чтобы создавать собственные массивы из объектов GUILayoutOption, мы воспользуемся ключевым словом C# params, которое позволяет вызвать метод c любым количеством параметров, из которых автоматически составляется массив. Вот так выглядит новая функция нашей полосы:

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

Как вы видите, все данные, введенные пользователем, передаются прямиком к GetRect.

Подобный метод совмещения функции элемента IMGUI с версией этой же функции, использующей автоматическое размещение по разметке, применим для любого элемента IMGUI, включая встроенные в класс GUI. Получается, что класс GUILayout предоставляет размещенные версии элементов из класса GUI (а мы используем класс EditorGUILayout, соответствующий EditorGUI).

Кроме того, элементы, размещенные автоматически и вручную, можно совмещать. Пространство резервируется с помощью GetRect, после чего его можно разделить на отдельные участки для различных элементов. Система разметки не использует идентификаторы управляющих элементов, поэтому на одном прямоугольнике можно размещать несколько элементов (или наоборот). Иногда такой подход работает гораздо быстрее, чем в случае полностью автоматического размещения.

Обратите внимание, что при написании PropertyDrawers не рекомендуется использовать разметку, вместо этого лучше использовать прямоугольник, передаваемый перегрузке PropertyDrawer.OnGUI (). Дело в том, что сам класс Editor не использует разметку, а вычисляет простой прямоугольник, который сдвигается вниз для каждого следующего свойства. Поэтому, если для PropertyDrawer используется разметка, Editor не будет знать о предыдущих свойствах и, следовательно, разместит прямоугольник неправильно.

Использование сериализованных свойств

Итак, вы уже можете создать собственный элемент IMGUI. Осталось обсудить пару моментов, которые помогут привести его к стандарту качества Unity.

Во-первых, это использование SerializedProperty. Мы поговорим о системе сериализации более подробно в следующей статье, а пока обобщим: интерфейс SerializedProperty позволяет получить доступ к любому свойству, к которому подключена система сериализации (загрузки и сохранения) Unity. Таким образом, мы можем использовать любую переменную из скриптов или объектов, отображаемых в Unity Inspector.
SerializedProperty предоставляет доступ не только к значению переменной, но и к разного рода информации, например, сравнение текущего и изначального значения переменной или состояние переменной с дочерними полями в окне Inspector (свернута/развернута). Кроме того, интерфейс интегрирует любые пользовательские изменения значения переменной в системы Undo и scene-dirtying. При этом не используется управляемая версия объекта, что положительно сказывается на производительности. Поэтому использование SerializedProperty необходимо для полноценного функционирования любых сложных элементов интерфейса.

Сигнатура методов класса EditorGUI, получающих объекты SerializedProperty в качестве аргументов, несколько отличается от обычной. Такие методы ничего не возвращают, потому что изменения вносятся напрямую в SerializedProperty. Улучшенная версия нашей полосы будет выглядеть так:

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

Теперь у нас нет параметра value, вместо него в SerializedProperty передается параметр prop. С помощью prop.floatValue мы можем извлекать значение плавающего числа при отрисовке полосы и изменять его при перетаскивании ползунка.

Существуют и другие преимущества использования SerializedProperty в коде IMGUI. Допустим, значение prefabOverride показывает изменения значения свойства в шаблонном объекте. По умолчанию измененные свойства выделяются жирным шрифтом, но мы можем установить другой стиль отображения с помощью GUIStyle.

Еще одна немаловажная возможность — редактирование множественных объектов, то есть отображение сразу нескольких значений с помощью одного элемента. Если для значения EditorGUI.showMixedValue установлено TRUE, элемент используется для отображения нескольких значений.
Использование механизмов prefabOverride и showMixedValue требует установки контекста для свойства с помощью EditorGUI.BeginProperty () и EditorGUI.EndProperty (). Как правило, если метод элемента принимает аргумент класса SerializedProperty, он должен сам вызывать BeginProperty и EndProperty. Если же он принимает «чистые» значения (например, метод EditorGUI.IntField, который принимает int и не работает со свойствами), вызовы BeginProperty и EndProperty должны содержаться в коде, вызывающем этот метод.

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 ();
}

Заключение

Надеюсь, эта статья поможет вам разобраться в основах IMGUI. Чтобы стать настоящим профессионалом, вам предстоит освоить много других аспектов: систему SerializedObject/SerializedProperty, особенности работы с CustomEditor/EditorWindow/PropertyDrawer, использование класса Undo и т. д. Так или иначе, IMGUI позволяет раскрыть широчайший потенциал Unity по созданию пользовательских инструментов для продажи на Asset Store или личного использования.

© Habrahabr.ru