Найти тему
Invariant World

Unity UI Elements из кода для CustomEditor. Система подгрузки префабов из ресурсов.

Оглавление

Очень часто натыкался на то, что разработчики (в основном молодые), делая интерфейс для своего приложения, сваливают его весь в сцене и открывают\закрывают нужные им экраны и окна, просто включая и выключая их. Для прототипа, который надо сварганить на коленке за один день - это нормально. Также, если весь интерфейс это кнопка "Начать" и Hud, то заморачиваться конечно же не стоит. Но если у нас экранов больше, и если они включают в себя разнообразный графический контент, то такой подход неправильный. Все эти экраны и вся графика для них висит в памяти и отжирает не малый её кусок. А также подгрузка сцены становится сложнее и дольше.

Цель:

В данной статье хочу продемонстрировать простой способ работы с экранами интерфейса, также его можно применять и для различных других ресурсов.
Заодно хочу показать работу с Unity UI Elements для CustomEditor, притом (чисто для эксперимента и разнообразия) сделать это из кода, без использования .uxml.

План:

Написать систему, которая будет подгружать префабы с экранами из ресурсов и инстанцировать их в сцене.
Также - написать эдитор для неё с использованием UI Elements.

Система подгрузки префабов:

Для начала нам нужен класс для информации о префабе. В качестве полей у него будет:
Уникальный идентификатор, по которому можно будет находить префаб в базе.
Путь к префабу. Тут надо пояснить, что нужен именно путь, а не ссылка на него. Если хранить ссылки, то и все префабы прилинкованные к базе будут храниться в памяти, а этого мы и хотим избежать. Префабы должны лежать в папке Resources, оттуда и будем их доставать по надобности.
И для эдитора сохраняем юнитевый guid. Это нужно для того, чтобы в случае переименования или перемещения префаба система сама нашла к нему новый путь.
На всякий случай сохраним также инстанцированный экземпляр для быстрого доступа к нему в случае, если будут необходимы несколько обращений к нему.

using System;
using UnityEngine;

/// <summary>
/// Описание экрана
/// </summary>
[Serializable]
public class ScreenAsset
{
/// <summary>
/// Уникальный идентификатор\название экрана
/// </summary>
public string UID;
/// <summary>
/// Путь к префабу в папке с ресурсами
/// </summary>
public string Path;
#if UNITY_EDITOR
/// <summary>
/// Guid в Unity проекте, для легкого поиска в редакторе
/// </summary>
public string Guid;
#endif
/// <summary>
/// Несериализуемый экземпляр для инстанцирования
/// в единичном экземпляре
/// </summary>
[NonSerialized] public GameObject Instance;

/// <summary>
/// Дефолтный конструктор для инспектора
/// </summary>
public ScreenAsset()
{
}

/// <summary>
/// Конструктор
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="assetPath">Путь к префабу в папке с ресурсами</param>
/// <param name="guid">Guid в Unity проекте,
/// для легкого поиска в редакторе</param>
public ScreenAsset(string uid, string assetPath, string guid)
{
UID = uid;
Path = assetPath;
#if UNITY_EDITOR
Guid = guid;
#endif
}
}

Теперь создаем саму базу с префабами. Для меня это префабы экранов. База будет представлять из себя ScripableObject, который можно будет прилинковать в будущем к нужной системе.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Object = UnityEngine.Object;

