ListView problem

6357933--707265--table.gif
So, im again try to learn how works UI Elements. In this case im create EditorWindow for scriptableObject. Geting list of items from source, and attached them to ListView

(example source code)

var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/ModelsDb/Styles/WindowBase.uxml");
        var listView = new ListView();
        Func<VisualElement> makeItem = () =>
        {
            return tree.CloneTree();
        };
        Action<VisualElement, int> bindItem = (e, i) =>
        {

            var foldout = e.Q<Foldout>("_foldout");
            foldout.value = modelsBase.models[i].isExpanded;
            e.style.height = foldout.value ? 140 : 30;
            foldout.RegisterValueChangedCallback(evt =>
            {
                e.style.height = evt.newValue ? 140 : 30;
                modelsBase.models[i].isExpanded = evt.newValue;
                listView.Refresh();

            });

            foldout.text = "Item";
        };

        listView.bindItem = bindItem;
        listView.makeItem = makeItem;
        listView.itemsSource = modelsBase.models;
        listView.itemHeight = 140;

        InitListView(listView);
        rootVisualElement.Add(listView);

I have two problems now. When im clicked on Foldout, it change height of current item and state of bool in SO. But with the first item, the last one changes too. And another problem related to rendering of elements that were outside of the rendering at the time of the bool state change. These problems are visible on the GIF.

ListView will reuse items when scrolling so after a makeItem has been called, multiple BindItems might be called to map to additional data.
The behavior you see happens when bindItem is called a second time, it will register the callback, but the previous one is was not unregistered. You can fix this in 2 ways, either provide an unbindItem deletage that will unregister the callback (you need to keep a reference to it so it can be unregistered), or register the callback into you makeItem. The latter one might be simpler: you can save the currently bound index into the userData property and retrive it in the value change callback this way:

Func<VisualElement> makeItem = () =>
{
    var item = tree.CloneTree();
   
    var foldout = e.Q<Foldout>("_foldout");
    foldout.RegisterValueChangedCallback(evt =>
    {
        e.style.height = evt.newValue ? 140 : 30;
        var f = evt.target as Foldout;
        int i = f.userData as int;
        modelsBase.models[i].isExpanded = evt.newValue;
        listView.Refresh();

    });
   
};
Action<VisualElement, int> bindItem = (e, i) =>
{
    var foldout = e.Q<Foldout>("_foldout");
    foldout.userData = i;
   
    //we don't want to send an event here
    foldout.SetValueWithoutNotify(modelsBase.models[i].isExpanded);
    e.style.height = foldout.value ? 140 : 30;

    foldout.text = "Item";
};

ListView doesn’t really support element items with different heights, so you might encounter weird issue when at the bottom of your list, if too many items are expanded. If you absolutely need this, You can implement your list directly over a ScrollView, at the cost of losing the virtualization, which might leads to performance issues with large lists

2 Likes