I've got a hierarchical data structure which i'm trying to visualize with a WPF TreeView and hierarchical data templates. The number of items can reach millions, so i decided to try the virtualization features which work easy and well, besides that one problem which arises when i use "Recycling" instead of "Standard" for the VirtualizationMode. A long time ago someone else seemed to have a similar problem:
"BindingExpression path error" using ItemsControl and VirtualizingStackPanel
Nevertheless, i have troubles to implement a working and performant filtering.
I tried two different approaches, based on hints i could find on the internet, like
- How to filter a TreeView in WPF?
- In WPF can you filter a CollectionViewSource without code behind?
- Filter WPF TreeView using MVVM
- How to filter a wpf treeview hierarchy using an ICollectionView?
- WPF: Filter TreeView without collapsing it's nodes
- Filtering Treeview
The first is to use a Converter to set TreeViewItem.Visibility based on a filter, the second is to create ICollectionView ad hoc for all elements and assign the same filter predicate to each one.
I like the first approach because it requires less code, seems to be more clear, and requires less hacks, and i feel like i had to use that one hack (HackToForceVisibilityUpdate) only because i don't know better, but it slows down the UI considerably and i don't know how to fix that.
The problem with the second approach is that it collapses all nodes when the filter is changed (which i think could be fixed by tracking the states before the filter change and restoring them afterwards) and that it involves a lot of additional code (for the sake of the example, a singleton hack is used to not blow the code up too much and adding/removing items won't work).
I feel like both approaches could be fixed but i cannot figure out how and i really would like to fix the performance issues of the first approach.
It's a lot of code to show in a post and if you don't like the code blocks below, here are the files as pastebin
and as an archive containing a VS solution and
- a project WpfVirtualizedTreeViewPerItemVisibility for the 1st and
- a project WpfVirtualizedTreeViewPerItemVisibility3 for the 2nd approach
which can be found here
The first approach:
The data structures:
public class TvItemBase {}
public class TvItemType1 : TvItemBase
{
public string Name1 { get; set; }
public List<TvItemBase> Entries { get; } = new List<TvItemBase>();
}
public class TvItemType2 : TvItemBase
{
public string Name2 { get; set; }
public int i { get; set; }
}
The converter looks like this (it corresponds to the "filterFunc" in the 2nd approach)
public class TvItemType2VisibleConverter : IMultiValueConverter
{
public TvItemType2VisibleConverter() { }
public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (values.Length < 2)
return Visibility.Visible;
var tvItem = values[0] as TreeViewItem;
if (tvItem == null)
return Visibility.Visible;
var entry = tvItem.DataContext as TvItemType2;
if (entry == null)
return Visibility.Visible;
var model = values[1] as IFilterProvider;
if (model == null)
return Visibility.Visible;
if (!model.ShowA)
return Visibility.Collapsed;
else if (entry.i % 2 == 0)
return Visibility.Collapsed;
else
return Visibility.Visible;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture) { throw new System.NotImplementedException(); }
}
and the window impl, which is also the view model, looks like this
public interface IFilterProvider
{
bool ShowA { get; }
}
public partial class MainWindow : Window, INotifyPropertyChanged, IFilterProvider
{
public event PropertyChangedEventHandler PropertyChanged;
void NotifyChanged(string name) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }
bool ShowA_ = true;
public bool ShowA
{
get { return ShowA_; }
set { ShowA_ = value; NotifyChanged(nameof(ShowA)); NotifyChanged(nameof(HackToForceVisibilityUpdate)); }
}
public bool HackToForceVisibilityUpdate { get { return true; } }
void generateTestItems(TvItemType1 parent, int nof1, int nof2, int levels)
{
for (int i = 0; i < nof1; i++)
{
var i1 = new TvItemType1 { Name1 = string.Format("F_{0}.{1}.{2}.{3}", levels, i, nof1, nof2) };
parent.Entries.Add(i1);
if (levels > 0)
generateTestItems(i1, nof1, nof2, levels - 1);
}
for (int i = 0; i < nof2; i++)
parent.Entries.Add(new TvItemType2 { Name2 = string.Format("{0}.{1}.{2}.{3}", levels, nof1 + i, nof1, nof2), i = nof1 + i });
}
public MainWindow()
{
InitializeComponent();
DataContext = this;
var i1 = new TvItemType1 { Name1 = "root" };
generateTestItems(i1, 10, 1000, 3);
tv.ItemsSource = new List<TvItemBase> { i1 };
}
}
Finally, here's the xaml:
<Window.Resources>
<local:TvItemType2VisibleConverter x:Key="TvItemType2VisibleConverter"/>
<HierarchicalDataTemplate DataType="{x:Type local:TvItemType1}" ItemsSource="{Binding Entries}">
<TextBlock Text="{Binding Name1}" />
<HierarchicalDataTemplate.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="Visibility">
<Setter.Value>
<MultiBinding Converter="{StaticResource TvItemType2VisibleConverter}">
<Binding RelativeSource="{RelativeSource Self}" />
<!-- todo: how to specify the filter provider through a view model property (using Path and Source?) -->
<Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=local:IFilterProvider}" />
<!-- todo: how to enforce filter reevaluation without this hack -->
<Binding Path="HackToForceVisibilityUpdate" RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=local:IFilterProvider}" />
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="IsExpanded" Value="True" />
</Style>
</HierarchicalDataTemplate.ItemContainerStyle>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:TvItemType2}">
<TextBlock Text="{Binding Name2}" />
</HierarchicalDataTemplate>
</Window.Resources>
<Grid>
<ToggleButton Content="A" IsChecked="{Binding ShowA, Mode=TwoWay}" Width="20" Height="20" HorizontalAlignment="Left" VerticalAlignment="Top" />
<TreeView x:Name="tv"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.CanContentScroll="True"
VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Standard" Margin="0,24,0,0" />
<!-- todo: when using VirtualizingStackPanel.VirtualizationMode="Recycling", a lot of
System.Windows.Data Error: 40 : BindingExpression path error: 'Entries' property not found on 'object' ''TvItemType2' (HashCode=...)'. BindingExpression:Path=Entries; DataItem='TvItemType2' (HashCode=...); target element is 'TreeViewItem' (Name=''); target property is 'ItemsSource' (type 'IEnumerable')
are flooding the output window.
See f.e. https://stackoverflow.com/questions/4950208/bindingexpression-path-error-using-itemscontrol-and-virtualizingstackpanel -->
</Grid>
Code for the second approach:
The data structures:
public class TvItemBase {}
public class TvItemType1 : TvItemBase
{
public string Name1 { get; set; }
public List<TvItemBase> Entries { get; } = new List<TvItemBase>();
public ICollectionView FilteredEntries
{
get
{
var dv = CollectionViewSource.GetDefaultView(Entries);
dv.Filter = MainWindow.singleton.filterFunc; // todo:hack
return dv;
}
}
}
public class TvItemType2 : TvItemBase
{
public string Name2 { get; set; }
public int i { get; set; }
}
and the window impl, which is also the view model, looks like this
public interface IFilterProvider
{
bool ShowA { get; }
}
public partial class MainWindow : Window, INotifyPropertyChanged, IFilterProvider
{
public event PropertyChangedEventHandler PropertyChanged;
void NotifyChanged(string name) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }
bool ShowA_ = true;
public bool ShowA
{
get { return ShowA_; }
set
{
ShowA_ = value;
// todo:hack
// todo:why does this update the whole tree
(tv.ItemsSource as ICollectionView).Refresh();
NotifyChanged(nameof(ShowA));
}
}
void generateTestItems(TvItemType1 parent, int nof1, int nof2, int levels)
{
for (int i = 0; i < nof1; i++)
{
var i1 = new TvItemType1 { Name1 = string.Format("F_{0}.{1}.{2}.{3}", levels, i, nof1, nof2) };
parent.Entries.Add(i1);
if (levels > 0)
generateTestItems(i1, nof1, nof2, levels - 1);
}
for (int i = 0; i < nof2; i++)
parent.Entries.Add(new TvItemType2 { Name2 = string.Format("{0}.{1}.{2}.{3}", levels, nof1 + i, nof1, nof2), i = nof1 + i });
}
public bool filterFunc(object obj)
{
var entry = obj as TvItemType2;
if (entry == null)
return true;
var model = this;
if (!model.ShowA)
return false;
else if (entry.i % 2 == 0)
return false;
else
return true;
}
public MainWindow()
{
InitializeComponent();
DataContext = this;
singleton = this; // todo:hack
var i1 = new TvItemType1 { Name1 = "root" };
generateTestItems(i1, 10, 1000, 3);
//generateTestItems(i1, 3, 10, 3);
var l = new List<TvItemBase> { i1 };
var dv = CollectionViewSource.GetDefaultView(l);
dv.Filter = filterFunc;
tv.ItemsSource = dv;
}
public static MainWindow singleton = null; // todo:[really big]hack
}
Finally, here's the xaml:
<Window.Resources>
<HierarchicalDataTemplate DataType="{x:Type local:TvItemType1}" ItemsSource="{Binding FilteredEntries}">
<TextBlock Text="{Binding Name1}" />
<HierarchicalDataTemplate.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="True" />
</Style>
</HierarchicalDataTemplate.ItemContainerStyle>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:TvItemType2}">
<TextBlock Text="{Binding Name2}" />
</HierarchicalDataTemplate>
</Window.Resources>
<Grid>
<ToggleButton Content="A" IsChecked="{Binding ShowA, Mode=TwoWay}" Width="20" Height="20" HorizontalAlignment="Left" VerticalAlignment="Top" />
<TreeView x:Name="tv"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.CanContentScroll="True"
VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Standard" Margin="0,24,0,0" />
</Grid>