From a974ebcb7e89e0aff77a724985e09b4bce627afb Mon Sep 17 00:00:00 2001 From: Janvi Mahajan Date: Tue, 7 Apr 2026 08:48:38 +0530 Subject: [PATCH 1/4] Implement data grouping feature for WinUI TableView Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TableView.Properties.cs | 111 +++++++- src/TableView.cs | 391 +++++++++++++++++++++++++- src/TableViewCell.cs | 7 + src/TableViewRow.cs | 57 +++- src/TableViewRowPresenter.cs | 65 +++++ src/Themes/TableViewRowPresenter.xaml | 35 ++- 6 files changed, 647 insertions(+), 19 deletions(-) diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 6dbf992d..fb3b6698 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -264,6 +264,26 @@ public partial class TableView /// public static readonly DependencyProperty ShowFilterItemsCountProperty = DependencyProperty.Register(nameof(ShowFilterItemsCount), typeof(bool), typeof(TableView), new PropertyMetadata(false)); + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty GroupByPathProperty = DependencyProperty.Register(nameof(GroupByPath), typeof(string), typeof(TableView), new PropertyMetadata(null, OnGroupByPathChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ShowGroupHeadersProperty = DependencyProperty.Register(nameof(ShowGroupHeaders), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupHeadersChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty GroupSortDirectionProperty = DependencyProperty.Register(nameof(GroupSortDirection), typeof(SortDirection), typeof(TableView), new PropertyMetadata(SortDirection.Ascending, OnGroupSortDirectionChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ShowGroupItemCountProperty = DependencyProperty.Register(nameof(ShowGroupItemCount), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupItemCountChanged)); + /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. /// @@ -281,6 +301,42 @@ public bool UseRightClickForColumnFilter /// /// Gets the collection of sort descriptions applied to the items. /// + /// + /// Gets or sets the property path used to group items in the TableView. + /// + public string? GroupByPath + { + get => (string?)GetValue(GroupByPathProperty); + set => SetValue(GroupByPathProperty, value); + } + + /// + /// Gets or sets a value indicating whether group headers are shown. + /// + public bool ShowGroupHeaders + { + get => (bool)GetValue(ShowGroupHeadersProperty); + set => SetValue(ShowGroupHeadersProperty, value); + } + + /// + /// Gets or sets the sorting direction used for grouping. + /// + public SortDirection GroupSortDirection + { + get => (SortDirection)GetValue(GroupSortDirectionProperty); + set => SetValue(GroupSortDirectionProperty, value); + } + + /// + /// Gets or sets a value indicating whether group headers show item counts. + /// + public bool ShowGroupItemCount + { + get => (bool)GetValue(ShowGroupItemCountProperty); + set => SetValue(ShowGroupItemCountProperty, value); + } + public IList SortDescriptions => _collectionView.SortDescriptions; /// @@ -1097,12 +1153,65 @@ private static void OnAreRowDetailsFrozen(DependencyObject d, DependencyProperty } } + /// + /// Handles changes to the GroupByPath property. + /// + private static void OnGroupByPathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.EnsureGroupingSortDescription(); + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the ShowGroupHeaders property. + /// + private static void OnShowGroupHeadersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the GroupSortDirection property. + /// + private static void OnGroupSortDirectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.EnsureGroupingSortDescription(); + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the ShowGroupItemCount property. + /// + private static void OnShowGroupItemCountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.RebuildDisplayedItems(); + } + } + /// /// Throws an exception if the base ItemsSource property is set directly. /// private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { - throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); + if (_isUpdatingBaseItemsSource || ReferenceEquals(base.ItemsSource, _displayItems)) + { + return; + } + + _isUpdatingBaseItemsSource = true; + base.ItemsSource = _displayItems; + _isUpdatingBaseItemsSource = false; } /// diff --git a/src/TableView.cs b/src/TableView.cs index a07cd7ea..2dbffac7 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -6,6 +6,8 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO; @@ -15,6 +17,7 @@ using System.Threading.Tasks; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; +using Windows.Foundation.Collections; using Windows.Storage; using Windows.Storage.Pickers; using Windows.System; @@ -30,13 +33,31 @@ namespace WinUI.TableView; [StyleTypedProperty(Property = nameof(CellStyle), StyleTargetType = typeof(TableViewCell))] public partial class TableView : ListView { + private sealed class GroupHeaderRowItem + { + public required object GroupKey { get; init; } + + public required string Header { get; init; } + } + + private static readonly object NullGroupKey = new(); private TableViewHeaderRow? _headerRow; private ScrollViewer? _scrollViewer; private RowDefinition? _headerRowDefinition; private bool _shouldThrowSelectionModeChangedException; + private bool _isUpdatingBaseItemsSource; private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; + private readonly ObservableCollection _displayItems = []; + private readonly Dictionary _groupHeadersByItem = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _groupKeysByItem = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _groupHeaderItemsByKey = []; + private readonly HashSet _collapsedGroupKeys = []; + private readonly Dictionary<(Type Type, string Path), Func?> _propertyPathAccessorCache = []; + private SortDescription? _groupSortDescription; + private bool _isUpdatingGroupingSortDescription; + private bool _isDisplayedItemsRebuildQueued; /// /// Initializes a new instance of the TableView class. @@ -48,7 +69,9 @@ public TableView() Columns = new TableViewColumnsCollection(this); FilterHandler = new ColumnFilterHandler(this); - base.ItemsSource = _collectionView; + _isUpdatingBaseItemsSource = true; + base.ItemsSource = _displayItems; + _isUpdatingBaseItemsSource = false; base.SelectionMode = SelectionMode; SetValue(ConditionalCellStylesProperty, new TableViewConditionalCellStylesCollection()); @@ -59,6 +82,65 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; + _collectionView.VectorChanged += OnCollectionViewVectorChanged; + + if (SortDescriptions is INotifyCollectionChanged sortDescriptions) + { + sortDescriptions.CollectionChanged += OnSortDescriptionsCollectionChanged; + } + } + + private void OnCollectionViewVectorChanged(IObservableVector sender, IVectorChangedEventArgs args) + { + QueueDisplayedItemsRebuild(); + } + + private bool _isEnsureGroupingSortQueued; + + private void OnSortDescriptionsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_isEnsureGroupingSortQueued || _isUpdatingGroupingSortDescription) + { + return; + } + + _isEnsureGroupingSortQueued = true; + + if (DispatcherQueue is null) + { + _isEnsureGroupingSortQueued = false; + EnsureGroupingSortDescription(); + return; + } + + DispatcherQueue.TryEnqueue(() => + { + _isEnsureGroupingSortQueued = false; + EnsureGroupingSortDescription(); + }); + } + + private void QueueDisplayedItemsRebuild() + { + if (_isDisplayedItemsRebuildQueued) + { + return; + } + + _isDisplayedItemsRebuildQueued = true; + + if (DispatcherQueue is null) + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + return; + } + + _ = DispatcherQueue.TryEnqueue(() => + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + }); } /// @@ -394,9 +476,43 @@ private TableViewCellSlot GetNextSlot(TableViewCellSlot? currentSlot, bool isShi } } + nextRow = GetNextSelectableRowIndex(nextRow, isShiftKeyDown ? -1 : 1); + return new TableViewCellSlot(nextRow, nextColumn); } + private int GetNextSelectableRowIndex(int startIndex, int step) + { + if (Items.Count == 0) + { + return -1; + } + + step = step == 0 ? 1 : Math.Sign(step); + var index = Math.Clamp(startIndex, 0, Items.Count - 1); + + for (var count = 0; count < Items.Count; count++) + { + if (IsSelectableItem(Items[index])) + { + return index; + } + + index += step; + + if (index < 0) + { + index = Items.Count - 1; + } + else if (index >= Items.Count) + { + index = 0; + } + } + + return -1; + } + /// /// Copies the selected rows or cells content to the clipboard. /// @@ -667,15 +783,264 @@ private static TableViewBoundColumn GetTableViewColumnFromType(string? propertyN /// private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e) { + if (!ReferenceEquals(e.OldValue, e.NewValue)) + { + _collapsedGroupKeys.Clear(); + } + using var defer = _collectionView.DeferRefresh(); + + EnsureGroupingSortDescription(); + + _groupHeadersByItem.Clear(); + _groupKeysByItem.Clear(); + _groupHeaderItemsByKey.Clear(); + _displayItems.Clear(); + _collectionView.Source = null!; if (e.NewValue is IEnumerable source) { EnsureAutoColumns(); - _collectionView.Source = source; } + + RebuildDisplayedItems(); + } + + private void EnsureGroupingSortDescription() + { + if (_isUpdatingGroupingSortDescription) + { + return; + } + + var desiredPath = string.IsNullOrWhiteSpace(GroupByPath) ? null : GroupByPath; + + if (_groupSortDescription is not null && desiredPath is not null + && _groupSortDescription.PropertyName == desiredPath + && _groupSortDescription.Direction == GroupSortDirection + && SortDescriptions.IndexOf(_groupSortDescription) == 0) + { + return; + } + + _isUpdatingGroupingSortDescription = true; + + try + { + using var defer = _collectionView.DeferRefresh(); + + if (_groupSortDescription is not null) + { + SortDescriptions.Remove(_groupSortDescription); + _groupSortDescription = null; + } + + if (desiredPath is not null) + { + _groupSortDescription = new SortDescription(desiredPath, GroupSortDirection); + SortDescriptions.Insert(0, _groupSortDescription); + } + } + finally + { + _isUpdatingGroupingSortDescription = false; + } + } + + private void RebuildDisplayedItems() + { + BuildGroupHeadersFromCurrentView(); + + _displayItems.Clear(); + + if (!string.IsNullOrWhiteSpace(GroupByPath) && ShowGroupHeaders) + { + object? previousGroupKey = null; + var hasPreviousGroup = false; + + foreach (var item in _collectionView.OfType()) + { + var groupKey = GetNormalizedGroupKeyForItem(item); + + if (!hasPreviousGroup || !Equals(previousGroupKey, groupKey)) + { + if (_groupHeaderItemsByKey.TryGetValue(groupKey, out var headerItem)) + { + _displayItems.Add(headerItem); + } + + previousGroupKey = groupKey; + hasPreviousGroup = true; + } + + if (!_collapsedGroupKeys.Contains(groupKey)) + { + _displayItems.Add(item); + } + } + + return; + } + + foreach (var item in _collectionView.OfType()) + { + _displayItems.Add(item); + } + } + + private void BuildGroupHeadersFromCurrentView() + { + _groupHeadersByItem.Clear(); + _groupKeysByItem.Clear(); + _groupHeaderItemsByKey.Clear(); + + if (string.IsNullOrWhiteSpace(GroupByPath)) + { + return; + } + + var groupedItems = _collectionView.OfType() + .Select(item => new + { + Item = item, + GroupKey = ResolvePropertyPathValue(item, GroupByPath!) + }) + .ToList(); + + if (groupedItems.Count == 0) + { + return; + } + + var groupStartIndex = 0; + + for (var index = 1; index <= groupedItems.Count; index++) + { + var isBoundary = index == groupedItems.Count + || !Equals(groupedItems[index - 1].GroupKey, groupedItems[index].GroupKey); + + if (!isBoundary) + { + continue; + } + + var startItem = groupedItems[groupStartIndex]; + var count = index - groupStartIndex; + var groupKey = NormalizeGroupKey(startItem.GroupKey); + + for (var groupItemIndex = groupStartIndex; groupItemIndex < index; groupItemIndex++) + { + _groupKeysByItem[groupedItems[groupItemIndex].Item] = groupKey; + } + + if (ShowGroupHeaders) + { + var headerItem = new GroupHeaderRowItem + { + GroupKey = groupKey, + Header = FormatGroupHeader(startItem.GroupKey, count) + }; + + _groupHeaderItemsByKey[groupKey] = headerItem; + _groupKeysByItem[headerItem] = groupKey; + _groupHeadersByItem[headerItem] = headerItem.Header; + } + + groupStartIndex = index; + } + } + + private string FormatGroupHeader(object? groupKey, int count) + { + var title = groupKey?.ToString() ?? "(null)"; + + if (!ShowGroupItemCount) + { + return title; + } + + return $"{title} ({count})"; + } + + private object? ResolvePropertyPathValue(object item, string propertyPath) + { + var key = (item.GetType(), propertyPath); + + if (!_propertyPathAccessorCache.TryGetValue(key, out var accessor)) + { + accessor = item.GetFuncCompiledPropertyPath(propertyPath); + _propertyPathAccessorCache[key] = accessor; + } + + return accessor?.Invoke(item); + } + + internal bool TryGetGroupHeader(object? item, out string header) + { + if (item is not null && _groupHeadersByItem.TryGetValue(item, out var h)) + { + header = h; + return true; + } + + header = string.Empty; + return false; + } + + internal bool IsGroupHeaderItem(object? item) + { + return item is GroupHeaderRowItem; + } + + internal bool IsSelectableItem(object? item) + { + return item is not GroupHeaderRowItem; + } + + internal bool IsGroupExpanded(object? item) + { + if (item is null || !_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return true; + } + + return !_collapsedGroupKeys.Contains(groupKey); + } + + internal void ToggleGroupExpansion(object? item) + { + if (item is null || !_groupHeadersByItem.ContainsKey(item) || !_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return; + } + + if (_collapsedGroupKeys.Contains(groupKey)) + { + _collapsedGroupKeys.Remove(groupKey); + } + else + { + _collapsedGroupKeys.Add(groupKey); + } + + RebuildDisplayedItems(); + } + + private object GetNormalizedGroupKeyForItem(object item) + { + if (_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return groupKey; + } + + return NormalizeGroupKey(ResolvePropertyPathValue(item, GroupByPath!)); + } + + private static object NormalizeGroupKey(object? groupKey) + { + return groupKey ?? NullGroupKey; } /// @@ -956,6 +1321,17 @@ internal void MakeSelection(TableViewCellSlot slot, bool shiftKey, bool ctrlKey return; } + if (!IsSelectableItem(Items[slot.Row])) + { + var selectableRow = GetNextSelectableRowIndex(slot.Row, 1); + if (selectableRow < 0) + { + return; + } + + slot = new TableViewCellSlot(selectableRow, slot.Column); + } + if (SelectionMode != ListViewSelectionMode.None) { ctrlKey = ctrlKey || SelectionMode is ListViewSelectionMode.Multiple; @@ -1224,10 +1600,19 @@ public async Task ScrollCellIntoView(TableViewCellSlot slot) /// The index of the row to scroll into view. public async Task ScrollRowIntoView(int index) { - if (_scrollViewer is null || index < 0) return default!; + if (_scrollViewer is null || index < 0 || index >= Items.Count) + { + return default!; + } var item = Items[index]; index = Items.IndexOf(item); // if the ItemsSource has duplicate items in it. ScrollIntoView will only bring first index of the item. + + if (index < 0 || index >= Items.Count) + { + return default!; + } + ScrollIntoView(item); var tries = 0; diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index a552d0c8..e55d4880 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -453,6 +453,13 @@ internal void EndEditing(TableViewEditAction editAction) /// internal void SetElement() { + if (TableView?.IsGroupHeaderItem(Row?.Content) is true) + { + Content = null; + DataContext = null; + return; + } + var element = Column?.GenerateElement(this, Row?.Content); if (element is not null && Column is TableViewBoundColumn { ElementStyle: { } } boundColumn) diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index d403a149..d9d1ba9a 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -132,27 +132,45 @@ protected override void OnApplyTemplate() /// protected override void OnContentChanged(object oldContent, object newContent) { - base.OnContentChanged(oldContent, newContent); + var isGroupHeaderItem = TableView?.IsGroupHeaderItem(newContent) is true; - if (_ensureCells) + if (isGroupHeaderItem) { - EnsureCells(); + RowPresenter?.ClearCells(); + _ensureCells = true; } - else + + base.OnContentChanged(oldContent, newContent); + + if (!isGroupHeaderItem) { - foreach (var cell in Cells) + if (_ensureCells || Cells.Count == 0) { - cell.RefreshElement(); + EnsureCells(); + } + else + { + foreach (var cell in Cells) + { + cell.RefreshElement(); + } } } - RowPresenter?.InvalidateMeasure(); // The cells presenter does not measure every time. + RowPresenter?.InvalidateMeasure(); _tableView?.EnsureAlternateRowColors(); + RowPresenter?.SetGroupHeaderPresentation(); } /// protected override void OnPointerPressed(PointerRoutedEventArgs e) { + if (TableView?.IsGroupHeaderItem(Content) is true) + { + e.Handled = true; + return; + } + if (TableView is { IsEditing: false }) { base.OnPointerPressed(e); @@ -167,6 +185,12 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) /// protected override void OnPointerReleased(PointerRoutedEventArgs e) { + if (TableView?.IsGroupHeaderItem(Content) is true) + { + e.Handled = true; + return; + } + base.OnPointerReleased(e); if (!KeyboardHelper.IsShiftKeyDown() && TableView is not null) @@ -179,6 +203,12 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) /// protected override void OnTapped(TappedRoutedEventArgs e) { + if (TableView?.IsGroupHeaderItem(Content) is true) + { + e.Handled = true; + return; + } + base.OnTapped(e); if (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow) @@ -191,6 +221,12 @@ protected override void OnTapped(TappedRoutedEventArgs e) /// protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) { + if (TableView?.IsGroupHeaderItem(Content) is true) + { + e.Handled = true; + return; + } + var eventArgs = new TableViewRowDoubleTappedEventArgs(Index, this, Content); TableView?.OnRowDoubleTapped(eventArgs); e.Handled = eventArgs.Handled; @@ -221,6 +257,13 @@ internal void EnsureCells() return; } + if (TableView.IsGroupHeaderItem(Content)) + { + RowPresenter?.ClearCells(); + _ensureCells = true; + return; + } + if (RowPresenter is not null && _ensureCells) { RowPresenter.ClearCells(); diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index 58cb3963..a75ccacb 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -22,6 +22,10 @@ namespace WinUI.TableView; [TemplateVisualState(Name = VisualStates.StateDetailsButtonCollapsed, GroupName = VisualStates.GroupRowDetailsButton)] public partial class TableViewRowPresenter : Control { + private Border? _groupHeaderPanel; + private ToggleButton? _groupHeaderToggleButton; + private TextBlock? _groupHeaderTextBlock; + private bool _isUpdatingGroupToggle; private TableViewRowHeader? _rowHeader; private Panel? _rootPanel; private StackPanel? _scrollableCellsPanel; @@ -46,6 +50,9 @@ protected override void OnApplyTemplate() { base.OnApplyTemplate(); + _groupHeaderPanel = GetTemplateChild("GroupHeaderPanel") as Border; + _groupHeaderToggleButton = GetTemplateChild("GroupHeaderToggleButton") as ToggleButton; + _groupHeaderTextBlock = GetTemplateChild("GroupHeaderTextBlock") as TextBlock; _rowHeader = GetTemplateChild("RowHeader") as TableViewRowHeader; _rootPanel = GetTemplateChild("RootPanel") as Panel; _scrollableCellsPanel = GetTemplateChild("ScrollableCellsPanel") as StackPanel; @@ -72,6 +79,14 @@ protected override void OnApplyTemplate() _detailsToggleButton.Unchecked += OnDetailsToggleButtonUnChecked; } + if (_groupHeaderToggleButton is not null) + { + _groupHeaderToggleButton.Checked -= OnGroupHeaderToggleButtonChanged; + _groupHeaderToggleButton.Unchecked -= OnGroupHeaderToggleButtonChanged; + _groupHeaderToggleButton.Checked += OnGroupHeaderToggleButtonChanged; + _groupHeaderToggleButton.Unchecked += OnGroupHeaderToggleButtonChanged; + } + if (_detailsPanel is not null) { _detailsPanel.SizeChanged += (_, _) => TableViewRow?.EnsureLayout(); @@ -84,11 +99,52 @@ protected override void OnApplyTemplate() SetRowHeaderBindings(); SetRowHeaderVisibility(); SetRowHeaderTemplate(); + SetGroupHeaderPresentation(); SetRowHeaderWidth(); SetRowDetailsVisibility(); SetRowDetailsTemplate(); } + private void OnGroupHeaderToggleButtonChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingGroupToggle) + { + return; + } + + TableView?.ToggleGroupExpansion(TableViewRow?.Content); + } + + internal void SetGroupHeaderPresentation() + { + var header = string.Empty; + var isGroupHeaderItem = TableView?.IsGroupHeaderItem(TableViewRow?.Content) is true; + var hasGroupHeader = isGroupHeaderItem && TableView?.TryGetGroupHeader(TableViewRow?.Content, out header) is true; + + if (_groupHeaderPanel is not null) + { + _groupHeaderPanel.Visibility = hasGroupHeader ? Visibility.Visible : Visibility.Collapsed; + } + + if (_groupHeaderTextBlock is not null) + { + _groupHeaderTextBlock.Text = hasGroupHeader ? header : string.Empty; + } + + if (_groupHeaderToggleButton is not null) + { + _isUpdatingGroupToggle = true; + _groupHeaderToggleButton.IsChecked = hasGroupHeader && TableView?.IsGroupExpanded(TableViewRow?.Content) is true; + _groupHeaderToggleButton.Visibility = hasGroupHeader ? Visibility.Visible : Visibility.Collapsed; + _isUpdatingGroupToggle = false; + } + + if (_rootPanel is not null) + { + _rootPanel.Visibility = isGroupHeaderItem ? Visibility.Collapsed : Visibility.Visible; + } + } + /// protected override Size MeasureOverride(Size availableSize) { @@ -234,6 +290,15 @@ internal void SetRowDetailsTemplate() _detailsPresenter.ContentTemplate = TableView.RowDetailsTemplateSelector?.SelectTemplate(TableViewRow?.Content) ?? TableView.RowDetailsTemplate; + + if (TableView.IsGroupHeaderItem(TableViewRow?.Content) is not true) + { + _detailsPresenter.Content = TableViewRow?.Content; + } + else + { + _detailsPresenter.Content = null; + } } } diff --git a/src/Themes/TableViewRowPresenter.xaml b/src/Themes/TableViewRowPresenter.xaml index a7b060af..977a940e 100644 --- a/src/Themes/TableViewRowPresenter.xaml +++ b/src/Themes/TableViewRowPresenter.xaml @@ -57,11 +57,36 @@ + + + + + + + + + + + + + + @@ -105,12 +130,7 @@ - - - + Orientation="Horizontal" /> From 9de0d85b472d467226de4fbf6f58831ca4af0ad5 Mon Sep 17 00:00:00 2001 From: Janvi Mahajan Date: Thu, 9 Apr 2026 21:17:15 +0530 Subject: [PATCH 2/4] Code reflacting to partial class --- src/TableView.Grouping.cs | 418 ++++++++++++++++++++++++++++++++++++ src/TableView.Properties.cs | 12 +- src/TableView.cs | 352 +----------------------------- 3 files changed, 425 insertions(+), 357 deletions(-) create mode 100644 src/TableView.Grouping.cs diff --git a/src/TableView.Grouping.cs b/src/TableView.Grouping.cs new file mode 100644 index 00000000..d9dfa3a7 --- /dev/null +++ b/src/TableView.Grouping.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using Windows.Foundation; +using Windows.Foundation.Collections; +using WinUI.TableView.Extensions; + +namespace WinUI.TableView; + +/// +/// Partial class for TableView that contains row grouping logic. +/// +public partial class TableView +{ + /// + /// Represents a sentinel item inserted into the display list to render a group header row. + /// + private sealed class GroupHeaderRowItem + { + public required object GroupKey { get; init; } + + public required string Header { get; init; } + } + + /// + /// Sentinel object used as a dictionary key when the group property value is null. + /// + private static readonly object NullGroupKey = new(); + + private readonly ObservableCollection _displayItems = []; + + // Maps GroupHeaderRowItem sentinels to their display text (e.g., "Engineering (4)"). + private readonly Dictionary _groupHeadersByItem = new(ReferenceEqualityComparer.Instance); + + // Maps any item (data or sentinel) to its normalized group key. + private readonly Dictionary _groupKeysByItem = new(ReferenceEqualityComparer.Instance); + + // Maps a group key to its GroupHeaderRowItem sentinel object. + private readonly Dictionary _groupHeaderItemsByKey = []; + + private readonly HashSet _collapsedGroupKeys = []; + private readonly Dictionary<(Type Type, string Path), Func?> _propertyPathAccessorCache = []; + private SortDescription? _groupSortDescription; + private bool _isUpdatingGroupingSortDescription; + private bool _isDisplayedItemsRebuildQueued; + private bool _isEnsureGroupingSortQueued; + + /// + /// Subscribes to events needed for grouping. Called from the constructor. + /// + private void InitializeGrouping() + { + _collectionView.VectorChanged += OnCollectionViewVectorChanged; + + if (SortDescriptions is INotifyCollectionChanged sortDescriptions) + { + sortDescriptions.CollectionChanged += OnSortDescriptionsCollectionChanged; + } + } + + private void OnCollectionViewVectorChanged(IObservableVector sender, IVectorChangedEventArgs args) + { + QueueDisplayedItemsRebuild(); + } + + private void OnSortDescriptionsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_isEnsureGroupingSortQueued || _isUpdatingGroupingSortDescription) + { + return; + } + + _isEnsureGroupingSortQueued = true; + + if (DispatcherQueue is null) + { + _isEnsureGroupingSortQueued = false; + EnsureGroupingSortDescription(); + return; + } + + DispatcherQueue.TryEnqueue(() => + { + _isEnsureGroupingSortQueued = false; + EnsureGroupingSortDescription(); + }); + } + + private void QueueDisplayedItemsRebuild() + { + if (_isDisplayedItemsRebuildQueued) + { + return; + } + + _isDisplayedItemsRebuildQueued = true; + + if (DispatcherQueue is null) + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + return; + } + + _ = DispatcherQueue.TryEnqueue(() => + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + }); + } + + /// + /// Ensures the group sort description is at index 0 of SortDescriptions. + /// + internal void EnsureGroupingSortDescription() + { + if (_isUpdatingGroupingSortDescription) + { + return; + } + + var desiredPath = string.IsNullOrWhiteSpace(GroupByPath) ? null : GroupByPath; + + if (_groupSortDescription is not null && desiredPath is not null + && _groupSortDescription.PropertyName == desiredPath + && _groupSortDescription.Direction == GroupSortDirection + && SortDescriptions.IndexOf(_groupSortDescription) == 0) + { + return; + } + + _isUpdatingGroupingSortDescription = true; + + try + { + using var defer = _collectionView.DeferRefresh(); + + if (_groupSortDescription is not null) + { + SortDescriptions.Remove(_groupSortDescription); + _groupSortDescription = null; + } + + if (desiredPath is not null) + { + _groupSortDescription = new SortDescription(desiredPath, GroupSortDirection); + SortDescriptions.Insert(0, _groupSortDescription); + } + } + finally + { + _isUpdatingGroupingSortDescription = false; + } + } + + /// + /// Rebuilds the display items list from the collection view, injecting group header sentinels. + /// + internal void RebuildDisplayedItems() + { + BuildGroupHeadersFromCurrentView(); + + _displayItems.Clear(); + + if (!string.IsNullOrWhiteSpace(GroupByPath) && ShowGroupHeaders) + { + object? previousGroupKey = null; + var hasPreviousGroup = false; + + foreach (var item in _collectionView.OfType()) + { + var groupKey = GetNormalizedGroupKeyForItem(item); + + if (!hasPreviousGroup || !Equals(previousGroupKey, groupKey)) + { + if (_groupHeaderItemsByKey.TryGetValue(groupKey, out var headerItem)) + { + _displayItems.Add(headerItem); + } + + previousGroupKey = groupKey; + hasPreviousGroup = true; + } + + if (!_collapsedGroupKeys.Contains(groupKey)) + { + _displayItems.Add(item); + } + } + + return; + } + + foreach (var item in _collectionView.OfType()) + { + _displayItems.Add(item); + } + } + + /// + /// Scans the collection view and builds group header sentinels and tracking dictionaries. + /// + private void BuildGroupHeadersFromCurrentView() + { + _groupHeadersByItem.Clear(); + _groupKeysByItem.Clear(); + _groupHeaderItemsByKey.Clear(); + + if (string.IsNullOrWhiteSpace(GroupByPath)) + { + return; + } + + var groupedItems = _collectionView.OfType() + .Select(item => new + { + Item = item, + GroupKey = ResolvePropertyPathValue(item, GroupByPath!) + }) + .ToList(); + + if (groupedItems.Count == 0) + { + return; + } + + var groupStartIndex = 0; + + for (var index = 1; index <= groupedItems.Count; index++) + { + var isBoundary = index == groupedItems.Count + || !Equals(groupedItems[index - 1].GroupKey, groupedItems[index].GroupKey); + + if (!isBoundary) + { + continue; + } + + var startItem = groupedItems[groupStartIndex]; + var count = index - groupStartIndex; + var groupKey = NormalizeGroupKey(startItem.GroupKey); + + for (var groupItemIndex = groupStartIndex; groupItemIndex < index; groupItemIndex++) + { + _groupKeysByItem[groupedItems[groupItemIndex].Item] = groupKey; + } + + if (ShowGroupHeaders) + { + var headerItem = new GroupHeaderRowItem + { + GroupKey = groupKey, + Header = FormatGroupHeader(startItem.GroupKey, count) + }; + + _groupHeaderItemsByKey[groupKey] = headerItem; + _groupKeysByItem[headerItem] = groupKey; + _groupHeadersByItem[headerItem] = headerItem.Header; + } + + groupStartIndex = index; + } + } + + private string FormatGroupHeader(object? groupKey, int count) + { + var title = groupKey?.ToString() ?? "(null)"; + + if (!ShowGroupItemCount) + { + return title; + } + + return $"{title} ({count})"; + } + + private object? ResolvePropertyPathValue(object item, string propertyPath) + { + var key = (item.GetType(), propertyPath); + + if (!_propertyPathAccessorCache.TryGetValue(key, out var accessor)) + { + accessor = item.GetFuncCompiledPropertyPath(propertyPath); + _propertyPathAccessorCache[key] = accessor; + } + + return accessor?.Invoke(item); + } + + /// + /// Tries to get the display text for a group header sentinel. + /// + internal bool TryGetGroupHeader(object? item, out string header) + { + if (item is not null && _groupHeadersByItem.TryGetValue(item, out var h)) + { + header = h; + return true; + } + + header = string.Empty; + return false; + } + + /// + /// Returns true if the item is a group header sentinel, not a data item. + /// + internal bool IsGroupHeaderItem(object? item) + { + return item is GroupHeaderRowItem; + } + + /// + /// Returns true if the item is a selectable data item (not a group header). + /// + internal bool IsSelectableItem(object? item) + { + return item is not GroupHeaderRowItem; + } + + /// + /// Returns true if the group containing the given item is expanded. + /// + internal bool IsGroupExpanded(object? item) + { + if (item is null || !_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return true; + } + + return !_collapsedGroupKeys.Contains(groupKey); + } + + /// + /// Toggles the expand/collapse state of the group containing the given item. + /// + internal void ToggleGroupExpansion(object? item) + { + if (item is null || !_groupHeadersByItem.ContainsKey(item) || !_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return; + } + + if (_collapsedGroupKeys.Contains(groupKey)) + { + _collapsedGroupKeys.Remove(groupKey); + } + else + { + _collapsedGroupKeys.Add(groupKey); + } + + RebuildDisplayedItems(); + } + + private object GetNormalizedGroupKeyForItem(object item) + { + if (_groupKeysByItem.TryGetValue(item, out var groupKey)) + { + return groupKey; + } + + return NormalizeGroupKey(ResolvePropertyPathValue(item, GroupByPath!)); + } + + private static object NormalizeGroupKey(object? groupKey) + { + return groupKey ?? NullGroupKey; + } + + /// + /// Finds the next row index that is not a group header, scanning in the given direction. + /// + private int GetNextSelectableRowIndex(int startIndex, int step) + { + if (Items.Count == 0) + { + return -1; + } + + step = step == 0 ? 1 : Math.Sign(step); + var index = Math.Clamp(startIndex, 0, Items.Count - 1); + + for (var count = 0; count < Items.Count; count++) + { + if (IsSelectableItem(Items[index])) + { + return index; + } + + index += step; + + if (index < 0) + { + index = Items.Count - 1; + } + else if (index >= Items.Count) + { + index = 0; + } + } + + return -1; + } + + /// + /// Clears all grouping state. Called when ItemsSource changes. + /// + private void ClearGroupingState() + { + _groupHeadersByItem.Clear(); + _groupKeysByItem.Clear(); + _groupHeaderItemsByKey.Clear(); + _displayItems.Clear(); + } +} diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index fb3b6698..121ba5de 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -298,9 +298,6 @@ public bool UseRightClickForColumnFilter /// public ICollectionView CollectionView => _collectionView; - /// - /// Gets the collection of sort descriptions applied to the items. - /// /// /// Gets or sets the property path used to group items in the TableView. /// @@ -337,6 +334,9 @@ public bool ShowGroupItemCount set => SetValue(ShowGroupItemCountProperty, value); } + /// + /// Gets the collection of sort descriptions applied to the items. + /// public IList SortDescriptions => _collectionView.SortDescriptions; /// @@ -1204,14 +1204,12 @@ private static void OnShowGroupItemCountChanged(DependencyObject d, DependencyPr /// private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { - if (_isUpdatingBaseItemsSource || ReferenceEquals(base.ItemsSource, _displayItems)) + if (_isUpdatingBaseItemsSource) { return; } - _isUpdatingBaseItemsSource = true; - base.ItemsSource = _displayItems; - _isUpdatingBaseItemsSource = false; + throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); } /// diff --git a/src/TableView.cs b/src/TableView.cs index 2dbffac7..b183d649 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -6,8 +6,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO; @@ -33,14 +31,6 @@ namespace WinUI.TableView; [StyleTypedProperty(Property = nameof(CellStyle), StyleTargetType = typeof(TableViewCell))] public partial class TableView : ListView { - private sealed class GroupHeaderRowItem - { - public required object GroupKey { get; init; } - - public required string Header { get; init; } - } - - private static readonly object NullGroupKey = new(); private TableViewHeaderRow? _headerRow; private ScrollViewer? _scrollViewer; private RowDefinition? _headerRowDefinition; @@ -49,15 +39,6 @@ private sealed class GroupHeaderRowItem private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; - private readonly ObservableCollection _displayItems = []; - private readonly Dictionary _groupHeadersByItem = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _groupKeysByItem = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _groupHeaderItemsByKey = []; - private readonly HashSet _collapsedGroupKeys = []; - private readonly Dictionary<(Type Type, string Path), Func?> _propertyPathAccessorCache = []; - private SortDescription? _groupSortDescription; - private bool _isUpdatingGroupingSortDescription; - private bool _isDisplayedItemsRebuildQueued; /// /// Initializes a new instance of the TableView class. @@ -82,65 +63,7 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; - _collectionView.VectorChanged += OnCollectionViewVectorChanged; - - if (SortDescriptions is INotifyCollectionChanged sortDescriptions) - { - sortDescriptions.CollectionChanged += OnSortDescriptionsCollectionChanged; - } - } - - private void OnCollectionViewVectorChanged(IObservableVector sender, IVectorChangedEventArgs args) - { - QueueDisplayedItemsRebuild(); - } - - private bool _isEnsureGroupingSortQueued; - - private void OnSortDescriptionsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - if (_isEnsureGroupingSortQueued || _isUpdatingGroupingSortDescription) - { - return; - } - - _isEnsureGroupingSortQueued = true; - - if (DispatcherQueue is null) - { - _isEnsureGroupingSortQueued = false; - EnsureGroupingSortDescription(); - return; - } - - DispatcherQueue.TryEnqueue(() => - { - _isEnsureGroupingSortQueued = false; - EnsureGroupingSortDescription(); - }); - } - - private void QueueDisplayedItemsRebuild() - { - if (_isDisplayedItemsRebuildQueued) - { - return; - } - - _isDisplayedItemsRebuildQueued = true; - - if (DispatcherQueue is null) - { - _isDisplayedItemsRebuildQueued = false; - RebuildDisplayedItems(); - return; - } - - _ = DispatcherQueue.TryEnqueue(() => - { - _isDisplayedItemsRebuildQueued = false; - RebuildDisplayedItems(); - }); + InitializeGrouping(); } /// @@ -481,38 +404,6 @@ private TableViewCellSlot GetNextSlot(TableViewCellSlot? currentSlot, bool isShi return new TableViewCellSlot(nextRow, nextColumn); } - private int GetNextSelectableRowIndex(int startIndex, int step) - { - if (Items.Count == 0) - { - return -1; - } - - step = step == 0 ? 1 : Math.Sign(step); - var index = Math.Clamp(startIndex, 0, Items.Count - 1); - - for (var count = 0; count < Items.Count; count++) - { - if (IsSelectableItem(Items[index])) - { - return index; - } - - index += step; - - if (index < 0) - { - index = Items.Count - 1; - } - else if (index >= Items.Count) - { - index = 0; - } - } - - return -1; - } - /// /// Copies the selected rows or cells content to the clipboard. /// @@ -791,11 +682,7 @@ private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e) using var defer = _collectionView.DeferRefresh(); EnsureGroupingSortDescription(); - - _groupHeadersByItem.Clear(); - _groupKeysByItem.Clear(); - _groupHeaderItemsByKey.Clear(); - _displayItems.Clear(); + ClearGroupingState(); _collectionView.Source = null!; @@ -808,241 +695,6 @@ private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e) RebuildDisplayedItems(); } - private void EnsureGroupingSortDescription() - { - if (_isUpdatingGroupingSortDescription) - { - return; - } - - var desiredPath = string.IsNullOrWhiteSpace(GroupByPath) ? null : GroupByPath; - - if (_groupSortDescription is not null && desiredPath is not null - && _groupSortDescription.PropertyName == desiredPath - && _groupSortDescription.Direction == GroupSortDirection - && SortDescriptions.IndexOf(_groupSortDescription) == 0) - { - return; - } - - _isUpdatingGroupingSortDescription = true; - - try - { - using var defer = _collectionView.DeferRefresh(); - - if (_groupSortDescription is not null) - { - SortDescriptions.Remove(_groupSortDescription); - _groupSortDescription = null; - } - - if (desiredPath is not null) - { - _groupSortDescription = new SortDescription(desiredPath, GroupSortDirection); - SortDescriptions.Insert(0, _groupSortDescription); - } - } - finally - { - _isUpdatingGroupingSortDescription = false; - } - } - - private void RebuildDisplayedItems() - { - BuildGroupHeadersFromCurrentView(); - - _displayItems.Clear(); - - if (!string.IsNullOrWhiteSpace(GroupByPath) && ShowGroupHeaders) - { - object? previousGroupKey = null; - var hasPreviousGroup = false; - - foreach (var item in _collectionView.OfType()) - { - var groupKey = GetNormalizedGroupKeyForItem(item); - - if (!hasPreviousGroup || !Equals(previousGroupKey, groupKey)) - { - if (_groupHeaderItemsByKey.TryGetValue(groupKey, out var headerItem)) - { - _displayItems.Add(headerItem); - } - - previousGroupKey = groupKey; - hasPreviousGroup = true; - } - - if (!_collapsedGroupKeys.Contains(groupKey)) - { - _displayItems.Add(item); - } - } - - return; - } - - foreach (var item in _collectionView.OfType()) - { - _displayItems.Add(item); - } - } - - private void BuildGroupHeadersFromCurrentView() - { - _groupHeadersByItem.Clear(); - _groupKeysByItem.Clear(); - _groupHeaderItemsByKey.Clear(); - - if (string.IsNullOrWhiteSpace(GroupByPath)) - { - return; - } - - var groupedItems = _collectionView.OfType() - .Select(item => new - { - Item = item, - GroupKey = ResolvePropertyPathValue(item, GroupByPath!) - }) - .ToList(); - - if (groupedItems.Count == 0) - { - return; - } - - var groupStartIndex = 0; - - for (var index = 1; index <= groupedItems.Count; index++) - { - var isBoundary = index == groupedItems.Count - || !Equals(groupedItems[index - 1].GroupKey, groupedItems[index].GroupKey); - - if (!isBoundary) - { - continue; - } - - var startItem = groupedItems[groupStartIndex]; - var count = index - groupStartIndex; - var groupKey = NormalizeGroupKey(startItem.GroupKey); - - for (var groupItemIndex = groupStartIndex; groupItemIndex < index; groupItemIndex++) - { - _groupKeysByItem[groupedItems[groupItemIndex].Item] = groupKey; - } - - if (ShowGroupHeaders) - { - var headerItem = new GroupHeaderRowItem - { - GroupKey = groupKey, - Header = FormatGroupHeader(startItem.GroupKey, count) - }; - - _groupHeaderItemsByKey[groupKey] = headerItem; - _groupKeysByItem[headerItem] = groupKey; - _groupHeadersByItem[headerItem] = headerItem.Header; - } - - groupStartIndex = index; - } - } - - private string FormatGroupHeader(object? groupKey, int count) - { - var title = groupKey?.ToString() ?? "(null)"; - - if (!ShowGroupItemCount) - { - return title; - } - - return $"{title} ({count})"; - } - - private object? ResolvePropertyPathValue(object item, string propertyPath) - { - var key = (item.GetType(), propertyPath); - - if (!_propertyPathAccessorCache.TryGetValue(key, out var accessor)) - { - accessor = item.GetFuncCompiledPropertyPath(propertyPath); - _propertyPathAccessorCache[key] = accessor; - } - - return accessor?.Invoke(item); - } - - internal bool TryGetGroupHeader(object? item, out string header) - { - if (item is not null && _groupHeadersByItem.TryGetValue(item, out var h)) - { - header = h; - return true; - } - - header = string.Empty; - return false; - } - - internal bool IsGroupHeaderItem(object? item) - { - return item is GroupHeaderRowItem; - } - - internal bool IsSelectableItem(object? item) - { - return item is not GroupHeaderRowItem; - } - - internal bool IsGroupExpanded(object? item) - { - if (item is null || !_groupKeysByItem.TryGetValue(item, out var groupKey)) - { - return true; - } - - return !_collapsedGroupKeys.Contains(groupKey); - } - - internal void ToggleGroupExpansion(object? item) - { - if (item is null || !_groupHeadersByItem.ContainsKey(item) || !_groupKeysByItem.TryGetValue(item, out var groupKey)) - { - return; - } - - if (_collapsedGroupKeys.Contains(groupKey)) - { - _collapsedGroupKeys.Remove(groupKey); - } - else - { - _collapsedGroupKeys.Add(groupKey); - } - - RebuildDisplayedItems(); - } - - private object GetNormalizedGroupKeyForItem(object item) - { - if (_groupKeysByItem.TryGetValue(item, out var groupKey)) - { - return groupKey; - } - - return NormalizeGroupKey(ResolvePropertyPathValue(item, GroupByPath!)); - } - - private static object NormalizeGroupKey(object? groupKey) - { - return groupKey ?? NullGroupKey; - } - /// /// Ensures that columns are automatically generated based on the current state of the control. /// From 6d3cd37373c326cc2f085ab539bc69e744a30f8d Mon Sep 17 00:00:00 2001 From: Janvi Mahajan Date: Mon, 13 Apr 2026 12:59:56 +0530 Subject: [PATCH 3/4] Move grouping properties to TableView.Grouping.cs and fix XML docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TableView.Grouping.cs | 107 ++++++++++++++++++++++++++++++++++++ src/TableView.Properties.cs | 102 ---------------------------------- 2 files changed, 107 insertions(+), 102 deletions(-) diff --git a/src/TableView.Grouping.cs b/src/TableView.Grouping.cs index d9dfa3a7..61db1a35 100644 --- a/src/TableView.Grouping.cs +++ b/src/TableView.Grouping.cs @@ -1,3 +1,4 @@ +using Microsoft.UI.Xaml; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -14,6 +15,112 @@ namespace WinUI.TableView; /// public partial class TableView { + #region Grouping Dependency Properties + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty GroupByPathProperty = DependencyProperty.Register(nameof(GroupByPath), typeof(string), typeof(TableView), new PropertyMetadata(null, OnGroupByPathChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ShowGroupHeadersProperty = DependencyProperty.Register(nameof(ShowGroupHeaders), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupHeadersChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty GroupSortDirectionProperty = DependencyProperty.Register(nameof(GroupSortDirection), typeof(SortDirection), typeof(TableView), new PropertyMetadata(SortDirection.Ascending, OnGroupSortDirectionChanged)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ShowGroupItemCountProperty = DependencyProperty.Register(nameof(ShowGroupItemCount), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupItemCountChanged)); + + /// + /// Gets or sets the property path used to group items in the TableView. + /// + public string? GroupByPath + { + get => (string?)GetValue(GroupByPathProperty); + set => SetValue(GroupByPathProperty, value); + } + + /// + /// Gets or sets a value indicating whether group headers are shown. + /// + public bool ShowGroupHeaders + { + get => (bool)GetValue(ShowGroupHeadersProperty); + set => SetValue(ShowGroupHeadersProperty, value); + } + + /// + /// Gets or sets the sorting direction used for grouping. + /// + public SortDirection GroupSortDirection + { + get => (SortDirection)GetValue(GroupSortDirectionProperty); + set => SetValue(GroupSortDirectionProperty, value); + } + + /// + /// Gets or sets a value indicating whether group headers show item counts. + /// + public bool ShowGroupItemCount + { + get => (bool)GetValue(ShowGroupItemCountProperty); + set => SetValue(ShowGroupItemCountProperty, value); + } + + /// + /// Handles changes to the GroupByPath property. + /// + private static void OnGroupByPathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.EnsureGroupingSortDescription(); + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the ShowGroupHeaders property. + /// + private static void OnShowGroupHeadersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the GroupSortDirection property. + /// + private static void OnGroupSortDirectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.EnsureGroupingSortDescription(); + tableView.RebuildDisplayedItems(); + } + } + + /// + /// Handles changes to the ShowGroupItemCount property. + /// + private static void OnShowGroupItemCountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.RebuildDisplayedItems(); + } + } + + #endregion + /// /// Represents a sentinel item inserted into the display list to render a group header row. /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index c6ff8f0c..37beb34a 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -266,26 +266,6 @@ public partial class TableView /// public static readonly DependencyProperty ShowFilterItemsCountProperty = DependencyProperty.Register(nameof(ShowFilterItemsCount), typeof(bool), typeof(TableView), new PropertyMetadata(false)); - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty GroupByPathProperty = DependencyProperty.Register(nameof(GroupByPath), typeof(string), typeof(TableView), new PropertyMetadata(null, OnGroupByPathChanged)); - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty ShowGroupHeadersProperty = DependencyProperty.Register(nameof(ShowGroupHeaders), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupHeadersChanged)); - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty GroupSortDirectionProperty = DependencyProperty.Register(nameof(GroupSortDirection), typeof(SortDirection), typeof(TableView), new PropertyMetadata(SortDirection.Ascending, OnGroupSortDirectionChanged)); - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty ShowGroupItemCountProperty = DependencyProperty.Register(nameof(ShowGroupItemCount), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupItemCountChanged)); - /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. /// @@ -300,42 +280,6 @@ public bool UseRightClickForColumnFilter /// public ICollectionView CollectionView => _collectionView; - /// - /// Gets or sets the property path used to group items in the TableView. - /// - public string? GroupByPath - { - get => (string?)GetValue(GroupByPathProperty); - set => SetValue(GroupByPathProperty, value); - } - - /// - /// Gets or sets a value indicating whether group headers are shown. - /// - public bool ShowGroupHeaders - { - get => (bool)GetValue(ShowGroupHeadersProperty); - set => SetValue(ShowGroupHeadersProperty, value); - } - - /// - /// Gets or sets the sorting direction used for grouping. - /// - public SortDirection GroupSortDirection - { - get => (SortDirection)GetValue(GroupSortDirectionProperty); - set => SetValue(GroupSortDirectionProperty, value); - } - - /// - /// Gets or sets a value indicating whether group headers show item counts. - /// - public bool ShowGroupItemCount - { - get => (bool)GetValue(ShowGroupItemCountProperty); - set => SetValue(ShowGroupItemCountProperty, value); - } - /// /// Gets the collection of sort descriptions applied to the items. /// @@ -1160,52 +1104,6 @@ private static void OnAreRowDetailsFrozen(DependencyObject d, DependencyProperty } } - /// - /// Handles changes to the GroupByPath property. - /// - private static void OnGroupByPathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is TableView tableView) - { - tableView.EnsureGroupingSortDescription(); - tableView.RebuildDisplayedItems(); - } - } - - /// - /// Handles changes to the ShowGroupHeaders property. - /// - private static void OnShowGroupHeadersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is TableView tableView) - { - tableView.RebuildDisplayedItems(); - } - } - - /// - /// Handles changes to the GroupSortDirection property. - /// - private static void OnGroupSortDirectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is TableView tableView) - { - tableView.EnsureGroupingSortDescription(); - tableView.RebuildDisplayedItems(); - } - } - - /// - /// Handles changes to the ShowGroupItemCount property. - /// - private static void OnShowGroupItemCountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is TableView tableView) - { - tableView.RebuildDisplayedItems(); - } - } - /// /// Throws an exception if the base ItemsSource property is set directly. /// From a1649dc6124b83a5bfd7656e6982b74737b32bf8 Mon Sep 17 00:00:00 2001 From: Janvi Mahajan Date: Mon, 13 Apr 2026 20:45:20 +0530 Subject: [PATCH 4/4] Added Grouping page in Sample App --- .../WinUI.TableView.SampleApp/MainPage.xaml | 5 ++ .../MainPage.xaml.cs | 1 + .../Pages/GroupingPage.xaml | 76 +++++++++++++++++++ .../Pages/GroupingPage.xaml.cs | 54 +++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml create mode 100644 samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml.cs diff --git a/samples/WinUI.TableView.SampleApp/MainPage.xaml b/samples/WinUI.TableView.SampleApp/MainPage.xaml index 701278a9..2f0fe4a3 100644 --- a/samples/WinUI.TableView.SampleApp/MainPage.xaml +++ b/samples/WinUI.TableView.SampleApp/MainPage.xaml @@ -139,6 +139,11 @@ + + + + + diff --git a/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs b/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs index ef811570..36b91ac1 100644 --- a/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs +++ b/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs @@ -112,6 +112,7 @@ private void OnNavigationSelectionChanged(NavigationView sender, NavigationViewS "Editing" => typeof(EditingPage), "Sorting" => typeof(SortingPage), "Custom Sorting" => typeof(CustomizeSortingPage), + "Grouping" => typeof(GroupingPage), "Data Export" => typeof(ExportPage), "Large Dataset" => typeof(LargeDataPage), "Conditional Cell Styling" => typeof(ConditionalStylingPage), diff --git a/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml new file mode 100644 index 00000000..38355cb0 --- /dev/null +++ b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +<tv:TableView ItemsSource="{Binding Items}" + GroupByPath="$(GroupByPath)" + ShowGroupHeaders="$(ShowGroupHeaders)" + ShowGroupItemCount="$(ShowGroupItemCount)" + GroupSortDirection="$(GroupSortDirection)"> + + + + + + + + + + + diff --git a/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml.cs b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml.cs new file mode 100644 index 00000000..bbd87786 --- /dev/null +++ b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml.cs @@ -0,0 +1,54 @@ +using Microsoft.UI.Xaml.Controls; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace WinUI.TableView.SampleApp.Pages; + +public sealed partial class GroupingPage : Page, INotifyPropertyChanged +{ + private string? _groupByPath = "Department"; + private SortDirection _groupSortDirection = SortDirection.Ascending; + + public GroupingPage() + { + InitializeComponent(); + } + + public string? GroupByPath + { + get => _groupByPath; + set { _groupByPath = value; OnPropertyChanged(); } + } + + public SortDirection GroupSortDirection + { + get => _groupSortDirection; + set { _groupSortDirection = value; OnPropertyChanged(); } + } + + private void OnGroupBySelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is ComboBox comboBox && comboBox.SelectedItem is ComboBoxItem item) + { + var path = item.Tag?.ToString(); + GroupByPath = string.IsNullOrEmpty(path) ? null : path; + } + } + + private void OnSortDirectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is ComboBox comboBox) + { + GroupSortDirection = comboBox.SelectedIndex == 0 + ? SortDirection.Ascending + : SortDirection.Descending; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +}