UI caching with ItemsControl

645 Views Asked by At

I have a performance problem in my WPF application. (I guess that's not a new thing...) Basically the application contains of a ListBox with items that can be selected, and a ContentControl that shows details for the selected item. For one type of item, there's an ItemsControl involved that displays a list of subitems. And this is the problem. As soon as I remove that ItemsControl, or only have it display a very small number of subitems (< 3), it's fast. But with 50 subitems, browsing through the list feels way too slow.

You'd probably suggest me some sort of virtualisation, but that doesn't apply here. In many cases, all items will fit on the screen, so they need to be displayed immediately anyway. No need to virtualise items that are out of sight.

I could strip down my whole application to just a few short classes (views and view models) that demonstrate the performance issue. This test case can be downloaded here. Just run the application, select an item from the list on the left side and move to other items by pressing and holding the up or down arrow keys. Normally, this shows the other item instantly, but here it takes a very noticeable time of ~160 ms each.

The core of the view looks like this:

<UserControl ...>
  <ScrollViewer VerticalScrollBarVisibility="Auto">
    <ItemsControl ItemsSource="{Binding StackFrameVMs, Mode=OneTime}">
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <StackPanel Orientation="Horizontal">
            <TextBlock Text="•" Foreground="{Binding ...}"/>
            <TextBox Margin="4,0,0,0" Text="{Binding ...}"/>
            <TextBox Margin="12,0,0,0" Text="{Binding ...}"/>
          </StackPanel>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
  </ScrollViewer>
</UserControl>

That's not a lot of UI controls, but I need them all. Actually there's a few more controls and bindings in my real application, but these are sufficient for the demo. When I move the contents of the DataTemplate to a separate UserControl and insert that instead, it takes even longer.

From previous profilings I believe that ItemsControl throws away and recreates all controls for the list items every time the list changes, because a different item is selected (and DataContext of the details view changes). Even if the view itself is reused because the new selected item has the same type and uses the same DataTemplate.

Is there a way to make ItemsControl reuse the items it has once created? Maybe even all of them, if one of the selected items needs fewer but the next needs more again? Or is there any way to improve the performance for this very simple use case in another way?

Update: BTW, note that this example code is a very much stripped down version of what my full application looks like. You might think you could simplify the structure I chose, but consider that the full application looks something like the following screenshot. There's more than just the ItemsControl, and there are different detail views for different item types selectable from the left list. So I basically need the structure I have, it just needs to be faster.

Screenshot

The whole project is open source, you may take a look at the complete solution if you like: https://github.com/dg9ngf/FieldLog

1

There are 1 best solutions below

3
On

I've found a solution but at the cost of start-up time.

Put the right hand list in an ItemsControl with a Grid as its panel, this will load every item at the start up but with a little change you can make it look just like before:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <ListBox Name="LogItemsList" ItemsSource="{Binding LogItems}" SelectedItem="{Binding SelectedItem}"/>

    <ItemsControl ItemsSource="{Binding LogItems}" Grid.Column="1">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Grid/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="{x:Type vm:FieldLogExceptionItemViewModel}">
                <v:FieldLogExceptionItemView Visibility="{Binding IsVisible, Converter={StaticResource BooleanToVis}}"/>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

</Grid>

Now add this to MainViewModel:

    /// <summary>
    /// Gets or sets a bindable value that indicates SelectedItem
    /// </summary>
    public FieldLogExceptionItemViewModel SelectedItem
    {
        get { return (FieldLogExceptionItemViewModel)GetValue(SelectedItemProperty); }
        set { SetValue(SelectedItemProperty, value); }
    }
    public static readonly DependencyProperty SelectedItemProperty =
        DependencyProperty.Register("SelectedItem", typeof(FieldLogExceptionItemViewModel), typeof(MainViewModel),
        new PropertyMetadata(null, (d, e) =>
        {
            var vm = (MainViewModel)d;
            var val = (FieldLogExceptionItemViewModel)e.NewValue;
            if(val!=null)
            {
                foreach (FieldLogExceptionItemViewModel item in vm.LogItems)
                {
                    item.IsVisible = (item==val);
                }
            }
        }));

And add this to FieldLogExceptionItemViewModel:

    /// <summary>
    /// Gets or sets a bindable value that indicates IsVisible
    /// </summary>
    public bool IsVisible
    {
        get { return (bool)GetValue(IsVisibleProperty); }
        set { SetValue(IsVisibleProperty, value); }
    }
    public static readonly DependencyProperty IsVisibleProperty =
        DependencyProperty.Register("IsVisible", typeof(bool), typeof(FieldLogExceptionItemViewModel), new PropertyMetadata(false));

Then in order to speed it up even more:

Notice the BooleanToVis Converter. you need to implement a new BooleanToVisibilityConvereter because the system default converter uses Visibility.Collapsed when passed false and it causes the UI to reload the collapsed item after it's changed back to Visibility.Visibile, but what we need is a Visibility.Hidden since we need to keep everything ready at any time.

public class BooleanToVis : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return (bool)value ? Visibility.Visible : Visibility.Hidden;
    }

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

<Window.Resources>
    <v:BooleanToVis x:Key="BooleanToVis"/>
</Window.Resources>