How to draw an array of items in UIToolkit in UnityEditor

After a few years of working with UIToolkit, and continually deploying it in apps and trialling solutions from the community, I eventually arrived at this "simple" code example that appears to work in all versions of Unity from 2019 through 2021+. I'm using this in production, will update this post if I find new problems with it - but most of it comes from Unity's own code examples on forums etc, so I expect it to be pretty solid.

Problem: Lists and Arrays in UIToolkit don't work with Undo, ScriptableObjects, refreshing on change, etc

UIToolkit doesn't directly support Lists or Arrays (from Unity staff on the forums: "you're somewhat in uncharted waters" - https://forum.unity.com/threads/propertydrawer-with-uielements-changes-in-array-dont-refresh-inspector.747467/#post-4985327). The official workaround is to "use ListView" - a non-customisable renderer provided by the UIToolkit team that has often had its own bugs and performance issues over the years.

For simple cases: use ListView! Just test carefully and be cautious about upgrading the UnityEditor. If you just want a list in the UnityEditor: it's fine (although in some cases ListView is even more complicated than doing it manually - ListView has extra features you may have to configure: e.g. re-ordering elements in the Inspector)

But for custom rendering ... PropertyField won't work out of the box, and CustomEditor won't work out of the box (due to bugs introduced by UIToolkit).

Symptoms include:

  • Undo doesn't work in the Editor; it corrupts the scene/project/asset state (Unity UIToolkit team don't seem to remember to always test Undo before they ship features/updates)
  • Arrays can be resized but the Inspector doesn't update
  • Arrays can be grown and the Inspector updates, and when you perform Undo the data updates - but the Inspector does not

Solution: Explanation (skip to "Code" section if you prefer)

UIToolkit creates a couple of problems. Firstly: you cannot refresh the Inspector (reported as a bug, closed as wont-fix). Secondly: the inspector won't auto-refresh when SerializedObject etc changes - although individual PropertyFields will refresh (so one workaround: create a CustomPropertyDrawer for every array. But note: CustomPropertyDrawer is broken in all verisions of Unity up to 2021/2022, bugs again closed by Unity as wont-fix, because they want people to delete existing code, stop using existing Assetstore assets (unless/until they are rewritten to be "UIToolkit-only"), and move everything to UIToolkit). Thirdly: manual solutions to the above break UIToolkit's internal state. Fourthly: using SerializedProperty directly ... breaks Undo in the Editor.

... the list goes on. And you can workaround each of these individually (a lot of us have), but it becomes an ever-increasing list of hacks.

But there's a different approach:

  1. Instead of creating an array of PropertyField's (as Unity docs say you should) ... use Unity's own 20 lines of low-level code to manually build up the set of PropertyFields
  2. Create an invisible PropertyField bound to the list's own ".Array.size" (why not use Count? probably it would work, but this idea came from Unity themselves, so I'm not messing with it) and use it to force a refresh when the list-size changes. This seems ridiculous, but actually makes sense when you think it through (it's only the "making it invisible" that feels hacky: using a UI callback to make a data change!).

Code solution

Your class:

public class MyDataClass : ScriptableObject
{
   public List<float> myListField;
}

Your custom editor, with a basic skeleton that implements Refreshing of the Inspector (this is not the main focus of this post - but you need SOMETHING that let you call 'refresh' in the later part that has the solution):


public abstract class BaseEditorForThisExample : Editor
{
protected VisualElement _rootForRefreshing;

public override VisualElement CreateInspectorGUI()
{
    _rootForRefreshing = new VisualElement();
    _RebuildUIInside( _rootForRefreshing );
    return _rootForRefreshing;
}

/// <summary>
/// Note: Unity UIToolkit team disabled the standard method for this, require us to do manually instead
/// </summary>
protected void _RefreshInspector()
{
    _RebuildUIInside( _rootForRefreshing );
}

protected abstract void _RebuildUIInside( VisualElement localRoot );

The custom code to build an array, and the code to add a UI item bound to the 'array size':


[CustomEditor( typeof(MyDataClass) )]
public class EditorForMyDataClass : BaseEditorForThisExample
{
protected T ReuseOrAppendNew( VisualElement parent, string uniqueID, Action<VisualElement> constructor )
{
    Runs the constructor, and adds it to parent, but automatically
    runs checks/balances that UIToolkit is missing, to increase
    performance.

    You can instead just do "var newElement = new VisualElement()..."
    and "parent.Add( newElement )"
    ...but you'll have to handle the edge cases etc.

    OR download this method and others from https://github.com/adamgit/PublishersFork
}

void UIToolkit_Teams_Special_Hack( VisualElement container )
{
    var property = serializedObject.FindProperty( nameof(MyDataClass.myListField) + ".Array" );
    var invisible_UITOOLKIT_TEAM_ARGH = ReuseOrAppendNew( _rootForRefreshing, "kInvisibleField_uDamiens_hack", () => new PropertyField( serializedObject.FindProperty( nameof(MyDataClass.myListField) + ".Array.size" ) ), ( newField ) =>
    {
        newField.RegisterValueChangeCallback( evt =>
        {
            //Debug.Log( "Invisible field changed value" );
            _RefreshInspector();
        } );
    } );


    var endProperty = property.GetEndProperty();

    property.NextVisible( true ); // Expand the first child.

    var childIndex = 0;

    // Iterate each property under the array, and populate the container with preview elements
    do
    {
        // Stop if you reach the end of the array
        if( SerializedProperty.EqualContents( property, endProperty ) )
            break;

        // Skip the array size property
        if( property.propertyType == SerializedPropertyType.ArraySize )
            continue;

        ColorField element;

        // Find an existing element or create one
        if( childIndex < container.childCount )
        {
            element = (ColorField)container[childIndex];
        }
        else
        {
            element = new ColorField();
            container.Add( element );
        }

        element.BindProperty( property );

        ++childIndex;
    } while( property.NextVisible( false ) ); // Never expand children.

    // Remove excess elements if the array is now smaller
    while( childIndex < container.childCount )
    {
        container.RemoveAt( container.childCount - 1 );
    }
}

protected override void _RebuildUIInside( VisualElement localRoot )
{
    serializedObject.Update();

    var bodyColoursList = ReuseOrAppendNew( localRoot, "kSection1", () => new VisualElement() );
    UIToolkit_Teams_Special_Hack( bodyColoursList );
    localRoot.ReuseOrAppendNew( "kAddArrayItem", () => new Button( () =>
    {
        var property = serializedObject.FindProperty( nameof(MyDataClass.myListField) );
        property.arraySize += 1;
        serializedObject.ApplyModifiedProperties();
    } ) { text = "Add" } );
}
}

Note: this doesn't HAVE to be a subclass of the previous one, I just split them like this so it's easier for you to see what is specific to this problem, vs general "adding missing features to UIToolkit":

Note: in the example above I've left the 'invisible' field visible for debugging and because sometimes you want it to appear. But if you want it to disappear, set the style.display to "NONE":


invisible_UITOOLKIT_TEAM_ARGH.style.display = Display.NONE;