Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice

RegisterValueChangedCallback triggers for childs as well

Discussion in 'UI Toolkit' started by ayellowpaper, Nov 14, 2019.

  1. ayellowpaper

    ayellowpaper

    Joined:
    Dec 8, 2013
    Posts:
    49
    While working on my own implementation of a slider with field I stumbled upon something I found strange and made it hard to get my slider working. I'd like to hear from Unity Devs about what they are thinking. The issue is following: RegisterValueChangedCallback also triggers when children change their value. An example and explanation:
    Code (CSharp):
    1. var outerField = new FloatField();
    2. var innerField = new FloatField();
    3. outerField.Add(innerField);
    4. outerField.RegisterValueChangedCallback(x => Debug.Log(x.newValue));
    Changing the value of the outerField results in 1 callback. This is expected.
    Changing the value of the innerField results in 2 callbacks. I expected zero.
    Is this intended? I know events bubble up, and that doesn't have to change. I can always use RegisterCallback if I want to use the event system. But I feel like RegisterValueChangedCallback should only be called when the value of the field I actually registered to changes. It just feels a lot less confusing and there's nothing lost in my opinion.
    Also, I know the example above seems artificial because no one would nest input fields like that. But in my case I wanted to create slider with a field. My idea was to create new class inheriting from BaseField<float> which has a Slider and FloatField as children. It listens for changes in it's children and applies them to itself. But this lead to the above mentioned issue that the class I created would give two callbacks: One for child that has changed and one for changing the value on itself.
     
    EDevJogos likes this.
  2. AlexandreT-unity

    AlexandreT-unity

    Unity Technologies

    Joined:
    Feb 1, 2018
    Posts:
    325
  3. ayellowpaper

    ayellowpaper

    Joined:
    Dec 8, 2013
    Posts:
    49
    Thank you for your response. I know StopPropagation, but I think I didn't properly articulate what I was getting at. I'm proposing the idea that RegisterValueChangedCallback should behave differently by default. As mentioned in the example above, I get 1 callback (not 2, my bad) when changing the value of the innerField. In my opinion this is confusing. I want to illustrate with another example how confusing this can get:

    Code (CSharp):
    1. var floatField = new FloatField();
    2. var intField = new IntegerField();
    3. floatField.Add(intField);
    4. floatField.RegisterValueChangedCallback(x => Debug.Log("outer: " + x.newValue));
    This is a simple script I created. When I change the value of floatField, I get 1 callback. When I change the intField I get no callback. As a developer I expected this. Why wouldn't I? Now I'm deep in development, changing code in different places and refactoring, when I realize I want to change the IntegerField to a FloatField. Happy with the quick progress I have made with the incredible UIElements already, I realize I can simply change the class to FloatField. Right? Wrong. I suddenly get more callbacks than before. I get callbacks when changing the value of the inner element.
    Now think bigger, a huge editor with lots of dynamically created fields. I add more and more fields as children to the container, some TextFields, some VectorFields etc. I suddenly get more callbacks on the parent when changing the value of their children that coincidentally have the same ChangeEvent. It's horror to track down.
    And if the developer really wants this behaviour, he/she can still use RegisterEventCallback<ChangeEvent<float>> and trust that it works as the Event System does.

    In the end it just comes down to naming and expectations. floatField.RegisterValueChangedCallback just reads as if I will only get changes from the floatField. Renaming the callback to RegisterValueChangedEventCallback could also alleviate some confusion, though possibly not much.

    Just a developers two cents.
     
  4. uDamian

    uDamian

    Unity Technologies

    Joined:
    Dec 11, 2017
    Posts:
    1,203
    Fields were never meant to have children (doesn't that sound great out of context). When you put the IntegerField inside the FloatField, you are entering in the land of "undefined behavior". Although nothing crazy happens, the reason you only get the callback called for float changes and not integer changes is because RegisterValueChangedCallback is strongly typed to listen for ChangeEvent<float>(), so it doesn't get triggered by the ChangeEvent<int> sent by the child IntegerField. If you also register for the ChangeEven<int>() callback, on the floatField, you should get the IntegerField changes as well:

    Code (CSharp):
    1. var floatField = new FloatField();
    2. var intField = new IntegerField();
    3. floatField.Add(intField);
    4. floatField.RegisterValueChangedCallback(x => Debug.Log("outer: " + x.newValue));
    5. floatField.RegisterCallback<ChangeEvent<int>>(x => Debug.Log("inner:" + x.newValue));
     
    blisz likes this.
  5. ayellowpaper

    ayellowpaper

    Joined:
    Dec 8, 2013
    Posts:
    49
    Thank you for your response and your patience with me. This makes some sense, and accepting "Fields were never meant to have children" as a fact then I guess that's fine. But as a last question related to this one: How would you create a compound element, like a slider with a field? Because with all my ideas I run against a brick wall.
    • Solution I started with: custom class that inherits from BaseField<float> which has a slider and a field as children. This is apparently the wrong way, since it shouldn't have children
    • Either make Slider or FloatField the parent and attach the other. Same issue as above, a field shouldn't have children
    • Custom class inheriting from UIElement which has the slider and field as children. But this is also annoying, because it doesn't act as a single element, so I can't bind properties, call RegisterValueChangedCallback etc.
    I feel like I'm overthinking, but this doesn't let me go. Really curious what the inteded way of doing this is. Perhaps I should just wait until you release the slider with fields yourself :p
     
  6. uDamian

    uDamian

    Unity Technologies

    Joined:
    Dec 11, 2017
    Posts:
    1,203
    This first option is actually correct. I implemented a similar control in the UI Builder and that's the path I took. What I specifically meant by no children was that IntegerField, or TextField, should not have children. Those fields are concrete fields that assume their internal structure. But you can still create compound fields inheriting from BaseField that contain other BaseFields because you are then in control of the internal structure, especially when it comes to event handling.

    It's just a little awkward because you need to set the visualInput property on the BaseField<float> to be your "slider+field container element" (you want these in one container element, inside your custom field element), which the BaseField<float> will pad and position correctly given an optional Label. However, visualnput is internal, but, you should be able to set it in the BaseField<float>'s constructor, which is just protected.

    After that, you just need to RegisterValueChangedCallback() on your two fields and make sure those ChangeEvents don't escape and use them to sync the values between the two fields. Then, override SetValueWithoutNotify() on your compound field to update both children fields.

    Here's a copy of my Slider with field implementation, but you'll have to fix it up because I'm using some internal functions, I think. Hope at least it's a start:

    Code (CSharp):
    1. internal class PercentSlider : BaseField<float>
    2. {
    3.     static readonly string s_UssPath = absolute_path_to_UI_files + "PercentSlider.uss";
    4.     static readonly string s_UxmlPath = absolute_path_to_UI_files + "PercentSlider.uxml";
    5.  
    6.     static readonly string s_UssClassName = "unity-percent-slider";
    7.     static readonly string s_VisualInputName = "unity-visual-input";
    8.     static readonly string s_SliderName = "unity-slider";
    9.     static readonly string s_FieldName = "unity-field";
    10.  
    11.     public new class UxmlFactory : UxmlFactory<PercentSlider, UxmlTraits> { }
    12.  
    13.     public new class UxmlTraits : BaseField<float>.UxmlTraits
    14.     {
    15.         public UxmlTraits()
    16.         {
    17.         }
    18.  
    19.         public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
    20.         {
    21.             base.Init(ve, bag, cc);
    22.         }
    23.     }
    24.  
    25.     SliderInt mSlider;
    26.     IntegerField mField;
    27.  
    28.     public override void SetValueWithoutNotify(float newValue)
    29.     {
    30.         base.SetValueWithoutNotify(newValue);
    31.  
    32.         RefreshSubFields();
    33.     }
    34.  
    35.     public PercentSlider() : this(null) {}
    36.  
    37.     public PercentSlider(string label) : base(label)
    38.     {
    39.         AddToClassList(s_UssClassName);
    40.  
    41.         styleSheets.Add(AssetDatabase.LoadAssetAtPath<StyleSheet>(s_UssPath));
    42.  
    43.         var template = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(s_UxmlPath);
    44.         template.CloneTree(this);
    45.  
    46.         visualInput = this.Q(s_VisualInputName);
    47.         mSlider = this.Q<SliderInt>(s_SliderName);
    48.         mField = this.Q<IntegerField>(s_FieldName);
    49.  
    50.         mSlider.RegisterValueChangedCallback(OnSubFieldValueChange);
    51.         mField.RegisterValueChangedCallback(OnSubFieldValueChange);
    52.     }
    53.  
    54.     void RefreshSubFields()
    55.     {
    56.         var intNewValue = (int)(value * 100);
    57.  
    58.         mSlider.SetValueWithoutNotify(intNewValue);
    59.         if (mSlider.elementPanel != null)
    60.             mSlider.OnViewDataReady(); // Hack to force update the slide handle position.
    61.  
    62.         mField.SetValueWithoutNotify(intNewValue);
    63.     }
    64.  
    65.     void OnSubFieldValueChange(ChangeEvent<int> evt)
    66.     {
    67.         var newValue = Mathf.Clamp(evt.newValue, 0, 100);
    68.         var newValueFloat = ((float)newValue) / 100;
    69.  
    70.         if (value == newValueFloat)
    71.             RefreshSubFields(); // This enforces min/max on the sub fields.
    72.         else
    73.             value = newValueFloat;
    74.     }
    75. }
    USS:
    Code (CSharp):
    1. .unity-percent-slider__visual-input {
    2.     flex-direction: row;
    3. }
    4.  
    5. .unity-percent-slider__slider {
    6.     flex-grow: 1;
    7. }
    8.  
    9. .unity-percent-slider__field {
    10.     width: 40px;
    11.     margin-left: 5px;
    12. }
    UXML:

    Code (CSharp):
    1. <UXML xmlns="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
    2.     <VisualElement name="unity-visual-input" class="unity-percent-slider__visual-input">
    3.         <SliderInt
    4.             name="unity-slider"
    5.             class="unity-percent-slider__slider"
    6.             low-value="0"
    7.             high-value="100" />
    8.         <uie:IntegerField
    9.             name="unity-field"
    10.             class="unity-percent-slider__field"
    11.             max-length="3" />
    12.     </VisualElement>
    13. </UXML>
     
    TomTrottel and ayellowpaper like this.
  7. ayellowpaper

    ayellowpaper

    Joined:
    Dec 8, 2013
    Posts:
    49
    Hey uDamian,

    Thanks man. I really appreciate the time you take for your responses, it helps a lot. Didn't have time to work on my scripts today, but this is something I wrote a few days ago which basically works, but I want to refine it at some point. I'll update it in the next few days with the info from above. And yes, unfortunately I have to use reflection to update the visuals of the slider.
    Code (CSharp):
    1. public class SliderWithField : BaseField<float>
    2. {
    3.     public Slider Slider { get; private set; }
    4.     public FloatField FloatField { get; private set; }
    5.  
    6.     public override float value {
    7.         get => base.value;
    8.         set {
    9.             SetFieldValueWithoutNotify(value);
    10.             SetSliderValueWithoutNotify(value);
    11.             base.value = value;
    12.         }
    13.     }
    14.  
    15.     public override void SetValueWithoutNotify(float newValue)
    16.     {
    17.         SetFieldValueWithoutNotify(newValue);
    18.         SetSliderValueWithoutNotify(newValue);
    19.         base.SetValueWithoutNotify(newValue);
    20.     }
    21.  
    22.     public SliderWithField(string in_Label, float in_Start, float in_End) : base(null, null)
    23.     {
    24.         EventCallback<ChangeEvent<float>> callback = (evt) => { value = evt.newValue; evt.StopPropagation(); };
    25.         Slider = new Slider(in_Label, in_Start, in_End, SliderDirection.Horizontal);
    26.         Slider.style.flexGrow = 1f;
    27.         Slider.Query(className: Slider.inputUssClassName).First().style.marginRight = 4;
    28.         Slider.RegisterValueChangedCallback(callback);
    29.         FloatField = new ConstrainedFloatField(in_Start, in_End);
    30.         FloatField.style.width = 50;
    31.         FloatField.RegisterValueChangedCallback(callback);
    32.  
    33.         // TODO: this feels dirty :(
    34.         this.Remove(this.Query(className:inputUssClassName));
    35.  
    36.         this.Add(Slider);
    37.         this.Add(FloatField);
    38.     }
    39.  
    40.     private void SetFieldValueWithoutNotify(float in_NewValue)
    41.     {
    42.         FloatField.SetValueWithoutNotify(in_NewValue);
    43.     }
    44.  
    45.     private void SetSliderValueWithoutNotify(float in_NewValue)
    46.     {
    47.         // I need to update the slider because it won't get updated properly
    48.         System.Reflection.MethodInfo mi = Slider.GetType().BaseType.GetMethod("UpdateDragElementPosition", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, System.Reflection.CallingConventions.Any, new Type[0], null);
    49.         if (mi != null)
    50.         {
    51.             Slider.SetValueWithoutNotify(in_NewValue);
    52.             mi.Invoke(Slider, null);
    53.         }
    54.         else
    55.             Slider.value = in_NewValue;
    56.  
    57.     }
    58. }
     
    uDamian likes this.
  8. pirho_luke

    pirho_luke

    Joined:
    Oct 24, 2017
    Posts:
    21
    If I remember correctly, I've also encountered similar situations like this with nested value change events. One of the things I did to isolate them was to check if "event.target" is the correct element in the actual callback.

    For example:

    Code (CSharp):
    1. var parent = new FloatField();
    2. var child = new FloatField();
    3.  
    4. parent.Add(child);
    5. parent.RegisterValueChangedCallback(evt =>
    6. {
    7.     if (evt.target == parent)
    8.     {} // Do stuff
    9.     else if (evt.target == child)
    10.     {} // Do other stuff
    11. });
    At least I believe this works!
     
    Catsoft-Studios likes this.
  9. richardNXRT

    richardNXRT

    Joined:
    Aug 4, 2022
    Posts:
    26

    Compared to the UI_canvas system, this is an insane of work just to get a value from a slider
     
    R0man likes this.