Что несет нам день грядущий или новые возможности UI Toolkit'а (с кодом и картинками)

Вот кстати, держите, не скучная обоина у Unity.
Вот кстати, держите, не скучная обоина у Unity.

О чем это статья?

В ноябре прошлого года, прошла конференция Unite 2023 и команда разработчиков демонстрировали новый функционал игрового движка.

Меня заинтересовало выступление про возможности UI Toolkit'а, которые появятся в будущем.

То самое выступление

Меня порадовал функционал который там был представлен, он действительно ускоряет и упрощает работы с UI Toolkit'ом.

Поэтому я решил сделать текстовый пересказ видео + дополнения/разъяснения к нему.

Подготовка

Для начала, нужно установить себе версию Unity, не ниже 2023.2, так как именно в ней добавились новые фичи.

Нужные версии можно найти во вкладке Pre-releases
Нужные версии можно найти во вкладке Pre-releases

Улучшение фундамента

Именно с этого оглавления начинается рассказ про развитие UI Toolkit'а.

Вот они слева на право
Вот они слева на право

Команда разработчиков сделала изменение в трех аспектах разработки пользовательских интерфесов:

  • Ускорение разработки
  • Упрощение систем
  • Дополнительные элементы (добавились новые UI элементы и контроллеры)

Давайте разбираться, что же они нам готовят в будущем?

Подключение данных к интерфейсу

Или как это называется в простонародье дата байндинг.

Как это работало раньше?

У нас было две сущности, первая это простая UXML в которой описана верстка, по-типу:

<ui:UXML> <ui:VisualElement name="Background"> <ui:VisualElement name="playerDataBlock"> <ui:Label name="playerName" text="PlayerName" /> <ui:ProgressBar name="playerRating" /> </ui:VisualElement> </ui:VisualElement> </ui:UXML>

И код, который инициализировал и обновлял значения:

using Player; using UnityEngine; using UnityEngine.UIElements; namespace Interfaces.OldWay { public class MainController : MonoBehaviour { public UIDocument _UIDocument; private Label _playerName; private ProgressBar _progressBar; void Start() { var rootElement = _UIDocument.rootVisualElement; _playerName = rootElement.Q<Label>(); _progressBar = rootElement.Q<ProgressBar>(); var newPlayerData = new PlayerData() { Name = "Linar", Rating = 33 }; newPlayerData.OnRatingChanged += UpdateRating; _playerName.text = newPlayerData.Name; _progressBar.value = newPlayerData.Rating; } private void UpdateRating(int newRating) { _progressBar.value = newRating; } } }

