Skip to content

Instantly share code, notes, and snippets.

@LeMartinParadis
Last active July 21, 2024 12:17
Show Gist options
  • Select an option

  • Save LeMartinParadis/3e4c7ad5fd1b57221f0978584a07443b to your computer and use it in GitHub Desktop.

Select an option

Save LeMartinParadis/3e4c7ad5fd1b57221f0978584a07443b to your computer and use it in GitHub Desktop.
Part 1: Introduction to the UI Toolkit runtime bindings system.

This is the first part in the series: UI Toolkit runtime bindings system

This part will provide an overview of the UI Toolkit Runtime Bindings API that recently landed in Unity 2023.2.

A bit of history

UI Toolkit has offered a solution to bind a SerializedProperty against a UI control using the IBindable and INotifyValueChanged<T> interfaces for a long time. While this system is working reliably well for binding against serialized data, it has a few drawbacks:

  1. It only supports the data types supported by the SerializedProperty.
  2. It can only target the value property of a INotifyValueChanged<T> control.
  3. It is only available in the editor.
  4. It is limited to data serialized by Unity.
  5. The editor binding system itself is more or less a black box.

We wanted to add more flexibility and reduce some of the drawbacks. As such, we aimed for:

  1. The ability to use plain C# object as a data source.
  2. The ability to target multiple properties of the same control.
  3. The ability to use the binding system both in the editor and in the runtime.
  4. The ability to create and extend binding objects and manipulate them.

In order to achieve this, we've had to detach ourselves from the serialization system of Unity, since it's editor only. In the end, to avoid breaking any existing code while providing the features we wanted to use, we've had to create an entirely new system. This new system works side-by-side with the existing one so that both can be used simultaneously. What this means is that for binding against serialized data (and keep the automatic undo/redo support), you can continue to use the bindingPath property and we will create a serialized binding for you in the same way that you are used to. Under the hood, the existing system has been migrated to the new one in a seamless manner, so the code that you have today will continue to work. This is made possible by the fact that we previously had very few public APIs around the editor binding system.

We plan to eventually add dedicated binding types to deal with the serialization system of Unity so that you end up having more control over the binding process in the editor.

One notable difference between the existing system and the new one is how we deal with property paths with regards to lists and arrays.

In the editor binding system, paths containing a list or an array will look like this: "Path.To.List.Array.data[2]" whereas in the runtime binding system, paths containing a list or an array will look like this: "Path.To.List[2]".

Runtime Bindings

With the runtime bindings, you have a greater control over what you are binding to, but also what you are binding against. We have removed the limitation on which visual elements can be used with binding: you can now use any custom element with runtime bindings.

We have added the following APIs to VisualElement in order to define a source to bind against:

public object dataSource { get; set; }
public Unity.Properties.PropertyPath dataSourcePath { get; set; }

The dataSource property allows to define a source that will be used by the binding objects. This source will be accessible to the element itself and its descendant(s) (unless overridden by a child). The dataSourcePath property allows to define a relative path from the dataSource object to use. The dataSourcePath is always relative to the closest data source it finds.

using UnityEngine;
using UnityEngine.UIElements;
using Unity.Properties;

public class DataSource
{
    public Vector3 vector3 { get; set; }
}

// Example 1: Binding added to `element` will have access to the `DataSource` object.
var element = new VisualElement();
element.dataSource = new DataSource();

// Example 2: Binding added to `element` will have access to the `vector3` field of the `DataSource` object.
var element = new VisualElement();
element.dataSource = new DataSource();
element.dataSourcePath = new PropertyPath(nameof(DataSource.vector3));

// Example 3: Binding added to `element` will have access to the `DataSource` object while bindings added to "child" will have access to the `vector3` field of the `DataSource` object.
var element = new VisualElement();
element.dataSource = new DataSource();

var child = new VisualElement();
child.dataSourcePath = new PropertyPath(nameof(DataSource.vector3));
element.Add(child);

Important notice about bindings support from UXML

The runtime binding system relies on a new feature called UXML Object, which allows to add complex non-visual elements in UXML files. This feature is part of a bigger effort called UXML Serialization, which aims to replace the boilerplate code needed to use a VisualElement derived type in UXML. The UXML Serialization is a replacement for the UXMLFactory and UXMLTraits system that was previously needed. In a nutshell, this new feature allows users to go from:

public class MyElement : VisualElement
{
    new class UXMLFactory : UXMLFactory<MyElement, UXMLTraits>
    {
    }

    new class UXMLTraits : VisualElement.UXMLTraits
    {
        private UXMLFloatAttributeDescription m_Value = new UXMLFloatAttributeDescription {name = "value"};

        public override void Init(VisualElement ve, IUXMLAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            var baseField = (MyElement) ve;
            baseField.value = m_Value.GetValueFromBag(bag, cc);
        }
    }

    public float value { get; set; }
}

to:

[UXMLElement]
public partial class MyElement : VisualElement
{
    [UXMLAttribute]
    public float value { get; set; }
}

In turn, this allows the runtime binding system to define the binding objects in UXML in a way that was not previously possible.

Instead of defining bindings like this:

<ui:IntegerField
    label="{DataBinding data-source-path=Path.to.Label}"
    value="{DataBinding data-source-path=Path.To.Value}"
/>

We can use the UXML Serialization feature and define the bindings like this:

