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?
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).
PropertyGroupDescription
Assumptions:
CollectionViewSource
defined inside a
ResourceDictionary
and named CollectionViewSource.SettingsCategoryName
(e.g. Formatting).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.
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.
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:
ItemsControl
elements:
TreeView
for the navigation pane on the left with two levels
ListView
for the actual settings and their category headers.IData
with shared attributes (e.g. a header)IHeaderData
ISettingData
TreeView
implement an additional ISectionData
which has children of type IHeaderData
IEnumerable<IData>
)
TreeView
(which holds the categories only), a SectionCollection
of type ISectionData
CategoryCollection
of type IHeaderData
SettingCollection
of type IData
ISectionData
to the source collection SectionCollection
of the TreeView
IHeaderData
to both source collections CategoryCollection
and SettingCollection
ISettingData
, one for each setting of the category, to the SettingCollection
onlyCategoryCollection
to the child collection of the ISectionData
root nodeSectionCollection
to the TreeView
SettingsCollection
to the LIstView
HierarchicalDataTemplate
for the TreeView
data where ISectionData
type is the rootDataTemplate
for the ListView
IHeaderData
ISettingData
The logic:
IHeaderData
item of the TreeView
is selected then
ListView
item container of this data item using var container = ItemsContainerGenerator.GetContainerFromItem(selectedTreeViewCategoryItem)
container.BringIntoView()
(to realize virtualized items that are out of 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.