/// <summary>
/// База префабов с UI экранами в ресурсах
/// </summary>
#if UNITY_EDITOR
[CreateAssetMenu(fileName = "Data",
menuName = "Chrono/CrerateScreensDataBase", order = 2)]
#endif
public class ScreensDataBase : ScriptableObject
{
/// <summary>
/// Путь к папке с ресурсам
/// </summary>
private const string ResourcesPath = "Assets/Resources/";
/// <summary>
/// расширение объектов префабов
/// </summary>
private const string ResourcesExtensions = ".prefab";
/// <summary>
/// Список экранов с ссылками на префабы.
/// </summary>
[SerializeField] private List<ScreenAsset> _screens;
/// <summary>
/// Список описаний экранов с ссылками на префабы. Только для чтения
/// </summary>
public List<ScreenAsset> Screens => _screens;
/// <summary>
/// Получить описание экрана
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <returns>Описание найденного экрана</returns>
public ScreenAsset GetScreenAsset(string uid)
{
return _screens.FirstOrDefault(s => s.UID == uid);
}
/// <summary>
/// Получение префаба из папки с ресурсами
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <returns>Префаб для инстанцирования</returns>
public Object GetScreenObject(string uid)
{
//Находим описание нужного экрана
var screen = GetScreenAsset(uid);
if (screen == null) return null;
//проверяем что префаб лежит в папке с ресурсами
//и он нужного расширения
if (screen.Path.StartsWith(ResourcesPath)
&& screen.Path.EndsWith(ResourcesExtensions))
{
//Находим нужный префаб в ресурсах
//Перед этим удаляем из пути ту часть, что ведет к папке
//с ресурсами Assets/Resources/ и удаляем расширение,
//так как Resources.Load нужен путь без этой информации
var asset = Resources.Load(screen.Path
.Remove(screen.Path.Length - ResourcesExtensions.Length,
ResourcesExtensions.Length)
.Remove(0, ResourcesPath.Length));
return asset;
}
//В случае, если ресурс не там лежит, то сообщаем об этом
Debug.LogWarning($"{screen.UID} not in {ResourcesPath}");
return null;
}
/// <summary>
/// Инстанцирование экрана
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="parent">Transform где необходимо инстацировать</param>
/// <param name="single">Единственный экземпляр в сцене?
/// Если да и такой экран уже в сцене,
/// то возвращаете старый, если нет, то создаете новый</param>
/// <returns>Инстанцированный экран</returns>
public GameObject InstanceScreen(string uid, RectTransform parent = null,
bool single = false)
{
//Находим описание нужного экрана
var asset = GetScreenAsset(uid);
//Если экземпляр должен быть единичный и он уже существует,
//то возвращаем его.
if (single)
{
if (asset.Instance != null)
{
return asset.Instance;
}
}
//Находим префаб
var screenObject = GetScreenObject(uid);
if (screenObject == null) return null;
//Инстанцируем префаб как ребенка для указанной Transform
var instance = Instantiate(screenObject, parent) as GameObject;
//Обозначаем инстанцированный объект как экземпляр
//для этого описания экрана,
//чтобы использовать при повторных вызовах с единичным экземпляром
asset.Instance = instance;
return instance;
}
/// <summary>
/// Инстанцирование экрана с необходимым компонентом
/// </summary>
/// <param name="uid">Уникальный идентификатор\название экрана</param>
/// <param name="parent">Transform где необходимо инстациировать</param>
/// <param name="single">Единственный экземпляр в сцене?
/// Если да и такой экран уже в сцене,
/// то возвращаете старый, если нет, то создаете новый</param>
/// <typeparam name="T">Тип компонента в корневом объекте</typeparam>
/// <returns>Компонент необходимого типа</returns>
public T InstanceScreen<T>(string uid, RectTransform parent = null,
bool single = false)
{
//Инстанцируем сам объект
var gameObject = InstanceScreen(uid, parent, single);
if (gameObject != null)
{
//находим у него компонент необходимого типа
var component = gameObject.GetComponent<T>();
return component;
}

return default;
}
}

Надеюсь по коду все понятно, что и для чего там нужно. Как это использовать? Система, который нужен будет соответствующий экран, может обратиться к этой базе и по идентификатору инстанцировать его в нужном месте. Можно сразу передавать тип компонента, который должен быть у корневого объекта.
С заполненной базой легко работать, но как её правильно заполнить? Тут вступает в игру Editor для базы.

UI Elements в CustomEditor

Тут главный момент, что мы в редакторе добавляем ссылку на объект, но так как нам её хранить нельзя, мы получаем с неё нужные нам параметры (путь и guid) и сохраняем их. Для отображения в редакторе нужных объектов мы также используем сохраненные параметры, по ним находим сам объект и отображаем в редакторе.

using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Эдитор для ScreensDataBase
/// </summary>
[CustomEditor(typeof(ScreensDataBase))]
public class ScreensDataBaseEditor : Editor
{
/// <summary>
/// Экземпляр объекта для отображения в Инспекторе
/// </summary>
private ScreensDataBase _target;
/// <summary>
/// Сериализованный объект редактируемого экземпляра
/// Необходим для работы с его сериализуемыми полями
/// </summary>
private SerializedObject _targetSerializedObject;
// UI Elements для работы с добавлением нового префаба
private ObjectField _newPrefab;
private TextField _newScreenUID;
private VisualElement _listScreens;
// Поисковый запрос для фильтрации префабов в базе
private string _searchText = String.Empty;

/// <summary>
/// Отрисовка Инспектора объекта
/// </summary>
/// <returns></returns>
public override VisualElement CreateInspectorGUI()
{
//Сохраняем экземпляр редактируемой базы
_target = target as ScreensDataBase;
if (_target == null) return null;

_targetSerializedObject = new SerializedObject(_target);

//Создаем корневой UI Element в котором будут отрисовываться остальные
var root = new VisualElement();
root.Clear();
root.AddToClassList("root");

//Создаем блок для добавления нового экрана в список
var addNewBlock = new VisualElement();
//Присваиваем стили бля блока
addNewBlock.AddToClassList("body");
addNewBlock.AddToClassList("horizontalLayout");
//Поле ввода UID нового экрана
_newScreenUID = new TextField();
_newScreenUID.AddToClassList("chrono-textfield");
//Поле для линка на префаб для добавления в список
_newPrefab = new ObjectField {allowSceneObjects = false, objectType = typeof(GameObject)};
_newPrefab.style.width = 140;
//Кнопка Добавить с колбэком на нажатие OnNewButtonClick
var newButton = new Button(OnNewButtonClick) {text = "Add"};
newButton.AddToClassList("chrono-button");
newButton.AddToClassList("hero-button");
//Добавляем элементы ввода UID, линк на префаб и кнопку в блок addNewBlock
addNewBlock.Add(_newScreenUID);
addNewBlock.Add(_newPrefab);
addNewBlock.Add(newButton);
//Добавляем блок addNewBlock к корневому объекту
root.Add(addNewBlock);

//Создаем элемент поиска для фильтрации в списке экранов
var search = new ToolbarSearchField();
search.style.flexShrink = 1;
//Регистрируем колбэк на изменение текста в поисковой строке
search.RegisterValueChangedCallback(OnSearch);
//Добавляем строку поиска к корневому элементу
root.Add(search);

//Создаем блок с списком экранов. Храним этот блок в переменной для управления содержимым
//при фильтрации с помощью поисковой строки
_listScreens = new VisualElement();
_listScreens.AddToClassList("body");
//Отрисовываем список в отдельном переиспользуемом методе
DrawScreens();
//Добавляем список к корневому элементу
root.Add(_listScreens);

//Находим стили и добавляем их корневому элементу
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Editor/UI/ChronoStyle.uss");
root.styleSheets.Add(uss);

//Возвращаем корневой элемент как используемый для отрисовки в инспекторе
return root;
}

/// <summary>
/// Колбэк при изменении поискового запроса
/// </summary>
/// <param name="evt">эвент изменения</param>
private void OnSearch(ChangeEvent<string> evt)
{
//Сохраняем текст поиска
_searchText = evt.newValue;
//Отрисовываем блок со списком экранов, на основании введенного текста
DrawScreens();
}

/// <summary>
/// Отрисовываем список добавленных экранов
/// </summary>
private void DrawScreens()
{
//Очищаем предыдущий список
_listScreens.Clear();
//Находим список экранов в базе как SerializedProperty
//Необходимо работать именно с SerializedProperty для сохранения изменения в редакторе
var listProperty = _targetSerializedObject.FindProperty("_screens");
//Проходимся по списку экранов в базе
for (var ii = 0; ii < _target.Screens.Count; ii++)
{
//Находим отдельный элемент в списке как SerializedProperty
var prop = listProperty.GetArrayElementAtIndex(ii);
//Если текст в поисковом запросе содержится в UID или в пути и названии префаба,
//то отмечаем что его нужно отобразить в инспекторе
var needShow = _searchText == String.Empty ||
prop.FindPropertyRelative("UID").stringValue.ToLower().Contains(_searchText.ToLower()) ||
prop.FindPropertyRelative("Path").stringValue.ToLower().Contains(_searchText.ToLower());
if (needShow)
{
//Отрисовываем разделительную линию
var sep = new VisualElement();
sep.AddToClassList("distinction");
_listScreens.Add(sep);
//Добавляем отрисовку элемента через метод DrawScreenAsset
_listScreens.Add(DrawScreenAsset(prop, ii));
}
}
}

/// <summary>
/// Отрисовываем элемент списка экранов
/// </summary>
/// <param name="property">SerializedProperty этого элемента</param>
/// <param name="number">порядковый номер элемента</param>
/// <returns>верстка элемента</returns>
private VisualElement DrawScreenAsset(SerializedProperty property, int number)
{
//Блок, в котором будет все отрисовываться, с горизонтальным расположением элементов
var block = new VisualElement();
block.AddToClassList("horizontalLayout");

//Текстовое поле с отображением и изменением UID
var screenUID = new TextField();
//Связываем значение текстового поля с SerializedProperty UID
screenUID.BindProperty(property.FindPropertyRelative("UID"));
screenUID.AddToClassList("chrono-textfield");
//В случае изменения названия, вызываем автосохранение ассетов проекта
screenUID.RegisterValueChangedCallback(evt => { AssetDatabase.SaveAssets(); });
block.Add(screenUID);

//Поле с префабом
var prefab = new ObjectField
{
//Не использовать объекты на сцене (только в Ассетах)
allowSceneObjects = false,
//Использовать только тип GameObject
objectType = typeof(GameObject),
//Находим прилинкованный префаб по ранее сохраненному guid
value = GetByGUID(property.FindPropertyRelative("Guid").stringValue)
};
prefab.style.width = 140;
//В случае перелинковки префаба обновляем параметры
prefab.RegisterValueChangedCallback(evt =>
{
_targetSerializedObject.Update();
//Сохраняем путь к прилинкованному префабу
property.FindPropertyRelative("Path").stringValue = AssetDatabase.GetAssetPath(evt.newValue);
//Сохраняем его guid
property.FindPropertyRelative("Guid").stringValue = GetGUID(evt.newValue as GameObject);
//Сохраняем\сериализуем новые значения
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
});
block.Add(prefab);

//На случай, если префаб был переименован или перемещен, проверяем его текущий путь
var path = AssetDatabase.GUIDToAssetPath(property.FindPropertyRelative("Guid").stringValue);
//Если текущий путь и сохраненный не совпадают, то обновляем эту информацию
if (property.FindPropertyRelative("Path").stringValue != path)
{
_targetSerializedObject.Update();
property.FindPropertyRelative("Path").stringValue = path;
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
}

//Кнопка удаления элемента из списка
var delButton = new Button {text = "Del"};
//Добавляем делегат на нажатие кнопки
delButton.clicked += () => ClickDelete(number);
delButton.AddToClassList("chrono-button");
delButton.AddToClassList("community-button");
block.Add(delButton);

//Возвращаем верстку
return block;
}

/// <summary>
/// Удаление элемента из списка экранов
/// </summary>
/// <param name="screenAssetNumber"></param>
private void ClickDelete(int screenAssetNumber)
{
_targetSerializedObject.Update();
//Находим список экранов
var listProperty =_targetSerializedObject.FindProperty("_screens");
//Удаляем элемент
listProperty.DeleteArrayElementAtIndex(screenAssetNumber);
//Сохраняем изменения
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
//Периресовываем список в инспекторе
DrawScreens();
}

/// <summary>
/// Добавление нового экрана в список
/// </summary>
private void OnNewButtonClick()
{
//Проверяем, выбран ли префаб и введен ли UID
if (_newPrefab.value == null || _newScreenUID.value == String.Empty) return;
_targetSerializedObject.Update();
//Находим список с экранами
var listProperty =_targetSerializedObject.FindProperty("_screens");
//Создаем новый элемент списка в самом верху
listProperty.InsertArrayElementAtIndex(0);
//Берем вновь созданный элемент для заполнения
var newElementProperty = listProperty.GetArrayElementAtIndex(0);
//Записываем UID
newElementProperty.FindPropertyRelative("UID").stringValue = _newScreenUID.value;
//Находим путь и записываем его
newElementProperty.FindPropertyRelative("Path").stringValue = AssetDatabase.GetAssetPath(_newPrefab.value);
//Находим guid и записываем его
newElementProperty.FindPropertyRelative("Guid").stringValue = GetGUID(_newPrefab.value as GameObject);
//Сохраняем изменения в списке
_targetSerializedObject.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
//Перерисовываем список в испекторе
DrawScreens();
}

/// <summary>
/// Находим guid префаба
/// </summary>
/// <param name="prefab">префаб</param>
/// <returns>guid</returns>
private string GetGUID(GameObject prefab)
{
var fullPath = AssetDatabase.GetAssetPath(prefab);
return AssetDatabase.AssetPathToGUID(fullPath);
}

/// <summary>
/// Находим префаб по guid
/// </summary>
/// <param name="guid">guid</param>
/// <returns>префаб</returns>
private GameObject GetByGUID(string guid)
{
return AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid), typeof(GameObject)) as GameObject;
}
}

