Search code examples
c#wpfxamlwpf-listview

Is it possible to programmatically scroll a WPF ListView so that a desired grouping header is placed at its top?


Given a ListView bound to items that have been grouped using a PropertyGroupDescription, is it possible to programmatically scroll so that a group is placed at the top of the list? I am aware that I can scroll to the first item in the group, since that item belongs to the collection the ListView is bound to. However, I have not been able to find any resources describing how to scroll to a group header (styled with a GroupStyle).

To give an example of the desired functionality, let's look at the settings page in Visual Studio Code. This page consists of a panel that allows the user to scroll through all of the application's settings (organized under their respective groups) as well as a tree structure on the left for faster navigation to a specific group in the main panel. In the screenshot attached, I clicked the Formatting option in the tree on the left, and the main panel automatically scrolled so that the corresponding group header was place at the top of the main panel.

How can this be recreated in WPF (if at all possible)? Could the "infinite" scrolling of the main settings panel in Visual Studio Code be mimicked with another WPF control?

enter image description here


Solution

  • The tree on the left (the TOC) has root nodes (the sections e.g. 'TextEditor'). Each section holds categories of settings (e.g. 'Formatting'). The ListView on the right (settings view) has items that have a group header with category names that match those of the TOC (e.g. Formatting).

    1. Edit to address the use of PropertyGroupDescription

    Assumptions:

    • there exists a CollectionViewSource defined inside a ResourceDictionary and named CollectionViewSource.
    • The settings data items have a property SettingsCategoryName (e.g. Formatting).
    • The SettingsCategoryName of the SelectedItem of the TreeView is bound to a property SelectedSettingsCategoryName

    View.xaml:

    <ResourceDictionary>
      <CollectionViewSource x:Key="CollectionViewSource" Source="{Binding Settings}">
          <CollectionViewSource.GroupDescriptions>
            <PropertyGroupDescription PropertyName="SettingsCategoryName"/>
          </CollectionViewSource.GroupDescriptions>
      </CollectionViewSource>
    </ResourceDictionary>
    
    <ListView x:Name="ListView" ItemsSource="{Binding Source={StaticResource CollectionViewSource}}">
      <ListView.GroupStyle>
        <GroupStyle>
          <GroupStyle.HeaderTemplate>
            <DataTemplate>
              <TextBlock FontWeight="Bold"
                         FontSize="14"
                         Text="{Binding Name}" />
            </DataTemplate>
          </GroupStyle.HeaderTemplate>
        </GroupStyle>
      </ListView.GroupStyle>
    </ListView>
    

    View.xaml.cs:
    Find the selected category and scroll it to the top of the viewport.

    // Scroll the selected section to top when the selected item has changed
    private void ScrollToSection()
    {
      CollectionViewSource viewSource = FindResource("CollectionViewSource") as CollectionViewSource;
      CollectionViewGroup selectedGroupItemData = viewSource
        .View
        .Groups
        .OfType<CollectionViewGroup>()
        .FirstOrDefault(group => group.Name.Equals(this.SelectedSettingsCategoryName));
    
      GroupItem selectedroupItemContainer = this.ListView.ItemContainerGenerator.ContainerFromItem(selectedGroupItemData) as GroupItem;
    
      ScrollViewer scrollViewer;
      if (!TryFindCildElement(this.ListView, out scrollViewer))
      {
        return;
      }
    
      // Subscribe to scrollChanged event 
      // because the scroll executed by `BringIntoView` is deferred.
      scrollViewer.ScrollChanged += ScrollSelectedGroupToTop;
    
      selectedGroupItemContainer?.BringIntoView();
    }
    
    private void ScrollSelectedGroupToTop(object sender, ScrollChangedEventArgs e)
    {
      ScrollViewer scrollViewer;
      if (!TryFindCildElement(this.ListView, out scrollViewer))
      {
        return;
      }
    
      scrollViewer.ScrollChanged -= ScrollGroupToTop;
      var viewSource = FindResource("CollectionViewSource") as CollectionViewSource;
    
      CollectionViewGroup selectedGroupItemData = viewSource
        .View
        .Groups
        .OfType<CollectionViewGroup>()
        .FirstOrDefault(group => group.Name.Equals(this.SelectedSettingsCategoryName));
    
      var groupIndex = viewSource
        .View
        .Groups.IndexOf(selectedGroupItemData);
    
      var absoluteVerticalScrollOffset = viewSource
        .View
        .Groups
        .OfType<CollectionViewGroup>()
        .TakeWhile((group, index) => index < groupIndex)
        .Sum(group =>
          (this.ListView.ItemContainerGenerator.ContainerFromItem(group) as GroupItem)?.ActualHeight 
         ?? 0
        );
    
      scrollViewer.ScrollToVerticalOffset(absoluteVerticalScrollOffset);
    }
    
    // Generic method to find any `DependencyObject` in the visual tree of a parent element
    private bool TryFindCildElement<TElement>(DependencyObject parent, out TElement resultElement) where TElement : DependencyObject
    {
      resultElement = null;
      for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
      {
        DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);
    
        if (childElement is Popup popup)
        {
          childElement = popup.Child;
        }
    
        if (childElement is TElement)
        {
          resultElement = childElement as TElement;
          return true;
        }
    
        if (TryFindCildElement(childElement, out resultElement))
        {
          return true;
        }
      }
    
      return false;
    }
    

    You can move this method into a ListView derived type. Then add a CommandBindings to the new custom ListView that handles a Routed Command e.g. ScrollToSectionRoutedCommand. Template the TreeViewItems to be a Button and let them emit the command to pass the section name as CommandParameter to the custom ListView.

    Remarks
    Since the use of PropertyGroupDescription results in an items source of mixed data types (GroupItemData for the group headers and in addition the actual data items) the UI virtualization of the hosting ItemsControl is disabled and not possible (see Microsoft Docs: Optimizing performance: Controls). In this scenario the attached property ScrollViewer.CanContentScroll is automatically set to False (forced). For big list this can be a huge drawback and a reason to follow an alternative approach.

    2. Alternative solution (with UI virtualization support)

    There are several possible variations when it comes to the design of the actual settings structure. It can be a tree where each category header node has its own child nodes which represent settings of a category or a flat list structure where category headers and settings are all siblings. For the sake of the example's simplicity I choose the second option: a flat list data structure.

    2.1 The setup

    Basic idea:
    the TreeView is templated using a HierarchicalDataTemplate with two levels. The second level of the TreeView (leafs) and ListView share the same instances of the header items (IHeaederData. See later). Therefore the selected header item of the TreeView references the exact same item header in the ListView - no search required.

    Implementation overview:

    • You need two ItemsControl elements:
      • one TreeView for the navigation pane on the left with two levels
        • with a section root node (e.g. 'Text Editor')
        • and the settings category header child nodes (leaf nodes) of that section (e.g. 'Font', 'Formatting')
      • one ListView for the actual settings and their category headers.
    • Then design the data types to represent a setting, a settings header and a section root node
      • Let them all implement a common IData with shared attributes (e.g. a header)
      • Let the settings header data type implement an additional IHeaderData
      • Let the settings data type implement an additional ISettingData
      • Let the parent section node data type (root node) for the TreeView implement an additional ISectionData which has children of type IHeaderData
    • Create the item source collections (all of type IEnumerable<IData>)
      • one for each parent section node of the TreeView (which holds the categories only), a SectionCollection of type ISectionData
      • one for each of the categories, a CategoryCollection of type IHeaderData
      • a single one for the settings data and the shared categories (the header data), a SettingCollection of type IData
    • populate the sorted source collections section by section
      • add a section data instance of type ISectionData to the source collection SectionCollection of the TreeView
      • add a shared category data header instance of type IHeaderData to both source collections CategoryCollection and SettingCollection
      • add a settings instances of type ISettingData, one for each setting of the category, to the SettingCollection only
      • repeat the last two steps for all categories of the current section
      • assign the CategoryCollection to the child collection of the ISectionData root node
      • repeat the steps for all sections (with its categories and the corresponding settings)
    • Bind the SectionCollection to the TreeView
    • Bind the SettingsCollection to the LIstView
    • Create a HierarchicalDataTemplate for the TreeView data where ISectionData type is the root
    • Create two DataTemplate for the ListView
      • one that targets IHeaderData
      • one that targets ISettingData

    The logic:

    • When a IHeaderData item of the TreeView is selected then
      • get the ListView item container of this data item using var container = ItemsContainerGenerator.GetContainerFromItem(selectedTreeViewCategoryItem)
      • Scroll the container into view container.BringIntoView() (to realize virtualized items that are out of view)
      • Scroll the container to the top of the view

    Because TreeView and ListView share the same category header data (IHeaderData) the selected items are easy to track and to find. You don't have to search the group of settings. You can directly jump to the group using the reference. This means the structure of the data is the key of the solution.