Нам приходилось искать нужный нам UI элемент по типу/по имени, а потом назначать ему значения (а если оно еще менялось, как часто это бывает, обновлять его, например через event'ы);

Как это работает сейчас?

У нас также есть верстка, но уже дополненная информацией, о том, какую информацию/тип должен отображать.

Рассмотрим на примере создания биндинга для Scriptable Object через UI Builder.

В нашем случае, мы хотим добавить текстовому полю PlayerName связь с типом PlayerInformation, для этого надо добавить Data Source и указать Data Source Path.

Мы установили Data Source наш созданный Scriptable Object типа Player Information
Мы установили Data Source наш созданный Scriptable Object типа Player Information
В ней всего два поля, строка и целочисленное значение
В ней всего два поля, строка и целочисленное значение

После чего, нам нужно добавить само связывание в поле Text.

Это делается кликом правой кнопкой и нажатие Add Binding и тогда откроется следующее окно:

Здесь мы назначаем снова наш PlayerInformation и выбираем Data Source Path поле Name (оно само предложить выбрать из возможных полей SO)
Здесь мы назначаем снова наш PlayerInformation и выбираем Data Source Path поле Name (оно само предложить выбрать из возможных полей SO)

И сразу после этого, у нас отобразится результат в верстке:

Все работает и вы прекрасны.
Все работает и вы прекрасны.

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

А что тогда с динамическими данными?

Честно, надеялся вы не спросите, но раз спрашиваете...

– А слабо биндинг без своего UI Builder'а сделать, только через код?

Сделаем. Соответственно, у нас для этого чистый UXML без связываний, только UI элементы:

<ui:UXML> <ui:VisualElement name="Background"> <ui:VisualElement name="playerDataBlock"> <ui:Label name="playerName" text="PlayerName" /> <ui:ProgressBar name="playerRating" /> </ui:VisualElement> </ui:VisualElement> </ui:UXML>

А вот с кодом интереснее выходит:

using Player; using Unity.Properties; using UnityEngine; using UnityEngine.UIElements; namespace Interfaces.NewWay { public class NewMainController : MonoBehaviour { public UIDocument _UIDocument; private Label _playerName; private ProgressBar _progressBar; void Start() { var rootElement = _UIDocument.rootVisualElement; var newPlayerData = new PlayerData() { Name = "Linar Code", Rating = 33 }; _playerName = rootElement.Q<Label>(); _progressBar = rootElement.Q<ProgressBar>(); _playerName.SetBinding(nameof(Label.text), new DataBinding() { bindingMode = BindingMode.ToTarget, dataSource = newPlayerData, dataSourcePath = PropertyPath.FromName(nameof(PlayerData.Name)) }); _progressBar.SetBinding(nameof(ProgressBar.value), new DataBinding() { bindingMode = BindingMode.ToTarget, dataSource = newPlayerData, dataSourcePath = PropertyPath.FromName(nameof(PlayerData.Rating)) }); _progressBar.SetBinding(nameof(ProgressBar.title), new DataBinding() { bindingMode = BindingMode.ToTarget, dataSource = newPlayerData, dataSourcePath = PropertyPath.FromName(nameof(PlayerData.Rating)) }); } } }

Давайте более подробно разберем это на примере текстового поля playerName:

_playerName.SetBinding(nameof(Label.text), new DataBinding() { bindingMode = BindingMode.ToTarget, dataSource = newPlayerData, dataSourcePath = PropertyPath.FromName(nameof(PlayerData.Name)) });

У любого VisualElement'а (от которого наследуется Label), есть метод SetBinding(), который регистрирует информацию о привязке – какую информацию он должен отображать.

В нашем случае, мы хотим, чтобы _playerName привязался к PlayerData.Name.

public void SetBinding(BindingId bindingId, Binding binding) { this.RegisterBinding(bindingId, binding); }

В аргументах метода SetBinding, мы сперва видим BindingId что равнозначно PropertyPath, т.е к какому полю визуального элемента его привязывают, в рамках текстового поля, это Label.text.

Вспомнили? Узнали?
Вспомнили? Узнали?

А вторым параметром идет класс Binding, который объясняет интерфейсу:

И после этого мы запускаем игру и видим, что наш код сработал:

It just works
It just works

Также отмечу, что у BindingMode есть 4 разных режима работы, которые решают ту или иную задачу в рамках построения интерфейсов.

У нас выбран BindingMode.ToTarget, именно благодаря нему, при изменение данных, оно сразу отобразится в интерфейсе (когда игрок повысил рейтинг или потерял).

Контроллируем обновление

Версионирование: когда обновлять

– А вот, Линар, ты сказал, мол оно само обновляется при изменение данных и отображается в интерфейсах, магия какая-то.

Согласен, тут есть нюанс, что оно обновляется при любом изменение данных, что не самый оптимальный путь.

By default, the binding system continuously polls the data source and updates the UI on every modification, without knowing if anything has actually changed since the last update

Documentation

– Вот ты и попался, давай рассказывай, как сделать это лучше.

Интегрируем управление версиями и отслеживание изменений

Для того, чтобы интегрировать это нам нужно всего лишь...

Использовать интерфейс IDataSourceViewHashProvider, который обязывает нас реализовать следующий метод:

public long GetViewHashCode() { return .... }

И тут начинается самое прикольное, так как можно сильно кастомизировать реализацию и подстраивать под свои нужды.

Давайте приведу примеры:

Помните наш класс PlayerData, (в котором имя и рейтинг игрока), так вот предположим, что мы хотим отображать изменения сразу при любом изменение любых значений, тогда мы сделаем что-то:

using System; using UnityEngine.UIElements; namespace Player { public class PlayerData : IDataSourceViewHashProvider { public string Name; public int Rating; public long GetViewHashCode() { return HashCode.Combine(Name, Rating); } } }

Опытный пользователи компьютера, вспомнят аналогию с методом GetHasCode(), у ссылочных типов.

– Простите, это конечно, все интересно, а что здесь происходит?

Прощаю.

Мы для нашего класса гененируем уникальное число (хэш) которое зависит от наших значений (Name, Rating) и если хоть одно из них изменится, тогда изменится сам хэш, а значит => надо обновить интерфейс актуальными значениями.

А если не изменился, значит обновлять ничего не надо.

Вот такой способ есть оптимизации, когда мы обновляет значения только при прямом изменение хэша нашего класса.

– Ага, ага, а че еще есть? Я вот люблю по экзотичнее

Такой как раз есть, называется буффер изменений.

Предположим, вы хотите обновлять интерфейс не всегда, а условно, когда поменяется оба поля (Name и Rating)

Тогда для этого нужно сделать некоторые измения в коде:

using System; using UnityEngine.UIElements; namespace Player { public class PlayerData : IDataSourceViewHashProvider { public string Name; public int Rating; private int _version; public void CommitChanges() { _version++; } public long GetViewHashCode() { return _version; } } }

У нас тут появился метод CommitChanges(), который мы будем вызывать каждый раз, когда хотим обновить значения в интерфейсе.

И как раз, метод GetViesHasCode() возвращает текущую версию изменений (состояние класса) и если оно изменится, как и в предыдущем примере, вызовится обновление интерфейса.

Более глубже про этот функционал можно почитать здесь.

Представление данных

– А бывало ли у вас такое, что хотите отобразить данные разными способами?

Предположим, что мы хотим дополнительно отображать символ звезды(*) в интерфейсе, за каждые 20 рейтинга:

Тогда нам в помощь приходят Converter'ы, про них не буду много писать, только приведу пример:

using UnityEngine; using UnityEngine.UIElements; namespace Player { [CreateAssetMenu(fileName = "PlayerInformation", menuName = "Data", order = 0)] public class PlayerInformation : ScriptableObject { public string Name; public int Rating; static PlayerInformation() { var group = new ConverterGroup("Stars Converter Group"); group.AddConverter((ref int v) => { string result = ""; int iterationAmount = v / 20; for (int i = 0; i < iterationAmount; i++) { result += "*"; } return result; } ); ConverterGroups.RegisterConverterGroup(group); } } }

Как помните, у нас был класс PlayerInformation, для него можно таким хитрым способом добавить поддержку альтернативного отображения одних и тех же данных.

В текущем примере, я создал Stars Converter Group, который надо будет выбрать в окне Edit Binding, в Advanced Settings.

Тут из выпавшего списка выбираем наш Stars Converter Group
Тут из выпавшего списка выбираем наш Stars Converter Group

Получится, что-то типа такого:

Что несет нам день грядущий или новые возможности UI Toolkit'а (с кодом и картинками)

Cоздаем свои способы связывания

Также, мы можем создавать свои способы связывания, это может пригодится, когда у нас свои кастомные типы, либо когда не используется Data Source.

Рассмотрим пример из документации:

using System; using Unity.Properties; using UnityEngine.UIElements; [UxmlObject] public partial class CurrentTimeBinding : CustomBinding { [UxmlAttribute] public string timeFormat = "HH:mm:ss"; public CurrentTimeBinding() { updateTrigger = BindingUpdateTrigger.EveryUpdate; } protected override BindingResult Update(in BindingContext context) { var timeNow = DateTime.Now.ToString(timeFormat); var element = context.targetElement; if (ConverterGroups.TrySetValueGlobal(ref element, context.bindingId, timeNow, out var errorCode)) return new BindingResult(BindingStatus.Success); // Error handling var bindingTypename = TypeUtility.GetTypeDisplayName(typeof(CurrentTimeBinding)); var bindingId = $"{TypeUtility.GetTypeDisplayName(element.GetType())}.{context.bindingId}"; return errorCode switch { VisitReturnCode.InvalidPath => new BindingResult(BindingStatus.Failure, $"{bindingTypename}: Binding id `{bindingId}` is either invalid or contains a `null` value."), VisitReturnCode.InvalidCast => new BindingResult(BindingStatus.Failure, $"{bindingTypename}: Invalid conversion from `string` for binding id `{bindingId}`"), VisitReturnCode.AccessViolation => new BindingResult(BindingStatus.Failure, $"{bindingTypename}: Trying set value for binding id `{bindingId}`, but it is read-only."), _ => throw new ArgumentOutOfRangeException() }; } }

Это кастомный байндинг который позволяет отображать текущее время.

Основой для создания является наследование класса CustomBinding.

Нам нужно реализовывать метод Update и возвращать BindingResult.

Не буду сильно заострять внимание на детали, просто покажу как это использовать, для этого нам надо добавить информацию в верстку:

<ui:Label text="Label"> <Bindings> <CurrentTimeBinding property="text" /> </Bindings> </ui:Label>

То же самое можно сделать через код:

var label = new Label(); label.SetBinding("text", new CurrentTimeBinding());

И все, у вас будет отображаться текущее время.

Улучшение сериализации UXML

– Ура, товарищи, на нашей улице перевернулся камаз с аттрибутами.

У нас появилось два важных аттрибута, это [UxmlElement] и [UxmlAttribute] .

Теперь, когда мы создаем свой кастомный элемент интерфейса, мы можем сделать так:

using UnityEngine.UIElements; namespace Interfaces { [UxmlElement] public partial class PlayerCard : VisualElement { [UxmlAttribute] public string DTF; } }

Он появится в иерархии в UI Builder

Что несет нам день грядущий или новые возможности UI Toolkit'а (с кодом и картинками)

И у него появилось наше поле:

в самом внизу
в самом внизу

Работает быстро, дешево и качественно. Всем советую.

Предфинальный

И на этой ноте, я бы хотел закончить статью, так как она перешла все мыслимые масштабы.

Если хочется глубже и более плотно ознакомиться с фичами, рекомендую посмотреть видео и читать документацию :)

Послесловье

Если у вас остались вопросы или что-то не понятно, пишите в комментариях – рассмотрим и разберемся.

Если статья понравилась тоже пишите. Если не понравилась тоже напишите, почему бы и нет.

Спасибо за внимание и до скорых встреч.

1111
15 комментариев

Маски так и не завезли?

Ответить

Прямую реализацию масок, еще не сделали, висит уже второй год в roadmap'е.
Но можно сделать, это через отображение)
https://docs.unity3d.com/Manual/UIE-masking.html

1
Ответить

Во всей этой интересной вещи расстраивает одно- нельзя в реальном времени отобразить все данные из uxml- все проходит через scriptable object. Суть в том, что хотелось бы принимать с сервера uxml (либо xml, а затем его интерпретировать в uxml) - было бы ооооочень круто. Все это для вебовских задач, естественно

Ответить

Не совсем понял, почему нельзя получать UXML файл, а потом его рендерить?

https://docs.unity3d.com/Manual/UIE-manage-asset-reference.html

В документации есть пример, где ты грузишь файл и потом создаешь Instance его и все, вот у тебя интерфейс

Ответить

А преимущества новой перед старой системой в чём?

Ответить

ты про UI Toolkit и uGUI?

Ответить

Спасибо! Жду еще статей по юньке

Ответить