Если объект был перемещен или переименован, то по отрисовке этого элемента эдитор находит этот объект по guid и автоматом переписывает путь на новый.

В результате у нас получается вот такое редактор

В верхнее поле перемещаем нужный для сохранения префаб, прописываем ему название и нажимаем "Add". Префаб сохраняется в список.
Далее идет поле поиска, оно для фильтрации списка. Поиск осуществляется как по названию, так и по пути. Когда списки становятся большими, это очень необходимо.
Ну и сам список. Тут можно переименовать объект, можно перелинковать на другой, а также можно удалить ненужный из списка.

Работать с этой системой просто. В нужном месте вызываем инициализацию нужного объекта и используем по назначению.

private void AddBookmark(ChronoObjectLogged objectLogged)
{
if (_db == null) return;
var screenName = "";
switch (objectLogged)
{
case ChronoObjectCommunity _:
screenName = "BookmarkCommunity";
break;
case ChronoObjectEnvironment _:
screenName = "BookmarkEnvironment";
break;
case ChronoObjectHero _:
screenName = "BookmarkHero";
break;
}
var bookmark = _db.InstanceScreen<EntryBookmark>(screenName, _bookmarksBlock);
bookmark.Setup(objectLogged, _bookmarkGroup);
_bookmarks.Add(objectLogged.UID, bookmark.Bookmark);
}

Итого:

Думаю, что все это не сложно и прозрачно. Такую систему я использую очень часто и она очень помогает при работе с интерфейсами и не забивает ими память.