<ui:IntegerField>
    <Bindings>
        <ui:DataBinding
            property="label"
            data-source-path="Path.to.Label"
        />
    </Bindings>
</ui:IntegerField>

The main advantage of this approach is that it will be significantly easier for users to extend the runtime binding system by defining their own binding types with their own attributes. Unfortunately, this also means that an element still using UXMLFactory and UXMLTraits will not be able to add bindings through UXML. This is a limitation only on the UXML, bindings can still be added through code.

Binding Types

Bindings are objects that you can create and register/unregister to a VisualElement through a unique id. We have added the followings APIs to VisualElement in order to add/remove/get binding objects:

public void SetBinding(BindingId bindingId, Binding binding);
public Binding GetBinding(BindingId bindingId);
public bool TryGetBinding(BindingId bindingId, out Binding binding);
public IEnumerable<BindingInfo> GetBindingInfos();
public bool HasBinding(BindingId bindingId);
public void ClearBinding(BindingId bindingId);
public void ClearBindings();

Even though the APIs to un/register bindings accept a Binding object, it is not a type that can be extended by users directly. Instead, we provide two bases classes for users to use and/or extend, which are DataBinding and CustomBinding.

It is important to note that, as much as possible, binding types should not keep per-element state. This is because a binding instance can be used on multiple elements (and even for multiple properties of an element) at the same time. The update and callbacks will each pass in a context object containing the target element of the binding, the binding id and additional relevant data. Any state of a binding object should take this in consideration.

A binding object can be updated using three distinct strategies:

  • Every frame
  • When we detect a change in the data source (or every frame if we can't detect a change, see the data sources section for more details) or when the binding object is marked as dirty.
  • Only when the binding object is marked as dirty.

We have provided the following APIs to help you control when a binding object is updated:

// This is used by the binding system to figure out if a binding should be updated or not during the next tick.
public bool isDirty { get; }

// This informs the binding system that the binding object should be updated during the next tick.
public void MarkDirty();
public enum BindingUpdateTrigger
{
    /// <summary>
    /// Only when <see cref="Binding.MarkDirty"/> has been called.
    /// </summary>
    WhenDirty,
    /// <summary>
    /// Only when a change is detected in the source or <see cref="Binding.MarkDirty"/> has been called.
    /// </summary>
    OnSourceChanged,
    /// <summary>
    /// On every update, regardless of data source changes.
    /// </summary>
    EveryUpdate,
}

// This informs the binding system of how the binding object should be updated.
public BindingUpdateTrigger updateTrigger { get; }

In addition, user-defined binding types will receive calls at various moment of their lifetime. The following APIs should be enough to handle most common scenarios, such as un/registering callbacks on an element or performing any setup and cleanup:

// This will get called when a binding object is registered on an element for a given binding id.
protected virtual void OnActivated(in BindingActivationContext context);

// This will get called when a binding object is unregistered from an element for a given binding id.
protected virtual void OnDeactivated(in BindingActivationContext context);

// This will get called when the resolved dataSource or dataSourcePath of a binding has changed. Note that this is not called when a field or a property of the resolved data source has changed.
protected virtual void OnDataSourceChanged(in DataSourceContextChanged context);

Whenever possible, these callbacks should be preferred to the Update callbacks. For example, if you only need to register/unregister a callback on the target element, it should be sufficient to only implement the OnDataSourceChanged callback.

A binding type can implement the IDataSourceProvider interface to provide a dataSource and a dataSourcePath that will be used by the binding system to calculate the resolved data source and resolved data source path. These "local" properties may override what is coming from the hierarchy, but they will not affect the element or its descendants.

@seb776

seb776 commented Aug 18, 2023

Copy link
Copy Markdown

Hello,

Don't know if this is the right place for it but here are some feedbacks.

  • There is a lot of confusion between the editor way and the runtime way
    The documentation mixes things and we never know when to use binding-path or data-source-path
    This is unclear between what I understand are 2 different implementation of almost the same features in different contexts.

  • We cannot bind style or classes
    This makes some simple scenarios a nightmare (changing color based on data for example)

  • Being able to write binding like this would be great !

<ui:IntegerField
    label="{DataBinding data-source-path=Path.to.Label}"
    value="{DataBinding data-source-path=Path.To.Value}"
/>

@LeMartinParadis

Copy link
Copy Markdown
Author

There is a lot of confusion between the editor way and the runtime way
The documentation mixes things and we never know when to use binding-path or data-source-path

We can make it clearer, but the gist of it is:

  • When you need to deal with serialization in the editor (and undo, multiple selection, presets, prefabs, etc.), use the binding-path and the editor binding system.
  • For every thing else, you can use the new system.

This is unclear between what I understand are 2 different implementation of almost the same features in different contexts.

They are similar at a glance, but works in different ways: the editor system is built on top on the SerializedObject/SerializedProperty API (editor-only) and can only support binding to a single thing for a restricted set of elements. The new system can work on any data and elements, provided they are instrumented correctly. Simply put, the new system is more generalized and the previous system was designed for a single purpose.

We cannot bind style or classes

With the new system, you can absolutely bind to styles. And using a CustomBinding, you can also bind to classes as well.

Being able to write binding like this would be great !

We did consider it, but ended up deciding on defining bindings through their own tags, as this will make it easier to define complex custom binding types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment