I have a question regarding how events should behave within a custom control. I am having some (for me) unexpected behaviour that seems like a bug, but I am not sure if it actually is.
I am working on an editor for one of our products and I am using UI Toolkit for runtime UI. First time using this, so encountering plenty of new things, but pretty much smooth sailing so far.
One of the custom controls I made is a text field that also visualizes the character count against the set max length of a text field if set. Just for reference, it looks like this:
Nothing really fancy. The code I have for this is as following:
public class InputField : VisualElement
{
public new class UxmlFactory : UxmlFactory<InputField, UxmlTraits>
{ }
public new class UxmlTraits : VisualElement.UxmlTraits
{
private UxmlIntAttributeDescription maxLength = new UxmlIntAttributeDescription { name = "Max-Length", defaultValue = -1 };
private UxmlStringAttributeDescription textValue = new UxmlStringAttributeDescription { name = "Text-Value", defaultValue = string.Empty };
private UxmlStringAttributeDescription label = new UxmlStringAttributeDescription { name = "Label", defaultValue = string.Empty };
public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
{
base.Init(ve, bag, cc);
InputField input = ve as InputField;
input.MaxLength = maxLength.GetValueFromBag(bag, cc);
input.Value = textValue.GetValueFromBag(bag, cc);
input.Label = label.GetValueFromBag(bag, cc);
TextField textField = ve.Q<TextField>("input");
textField.maxLength = input.MaxLength;
textField.value = input.Value;
Label fieldLabel = ve.Q<Label>("label");
fieldLabel.text = input.Label;
fieldLabel.visible = input.Label.Length > 0;
Label countLabel = ve.Q<Label>("count");
countLabel.SetEnabled(input.MaxLength > 0);
if (countLabel.visible)
{
countLabel.text = $"{textField.value.Length}/{input.MaxLength}";
}
}
}
public int MaxLength { get; set; }
public string Value { get; set; }
public string Label { get; set; }
private TextField textField;
private Label countLabel;
private Label label;
public InputField()
{
VisualTreeAsset visualTree = Addressables.LoadAssetAsync<VisualTreeAsset>("InputField").WaitForCompletion();
visualTree.CloneTree(this);
countLabel = this.Q<Label>("count");
textField = this.Q<TextField>("input");
label = this.Q<Label>("label");
}
public void RegisterValueChangedCallback(EventCallback<ChangeEvent<string>> callback)
{
textField.RegisterValueChangedCallback(callback);
}
public void UnregisterValueChangedCallback(EventCallback<ChangeEvent<string>> callback)
{
textField.UnregisterValueChangedCallback(callback);
}
public void SetValueWithoutNotify(string value)
{
textField.SetValueWithoutNotify(value);
countLabel.text = $"{textField.value.Length}/{MaxLength}";
}
}
It’s nothing really fancy and basically just updates the countlabel value to the amount of characters in the textfield and some “wrapper” functions for (un)registering callbacks.
The problem arises when I call SetValueWithoutNotify. When I call this function (and a callback is registered) the value of countLabel is set in textField.

From what I can see, it seems that the change in countLabel also fires a changeEvent “within” the custom control. This is not something I would expect to happen. I can stop this behaviour by stopping the propagation of the countLabel like this:
public InputField()
{
VisualTreeAsset visualTree = Addressables.LoadAssetAsync<VisualTreeAsset>("InputField").WaitForCompletion();
visualTree.CloneTree(this);
countLabel = this.Q<Label>("count");
textField = this.Q<TextField>("input");
label = this.Q<Label>("label");
countLabel.RegisterValueChangedCallback(StopPropagation);
label.RegisterValueChangedCallback(StopPropagation);
}
private void StopPropagation(ChangeEvent<string> evt)
{
evt.StopPropagation();
}
But I don’t want to subscribe to the callback for every visual element that may or may not change, especially not if I would make more complex custom controls.
Also I am not sure if this behaviour is intended or not, of if this is a bug? Is there a better way to stop this behaviour if it is intended?
Also for a complete overview of what is happening, the class that registers to the RegisterValueChangedCallback is some custom binding logic that also provides some undo/redo logic.
Binding code
public class InputFieldDataBinder : IDisposable
{
private CommandProcessor commandProcessor;
private InputField inputField;
private BaseData baseData;
private string bindingPath;
private int index;
[Inject]
private void InjectDependencies(CommandProcessor commandProcessor)
{
this.commandProcessor = commandProcessor;
}
public InputFieldDataBinder(InputField inputField, BaseData baseData, string bindingPath, int index = -1)
{
this.inputField = inputField;
this.baseData = baseData;
this.bindingPath = bindingPath;
this.index = index;
if (index >= 0)
{
IList listData = baseData.GetType().GetProperty(bindingPath).GetValue(baseData) as IList;
inputField.SetValueWithoutNotify(listData[index] as string);
}
else
{
inputField.SetValueWithoutNotify(baseData.GetType().GetProperty(bindingPath).GetValue(baseData) as string);
}
inputField.RegisterValueChangedCallback(OnValueChanged);
baseData.PropertyChanged += OnPropertyChanged;
}
private void OnValueChanged(ChangeEvent<string> evt)
{
SetDataCommand<string> command = new SetDataCommand<string>(baseData, bindingPath, evt.previousValue, evt.newValue, index);
commandProcessor.ExecuteCommand(command);
}
private void OnPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (bindingPath == e.PropertyName)
{
if (index >= 0)
{
IList listData = baseData.GetType().GetProperty(bindingPath).GetValue(baseData) as IList;
inputField.SetValueWithoutNotify(listData[index] as string);
}
else
{
inputField.SetValueWithoutNotify(baseData.GetType().GetProperty(bindingPath).GetValue(baseData) as string);
}
}
}
public void Dispose()
{
baseData.PropertyChanged -= OnPropertyChanged;
inputField.UnregisterValueChangedCallback(OnValueChanged);
}
}
Essentially: it subscribes to any changes to the inputfield, when that fires it creates a command that changes the targeted property. Once that property is changed, it will set that adjusted property in the proper visual element. The current implementation also makes it call on itself, causing it to set the value twice on itself, but due to the SetValueWithoutNotify call, it doesn’t keep calling on itself.
