Event Like Changeevent That Fires When User Commits Change

I have a custom type (Serializable, but not a UnityEngine.Object) that is a string that is sort of an expandable enum. The possible values default to data in a ScriptableObject, and new values are added to run/edit-time in-memory dictionary any time a new value is entered. There is an editor that allows updating the ScriptableObject data from the in-memory version.

I implemented a PropertyDrawer using UIElements that (when used with a custom Editor) displays the text field (using PropertyField) and adds a ContextualMenuManipulator to replace the context menu for the field with a list of current options for that value.

The hard part was dealing with automatically adding a newly-entered value to the list of possible values. I didn’t want every edit added–so I needed to wait until the user finished. I tried to add a listener for BlurEvent to the PropertyField, but BlurEvent doesn’t bubble up, so it had to be registered to the TextInput field–which I could not find using Querys from PropertyField–possibly because the underlying field was not set up yet during my CreatePropertyGUI() processing???

So I attach my OnBlur to the TextInput when I get ChangeEvent from the user making changes.

OnBlur is a necessary, but not sufficient, event. It does not fire when the user clicks somewhere outside the inspector. Furthermore, the PropertyDrawer does not have any form of lifecycle callbacks.

Instead, I needed register a listener for DetachFromPanelEvent on the PropertyField. This allowed me to know when the panel is being torn down. But at this point I don’t have any information about what is being edited.

I added a local UserData class to hold state information (wouldn’t want to register blur callbacks on Every ChangeEvent, after all) and added that to my root VisualElement I created in the CreatePropertyGUI() method. Then, when processing any event, I can walk the parent chain of VisualElements from the target until I find userData of the correct type.

So–lots of effort to detect all the ways a user can finish editing a value. It would be so much easier if this were exposed as an event fired directly by PropertyField.

Hello! Would you have some sample code we could take a look at?

I’ve stripped out stuff not germain to the issue, replacing that code with comments like “do something” or “get list of values”. The Tag class has a bit more to it, but the one string value is all that’s needed for the drawer example here.

    [Serializable]
    public class Tag
    {
        public string TagName;
    }

    [CustomPropertyDrawer(typeof(Tag), true)]
    public class TagPropertyDrawer : PropertyDrawer
    {
        private class UserData
        {
            public Tag Tag;
            public bool BlurRegistered;
            public string OriginalValue;
        }

        public override VisualElement CreatePropertyGUI(SerializedProperty property)
        {
            var target = GetTagFromSerializedProperty(property);

            var container = new VisualElement();
            var userData = new UserData();
            container.userData = userData;

            // Create property fields.
            var tagNameField = new PropertyField(
                                    property.FindPropertyRelative("TagName"),
                                    property.displayName);
            ContextualMenuManipulator m = new ContextualMenuManipulator(
                                                (e) => BuildMenu(e, property));
            m.target = tagNameField;

            // This one doesn't work since BlurEvent doesn't bubble up
            tagNameField.RegisterCallback<BlurEvent>(OnBlur);

            // ChangeEvent allows me to register OnBlur when user begins
            // making changes
            tagNameField.RegisterCallback<ChangeEvent<string>>(OnChange);
            // OnDetach allows catching cases where BlurEvent does not fire.
            tagNameField.RegisterCallback<DetachFromPanelEvent>(OnDetach);

            // keep track of context since PropertyDrawers instances are reused
            userData.BlurRegistered = false;
            userData.Tag = target;
            userData.OriginalValue = target.TagName;

            // Add fields to the container.
            container.Add(tagNameField);

            return container;
        }

        /// <summary>
        /// Walk the parent chain to find one with the correct type of user data.
        /// </summary>
        /// <param name="v"></param>
        /// <returns></returns>
        private UserData GetUserData(VisualElement v)
        {
            var cur = v;
            while (cur != null)
            {
                if (cur.userData is UserData)
                    return (UserData)cur.userData;
                cur = cur.parent;
            }
            return null;
        }

        // When ChangeEvent occurs, register a callback
        // for BlurEvent on the input field
        private void OnChange(ChangeEvent<string> evt)
        {
            var ve = (VisualElement)evt.target;
            var userData = GetUserData(ve);

            if (userData.BlurRegistered)
                return;

            ve.RegisterCallback<BlurEvent>(OnBlur);
            userData.BlurRegistered = true;
        }

        // This handles the case where the user clicks outside
        // of the inspector and OnBlur doesn't fire
        private void OnDetach(DetachFromPanelEvent evt)
        {
            var ve = (VisualElement)evt.target;
            var userData = GetUserData(ve);
            ProcessChangedValue(userData);
        }

        // This handles the cases where focus changes
        // within the Inspector
        private void OnBlur(BlurEvent evt)
        {
            var ve = (VisualElement)evt.target;
            var userData = GetUserData(ve);

            ve.UnregisterCallback<BlurEvent>(OnBlur);
            userData.BlurRegistered = false;

            ProcessChangedValue(userData);
        }

        // Handle the changed value, called from OnBlur and OnDetach
        private void ProcessChangedValue(UserData userData)
        {
            if (string.IsNullOrEmpty(userData?.Tag?.TagName))
                return;

            if (userData.Tag.TagName != userData.OriginalValue)
            {
                // Do stuff with new value
            }
        }

        // Build popup menu list of tag values
        private void BuildMenu(ContextualMenuPopulateEvent e, SerializedProperty property)
        {
            var tags = // Get list of possible tags

            e.menu.AppendSeparator();
            foreach (var tag in tags)
            {
                e.menu.AppendAction(tag, (menuAction) => target.TagName = (string)menuAction.userData,
                    (menuAction) => DropdownMenuAction.Status.Normal, tag);
            }
            e.StopPropagation();
        }

        // Get the non-Object reference from the SerializedProperty
        private Tag GetTagFromSerializedProperty(SerializedProperty property)
        {
            return (Tag)fieldInfo.GetValue(property.serializedObject.targetObject);
        }

        private string SafeName(IEventHandler evtHandler)
        {
            return (evtHandler as VisualElement)?.name ?? "NOT A VisualElement";
        }

    }
}

So you have a Tag property whose values is a string picked among a set of available strings, and the user may add new values to that set. I may be missing something but why not have separate widget for tag selection and new tag registration? It seems to me that one could easily add tags by mistake and that most of the time you’re likely to want to use an existing tag. Also, there are limitations to the event system, however besides those limitation I guess it’s a bit tricky to decide wether a tag should be added or not: basically when the user “leaves” the textfield, it is assumed that if the value held by the textfield is not in the set of available values then it should be added? Finally, I assume that if you let the user create tag you’ll end up providing a UI to also delete tags from the set?

Please focus on the functionality at a field level that is equivalent to the OnValidate method of a class.

I can debate the usefulness of this particular example, but that is not the point of this request.

And yes, in the current case, there is a separate editor window where one can manage the values.

What is missing in UIElements at the moment would be a way to set wether the ChangeEvent fires on any edit or only on completion, this is on our backlog. In the meantime, here’s something you can try, it’s obviously a patch but may help a bit: try retrieving the TextField within the PropertyField to set its isDelayed property. You need to wait till the bindings have been set up to do this, here’s a quick test that works on my end:

myPropertyField.RegisterCallback<AttachToPanelEvent>((e) =>
{
     myPropertyField.Q<TextField>().isDelayed = true;
});
2 Likes

Thank you! That should provide a better solution than what I have now. It also explains why my Query for the TextField didn’t work when I initially tried to attach the BlurEvent to it.

ChangeEvent vs ChangeCompletedEvent might be a solution worth investigating.

1 Like

Is this a thing yet?

1 Like