How to create custom visual element - both runtime and editor compatible

I am deeply confused about creation of custom UI toolkit elements. I need some guidance and explanations how things supposed to be done.
The goal is simple, there is some data type:

[System.Serializable]
public class PlaneData
{
    public Vector3 position;
    public Quaternion rotation;
}

There is component (or scriptable object) with field of type PlaneData and I want to create custom visual element that works both in editor and runtime and it’s used to display and manipulate data of binded object. To make it simple assume this control is square and it displays draggable red dot and it’s position is used to write position vector (current value Vector3(5, 10, 0)).


I want my control to be binded two ways, so changing value externally from script should update position of dot in that square.

My confusion comes from the fact there are so many methods to “bind” data, while some build in controls don’t even use that. For instance:

  1. ListView has property itemsSource to feed it with data, but in some examples they also use Bind() in addition, is that required? Is that only for editor? If I bind property then why itemsSource is set too?
  2. In other examples of ListView they use bind path. In my understanding is just the same as using Bind(), but different direct way. Is that true? If yes then what about itemsSource?
  3. Some controls, like Toggle or Slider are available without enabling editor extensions in ui builder, so they should work both in runtime and editor, but how is that implemented? I should not be able to use binding path during runtime, so if I want to make it work I need to implement binding manually?
  4. There is another system data source for runtime binding, so should I use it instead? What if I want editor and runtime binding to be available?
  5. Docs mentioned that I can make custom control with binding, but only basic and unity types are allowed.

I can guess answers to many of those questions above, but I am still puzzled how properly implement ui element, especially the part where data is binded, how should I update value is there some event or callback?

There’s two separate binding systems, serialized property binding and runtime binding. Both work in different ways and both can be implemented into the same visual element.

Both are covered in the manual: Unity - Manual: Data binding

For serialized property binding I believe you need to inherit from BaseField such as in this example: Unity - Manual: Create a bindable custom control

When it comes to serialized property binding, it needs the binding path set, and bound to a serialized property/object. The various extension methods all do the same thing really.

Though in most cases, if I have a visual element that is specifically designed around a specific data type, I just introduce a delegate into to publish any changes into the internal data, and have the visual element subscribe/unsubscribe in AttachToPanel/DetatchFromPanel events.

Yes, I have seen that, however those are two separate sections, and I am not sure how to combine them.
I admit there are sections of docs I haven’t read, but it’s a bit hard for me to understand how to combine editor code and runtime code. What if I provide both? Element should pick one I guess?
In any case where is the place I update ui from it’s data source, should I use scheduler?

I can’t use PropertyField (or BaseField you mentioned) because only some types are supported, so I guess I need some property that is set in code, then I update control every frame (?), but I am not sure how to do it even if this is correct way of thinking.

You don’t need to combine them. They work in entirely separate ways that don’t overlap, and can be implemented into the same visual element without treading on each other’s toes.

For runtime data binding you usually just need to add [CreateProperty] on the relevant members, and be sure to call NotifyPropertyChanged when something is changed via a bindable property: Unity - Scripting API: UIElements.CallbackEventHandler.NotifyPropertyChanged

Well you only use that for automatic drawing. If you set up a property drawer for your custom serializable type, then it would be used when using PropertyField.

That’s true, but underneath you will have primitive types that are supported by serialized property binding. So you would use the internal visual elements meant to represent said primitive data to handle the binding.

Which also means the outer element doesn’t need to be a BaseField either.

Here’s an example that works with editor binding and runtime binding:

namespace LBG.Examples
{
	using System;
	using Unity.Properties;
	using UnityEngine;
	using UnityEngine.UIElements;

#if UNITY_EDITOR
	using UnityEditor;
#endif

	[Serializable]
	public sealed class LBGPlane : INotifyBindablePropertyChanged // interface handles broadcasting runtime binding changes
	{
		#region Inspector Fields

		[SerializeField]
		private Vector2 _size;

		[SerializeField]
		private Vector3 _position;

		#endregion

		#region Properties

		[CreateProperty]
		public Vector2 Size
		{
			get => _size;
			set
			{
				_size = value;
				NotifyPropertyChanged(nameof(Size));
			}
		}

		[CreateProperty]
		public Vector3 Position
		{
			get => _position;
			set
			{
				_position = value;
				NotifyPropertyChanged(nameof(Position));
			}
		}

		public const string SIZE_PROPERTY_PATH = nameof(_size);

		public const string POSITION_PROPERTY_PATH = nameof(_position);

		public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;

		#endregion

		#region Constructors

		public LBGPlane() { }

		public LBGPlane(Vector2 size, Vector3 position)
		{
			_size = size;
			_position = position;
		}

		#endregion

		#region Methods

		private void NotifyPropertyChanged(BindingId caller)
		{
			var args = new BindablePropertyChangedEventArgs(caller);
			propertyChanged?.Invoke(this, args);
		}

		#endregion
	}

	public sealed class LBGPlaneElement : VisualElement
	{
		#region Constructors

		public LBGPlaneElement() : base()
		{
			_sizeField = new Vector2Field(SIZE_LABEL);
			Add(_sizeField);

			_positionField = new Vector3Field(POSITION_LABEL);
			Add(_positionField);
		}

		public LBGPlaneElement(LBGPlane plane) : this()
		{
			this.Plane = plane;
		}

		#endregion

		#region Internal Members

		const string SIZE_LABEL = "Size: ";

		const string POSITION_LABEL = "Position: ";

		private readonly Vector2Field _sizeField;
		private readonly Vector3Field _positionField;

		private LBGPlane _plane;

		#endregion

		#region Properties

		[CreateProperty]
		public LBGPlane Plane
		{
			get => _plane;
			set
			{
				if (value == _plane)
				{
					return;
				}

				_plane = value;
				NotifyPropertyChanged(nameof(Plane));
			}
		}

		public Vector2Field SizeField => _sizeField;

		public Vector3Field PositionField => _positionField;

		#endregion
	}


#if UNITY_EDITOR
	[CustomPropertyDrawer(typeof(LBGPlane))]
	public sealed class LBGPlanePropertyDrawer : PropertyDrawer
	{
		public override VisualElement CreatePropertyGUI(SerializedProperty property)
		{
			var sizeProperty = property.FindPropertyRelative(LBGPlane.SIZE_PROPERTY_PATH);
			var positionProperty = property.FindPropertyRelative(LBGPlane.POSITION_PROPERTY_PATH);

			var ve = new LBGPlaneElement()
			{
				SizeField =
				{
					bindingPath = sizeProperty.propertyPath
				},
				PositionField =
				{
					bindingPath = positionProperty.propertyPath
				}
			};
			return ve;
		}
	}
#endif
}

Here it is in the inspector:

And the backing data in the scriptable object correctly updates when I save after making changes in the inspector.