Search Unity

Radio Buttons/Controls

Discussion in 'UI Toolkit' started by SonicBloomEric, Oct 30, 2019.

  1. SonicBloomEric

    SonicBloomEric

    Joined:
    Sep 11, 2014
    Posts:
    1,090
    How does one create a group of Radio Controls using UIElements? IMGUI has this feature and is able to represent a Toggle as either a Radio or a Checkbox. It also supports the ability to logically group checkboxes together.

    What is the UIElements equivalent?
     
    a436t4ataf likes this.
  2. uMathieu

    uMathieu

    Unity Technologies

    Joined:
    Jun 6, 2017
    Posts:
    398
    There is nothing out of the box, however here is an implementation of a element that will scan all its descendant toggle elements and create a radio button behavior.

    Code (CSharp):
    1.  
    2. // This will catch all toggle change events and add a radio button behavior to them
    3. // It also provides setting/getting the index of the currently selected toggle.
    4. // No styling provided
    5. public class RadioButtonGroup : BindableElement, INotifyValueChanged<int>
    6. {
    7.     public new class UxmlFactory : UxmlFactory<RadioButtonGroup, UxmlTraits> {}
    8.  
    9.     private UQueryState<Toggle> toggleQuery;
    10.  
    11.     public RadioButtonGroup()
    12.     {
    13.         //we cache this query to avoid gc allocations each time we need to run it.
    14.         toggleQuery = this.Query<Toggle>().Build();
    15.      
    16.         //This means we get notified of all ChangeEvents<bool> in our descendants
    17.         RegisterCallback<ChangeEvent<bool>>((evt) => OnToggleChanged(evt));
    18.     }
    19.  
    20.  
    21.     public int SelectedIndex
    22.     {
    23.         get
    24.         {
    25.             //We find the first Toggle
    26.             int index = -1;
    27.             bool found = false;
    28.             toggleQuery.ForEach((toggle) =>
    29.             {
    30.                 if (!found)
    31.                 {
    32.                     ++index;
    33.                     found = toggle.value;
    34.                 }
    35.             });
    36.  
    37.             if(found)
    38.                 return index;
    39.  
    40.             return -1;
    41.         }
    42.         set
    43.         {
    44.             int index = -1;
    45.             toggleQuery.ForEach((toggle) =>
    46.             {
    47.                 ++index;
    48.                 if (index == value)
    49.                 {
    50.                     toggle.value = true;
    51.                 }
    52.             });
    53.         }
    54.     }
    55.  
    56.     void OnToggleChanged(ChangeEvent<bool> evt)
    57.     {
    58.         Toggle t = evt.target as Toggle;
    59.  
    60.         if (t != null)
    61.         {
    62.             if (evt.newValue)
    63.             {
    64.                 //User selected a new toggle, we need to disable all the others
    65.                 int index = -1;
    66.                 int newValue = -1;
    67.                 int previousValue = -1;
    68.              
    69.                 toggleQuery.ForEach((toggle) =>
    70.                 {
    71.                     ++index;
    72.                     if (ReferenceEquals(t, toggle))
    73.                     {
    74.                         newValue = index;
    75.                     }else
    76.                     {
    77.                         if (toggle.value)
    78.                         {
    79.                             previousValue = index;
    80.                         }
    81.                      
    82.                         toggle.SetValueWithoutNotify(false);
    83.                     }
    84.                 });
    85.              
    86.                 evt.StopPropagation();
    87.  
    88.                 using (var newEvent = ChangeEvent<int>.GetPooled(previousValue, newValue))
    89.                 {
    90.                     newEvent.target = this;
    91.                     SendEvent(newEvent);
    92.                 }
    93.             }
    94.             else if(evt.previousValue)
    95.             {
    96.                 //You can't unselect the currently selected toggle
    97.                 evt.StopPropagation();
    98.                 t.SetValueWithoutNotify(true);
    99.             }
    100.         }
    101.     }
    102.  
    103.     void INotifyValueChanged<int>.SetValueWithoutNotify(int newValue)
    104.     {
    105.         SelectedIndex = newValue;
    106.     }
    107.  
    108.     int INotifyValueChanged<int>.value
    109.     {
    110.         get => SelectedIndex;
    111.         set => SelectedIndex = value;
    112.     }
    113. }
     
    Last edited: Oct 31, 2019
  3. SonicBloomEric

    SonicBloomEric

    Joined:
    Sep 11, 2014
    Posts:
    1,090
    Awesome! Thanks @uMathieu!

    Can you go a step further and help me understand the purpose of the following elements in your example?
    1. INotifyValueChanged<T>
    2. UxmlFactory<RadioButtonGroup, UxmlTraits> - Is the UxmlTraits class necessary? Could you omit it?
    3. UQueryState<T>
    4. ChangeEvent<T>.GetPooled
    Is there documentation for us to follow somewhere that will inform us about how to properly leverage this stuff?
     
    Last edited: Nov 7, 2020
  4. uMathieu

    uMathieu

    Unity Technologies

    Joined:
    Jun 6, 2017
    Posts:
    398
    1. INotifyValueChange is the base interface for fields that contains a typed value and send ChangeEvent<T> when value changes. This was a bit overkill for this example... so I edited the code so that the class inherits from BindableElement. Implementing IBindable and INotifyValueChange allows the element to be bound to SerializedObject/SerializedProperties.
    2. In the original case, the since the UxmlTraits refered to the VisualElement one, it was redundant. However, now, it exposes the BindableElement attributes to UXML
    3. UQuery is a way to search for elements in a hierarchy by creating uss selectors from code. The QueryState is a saved selector, applied on a root element. Special care was taken to avoid gc allocations while iterating and evaluating the queries. If the basic selectors + ForEach are now enough, you can still use ToList() then have fun with LINQ :)
    4. Again, this is made to avoid gc allocations. Since events are often used but only live for a short time, we use an object pool. This is not mandatory, allocating them with new will work, it just puts more strain on the garbage collector.

    The only documentation we have for now is the rather short development guide:
    https://docs.unity3d.com/Manual/UIElements.html
    and the scripting api documentation:
    https://docs.unity3d.com/ScriptReference/UIElements.UQueryState_1.html

    We're currently working with the documentation team to improve this. In the meantime don't hesitate to ask questions here.
     
  5. M_R

    M_R

    Joined:
    Apr 15, 2015
    Posts:
    559
    you are still creating GC because of the lambda closures (they capture locals, this requires at least 2 heap allocations -- one for the closure and one for the delegate)

    you could add
    IndexOf
    and similar APIs for these use cases, or implement
    IEnumerable<T>
    to allow
    foreach (var e in query) {...}
    (compiler can foreach without boxing if the IEnumerable pattern is fully visible to it)
    Code (CSharp):
    1. struct a {
    2.         public b GetEnumerator() {return new b();}
    3.     }
    4.     struct b {
    5.         public int Current {get;set;}
    6.         public bool MoveNext() {return true;}
    7.     }
    8.     public static void Main()
    9.     {
    10.        
    11.         foreach (var c in new a()) {
    12.             Console.Write("ok");
    13.             break;
    14.         }
    15.     }
     
    daxiongmao likes this.
  6. SonicBloomEric

    SonicBloomEric

    Joined:
    Sep 11, 2014
    Posts:
    1,090
    I haven't profiled to verify this but it does match with my expectations.

    @uMathieu have you guys done any profiling that shows that this pattern (closures) actually doesn't allocate?

    Sure. But couldn't you also do this (which is more clear and more maintainable)?

    Code (CSharp):
    1. int index = 0;
    2. UQueryState<Toggle> toggle = toggleQuery.AtIndex(index);
    3.  
    4. while (toggle != null)
    5. {
    6.     if (ReferenceEquals(t, toggle))
    7.     {
    8.         newValue = index;
    9.     }
    10.     else
    11.     {
    12.         if (toggle.value)
    13.         {
    14.             previousValue = index;
    15.         }
    16.        
    17.         toggle.SetValueWithoutNotify(false);
    18.     }
    19.  
    20.     toggle = toggleQuery.AtIndex(++index);
    21. }
     
  7. SonicBloomEric

    SonicBloomEric

    Joined:
    Sep 11, 2014
    Posts:
    1,090
    What? How?

    How does the binding system work? What are the inputs/outputs? What connections are made?

    I have no idea how to link what you've written to the [not terribly helpful] documentation.

    What? What does that sentence even mean?

    What are BindableElement attributes? How are they different from VisualElement ones? If you're extending the BindableElement class wouldn't it still be redundant to specify the
    UXMLTraits
    there due to the fact that one would assume that the BindableElement class would have already handled that internally? Or is this necessary because we're totally shadowing the internal version? If it's the shadowing thing, isn't this a bit of a code smell thing? It's not terribly discoverable - you'd kind of just have to "know"...

    Got it.

    Some comments in that code snippet about what you're actually doing and why would be very helpful, I think - particularly for anyone else who happens to say "how do you do a radio button with UIElements". Lots of learning opportunities in there and without the comments, you're just kind of left scratching your head about what the code is and does and why any of it is necessary...
     
    a436t4ataf likes this.
  8. M_R

    M_R

    Joined:
    Apr 15, 2015
    Posts:
    559
  9. SonicBloomEric

    SonicBloomEric

    Joined:
    Sep 11, 2014
    Posts:
    1,090
    Well, isn't that just a shiny detail that belongs in the documentation.

    I agree. I'm just not a fan of adding and maintaining all the scaffolding code required to support it when you can get by without it.

    Ahh, I didn't realize that you could pass in a List for it to fill. That would definitely be preferable to the while+AtIndex...
     
  10. a436t4ataf

    a436t4ataf

    Joined:
    May 19, 2013
    Posts:
    1,933
    Using this in live code (for UIToolkit that was launched without this core class) I discovered that - of course! - it corrupts Unity prefabs: I think because it doesn't actively do anything with the binding part. I'm currently experimenting with reverse-guessing WTF is needed to make binding work (from my previous investigations into the Binding docs, that were only published this year, more than two years after this thread (!)), hoping it's straightforward.

    TL;DR: do not use the original code snippet provided at top of page, it will corrupt your prefabs (they don't save properly, and they actively overwrite (randomly) 'real' values with fake ones. I believe it's something with serialized data getting pre-empted. It looks to me like it can be easily fixed by simply adding the missing calls to Bind() in the right places. If you can figure out exactly what those are (as far as I know: tehre is still literally no way to debug Binding problems?)
     
  11. a436t4ataf

    a436t4ataf

    Joined:
    May 19, 2013
    Posts:
    1,933
    I appear to have got it working, by taking the example code in the new docs and extrapolating in obvious ways.

    However this now triggers an annoying dumb (dumb because: it seems to be wrong: all the code is correct, everything is working (where it didn't before!), so UIToolkit is generating a fake error case here?) message from UIToolkit, a "Warning" that spams the console - "Field type is not compatible with Enum property" - which appears to be this: https://forum.unity.com/threads/enumfield-is-not-compatible-with-enum-property.692767/

    I'm 99% sure that one of the most common cases for RadioButtonGroup is to represent an Enum (I mean ... that's pretty much literally what it is: a visual representation of a multi-value type where only one value can be active at once). But apparently some (undocumented?) magic is required to make UIToolkit stop complaining that you're not using a 'UIToolkit blessed' EnumField. Presumably something can be implemented to make this message go away - any ideas?