是否可以以编程方式滚动 WPF ListView,以便将所需的分组 header 放置在其顶部?

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

如果 ListView 绑定到已使用 PropertyGroupDescription 分组的项目,是否可以通过编程方式滚动以便将组置于列表顶部?我知道我可以滚动到组中的第一个项目,因为该项目属于 ListView 绑定到的 collection。但是,我找不到任何描述如何滚动到组 header(样式为 GroupStyle)的资源。

为了举例说明所需的功能,让我们看一下 Visual Studio 代码 中的设置页面。此页面包含一个面板,允许用户滚动浏览所有应用程序的设置(组织在各自的组下)以及左侧的树结构,以便更快地导航到主面板中的特定组。在所附的屏幕截图中,我单击了左侧树中的 Formatting 选项,主面板自动滚动,因此相应的组 header 位于顶部主面板。

如何在 WPF 中重新创建它(如果可能的话)? Visual Studio 代码 中主设置面板的"infinite" 滚动是否可以用另一个 WPF 控件模仿?

左边的树(TOC)有根节点(部分例如 'TextEditor')。每个部分都包含设置类别(例如 'Formatting')。右侧的 ListView(设置视图)中的项目的组 header 的类别名称与 TOC 的类别名称相匹配(例如格式)。

1。编辑以解决 PropertyGroupDescription

的使用

假设:

  • 存在一个 CollectionViewSource 定义在一个 ResourceDictionary 并命名为 CollectionViewSource.
  • 设置数据项有属性SettingsCategoryName(例如格式化)。
  • SettingsCategoryNameSelectedItemTreeView 绑定到 属性 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:
找到所选类别并将其滚动到视口顶部。

// 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;
}

您可以将此方法移动到 ListView 派生类型中。然后将 CommandBindings 添加到处理路由命令的新自定义 ListView,例如ScrollToSectionRoutedCommand。将 TreeViewItems 模板化为 Button 并让他们发出命令以将部分名称作为 CommandParameter 传递给自定义 ListView.

备注
由于使用 PropertyGroupDescription 导致混合数据类型的项目源(GroupItemData 组 headers 以及实际数据项) UI 的虚拟化托管 ItemsControl 已禁用且不可能(请参阅 Microsoft Docs: Optimizing performance: Controls)。在这种情况下,附加的 属性 ScrollViewer.CanContentScroll 会自动设置为 False(强制)。对于大列表,这可能是一个巨大的缺点,也是采用替代方法的原因。

2。替代解决方案(具有 UI 虚拟化支持)

实际设置结构的设计有多种可能的变化。它可以是一棵树,其中每个类别 header 节点都有自己的 child 节点,代表类别的设置或平面列表结构,其中类别 header 和设置都是兄弟。为了示例的简单起见,我选择了第二个选项:平面列表数据结构。

2.1 设置

基本思路:
TreeView 使用具有两个级别的 HierarchicalDataTemplate 进行模板化。 TreeView(叶子)和 ListView 的第二层共享 header 项的相同实例(IHeaederData。见后)。因此,TreeView 的所选 header 项目引用了 ListView 中的 完全相同的 项目 header - 无需搜索。

实施概述:

  • 您需要两个 ItemsControl 元素:
    • 一个 TreeView 用于左侧的两级导航窗格
      • 带有节根节点(例如'Text Editor')
      • 和该部分的设置类别header child节点(叶节点)(例如'Font'、'Formatting')
    • 一个 ListView 用于实际设置及其类别 headers。
  • 然后设计数据类型来表示一个设置,一个设置header和一个节根节点
    • 让他们都实现一个具有共享属性的公共 IData(例如 header)
    • 让设置header数据类型实现一个额外的 IHeaderData
    • 让设置数据类型实现额外的ISettingData
    • TreeView 的 parent 部分节点数据类型(根节点)实现一个额外的 ISectionData,它具有 children 类型 IHeaderData
  • 创建项目源 collections(所有类型 IEnumerable<IData>
    • TreeView(仅包含类别)的每个 parent 节节点一个,SectionCollection 类型 ISectionData
    • 每个类别一个,CategoryCollection 类型 IHeaderData
    • 一个用于设置数据和共享类别(header 数据),一个 SettingCollection 类型 IData
  • 逐段填充已排序的源 collections
    • ISectionData 类型的节数据实例添加到 TreeView
    • 的源 collection SectionCollection
    • 将类型为 IHeaderData 的共享类别数据 header 实例添加到源 collections CategoryCollectionSettingCollection
    • 仅将 ISettingData 类型的设置实例添加到 SettingCollection
    • ,每个类别的设置一个
    • 对当前部分的所有类别重复最后两个步骤
    • CategoryCollection赋值给ISectionData根节点的childcollection
    • 对所有部分重复这些步骤(及其类别和相应的设置)
  • SectionCollection 绑定到 TreeView
  • SettingsCollection 绑定到 LIstView
  • TreeView 数据创建一个 HierarchicalDataTemplate,其中 ISectionData 类型是根
  • ListView 创建两个 DataTemplate
    • 针对 IHeaderData
    • 一个针对 ISettingData

逻辑:

  • TreeViewIHeaderData 项被选中时
    • 使用var container = ItemsContainerGenerator.GetContainerFromItem(selectedTreeViewCategoryItem)
    • 获取此数据项的ListView项容器
    • 将容器滚动到视图中container.BringIntoView()(实现看不见的虚拟化物品)
    • 将容器滚动到视图顶部

因为 TreeViewListView 共享相同的类别 header 数据 (IHeaderData),所以所选项目很容易跟踪和查找。您不必搜索设置组。您可以使用引用直接跳转到该组。 这意味着数据的结构是解决方案的关键。