Search Unity

Can we expect less convoluted custom control creation process in the future ?

Discussion in 'UIElements' started by aybe, Dec 31, 2018.

  1. aybe

    aybe

    Joined:
    May 24, 2015
    Posts:
    104
    As usual with Unity, when trying to replicate somehow some editor features with the same UX-level, you end up with little code but a huge PITA on figuring things out (when possible at all).

    I was wondering if we'll get the chance to write custom controls in a less convoluted way in the future ?

    For people who are struggling in writing a control, here's a good example (without the associated schema, though); it's a string picker like ObjectField but allows custom logic for the picker which in my case was to show a file picker dialog instead:

    Unity_2018-12-31_23-55-15.png

    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. using JetBrains.Annotations;
    4. using UnityEditor.UIElements;
    5. using UnityEngine.UIElements;
    6.  
    7. [assembly: UxmlNamespacePrefix("Z.Controls", "controls")]
    8.  
    9. namespace Z.Controls
    10. {
    11.     public class Selector : BaseField<string>
    12.     {
    13.         private readonly SelectorButton _button;
    14.  
    15.         public Selector() : this(null, GetVisualInput())
    16.         {
    17.         }
    18.  
    19.         public Selector(string label, VisualElement visualInput) : base(label, visualInput)
    20.         {
    21.             visualInput.focusable = false;
    22.  
    23.             AddToClassList("unity-object-field");
    24.  
    25.             labelElement.focusable = false;
    26.             labelElement.AddToClassList("unity-object-field__label");
    27.  
    28.             _button = this.Q<SelectorButton>();
    29.         }
    30.  
    31.         /// <summary>
    32.         ///     Gets or sets the handler to invoke when button is pressed.
    33.         /// </summary>
    34.         [PublicAPI]
    35.         public Func<string> Handler
    36.         {
    37.             get => _button.Handler;
    38.             set => _button.Handler = value;
    39.         }
    40.  
    41.         private static VisualElement GetVisualInput()
    42.         {
    43.             var element = new VisualElement();
    44.  
    45.             element.AddToClassList("unity-object-field__input");
    46.             element.Add(new SelectorField());
    47.             element.Add(new SelectorButton());
    48.  
    49.             return element;
    50.         }
    51.  
    52.         #region Nested type: SelectorButton
    53.  
    54.         private sealed class SelectorButton : VisualElement
    55.         {
    56.             public SelectorButton()
    57.             {
    58.                 AddToClassList("unity-object-field__selector");
    59.             }
    60.  
    61.             public Func<string> Handler { get; set; }
    62.  
    63.             protected override void ExecuteDefaultAction(EventBase evt)
    64.             {
    65.                 base.ExecuteDefaultAction(evt);
    66.  
    67.                 if (!(evt is MouseDownEvent mde))
    68.                     return;
    69.  
    70.                 if (mde.button != 0)
    71.                     return;
    72.  
    73.                 var text = Handler?.Invoke();
    74.                 if (text == null)
    75.                     return;
    76.  
    77.                 var label = parent.Q<Label>();
    78.  
    79.                 label.text = text;
    80.             }
    81.         }
    82.  
    83.         #endregion
    84.  
    85.         #region Nested type: SelectorField
    86.  
    87.         private sealed class SelectorField : VisualElement
    88.         {
    89.             public SelectorField()
    90.             {
    91.                 AddToClassList("unity-object-field-display");
    92.                 AddToClassList("unity-object-field__object");
    93.  
    94.                 focusable = true;
    95.  
    96.                 var label = new Label
    97.                 {
    98.                     pickingMode = PickingMode.Ignore
    99.                 };
    100.  
    101.                 label.AddToClassList("unity-object-field-display__label");
    102.  
    103.                 Add(label);
    104.             }
    105.         }
    106.  
    107.         #endregion
    108.  
    109.         #region Nested type: UxmlFactory
    110.  
    111.         [UsedImplicitly]
    112.         public new class UxmlFactory : UxmlFactory<Selector, UxmlTraits>
    113.         {
    114.         }
    115.  
    116.         #endregion
    117.  
    118.         #region Nested type: UxmlTraits
    119.  
    120.         public new class UxmlTraits : BaseField<string>.UxmlTraits
    121.         {
    122.             public override IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription
    123.             {
    124.                 get { yield break; }
    125.             }
    126.         }
    127.  
    128.         #endregion
    129.     }
    130. }
    When you see it, it sounds simple, but it took me two days to get to this, i.e. see how Unity guys did with F12, use the debugger etc. Satisfying but very painful in the end :mad:.

    Hope this will ring some bells at the Unity team :D
     
  2. uDamian

    uDamian

    Unity Technologies

    Joined:
    Dec 11, 2017
    Posts:
    295
    Things can always be improved. I'd love to know some specifics on what parts you found unclear. Would it have helped to have better docs or better examples? Could the debugger be more effective in some way? Or is there something about the API that was not easy to follow?
     
  3. aybe

    aybe

    Joined:
    May 24, 2015
    Posts:
    104
    I would say that it's the usual pain with Unity, terrible documentation being the major issue. Compare for instance with MSDN, though it's bulky (strong contrast with Unity) you'll always find the solution since things are documented as they should be.

    Other than that I mean what it does take of code to get on the same UX-level when crafting a control, if it wasn't the F12 key for browsing the sources to try understand how things are done, I'd have abandoned a while ago. And the debugger has been of unvaluable help.

    Looking back as now my controls are working well (and I'm done with the pain), I am not certain my question is so relevant as the new Unity UI is still at early stage but here we go anyway; just take a look at the code I ended up writing for having an ObjectField-like control that can accept any type of object in the easiest possible way. You'd agree that it's quite convoluted to get to this result, when you compare say with WPF :eek: where it's much easier and much more fully featured ? :)

    Needed to hack things a bit:

    Code (CSharp):
    1. using System;
    2. using System.Reflection;
    3. using JetBrains.Annotations;
    4. using UnityEngine.UIElements;
    5.  
    6. namespace Scene.Editor.Controls
    7. {
    8.     public static class BaseFieldExtensions
    9.     {
    10.         public static VisualElement GetVisualInput<T>([NotNull] this BaseField<T> source)
    11.         {
    12.             if (source == null)
    13.                 throw new ArgumentNullException(nameof(source));
    14.  
    15.             var type = source.GetType();
    16.  
    17.             var property = type.GetProperty("visualInput", BindingFlags.Instance | BindingFlags.NonPublic);
    18.             if (property == null)
    19.                 throw new ArgumentNullException(nameof(property));
    20.  
    21.             if (!(property.GetValue(source) is VisualElement element))
    22.                 throw new ArgumentNullException(nameof(element));
    23.  
    24.             return element;
    25.         }
    26.     }
    27. }
    The base class for the picker that allows to pick any type of content:

    It's pixel-perfect like ObjectField but with a small addition to wrap things at small widths.

    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. using System.Diagnostics.CodeAnalysis;
    4. using JetBrains.Annotations;
    5. using UnityEditor;
    6. using UnityEditor.UIElements;
    7. using UnityEngine;
    8. using UnityEngine.UIElements;
    9.  
    10. [assembly: UxmlNamespacePrefix("Scene.Editor.Controls", "controls")]
    11.  
    12. namespace Scene.Editor.Controls
    13. {
    14.     /// <summary>
    15.     ///     Base class for an object picker.
    16.     /// </summary>
    17.     /// <typeparam name="TValue"></typeparam>
    18.     public abstract class Picker<TValue> : BaseField<TValue>
    19.     {
    20.         protected Picker() : base(null, null)
    21.         {
    22.             Field = new PickerField(this);
    23.  
    24.             Button = new PickerButton(this);
    25.  
    26.             var input = this.GetVisualInput();
    27.             input.focusable = false;
    28.             input.AddToClassList("unity-object-field__input");
    29.             input.Add(Field);
    30.             input.Add(Button);
    31.  
    32.             AddToClassList("unity-object-field");
    33.  
    34.             labelElement.focusable = false;
    35.             labelElement.AddToClassList("unity-object-field__label");
    36.         }
    37.  
    38.         /// <summary>
    39.         ///     Gets or sets the width in pixels at which this instance will wrap its content when not wide enough,
    40.         ///     set to <c>null</c> to disable wrapping.
    41.         /// </summary>
    42.         [PublicAPI]
    43.         public float? WrapWidth { get; set; } = 321.0f;
    44.  
    45.         private PickerButton Button { get; }
    46.  
    47.         private PickerField Field { get; }
    48.  
    49.         /// <summary>
    50.         ///     Gets or sets the method providing a value to this instance.
    51.         /// </summary>
    52.         [PublicAPI]
    53.         public PickerValueProvider<TValue> Provider { get; set; }
    54.  
    55.         protected override void ExecuteDefaultAction(EventBase evt)
    56.         {
    57.             base.ExecuteDefaultAction(evt);
    58.  
    59.             HandleGeometry();
    60.  
    61.             void HandleGeometry()
    62.             {
    63.                 if (!(evt is GeometryChangedEvent gve))
    64.                     return;
    65.  
    66.                 if (!WrapWidth.HasValue)
    67.                     return;
    68.  
    69.                 if (gve.newRect.width > WrapWidth.Value)
    70.                 {
    71.                     if (style.flexDirection == FlexDirection.Row)
    72.                         return;
    73.  
    74.                     style.flexDirection = new StyleEnum<FlexDirection>(FlexDirection.Row);
    75.                     style.height = EditorGUIUtility.singleLineHeight;
    76.                 }
    77.                 else
    78.                 {
    79.                     if (style.flexDirection == FlexDirection.Column)
    80.                         return;
    81.  
    82.                     style.flexDirection = new StyleEnum<FlexDirection>(FlexDirection.Column);
    83.                     style.height = EditorGUIUtility.singleLineHeight * 2.0f;
    84.                 }
    85.             }
    86.         }
    87.  
    88.         public override void SetValueWithoutNotify(TValue newValue)
    89.         {
    90.             var equals = EqualityComparer<TValue>.Default.Equals(value, newValue);
    91.  
    92.             base.SetValueWithoutNotify(newValue);
    93.  
    94.             if (equals)
    95.                 return;
    96.  
    97.             Update(newValue);
    98.         }
    99.  
    100.         private void Update(TValue newValue)
    101.         {
    102.             value = newValue;
    103.  
    104.             UpdateDisplay();
    105.         }
    106.  
    107.         /// <summary>
    108.         ///     Updates display with actual value.
    109.         /// </summary>
    110.         protected void UpdateDisplay()
    111.         {
    112.             var content = GetContent();
    113.  
    114.             Field.Update(content);
    115.         }
    116.  
    117.         /// <summary>
    118.         ///     Gets the content for actual value (see Remarks).
    119.         /// </summary>
    120.         /// <returns></returns>
    121.         /// <remarks>
    122.         ///     This method affects rendering only, not the underlying value.
    123.         /// </remarks>
    124.         [SuppressMessage("ReSharper", "InconsistentNaming")]
    125.         protected abstract GUIContent GetContent();
    126.  
    127.         private void Pick()
    128.         {
    129.             Provider(Update);
    130.         }
    131.  
    132.         private sealed class PickerButton : VisualElement
    133.         {
    134.             public PickerButton([NotNull] Picker<TValue> picker)
    135.             {
    136.                 Picker = picker ?? throw new ArgumentNullException(nameof(picker));
    137.  
    138.                 AddToClassList("unity-object-field__selector");
    139.             }
    140.  
    141.             private Picker<TValue> Picker { get; }
    142.  
    143.             protected override void ExecuteDefaultAction(EventBase evt)
    144.             {
    145.                 base.ExecuteDefaultAction(evt);
    146.  
    147.                 if (!(evt is MouseDownEvent mde))
    148.                     return;
    149.  
    150.                 if (mde.button != 0)
    151.                     return;
    152.  
    153.                 Picker.Pick();
    154.             }
    155.         }
    156.  
    157.         private sealed class PickerField : VisualElement
    158.         {
    159.             [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")]
    160.             public PickerField([NotNull] Picker<TValue> picker)
    161.             {
    162.                 Picker = picker ?? throw new ArgumentNullException(nameof(picker));
    163.  
    164.                 AddToClassList("unity-object-field-display");
    165.                 AddToClassList("unity-object-field__object");
    166.  
    167.                 focusable = true;
    168.  
    169.                 Image = new Image
    170.                 {
    171.                     scaleMode = ScaleMode.ScaleAndCrop,
    172.                     pickingMode = PickingMode.Ignore
    173.                 };
    174.                 Image.AddToClassList("unity-object-field-display__icon");
    175.  
    176.                 Label = new Label();
    177.                 Label.AddToClassList("unity-object-field-display__label");
    178.  
    179.                 Update();
    180.  
    181.                 Add(Image);
    182.                 Add(Label);
    183.             }
    184.  
    185.             private Picker<TValue> Picker { get; }
    186.  
    187.             private Image Image { get; }
    188.  
    189.             private Label Label { get; }
    190.  
    191.             protected override void ExecuteDefaultActionAtTarget(EventBase evt)
    192.             {
    193.                 base.ExecuteDefaultActionAtTarget(evt);
    194.  
    195.                 if (!(evt is KeyDownEvent kde))
    196.                     return;
    197.  
    198.                 if (kde.keyCode != KeyCode.KeypadEnter && kde.keyCode != KeyCode.Return)
    199.                     return;
    200.  
    201.                 Picker.Pick();
    202.  
    203.                 evt.StopPropagation();
    204.             }
    205.  
    206.             public void Update([CanBeNull] GUIContent content = null)
    207.             {
    208.                 if (content == null)
    209.                     content = GUIContent.none;
    210.  
    211.                 Image.image = content.image;
    212.                 Label.text = content.text;
    213.                 Label.tooltip = content.tooltip;
    214.             }
    215.         }
    216.     }
    217. }
    Here a derived string picker with the ability to format presented data, think poor man's WPF DataTemplate:

    Code (CSharp):
    1. using System;
    2. using JetBrains.Annotations;
    3. using UnityEditor.UIElements;
    4. using UnityEngine;
    5. using UnityEngine.UIElements;
    6.  
    7. [assembly: UxmlNamespacePrefix("Scene.Editor.Controls", "controls")]
    8.  
    9. namespace Scene.Editor.Controls
    10. {
    11.     [PublicAPI]
    12.     public sealed class PickerString : Picker<string>
    13.     {
    14.         private Func<string, string> _formatter;
    15.  
    16.         /// <summary>
    17.         ///     Gets or sets the function for formatting the text of the content presented.
    18.         /// </summary>
    19.         [PublicAPI]
    20.         public Func<string, string> Formatter
    21.         {
    22.             get => _formatter;
    23.             set
    24.             {
    25.                 _formatter = value;
    26.                 UpdateDisplay();
    27.             }
    28.         }
    29.  
    30.         protected override GUIContent GetContent()
    31.         {
    32.             var content = new GUIContent(value, value);
    33.  
    34.             content.text = Formatter?.Invoke(content.text) ?? content.text;
    35.  
    36.             return content;
    37.         }
    38.  
    39.         [UsedImplicitly]
    40.         public new class UxmlFactory : UxmlFactory<PickerString, UxmlTraits>
    41.         {
    42.         }
    43.  
    44.         public new class UxmlTraits : BaseField<string>.UxmlTraits
    45.         {
    46.         }
    47.     }
    48. }
    Remaining bits:

    Code (CSharp):
    1. namespace Scene.Editor.Controls
    2. {
    3.     /// <summary>
    4.     ///     Defines a value updater for <see cref="Picker{TValue}" />.
    5.     /// </summary>
    6.     /// <typeparam name="TValue"></typeparam>
    7.     /// <param name="value"></param>
    8.     public delegate void PickerValueUpdater<in TValue>(TValue value);
    9. }
    Code (CSharp):
    1. namespace Scene.Editor.Controls
    2. {
    3.     /// <summary>
    4.     ///     Defines a value provider for <see cref="Picker{TValue}" />.
    5.     /// </summary>
    6.     /// <typeparam name="TValue"></typeparam>
    7.     /// <param name="action"></param>
    8.     public delegate void PickerValueProvider<out TValue>(PickerValueUpdater<TValue> action);
    9. }
    Example:

    Code (CSharp):
    1. _uiPickerFile = tree.Q<PickerString>("FilePicker");
    2. _uiPickerFile.Formatter = s => s == null ? null : $"...{s.Substring(Math.Max(0, s.Length - 20))}";
    3. _uiPickerFile.Provider = s =>
    4. {
    5.     // whatever that 'might' return a value invokes s("data")
    6.     // e.g. open file dialog
    7.  
    8. };
    9.  
    There you are, an UI that is consistent with the rest of Unity:

    Unity_2019-01-09_08-35-09.png

    PS If you're looking for someone to write decent documentation, I'm all in :D
     
    Ethan_VisualVocal likes this.
  4. uDamian

    uDamian

    Unity Technologies

    Joined:
    Dec 11, 2017
    Posts:
    295
    That's awesome (and very detailed) feedback! Thank you! We'll digest and see how we can improve things.

    Docs are also in the works and we should have something more complete for the 2019.1 release.
     
  5. aybe

    aybe

    Joined:
    May 24, 2015
    Posts:
    104
    Great !
     
  6. aybe

    aybe

    Joined:
    May 24, 2015
    Posts:
    104
    Forgot to add, basically the main issue is the lack of docs/up-to-date examples and a few members that should be made public.

    Once I did figure these things out, it was relatively straightforward as the API was good enough to find my way out, it was then just a matter of time and juggling with the debugger to get to the result I was looking for. (as you can see, the PickerString ends up being like many of your controls, pretty small; the main substance tricky to figure out being on the base class)
     
    uDamian likes this.