1
votes

I've been working on a high-performance tree view that's actually based on a ListBox. To achieve this, we first start with a hierarchical model where each item implements an IParent interface that exposes an enumerable Children property.

We then 'flatten' that hierarchy into a list-based ViewModel, adding a 'depth' property to each item. We then use that list as the ItemsSource of the ListBox, using the new depth property to indent the ContentPresenter in our custom ListBoxItem template. It all works like a champ and allows us to display several thousand nodes, something a normal TreeView would choke on. It does this because again, it's just a list now and a ListBox easily virtualizes its containers by default whereas a TreeView notoriously struggles with virtualization.

Consider this example hierarchy:

Parent1
Parent2
    Child2a
        Grandchild2a1
        Grandchild2a2
    Child2b
Parent3
    Child3a

After flattening, it becomes this...

Parent1,       Level 0
Parent2,       Level 0
Child2a,       Level 1
Grandchild2a1, Level 2
Grandchild2a2, Level 2
Child2b,       Level 1
Parent3,       Level 0
Child3a,       Level 1

Currently, I'm doing all this flattening external to the control, but it occurred to me if I instead created a HierarchicalItemsControl, it could do that flattening internally, meaning I could use it for any hierarchical model data that implemented IParent (or even if it didn't, via a GetChildren delegate.)

The issue I'm running into with that approach is in a normal ItemsControl, there is a one-to-one relationship between the items in the Items/ItemsSource properties and the created containers that are arranged on the ItemsPanel. In this case, there is a one-to-many relationship.

Simple, I thought... add HierarchicalItems/HierarchicalItemsSource properties, then internally set the regular Items/ItemsSource properties after flattening. That would maintain the one-to-one relationship.

The problem there is the Items/ItemsSource properties are read/write, meaning people could directly manipulate them and that would break the internal logic of my control.

I'm starting to think I can't use an ItemsControl subclass and will instead have to create my own HierarchicalItemsControl base class, reimplementing most of the internals of ItemsControl manually, but I'm hoping there's another way.

In Summary...

The main problem I'm trying to solve is a way for this specialized HierarchicalItemsControl to create several containers per given item as opposed to the one-to-one of a normal ItemsControl.

Internally it would ultimately be one-to-one with the flattened list, but externally, I don't want people to be able to manipulate that flattened list (i.e. I wanted to lock-down Items/ItemsSource to be read-only, but I don't think you can do that since they are registered DependencyProperties, not simple CLR properties and AFAIK, you can't change a registered DependencyProperty's accessibility.)

1
So basically you just want to filter the items, yes? Plenty of examples on the net showing how to synchronize this with an observable collection including this one. - Mark Feldman
No, that's not what I'm asking here. The TLDR version is I want the panel to represent a different set of containers (one for each flattened item) as compared to the Items property (one per root item). It's not a one-to-one relationship and is actually the opposite of filtering since the panel will have more items than in the Items property. Make sense? - Mark A. Donohoe
I'll have a think about it and try to post a proper answer, but I still think this is a filtering issue based on what you've said. The one comment that sticks out though is "a lot of that expansion logic is in the ViewModel of the hierarchy, not in the control where I feel it belongs". The whole point of the view model is to prepare the data in a form that the view can readily consume, as soon as you find yourself trying to hit a square view model peg into a round view hole it's a good indication that your view model isn't doing its job properly. - Mark Feldman
Consider a Treeview control. The expansion and collapsing happens inside the control itself. The model, nor the view model knows anything about the expansion. That is purely View-related information. If the model needed to know about the expansion, then yes, it should be in the viewmodel, but other than that, I disagree. That said, I am doing filtering regarding expansion and collapsing from within the collectionview but as I said, that has nothing to do with the question at hand which has to do with displaying the non-filtered items. Pretend there is no filtering at all. - Mark A. Donohoe
Sorry, but I'm probably going to have to bow out of this one. I've read your description about 10 times and I still don't understand what you're trying to do. It sounds like you're trying to use a ListBox on your flattened list to take advantage of virtualization or something while still presenting a hierarchical display? Yet you talk about passing lists into your ItemsPanel, which the way you've described it is going to break virtualization anyway? Very confused, sorry. - Mark Feldman

1 Answers

0
votes

Ok, so if I'm understanding you correctly you want to bind to an array of Parents and have the control itself expand that out to the full flattened list, correct? So assuming you have a view model like this...

public class MainViewModel : ViewModelBase, IMainViewModel
{
    // create a list of 10000 parents, each with 3 children
    public IEnumerable<ViewModelItem> Items { get; } =
        Enumerable.Range(1, 10000)
        .Select(i => new ViewModelItem
        {
            Description = $"Parent {i}",
            Children = new ViewModelItem[] { $"Child {i}a", $"Child {i}b", $"Child {i}c" }
        });
}

public class ViewModelItem
{
    public string Description { get; set; }
    public ViewModelItem[] Children { get; set; }

    public ViewModelItem()
    {
    }

    public ViewModelItem(string desc)
    {
        this.Description = desc;
    }

    public static implicit operator ViewModelItem(string desc)
    {
        return new ViewModelItem(desc);
    }

    public override string ToString()
    {
        return this.Description;
    }
}

...then you can just use a regular converter:

<Window.Resources>
    <conv:FlattenedConverter x:Key="FlattenedConverter" ChildrenField="Children" />
</Window.Resources>

<ListBox ItemsSource="{Binding Items, Converter={StaticResource FlattenedConverter}}"
         VirtualizingStackPanel.IsVirtualizing="True"
         VirtualizingStackPanel.VirtualizationMode="Recycling" />

The converter itself:

public class FlattenedConverter : IValueConverter
{
    public string ChildrenField { get; set; }

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return FlattenArray(value as IEnumerable);
    }

    public IEnumerable FlattenArray(IEnumerable value)
    {
        if (value == null)
            yield break;

        foreach (var child in value)
            foreach (var item in FlattenItem(child))
                yield return item;
    }

    public IEnumerable FlattenItem(object value)
    {
        if (value == null)
            yield break;

        // return this item
        yield return value;

        // return any children
        var property = value.GetType().GetProperty(this.ChildrenField);
        if (property == null)
            yield break;
        var children = property.GetValue(value, null) as IEnumerable;
        foreach (var child in FlattenArray(children))
            yield return child;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Result:

enter image description here

Checking the visual tree confirms that this doesn't break virtualization in any way, and if you want the converter to respond to changes to the original collection then it's easy enough to modify it to support INotifyCollectionChanged.

Have I understood the question correctly this time?

UPDATE: So using my previous view model and replacing the ListBox with a subclassed ListBox looks like this:

<controls:FlattenedListBox ItemsSource="{Binding Items}"
         VirtualizingStackPanel.IsVirtualizing="True"
         VirtualizingStackPanel.VirtualizationMode="Recycling" />

To do that you replace the ListBox's dependency property with a new one and then bind the two together internally with a one-way binding that uses the converter code I posted above:

public class FlattenedListBox : ListBox
{
    public new IEnumerable ItemsSource
    {
        get { return (IEnumerable)GetValue(ItemsSourceProperty); }
        set { SetValue(ItemsSourceProperty, value); }
    }

    public new static readonly DependencyProperty ItemsSourceProperty =
        DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(FlattenedListBox), new PropertyMetadata(null));

    public FlattenedListBox()
    {
        Binding myBinding = new Binding("ItemsSource");
        myBinding.Source = this;
        myBinding.Mode = BindingMode.OneWay;
        myBinding.Converter = new FlattenedConverter { ChildrenField = "Children" };
        BindingOperations.SetBinding(this, ListBox.ItemsSourceProperty, myBinding);
    }
}