Search Unity

Question Writing Drawers for Classes with Properties That Can't Be Bound

Discussion in 'UI Toolkit' started by iamjwright159, Jan 1, 2023.

  1. iamjwright159

    iamjwright159

    Joined:
    Apr 26, 2020
    Posts:
    1
    For a long time now I've been struggling with trying to write a custom Drawer (or Field) for Serializable classes with members that can't be bound to directly for various reasons. One of my classes is immutable. Another contains a List that I want to modify by toggling items that could go inside it instead of the usual adding/removing of items directly. In either case, I can't bind the data inside the class, but I can change the whole value completely using SerializedProperty.boxedValue.

    Most of my attempts have been trying to create a custom Field that inherits from BindableElement and INotifyValueChanged, but for some reason custom data types aren't supported for that, even though boxedValue is now easily accessible in the big switch statement that silently (why???) dismisses SerializedPropertyType.Generic (which has been most of my headache for multiple weeks).

    I don't want to crack open the UnityEditor.UIElements assembly to support INotifyValueChanged<object>, so currently I'm just trying to figure out how I would go about writing a Drawer for my classes that doesn't try to use bindings, and I have it technically working, except for two specific things (so far):
    • Hitting Undo doesn't immediately refresh the Drawer (although the data does get successfully undo'd, as switching the inspector to something else and then back again shows the previous value). I could handle this myself honestly, but I don't know where
    • Prefabs with overrides don't indicate that the value has been changed (with the blue marker and bold text), and doesn't give me a way to Reset or Apply the value by right-clicking. For sub fields this makes sense, as there's no value bound to them, but I would expect that the entire box would at least be changed since the returned element from the Drawer is bound to the whole value. It should be pretty easy to tell when the object is dirty anyway, since, again, boxedValue is a thing, and should be able to be used the same way as the other types of accessors.
    Here's the simplest example code I could pull up for my current attempt, using Unity 2022.2.1f1:
    Code (CSharp):
    1.  
    2. using System;
    3. using UnityEditor;
    4. using UnityEngine;
    5. using UnityEngine.UIElements;
    6.  
    7. [Serializable]
    8. public class CustomDataType
    9. {
    10.     // Immutable data type, shouldn't bind directly to m_foo
    11.     [SerializeField]
    12.     private string m_foo;
    13.  
    14.     public string foo => m_foo;
    15.  
    16.     public CustomDataType() : this("default foo text") {}
    17.    
    18.     public CustomDataType(string foo)
    19.     {
    20.         m_foo = foo;
    21.     }
    22. }
    23.  
    24. [CustomPropertyDrawer(typeof(CustomDataType))]
    25. public class CustomDataTypeDrawer : PropertyDrawer
    26. {
    27.     public override VisualElement CreatePropertyGUI(SerializedProperty property)
    28.     {
    29.         // Give it the default value if it's null
    30.         if (property.boxedValue is null)
    31.         {
    32.             property.boxedValue = new CustomDataType();
    33.             property.serializedObject.ApplyModifiedPropertiesWithoutUndo();
    34.         }
    35.         Debug.Log($"Making {(property.boxedValue as CustomDataType).foo}");
    36.        
    37.         // Simple layout for making the drawer look like a field
    38.         VisualTreeAsset asset = Resources.Load<VisualTreeAsset>("CustomDataTypeDrawer");
    39.         TemplateContainer drawer = asset.Instantiate(property.propertyPath);
    40.         drawer.Q<Label>("Label").text = property.displayName;
    41.  
    42.         // A text field that can't be bound to a property, and an additional label
    43.         TextField fooTextField = drawer.Q<TextField>();
    44.         Label fooLabel = drawer.Q<Label>("FooLabel");
    45.  
    46.         // Helper for displaying the correct values in the inspector
    47.         void UpdateUI(CustomDataType value)
    48.         {
    49.             fooTextField.SetValueWithoutNotify(value.foo);
    50.             fooLabel.text = "foo: " + value.foo;
    51.         }
    52.         // Immediately show the current values
    53.         UpdateUI(property.boxedValue as CustomDataType);
    54.        
    55.         // Helper for setting values
    56.         // Similar to INotifyValueChanged.value_set or SetValueWithoutNotify if, you know, *that would actually work for Generic serialized types*
    57.         void SetValue(CustomDataType newValue)
    58.         {
    59.             property.boxedValue = newValue;
    60.             property.serializedObject.ApplyModifiedProperties();
    61.             UpdateUI(newValue);
    62.         }
    63.        
    64.         // Make the fields actually change the
    65.         fooTextField.RegisterValueChangedCallback(e => SetValue(new CustomDataType(e.newValue)));
    66.  
    67.         return drawer;
    68.     }
    69. }
    70.  
    So, how can I either bind to Generic boxedValue data (optimal, but would probably need fixing the UnityEditor.UIElements assembly), or fix my Undo/Prefab issues?

    Or should I just learn IMGUI instead?
     
  2. oscarAbraham

    oscarAbraham

    Joined:
    Jan 7, 2013
    Posts:
    431
    One thing that confuses me a bit about your usage is that Unity serializes plain objects as if they were value types (except when using [SerializeReference]). So the only thing that could be noticed by Unity's serialization is changes to the m_foo field. Two objects with the same m_foo value would be treated as the same value.

    Of course, you can change a whole CustomDataType instance with SerializedProperty.boxedValue, but, after reloading, the result of this is the same as the result of changing all the serialized fields individually.

    If you are doing this to ensure consistency between different fields inside your CustomDataType, you could use TrackPropertyValue to listen to changes in each of the individual serialized fields, and use SerializedProperties to change their values as a result of UI interaction. That way you can validate values before writing them.

    You can also add a listener to Undo.undoRedoPerformed in AttachToPanelEvent and remove the listener in DetachFromPanelEvent, in case you need to listen for changes outside of your SerializedProperty.

    For the Prefab blue bar and context menu, I don't think there's an official solution yet, but I have my own workaround here. Just set bindingPath to the property you want to show blue bar and context menu for, then put whatever you want inside that element. The property can be a composite property, like an array, or a custom class or struct. It should support UXML too, although I haven't tested that yet.
     
    Last edited: Jan 3, 2023