如何为WPF CollectionView实现固定分组表头?
WPF CollectionView 实现粘性分组表头方案
嗨,我完全懂你想要的效果——滚动列表时分组表头一直固定在顶部,用户始终能看到当前所在的分组对吧?WPF原生的CollectionView分组确实没自带这个功能,但咱们可以通过自定义实现搞定,下面给你两种实用的方案:
方案一:用附加属性实现(灵活不改动原有结构)
这种方式不需要大改你的现有XAML结构,只需要添加一个附加属性类,再给ListView/ListBox设置模板就行。
1. 创建附加属性类
新建一个StickyGroupHeaders.cs类,代码如下:
using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Linq; public static class StickyGroupHeaders { public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(StickyGroupHeaders), new PropertyMetadata(false, OnIsEnabledChanged)); public static bool GetIsEnabled(DependencyObject obj) { return (bool)obj.GetValue(IsEnabledProperty); } public static void SetIsEnabled(DependencyObject obj, bool value) { obj.SetValue(IsEnabledProperty, value); } private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ListView listView) { if ((bool)e.NewValue) { listView.Loaded += ListView_Loaded; } else { listView.Loaded -= ListView_Loaded; } } // 如果用的是ListBox,添加下面这段 else if (d is ListBox listBox) { if ((bool)e.NewValue) { listBox.Loaded += ListBox_Loaded; } else { listBox.Loaded -= ListBox_Loaded; } } } private static void ListView_Loaded(object sender, RoutedEventArgs e) { AttachScrollHandler(sender as ListView); } private static void ListBox_Loaded(object sender, RoutedEventArgs e) { AttachScrollHandler(sender as ListBox); } private static void AttachScrollHandler(ItemsControl itemsControl) { var scrollViewer = FindVisualChild<ScrollViewer>(itemsControl); if (scrollViewer != null) { scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged; } } private static void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) { var scrollViewer = sender as ScrollViewer; var itemsControl = scrollViewer.TemplatedParent as ItemsControl; var itemsPresenter = FindVisualChild<ItemsPresenter>(itemsControl); if (itemsPresenter == null) return; var containerGenerator = itemsPresenter.ItemContainerGenerator; var visibleGroups = itemsControl.Items.Groups.OfType<CollectionViewGroup>() .Where(g => IsGroupVisible(g, containerGenerator)); foreach (var group in visibleGroups) { var groupItem = containerGenerator.ContainerFromItem(group) as GroupItem; if (groupItem == null) continue; var groupHeader = FindVisualChild<ContentControl>(groupItem); if (groupHeader == null) continue; var transform = groupHeader.TransformToAncestor(scrollViewer); var position = transform.Transform(new Point(0, 0)); var headerContainer = scrollViewer.Template.FindName("StickyHeaderContainer", scrollViewer) as Panel; var contentHost = scrollViewer.Template.FindName("PART_ContentHost", scrollViewer) as UIElement; if (position.Y <= 0) { // 移除旧的克隆表头 var existingClone = headerContainer.Children.OfType<ContentControl>().FirstOrDefault(); if (existingClone != null) { headerContainer.Children.Remove(existingClone); } // 创建和原表头一致的克隆件 var clonedHeader = new ContentControl { Content = groupHeader.Content, ContentTemplate = groupHeader.ContentTemplate, Style = groupHeader.Style, Width = groupHeader.ActualWidth, Margin = groupHeader.Margin, HorizontalAlignment = HorizontalAlignment.Stretch }; headerContainer.Children.Add(clonedHeader); contentHost.SetValue(Panel.ZIndexProperty, 1); } else { // 表头回到可视区域,移除克隆件 var existingClone = headerContainer.Children.OfType<ContentControl>().FirstOrDefault(); if (existingClone != null) { headerContainer.Children.Remove(existingClone); contentHost.SetValue(Panel.ZIndexProperty, 0); } } } } private static bool IsGroupVisible(CollectionViewGroup group, ItemContainerGenerator generator) { var container = generator.ContainerFromItem(group) as GroupItem; if (container == null) return false; var scrollViewer = FindVisualParent<ScrollViewer>(container); if (scrollViewer == null) return false; var transform = container.TransformToAncestor(scrollViewer); var bounds = transform.TransformBounds(new Rect(0, 0, container.ActualWidth, container.ActualHeight)); return bounds.IntersectsWith(new Rect(0, 0, scrollViewer.ActualWidth, scrollViewer.ActualHeight)); } private static T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject { for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) { var child = VisualTreeHelper.GetChild(parent, i); if (child is T tChild) return tChild; var result = FindVisualChild<T>(child); if (result != null) return result; } return null; } private static T FindVisualParent<T>(DependencyObject child) where T : DependencyObject { var parentObject = VisualTreeHelper.GetParent(child); if (parentObject == null) return null; return parentObject is T parent ? parent : FindVisualParent<T>(parentObject); } }
2. 修改ListView/ListBox的模板
在你的XAML里,给ListView(或ListBox)设置模板,添加粘性表头的容器:
<!-- 记得先引入命名空间,比如xmlns:local="clr-namespace:你的项目命名空间" --> <ListView local:StickyGroupHeaders.IsEnabled="True"> <ListView.Template> <ControlTemplate TargetType="ListView"> <Border BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}" Background="{TemplateBinding Background}"> <Grid> <!-- 用来放置粘性表头的容器 --> <Grid x:Name="StickyHeaderContainer" VerticalAlignment="Top"/> <ScrollViewer x:Name="ScrollViewer" Padding="{TemplateBinding Padding}" Focusable="false"> <ItemsPresenter /> </ScrollViewer> </Grid> </Border> </ControlTemplate> </ListView.Template> <!-- 你的分组样式 --> <ListView.GroupStyle> <GroupStyle> <GroupStyle.HeaderTemplate> <DataTemplate> <Border Background="#FFE0E0E0" Padding="8" Margin="0"> <TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/> </Border> </DataTemplate> </GroupStyle.HeaderTemplate> </GroupStyle> </ListView.GroupStyle> <!-- 你的Item模板 --> <ListView.ItemTemplate> <DataTemplate> <Border Padding="10" BorderThickness="0 0 0 1" BorderBrush="#FFEEEEEE"> <TextBlock Text="{Binding CustomerName}"/> </Border> </DataTemplate> </ListView.ItemTemplate> </ListView>
方案二:简化版(代码后台直接处理)
如果不想用附加属性,也可以直接在窗口的代码后台处理ScrollChanged事件:
1. XAML部分
给ListView命名,并绑定ScrollChanged事件:
<ListView x:Name="CustomerListView" ScrollViewer.ScrollChanged="CustomerListView_ScrollChanged"> <!-- 你的分组和Item模板,和上面一样 --> </ListView>
2. 代码后台
private void CustomerListView_ScrollChanged(object sender, ScrollChangedEventArgs e) { var listView = sender as ListView; var scrollViewer = FindVisualChild<ScrollViewer>(listView); var itemsPresenter = FindVisualChild<ItemsPresenter>(listView); if (itemsPresenter == null) return; var containerGenerator = itemsPresenter.ItemContainerGenerator; var visibleGroups = listView.Items.Groups.OfType<CollectionViewGroup>() .Where(g => IsGroupVisible(g, containerGenerator)); foreach (var group in visibleGroups) { var groupItem = containerGenerator.ContainerFromItem(group) as GroupItem; if (groupItem == null) continue; var groupHeader = FindVisualChild<ContentControl>(groupItem); if (groupHeader == null) continue; var transform = groupHeader.TransformToAncestor(scrollViewer); var position = transform.Transform(new Point(0, 0)); // 这里可以自己创建一个固定的表头控件,比如在窗口里放一个Border作为固定表头 if (position.Y <= 0) { StickyHeaderBorder.Visibility = Visibility.Visible; StickyHeaderTextBlock.Text = group.Name.ToString(); } else { StickyHeaderBorder.Visibility = Visibility.Collapsed; } } } // 复用上面的FindVisualChild、FindVisualParent、IsGroupVisible方法
这个方案更简单,但需要在窗口里预先放一个用于显示固定表头的控件,视觉样式要和分组表头保持一致。
注意事项
- 确保你的CollectionView已经正确设置了
GroupDescriptions,比如:var collectionView = CollectionViewSource.GetDefaultView(CustomerCollection); collectionView.GroupDescriptions.Add(new PropertyGroupDescription("GroupPropertyName")); - 如果是多级分组,需要修改代码来处理多个表头的层级显示
- 克隆表头时要保证样式、模板和原表头完全一致,避免视觉差异
内容的提问来源于stack exchange,提问作者Soleil
相关产品推荐
相关产品推荐

