Event propagation to all visual elements in custom control (bug?)

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.

9504886--1339084--Unity_kgBa5DuI6U.gif

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.

The Change events that target any of the children of InputField will bubble up and can be caught at the parent level. What you need to do, is to check against evt.target to filter out the unwanted events.

One thing that you can do, is to register to the events in your InputField from it’s constructor and perform this filtering to stop any event that doesn’t come from the right child:

public InputField()
{
    /*
     * ...
     */

    this.RegisterCallback(FilterChangeEvents);
}

private void FilterChangeEvents(ChangeEvent<string> evt)
{
    if (evt.target != textField)
    {
        //StopImmediate will make sure the other remaining callbacks registered on this element won't be triggered
        evt.StopImmediatePropagation();
    }
}

Thanks for the answer, but this doesn’t seem to work. While it gets to the function to stop the propagation, it still adjusts the text to that of the countLabel.

I got it working now. I figured I also need to change the InputfieldDataBinding to make use of RegisterCallback instead of RegisterValueChangedCallback

Doing this makes it work as I would expect.