This is the third part in the series: UI Toolkit runtime bindings system
- Part 1: Introduction to the runtime binding system.
- Part 2: Overview of the available binding types.
- Part 3: Instrumenting your types and custom elements for runtime bindings. (This post)
- Part 4: Type conversions.
- Part 5: Tips & performance considerations.
- Part 6: Current limitations & what's next.
This part will provide an overview on how to instrument your types (both sources and custom elements) for the UI Toolkit runtime bindings system as well as how to opt-in versioning and change tracking.
Data sources in UI Toolkit can be any plain old C# object. Under the hood, we are using the Properties module to create property bags that we can use for binding data between two objects.
Property bags are generated based on the C# type information available. This means that for some built-in Unity types, the property bag we will generate might not contain the expected properties, because they lack the necessary attributes (i.e. Rect needed a custom solution because it only has public properties and private fields that are not attributed with [SerializeField]) or the fields are defined on the native side, which we have no way of knowing at runtime).
Note that since VisualElement.dataSource is defined as an object property, using a value type as a data source will incur a boxing cost as well as create a copy of the data. This will severely limit the ability to reflect changes from the UI back to the data source. Typically, you should prefer using a reference type for data sources.
A property bag for a given type is a companion object that enables efficient data traversal algorithms based on instances of that type. By default, the property bag of a given Type will be generated using reflection. This will happen lazily only once per Type when a property bag is not already registered. This reflection-based path is provided for convenience.
To avoid the use of reflection, you can opt-in to use code generation instead by tagging a Type with [Unity.Properties.GeneratePropertyBag]. Note that for code generation to kick in, the assembly must also be tagged with [assembly: Unity.Properties.GeneratePropertyBagsForAssembly]. When the property bag of a type is code generated, it will automatically be registered when the domain is loaded.
In both reflection and code generation cases, the property bag will generate bindable properties using these rules:
- A property will be generated for public fields.
- A property will be generated for private or internal fields tagged with
[UnityEngine.SerializeField],[UnityEngine.SerializeReference]or[Unity.Properties.CreateProperty]. - A property will be generated for public, private or internal properties tagged with
[Unity.Properties.CreateProperty]. - A property will not be generated for public, private or internal fields or properties tagged with
[Unity.Properties.DontCreateProperty]. - A generated property will be
readonlyif the field is readonly or if the property only has a getter. A generated property can also be madereadonly by using[Unity.Properties.CreateProperty(ReadOnly = true)].
One typical approach for defining a source that serves both runtime bindings and authoring/serializing purposes is by employing the following pattern:
using UnityEngine;
using Unity.Properties;
public class MyBehaviour : MonoBehaviour
{
// Serialization will go through the field, but bindings should opt-out.
[SerializeField, DontCreateProperty] private int m_Value;
// Bindings should go through the property instead of the field. This will give a chance to do validation, notify changes, etc.
[CreateProperty] public int value
{
get => m_Value;
set => m_Value = value;
}
// Similar example, but for an auto-property.
[field: SerializeField, DontCreateProperty]
[CreateProperty]
public float floatValue { get; set; }
}Contrarily to the Unity serialization system, properties of a Property Bag will not be considered as value types when [UnityEngine.SerializeField] is used. struct types will be considered as value types and class types will be considered as references.
Adding versioning and change tracking to a data source can yield tremendous performance improvements when they are used in the context of data binding. This is because by default, the binding system will poll the data source and update the UI on every update, since we do not know if something changed since the last update. While this is convenient as a starting point and for projects with minimal UI, it is not a use case that will scale very well with a lot of bindings.
Versioning and change tracking for sources is an optional, opt-in feature. By default, when a binding object is active, it will be updated every tick of the binding system. The update of a binding object can be a heavy process (see tips and performance considerations post for more details) and special care must be made to ensure that the binding system can do as little work as possible on each tick. We offer two distinct approaches for guiding the binding system on when to update binding objects associated with a source:
UnityEngine.UIElements.IDataSourceViewHashProvider provides a view hash code to instruct the system when to update all the bindings that resolve to this source.
UnityEngine.UIElements.INotifyBindablePropertyChanged allows notifying changes for each bindable property, instructing the system to update corresponding bindings.
They can be used either separately or simultaneously.
Note that currently, types implementing either interface will automatically opt-in to code generation when the assembly is tagged with [assembly: Unity.Properties.GeneratePropertyBagsForAssembly], though this is subject to change.
The IDataSourceViewHashProvider interface can be used to provide a view hash code for a given source. This allows the binding system to skip the update of some binding objects when the source did not change since the last update.
// A simple data source view that reports changes immediately
using UnityEngine.UIElements;
public class DataSource : IDataSourceViewHashProvider
{
public int intValue;
public float floatValue;
// Required by IDataSourceViewHashProvider
public long GetViewHashCode()
{
return HashCode.Combine(intValue, floatValue);
}
}IDataSourceViewHashProvider can also be used to buffer changes. This can be useful when the data is expected to change constantly, but the UI does not need to keep up with the changes.
Note that by default, the binding system will not update a binding object if the version of its data source did not change. However, binding objects might still be updated even if the version did not change, by calling its MarkDirty method or by setting the updateTrigger to BindingUpdateTrigger.EveryUpdate. When using IDataSourceViewHashProvider to buffer changes, avoid any structural changes in your source, such as adding or removing items from a list or changing the type of a (sub)field|property.
// A simple data source view that can buffer changes
using UnityEngine.UIElements;
public class DataSource : IDataSourceViewHashProvider
{
private long m_Version;
public int intValue;
public void CommitChanges()
{
++m_Version;
}
// Required by IDataSourceViewHashProvider
public long GetViewHashCode()
{
return m_Version;
}
}The INotifyBindablePropertyChanged interface can be used to notify the binding system that a chance occurred for a given property. When a source implements this interface, binding objects that are tied to it will only be updated when a change is detected along its property path. For example, if the source reports a change to the MyAwesomeObject property, the binding system will update all bindings that have a data source path with the prefix MyAwesomeObject. Additional binding objects tied to the source will not be updated.
This allows to have very fine-grained updates to the UI as the binding system will only perform the minimal amount of work.
// A simple data source that notifies changes on a per-property basis
using System.Runtime.CompilerServices;
using Unity.Properties;
using UnityEngine.UIElements;
public class DataSource : INotifyBindablePropertyChanged
{
private int m_Value;
// Required by INotifyBindablePropertyChanged
public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;
[CreateProperty]
public int value
{
get => m_Value;
set
{
if (m_Value == value)
return;
m_Value = value;
Notify();
}
}
void Notify([CallerMemberName] string property = "")
{
propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(property));
}
}Note that when implementing this interface, the binding system will not perform any checks when it gets notified of a change. Also note that failure to report a change means that the binding system will not update bindings tied to that property.
When using both interfaces simultaneously, you can combine the power of both mechanisms to achieve maximal performance with bindings. The functioning process involves the binding system actively monitoring any altered properties until it recognizes a change in the view's hash code. Once the view's hash code has changed, the binding system proceeds to update all the bindings associated with the altered properties.
This allows for maximum flexibility and performance, at the cost of more boilerplate code.
using System;
using System.Runtime.CompilerServices;
using Unity.Properties;
using UnityEngine.UIElements;
// A simple data source implementing both interfaces. This data source will notify the binding system
// of changes, but the bindings related to this data source won't be updated until a call to `Publish()`
// is made. This can be useful when the data is highly volatile, but the UI is not required to be updated
// right-away (i.e. updating a value every single frame has a performance cost, but will not necessarily
// provide more value to the user).
public class DataSource : IDataSourceViewHashProvider, INotifyBindablePropertyChanged
{
private long m_ViewVersion;
private int m_Value;
private int m_OtherValue;
public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;
[CreateProperty]
public int value
{
get => m_Value;
set
{
if (m_Value == value)
return;
m_Value = value;
Notify();
}
}
[CreateProperty]
public int otherValue
{
get => m_OtherValue;
set
{
if (m_OtherValue == value)
return;
m_OtherValue = value;
Notify();
}
}
public void Publish()
{
++m_ViewVersion;
}
public long GetViewHashCode()
{
return m_ViewVersion;
}
void Notify([CallerMemberName] string property = "")
{
propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(property));
}
}As stated previously, the runtime binding system can work on an given VisualElement. There is no need to derive from a specific base type and no need to implement an interface to be able to use the runtime binding feature. For a given element, you can create bindable fields/properties using the same rules described in the "Defining a data source" post. Since the bindable properties are created in the same way as other data sources, it means that VisualElement types can be used as data sources as well. The main difference between a VisualElement type and other data sources is that VisualElement types come with built-in versioning, which must be used in order to propagate changes.
Here is an example displaying how to report a change.
// Defining a binding property helps users of this element to register binding.
public static readonly BindingId intValueProperty = nameof(intValue);
private int m_IntValue;
[CreateProperty]
public int intValue
{
get => m_IntValue;
set
{
if (m_IntValue == value)
return;
m_IntValue = value;
// This will instruct the binding system that a change occured.
NotifyPropertyChanged(intValueProperty);
}
}Note that changes in element.style and element.resolvedStyle are not currently reported. This was because there was a performance concern about the volatility of styles. This will be addressed at some point. You can still use binding instances to target the [resolved]style of an element, but it is not possible to track changes to them out-of-the-box.