Are you getting sick already of WPF’s inability to handle large amounts of data? Well, I am,… definitely. After some final trials with the thesaurus (displayed in a tree), I gave up and decided to write my own virtualizing Treeview. Why? Here’s why:
- Scroll & load speed: Even with full virtualization turned on, scrolling crawls almost to a halt when you have 60 000+ root items and a few of them are expanded some levels down the tree. The same for loading large lists.
- Memory usage: probably related with the previous item, in short: kaboem!!!
- BringIntoView: Here’s the killer, when virtualization is turned on, and the UI TreeViewIem hasn’t been rendered yet, you can’t bring it into view. Translated: you can’t do a search and show the found items in large trees, only in small trees (go figure).
Note about my test data: The thesaurus demo contains about 100 000 words (syn-sets actually), almost all of which have multiple relationships, so in total, there are +500 000 nodes in the tree.
Ok,… and how do you fix this? Well here’s how I did it:
Container rendering
Virtualization means that you only render the UI elements that are currently visible, and not the whole tree (or a large part like microsoft does). This means that, for each UI object that got rendered, you somehow need to keep track of the data position in the tree. I do this by using 2 cursors: one for the first and another for the last visible element. This cursor is nothing more than a list of indexes into the tree.
All items in between those 2 cursors are flattened out (by rendering UI elements for them). The nr of indexes (or nr of parents) for each item, is used as the level for the UI element. This determines the amount of indentation to the right that is applied to the UI element.
When the user scrolls down, the bottom cursor is updated (advanced by the amount that the user scrolled). This is a basic tree walking algorithm. From this bottom index, we render objects while going up the tree until the panel is full (again, the same tree walking algorithm). The index path of the new top item becomes the new top cursor. The same technique is used to scroll up, but instead, you start from the top cursor and fill downwards.
Remark: Watch out while calculating the scroll difference: The scrollbar value is usually a double while we only consume integer values, so there might be some left over in the scroll difference which has to be consumed the next time you calculate the dif.
Of course, this technique only works if the TreeView has some knowledge of the data structure, how else can it find the children of the root items and see if they are expanded or not? This requires an interface. Here’s how mine looks like:
public interface ITreeViewItem { bool IsExpanded { get; set; } bool IsSelected { get; set; } bool HasChildren { get; } IList TreeItems { get; } ITreeViewPanelItem ParentTreeItem { get; } bool NeedsBringIntoView { get; set; } }
Each tree node should implement this interface so that the TreeView can properly traverse the tree. A few properties are mostly for the visual parts, like ‘IsSelected’ or ’HasChildren’ (to display the toggle button). The last property is perhaps a bit of an odd one: ‘NeedsBringIntoView’. It’s a quick method to let the Tree know that an element needs to be brought into the viewable area. You could also use a function on the TreeView (like microsoft does), but then you are forced to go find the UI element from your search code to bring the item into view, which I wanted to avoid.
Using this technique, you do create and destroy a lot of containers since each time the user scrolls, the entire visible area is re-rendered. To minimize the impact of this on the garbage collector, it’s probably best to use some recycling scheme. This doesn’t have to be fancy: a simple queue to put the items in when clearing the list which can be consumed again when rendering the containers. I chose a queue since it gives best chances to reassign the same container to the same data object when possible, which minimizes the impact of re-rendering on the binding system.
Events
The second important thing for virtualized TreeViews, is the event system. You can not simply go and hook up event handlers to every data element. This would blow up the memory. Also, you’d have to write a lot of code to keep track of those events as data objects are being created and destroyed (trees of such a size can only work if you combine UI virtualization with data virtualization, but that’s an entire subject all together). On the other hand, events are VERY important to get UI virtualization working for keeping track of the maximum scroll count, tree nodes that get expanded/collapsed, objects that need to be brought into view, and UI elements that need to be updated.
Are you seeing the bubbles already? Yep: use bubbling events, like WPF does. The only problem: that doesn’t exist for regular objects, it’s only available in the WPF world. You could inherit all your objects from DependencyObject, but with 200 0000 objects in memory, that somehow becomes bloated. Better to roll out your own sleek, mean and fast event mechanism, which isn’t hard at all to do. Here’s a little sample code to bubble events up a tree:
public static void OnPropertyChanged(IOnBubblingChanged sender, BubblingPropertyChangedEventArgs e) { while (sender != null) //use a while loop to avoid recursive calls. { sender.OnBubblingPropertyChanged(e); IOwnedObject iOwned = sender as IOwnedObject; if (iOwned != null) sender = iOwned.Owner as IOnBubblingChanged; else sender = null; //need to make the loop stop } }
This function is called whenever a property on a tree item is changed. As you can see, a lot of interfaces are at work here. IOnBubblingChanged defines a function that can be called which will raise the event. IOwnedObject is one of my core interfaces for all objects that can be ‘owned’ by another object. This maps to the ‘ParentTreeItem’ property of the first ITreeViewItem interface.
A similar thing needs to be done for the CollectionChanged events. A remark on the ‘reset collection’ event: make certain that you can pass along the list of items that got cleared, otherwise the TreeView will have a hard time figuring out exactly how much the count needs to be decreased (were any of the removed items expanded?). The default WPF NotifyCollectionChanged event doesn’t do that, so you can’t simply use it’s event arguments.
With this type of bubbling event system, it’s probably best to work with some kind of ‘root’ object that defines the events for the property and collection changes in the entire tree. Again a nice job for an interface, I suppose. This root interface can be used as the ItemsSource for the treeview instead of a list, like WPF does. This way, you can have lots of root objects without overtaxing the .net event management system.
Using this technique, you can make Treeviews that aren’t only fast to load and scroll, but also use very little memory. I am silently hoping that microsoft will eventually pick up on this and begin implementing proper UI virtualization in the coming versions of WPF (thus relieving me from the burden). In the mean time, I guess we are left to roll out our own stuff.
Hello.
Thank you for your idea.
But could you share a sample source codes so that everyone ,including me, can use your idea easily?
Link | July 25th, 2011 at 05:12
Perhaps that’s a nice exercise for you to do.
Link | July 25th, 2011 at 09:19