From 4f922f2c6f0055f008b2b8865efd980d25f1902a Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Thu, 20 Aug 2026 17:44:46 +0500 Subject: [PATCH 01/16] Add grouping support to TableView for Windows only Introduces Excel-like grouping to TableView for WinUI 3 (Windows only): - Adds GroupDescriptions, CollectionGroups, and group state APIs/events. - Implements TableViewGroupHeaderRow with expand/collapse and indentation. - Adds CanGroup to TableViewColumn and UI commands for grouping/ungrouping. - Updates CollectionView for grouped/filtered/sorted views and state changes. - Updates XAML styles, row alignment, and selection logic for grouping. - All grouping features are conditionally compiled for Windows only. --- src/Collections/ObservableVector.cs | 122 +++++++ src/Columns/TableViewColumn.cs | 14 + src/EventArgs/TableViewGroupingEventArgs.cs | 23 ++ src/ItemsSource/CollectionView.Events.cs | 15 + src/ItemsSource/CollectionView.Properties.cs | 42 ++- src/ItemsSource/CollectionView.cs | 344 ++++++++++++++++--- src/ItemsSource/CollectionViewGroup.cs | 28 ++ src/ItemsSource/ColumnGroupDescription.cs | 39 +++ src/ItemsSource/ColumnSortDescription.cs | 2 +- src/ItemsSource/GroupDescription.cs | 27 ++ src/ItemsSource/SortDescription.cs | 6 +- src/ItemsSource/TableViewGroupInfo.cs | 76 ++++ src/TableView.Events.cs | 16 + src/TableView.Properties.cs | 132 ++++++- src/TableView.cs | 171 ++++++++- src/TableViewColumnHeader.OptionComamnds.cs | 34 +- src/TableViewColumnHeader.cs | 195 ++++++++++- src/TableViewGroupHeaderRow.cs | 132 +++++++ src/TableViewGroupState.cs | 17 + src/TableViewHeaderRow.OptionComamnds.cs | 19 + src/TableViewHeaderRow.cs | 17 + src/TableViewRowPresenter.cs | 31 ++ src/Themes/Generic.xaml | 7 +- src/Themes/Resources.xaml | 18 + src/Themes/TableView.xaml | 3 + src/Themes/TableViewColumnHeader.xaml | 9 +- src/Themes/TableViewGroupHeaderRow.xaml | 106 ++++++ src/Themes/TableViewHeaderRow.xaml | 4 +- src/Themes/TableViewRowPresenter.xaml | 5 + 29 files changed, 1569 insertions(+), 85 deletions(-) create mode 100644 src/Collections/ObservableVector.cs create mode 100644 src/EventArgs/TableViewGroupingEventArgs.cs create mode 100644 src/ItemsSource/CollectionViewGroup.cs create mode 100644 src/ItemsSource/ColumnGroupDescription.cs create mode 100644 src/ItemsSource/GroupDescription.cs create mode 100644 src/ItemsSource/TableViewGroupInfo.cs create mode 100644 src/TableViewGroupHeaderRow.cs create mode 100644 src/TableViewGroupState.cs create mode 100644 src/Themes/TableViewGroupHeaderRow.xaml diff --git a/src/Collections/ObservableVector.cs b/src/Collections/ObservableVector.cs new file mode 100644 index 00000000..edfcf9a9 --- /dev/null +++ b/src/Collections/ObservableVector.cs @@ -0,0 +1,122 @@ +using System.Collections; +using Windows.Foundation.Collections; +using WinUI.TableView.Extensions; + +namespace WinUI.TableView.Collections; + +/// +/// An observable vector implementation for use with ICollectionViewGroup. +/// +internal partial class ObservableVector : IObservableVector +{ + private readonly List _innerItems; + public event VectorChangedEventHandler? VectorChanged; + + /// + /// Initializes a new instance of the class. + /// + public ObservableVector() + { + _innerItems = []; + } + + /// + /// Initializes a new instance of the class with the specified items. + /// + /// The items to initialize the vector with. + public ObservableVector(IEnumerable items) + { + _innerItems = [.. items]; + } + + /// + public T this[int index] + { + get => _innerItems[index]; + set + { + _innerItems[index] = value; + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemChanged, index)); + } + } + + /// + public void Add(T item) + { + _innerItems.Add(item); + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemInserted, _innerItems.Count - 1)); + } + + /// + public void Clear() + { + _innerItems.Clear(); + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.Reset, 0)); + + } + + /// + public bool Contains(T item) + { + return _innerItems.Contains(item); + } + + /// + public void CopyTo(T[] array, int arrayIndex) + { + _innerItems.CopyTo(array, arrayIndex); + } + + /// + public int IndexOf(T item) + { + return _innerItems.IndexOf(item); + } + + /// + public void Insert(int index, T item) + { + _innerItems.Insert(index, item); + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemInserted, index)); + } + + /// + public bool Remove(T item) + { + var index = _innerItems.IndexOf(item); + if (index < 0) return false; + _innerItems.RemoveAt(index); + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemRemoved, index)); + return true; + } + + /// + public void RemoveAt(int index) + { + _innerItems.RemoveAt(index); + VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemRemoved, index)); + } + + /// + public IEnumerator GetEnumerator() + { + return _innerItems.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public int BinarySearch(T item, IComparer? comparer) + { + return _innerItems.BinarySearch(item, comparer); + } + + /// + public int Count => _innerItems.Count; + + /// + public bool IsReadOnly => _innerItems.IsReadOnly(); +} \ No newline at end of file diff --git a/src/Columns/TableViewColumn.cs b/src/Columns/TableViewColumn.cs index c0c75009..8502b152 100644 --- a/src/Columns/TableViewColumn.cs +++ b/src/Columns/TableViewColumn.cs @@ -246,6 +246,15 @@ public TableViewColumnAutoWidthMode? ColumnAutoWidthMode set => SetValue(ColumnAutoWidthModeProperty, value); } + /// + /// Gets or sets a value indicating whether the column can be grouped. + /// + public bool CanGroup + { + get => (bool)GetValue(CanGroupProperty); + set => SetValue(CanGroupProperty, value); + } + /// /// Gets or sets a value indicating whether the column can be resized. /// @@ -629,6 +638,11 @@ public string? SortMemberPath /// public static readonly DependencyProperty ColumnAutoWidthModeProperty = DependencyProperty.Register(nameof(ColumnAutoWidthMode), typeof(TableViewColumnAutoWidthMode?), typeof(TableViewColumn), new PropertyMetadata(null, OnColumnAutoWidthModeChanged)); + /// + /// Identifies the CanGroup dependency property. + /// + public static readonly DependencyProperty CanGroupProperty = DependencyProperty.Register(nameof(CanGroup), typeof(bool), typeof(TableViewColumn), new PropertyMetadata(true)); + /// /// Identifies the CanResize dependency property. /// diff --git a/src/EventArgs/TableViewGroupingEventArgs.cs b/src/EventArgs/TableViewGroupingEventArgs.cs new file mode 100644 index 00000000..67303420 --- /dev/null +++ b/src/EventArgs/TableViewGroupingEventArgs.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; + +namespace WinUI.TableView; + +/// +/// Provides data for the event that is raised when a column is being grouped in a TableView. +/// +public partial class TableViewGroupingEventArgs : HandledEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The column that is being grouped. + public TableViewGroupingEventArgs(TableViewColumn column) + { + Column = column; + } + + /// + /// Gets the column that is being grouped. + /// + public TableViewColumn Column { get; } +} \ No newline at end of file diff --git a/src/ItemsSource/CollectionView.Events.cs b/src/ItemsSource/CollectionView.Events.cs index f5cf0860..dada6345 100644 --- a/src/ItemsSource/CollectionView.Events.cs +++ b/src/ItemsSource/CollectionView.Events.cs @@ -20,6 +20,7 @@ private void OnCurrentChanging(CurrentChangingEventArgs e) } CurrentChanging?.Invoke(this, e); + OnPropertyChanging(nameof(CurrentItem)); } /// @@ -55,6 +56,15 @@ private void OnVectorChanged(IVectorChangedEventArgs e) OnPropertyChanged(nameof(Count)); } + /// + /// Property changed event invoker + /// + /// name of the property that changed + private void OnPropertyChanging([CallerMemberName] string propertyName = null!) + { + PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName)); + } + /// /// Property changed event invoker /// @@ -79,6 +89,11 @@ private void OnPropertyChanged([CallerMemberName] string propertyName = null!) /// public event VectorChangedEventHandler? VectorChanged; + /// + /// Occurs when a property value is changing. + /// + public event PropertyChangingEventHandler? PropertyChanging; + /// /// Occurs when a property value changes. /// diff --git a/src/ItemsSource/CollectionView.Properties.cs b/src/ItemsSource/CollectionView.Properties.cs index ff75986f..47b30c0b 100644 --- a/src/ItemsSource/CollectionView.Properties.cs +++ b/src/ItemsSource/CollectionView.Properties.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using Windows.Foundation.Collections; +using WinUI.TableView.Collections; using WinUI.TableView.Extensions; namespace WinUI.TableView; @@ -20,7 +21,7 @@ public IEnumerable Source DetachCollectionChangedHandlers(field); DetachPropertyChangedHandlers(field); - + OnPropertyChanging(); field = value; AttachCollectionChangedHandlers(field); @@ -59,10 +60,49 @@ public object? this[int index] set => _view[index] = value; } +#if WINDOWS + /// + /// Gets the collection of group descriptions. + /// + public IList GroupDescriptions => _groupDescriptions; + + /// + /// Gets or sets whether a group starts out expanded or collapsed by default, unless it's been explicitly + /// toggled away from that. Changing this re-applies it to every group that hasn't been explicitly toggled. + /// + public TableViewGroupState DefaultGroupState + { + get; + set + { + if (field == value) return; + + field = value; + + if (GroupDescriptions.Count > 0) + { + HandleGroupChanged(); + } + } + } = TableViewGroupState.Collapsed; + /// /// Gets the collection groups. /// + public IObservableVector? CollectionGroups + { + get; + set + { + if (field == value) return; + OnPropertyChanging(); + field = value; + OnPropertyChanged(); + } + } +#else public IObservableVector? CollectionGroups { get; } = null; +#endif /// /// Gets or sets the current item in the view. diff --git a/src/ItemsSource/CollectionView.cs b/src/ItemsSource/CollectionView.cs index 96106468..e98406cc 100644 --- a/src/ItemsSource/CollectionView.cs +++ b/src/ItemsSource/CollectionView.cs @@ -1,13 +1,12 @@ -using Microsoft.UI.Xaml.Data; -using System; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Data; using System.Collections; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; -using System.Linq; using Windows.Foundation; using Windows.Foundation.Collections; +using WinUI.TableView.Collections; using WinUI.TableView.Extensions; namespace WinUI.TableView; @@ -15,12 +14,20 @@ namespace WinUI.TableView; /// /// A collection view implementation that supports filtering, sorting, and incremental loading. /// -internal partial class CollectionView : ICollectionView, ISupportIncrementalLoading, INotifyPropertyChanged, IComparer +internal partial class CollectionView : ICollectionView, ISupportIncrementalLoading, INotifyPropertyChanging, INotifyPropertyChanged, IComparer { private object[] _itemsCopy = []; // In case the source is ICollection, keep a copy of the items to keep track of removed items. private readonly List _view = []; + // The full filtered+sorted set, in source order, regardless of grouping. _view is pruned down to just + // the items currently visible under grouping (every leaf-level, expanded group's items, flattened in + // group order) - the two only ever match when nothing is grouped. + private readonly List _groupingSourceItems = []; private readonly ObservableCollection _filterDescriptions = []; private readonly ObservableCollection _sortDescriptions = []; +#if WINDOWS + private readonly ObservableCollection _groupDescriptions = []; + private readonly Dictionary> _groupExpandedStates = []; +#endif /// /// Initializes a new instance of the class. @@ -31,6 +38,9 @@ public CollectionView(IEnumerable? source = null, bool liveShapingEnabled = true { _filterDescriptions.CollectionChanged += OnFilterDescriptionsCollectionChanged; _sortDescriptions.CollectionChanged += OnSortDescriptionsCollectionChanged; +#if WINDOWS + _groupDescriptions.CollectionChanged += OnGroupDescriptionsCollectionChanged; +#endif AllowLiveShaping = liveShapingEnabled; Source = source ?? new List(); @@ -62,6 +72,38 @@ private void OnSortDescriptionsCollectionChanged(object? sender, NotifyCollectio HandleSortChanged(); } +#if WINDOWS + /// + /// Handles changes to the group descriptions collection. Also drops any recorded expanded/collapsed + /// overrides for a description once it's removed - it no longer groups anything, and re-adding the same + /// column later creates a brand new instance that should start fresh. + /// + private void OnGroupDescriptionsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + CollectionGroups = GroupDescriptions.Count > 0 ? new ObservableVector() : null; + + if (_deferCounter > 0) return; + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + _groupExpandedStates.Clear(); + HandleSourceChanged(); + } + else + { + if (e.OldItems is not null) + { + foreach (var removed in e.OldItems.Cast()) + { + _groupExpandedStates.Remove(removed); + } + } + + HandleGroupChanged(); + } + } +#endif + /// /// Attaches collection changed handlers to the source collection. /// @@ -256,13 +298,13 @@ private void OnItemPropertyChanged(object? item, PropertyChangedEventArgs e) if (FilterDescriptions.Any(fd => string.IsNullOrEmpty(fd.PropertyName) || fd.PropertyName == e.PropertyName)) { var filterResult = FilterDescriptions.All(x => x.Predicate(item)); - var viewIndex = _view.IndexOf(item); + var sourceIndex = _groupingSourceItems.IndexOf(item); - if (viewIndex != -1 && !filterResult) + if (sourceIndex != -1 && !filterResult) { - RemoveFromView(viewIndex, item); + RemoveFromView(sourceIndex, item); } - else if (viewIndex == -1 && filterResult) + else if (sourceIndex == -1 && filterResult) { var index = Source.IndexOf(item); HandleItemAdded(index, item); @@ -271,34 +313,40 @@ private void OnItemPropertyChanged(object? item, PropertyChangedEventArgs e) if (SortDescriptions.Any(sd => string.IsNullOrEmpty(sd.PropertyName) || sd.PropertyName == e.PropertyName)) { - var oldIndex = _view.IndexOf(item); + var oldIndex = _groupingSourceItems.IndexOf(item); - // Check if item is in view: + // Check if item is in the source set: if (oldIndex < 0) { return; } - _view.RemoveAt(oldIndex); - var targetIndex = _view.BinarySearch(item, this); + _groupingSourceItems.RemoveAt(oldIndex); + var targetIndex = _groupingSourceItems.BinarySearch(item, this); if (targetIndex < 0) { targetIndex = ~targetIndex; } + _groupingSourceItems.Insert(targetIndex, item); + +#if WINDOWS + if (GroupDescriptions.Count > 0) + { + HandleGroupChanged(); + return; + } +#endif + // Only trigger expensive UI updates if the index really changed: if (targetIndex != oldIndex) { + _view.RemoveAt(oldIndex); OnVectorChanged(new VectorChangedEventArgs(CollectionChange.ItemRemoved, oldIndex, item)); _view.Insert(targetIndex, item); - OnVectorChanged(new VectorChangedEventArgs(CollectionChange.ItemInserted, targetIndex, item)); } - else - { - _view.Insert(targetIndex, item); - } } else if (string.IsNullOrEmpty(e.PropertyName)) { @@ -312,7 +360,7 @@ private void OnItemPropertyChanged(object? item, PropertyChangedEventArgs e) private void HandleSourceChanged() { var currentItem = CurrentItem; - _view.Clear(); + _groupingSourceItems.Clear(); if (Source is not null) { @@ -321,21 +369,189 @@ private void HandleSourceChanged() foreach (var item in Source) { if (FilterDescriptions.All(x => x.Predicate(item))) - _view.Add(item); + _groupingSourceItems.Add(item); } } else { - _view.AddRange(Source.OfType()); + _groupingSourceItems.AddRange(Source.OfType()); } if (SortDescriptions.Count > 0) - _view.Sort(this); + _groupingSourceItems.Sort(this); + } + +#if WINDOWS + if (GroupDescriptions.Count > 0) + { + CreateGroupCollections(); + } + else + { + RebuildFlatView(); + } +#else + RebuildFlatView(); +#endif + + OnVectorChanged(new VectorChangedEventArgs(CollectionChange.Reset)); + MoveCurrentTo(currentItem); + } + + /// + /// Copies into as-is - the ungrouped case, where + /// nothing is pruned. + /// + private void RebuildFlatView() + { + _view.Clear(); + _view.AddRange(_groupingSourceItems); + } + +#if WINDOWS + + /// + /// Rebuilds - and, alongside it, pruned down to just + /// what's currently visible - from , the full filtered+sorted set. + /// + private void CreateGroupCollections() + { + CollectionGroups?.Clear(); + _view.Clear(); + + if (GroupDescriptions.Count == 0) + { + _view.AddRange(_groupingSourceItems); + return; + } + + BuildGroupLevel(_groupingSourceItems, level: 0, parentPath: []); + } + + /// + /// Groups by the at , + /// adding a flattened entry (in depth-first order) for every group at + /// every level, and recursing into deeper levels until the last is reached. + /// A collapsed group still gets its own header entry, but its descendant subgroups are not built at all, + /// and none of its items are appended to - only a leaf-level, expanded group's items + /// are, in group order, so ends up holding exactly what's currently displayed. + /// + private void BuildGroupLevel(IEnumerable items, int level, object[] parentPath) + { + var description = GroupDescriptions[level]; + var isLeafLevel = level == GroupDescriptions.Count - 1; + var comparer = Comparer.Create(description.Compare); + var groups = description.Direction == SortDirection.Ascending + ? items.GroupBy(description.GetPropertyValue).OrderBy(g => g.Key, comparer) + : items.GroupBy(description.GetPropertyValue).OrderByDescending(g => g.Key, comparer); + + var overridesForDescription = _groupExpandedStates.TryGetValue(description, out var existing) ? existing : null; + + foreach (var group in groups) + { + object[] groupPath = [.. parentPath, group.Key!]; + var isExpanded = overridesForDescription is not null && overridesForDescription.TryGetValue(groupPath, out var overridden) + ? overridden + : DefaultGroupState == TableViewGroupState.Expanded; + var groupedItems = group.ToList(); + // IsExpanded must be set before CollectionView: its DependencyProperty changed callback calls back + // into CollectionView.OnGroupExpandedChanged (which triggers a full group rebuild), and setting it + // here is just restoring already-computed state, not a real toggle - assigning CollectionView first + // would make that callback fire mid-rebuild and recurse into BuildGroupLevel until the stack overflows. + var groupInfo = new TableViewGroupInfo + { + Key = group.Key, + Level = level, + GroupPath = groupPath, + Description = description, + Count = group.Count(), + IsExpanded = isExpanded, + CollectionView = this, + }; + + CollectionGroups?.Add(new CollectionViewGroup + { + Group = groupInfo, + GroupItems = new ObservableVector(isLeafLevel && isExpanded ? groupedItems : []) + }); + + if (isLeafLevel) + { + if (isExpanded) + { + _view.AddRange(groupedItems); + } + + continue; + } + + if (isExpanded) + { + BuildGroupLevel(group, level + 1, groupPath); + } + } + } + + /// + /// Persists a group's expanded/collapsed state (across the fresh instances + /// created on every rebuild) and refreshes the grouped view to reflect it. Only recorded when it differs from + /// - toggling a group back to match the current default clears its override. + /// Scoped under the group's own so it's dropped entirely once that + /// description is removed (see ). + /// + internal void OnGroupExpandedChanged(TableViewGroupInfo info) + { + if (info.Description is not { } description) return; + + if (info.IsExpanded == (DefaultGroupState == TableViewGroupState.Expanded)) + { + if (_groupExpandedStates.TryGetValue(description, out var overrides)) + { + overrides.Remove(info.GroupPath); + } + } + else + { + if (!_groupExpandedStates.TryGetValue(description, out var overrides)) + { + overrides = new Dictionary(ObjectArrayComparer.Instance); + _groupExpandedStates[description] = overrides; + } + + overrides[info.GroupPath] = info.IsExpanded; + } + + HandleGroupChanged(); + } + + sealed class ObjectArrayComparer : IEqualityComparer + { + public static readonly ObjectArrayComparer Instance = new(); + + public bool Equals(object[]? x, object[]? y) + { + return x is not null && y is not null && x.SequenceEqual(y); } + public int GetHashCode(object[] obj) + { + return obj.Aggregate(0, (hash, item) => HashCode.Combine(hash, item)); + } + } + + /// + /// Handles changes to the group descriptions or an individual group's expanded state. + /// + private void HandleGroupChanged() + { + var currentItem = CurrentItem; + + CreateGroupCollections(); + OnVectorChanged(new VectorChangedEventArgs(CollectionChange.Reset)); MoveCurrentTo(currentItem); } +#endif /// /// Handles changes to the filter descriptions. @@ -344,9 +560,9 @@ private void HandleFilterChanged() { if (FilterDescriptions.Count > 0) { - for (var index = 0; index < _view.Count; index++) + for (var index = 0; index < _groupingSourceItems.Count; index++) { - var item = _view.ElementAt(index); + var item = _groupingSourceItems.ElementAt(index); if (FilterDescriptions.All(x => x.Predicate(item))) { continue; @@ -357,20 +573,20 @@ private void HandleFilterChanged() } } - var viewHash = new HashSet(_view); - var viewIndex = 0; + var sourceHash = new HashSet(_groupingSourceItems); + var sourceIndex = 0; var i = 0; foreach (var item in Source) { - if (viewHash.Contains(item)) + if (sourceHash.Contains(item)) { - viewIndex++; + sourceIndex++; continue; } - if (HandleItemAdded(i, item, viewIndex)) + if (HandleItemAdded(i, item, sourceIndex)) { - viewIndex++; + sourceIndex++; } i++; @@ -384,11 +600,22 @@ private void HandleSortChanged() { if (SortDescriptions.Count > 0) { - _view.Sort(this); + _groupingSourceItems.Sort(this); + +#if WINDOWS + if (GroupDescriptions.Count > 0) + { + HandleGroupChanged(); + return; + } +#endif + + RebuildFlatView(); } else { HandleSourceChanged(); + return; } OnVectorChanged(new VectorChangedEventArgs(CollectionChange.Reset)); @@ -397,21 +624,21 @@ private void HandleSortChanged() /// /// Handles the addition of an item to the collection. /// - private bool HandleItemAdded(int newStartingIndex, object? newItem, int? viewIndex = null) + private bool HandleItemAdded(int newStartingIndex, object? newItem, int? sourceItemsIndex = null) { if (!FilterDescriptions.All(x => x.Predicate(newItem))) { return false; } - var newViewIndex = newStartingIndex; + var newIndex = newStartingIndex; if (_sortDescriptions.Any()) { - newViewIndex = _view.BinarySearch(newItem!, this); - if (newViewIndex < 0) + newIndex = _groupingSourceItems.BinarySearch(newItem!, this); + if (newIndex < 0) { - newViewIndex = ~newViewIndex; + newIndex = ~newIndex; } } else if (FilterDescriptions.Any()) @@ -421,16 +648,26 @@ private bool HandleItemAdded(int newStartingIndex, object? newItem, int? viewInd HandleSourceChanged(); return false; } - newViewIndex = viewIndex ?? _view.Take(newStartingIndex).Count(); + newIndex = sourceItemsIndex ?? _groupingSourceItems.Take(newStartingIndex).Count(); + } + + _groupingSourceItems.Insert(newIndex, newItem!); + +#if WINDOWS + if (GroupDescriptions.Count > 0) + { + HandleGroupChanged(); + return true; } +#endif - _view.Insert(newViewIndex, newItem!); - if (newViewIndex <= CurrentPosition) + _view.Insert(newIndex, newItem!); + if (newIndex <= CurrentPosition) { CurrentPosition++; } - var e = new VectorChangedEventArgs(CollectionChange.ItemInserted, newViewIndex, newItem); + var e = new VectorChangedEventArgs(CollectionChange.ItemInserted, newIndex, newItem); OnVectorChanged(e); return true; @@ -446,9 +683,9 @@ private void HandleItemRemoved(int oldStartingIndex, object? oldItem) return; } - if (oldStartingIndex < 0 || oldStartingIndex >= _view.Count || !Equals(_view[oldStartingIndex], oldItem)) + if (oldStartingIndex < 0 || oldStartingIndex >= _groupingSourceItems.Count || !Equals(_groupingSourceItems[oldStartingIndex], oldItem)) { - oldStartingIndex = _view.IndexOf(oldItem!); + oldStartingIndex = _groupingSourceItems.IndexOf(oldItem!); } if (oldStartingIndex < 0) @@ -460,10 +697,21 @@ private void HandleItemRemoved(int oldStartingIndex, object? oldItem) } /// - /// Removes an item from the view. + /// Removes an item from (identified by its index there) and, if it's + /// currently visible, from too. /// private void RemoveFromView(int itemIndex, object? item) { + _groupingSourceItems.RemoveAt(itemIndex); + +#if WINDOWS + if (GroupDescriptions.Count > 0) + { + HandleGroupChanged(); + return; + } +#endif + _view.RemoveAt(itemIndex); if (itemIndex <= CurrentPosition) @@ -708,4 +956,14 @@ public void RefreshSorting() { HandleSortChanged(); } + +#if WINDOWS + /// + /// Refreshes the grouping applied to the view. + /// + public void RefreshGrouping() + { + HandleGroupChanged(); + } +#endif } diff --git a/src/ItemsSource/CollectionViewGroup.cs b/src/ItemsSource/CollectionViewGroup.cs new file mode 100644 index 00000000..32717e30 --- /dev/null +++ b/src/ItemsSource/CollectionViewGroup.cs @@ -0,0 +1,28 @@ +#if WINDOWS +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Windows.Foundation.Collections; +using WinUI.TableView.Collections; + +namespace WinUI.TableView; + +/// +/// A single flattened entry in . For a leaf-level group, +/// holds the actual items; for a non-leaf (parent) group it's always empty, since +/// flattens the group tree depth-first so a plain +/// consumer (like a grouped ListView) can render nested grouping without needing to understand the hierarchy itself. +/// +internal partial class CollectionViewGroup : DependencyObject, ICollectionViewGroup +{ + /// + /// Gets the describing this group (its name, nesting level, item count, + /// and expanded/collapsed state). + /// + public object? Group { get; init; } + + /// + /// Gets the items belonging to this group. Empty for a non-leaf (parent) group or a collapsed group. + /// + public IObservableVector? GroupItems { get; init; } +} +#endif diff --git a/src/ItemsSource/ColumnGroupDescription.cs b/src/ItemsSource/ColumnGroupDescription.cs new file mode 100644 index 00000000..57f4892c --- /dev/null +++ b/src/ItemsSource/ColumnGroupDescription.cs @@ -0,0 +1,39 @@ +namespace WinUI.TableView; + +/// +/// A created for grouping by a from its header's +/// "Group" command - grouping by the column's bound property (or +/// when set) if it has one, otherwise by its rendered cell content. +/// +internal class ColumnGroupDescription : GroupDescription +{ + /// + /// Initializes a new instance of the class. + /// + /// The column being grouped by. + /// The property to group by, or to group by the column's rendered cell content. + /// The direction groups are ordered in. + public ColumnGroupDescription(TableViewColumn column, + string? propertyName, + SortDirection direction = SortDirection.Ascending) + : base(propertyName, direction) + { + Column = column; + } + + /// + public override object? GetPropertyValue(object? item) + { + // Use reflection-based property access when SortMemberPath is explicitly provided; otherwise, fall back to column cell content. + if (!string.IsNullOrEmpty(Column.SortMemberPath)) + { + return base.GetPropertyValue(item); + } + return Column.GetCellContent(item); + } + + /// + /// Gets the column associated with this group description. + /// + public TableViewColumn Column { get; } +} diff --git a/src/ItemsSource/ColumnSortDescription.cs b/src/ItemsSource/ColumnSortDescription.cs index 02692020..f14bc5ff 100644 --- a/src/ItemsSource/ColumnSortDescription.cs +++ b/src/ItemsSource/ColumnSortDescription.cs @@ -13,7 +13,7 @@ internal class ColumnSortDescription : SortDescription /// The direction of the sort. public ColumnSortDescription(TableViewColumn column, string? propertyName, - SortDirection direction) + SortDirection direction = SortDirection.Ascending) : base(propertyName!, direction, null, null) { Column = column; diff --git a/src/ItemsSource/GroupDescription.cs b/src/ItemsSource/GroupDescription.cs new file mode 100644 index 00000000..80743051 --- /dev/null +++ b/src/ItemsSource/GroupDescription.cs @@ -0,0 +1,27 @@ +using System.Collections; + +namespace WinUI.TableView; + +/// +/// Describes a grouping operation applied to TableView items. Extends since +/// grouping and sorting share the same "extract a value, then order/compare by it" shape - a group's own +/// items, and the groups themselves, are ordered using this description's +/// and . +/// +public class GroupDescription : SortDescription +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the property to group by. + /// The direction groups are ordered in. + /// An optional comparer to use for ordering groups. + /// An optional delegate to extract the value to group by. + public GroupDescription(string? propertyName, + SortDirection direction = SortDirection.Ascending, + IComparer? comparer = null, + Func? valueDelegate = null) + : base(propertyName, direction, comparer, valueDelegate) + { + } +} diff --git a/src/ItemsSource/SortDescription.cs b/src/ItemsSource/SortDescription.cs index 3c37dc97..38d8898e 100644 --- a/src/ItemsSource/SortDescription.cs +++ b/src/ItemsSource/SortDescription.cs @@ -21,7 +21,7 @@ public class SortDescription /// An optional comparer to use for sorting. /// An optional delegate to extract the value to sort by. public SortDescription(string? propertyName, - SortDirection direction, + SortDirection direction = SortDirection.Ascending, IComparer? comparer = null, Func? valueDelegate = null) { @@ -71,9 +71,9 @@ public int Compare(object? x, object? y) public string? PropertyName { get; } /// - /// Gets the direction of the sort. + /// Gets or sets the direction of the sort. /// - public SortDirection Direction { get; } + public SortDirection Direction { get; set; } /// /// Gets the comparer to use for sorting. diff --git a/src/ItemsSource/TableViewGroupInfo.cs b/src/ItemsSource/TableViewGroupInfo.cs new file mode 100644 index 00000000..69566a1a --- /dev/null +++ b/src/ItemsSource/TableViewGroupInfo.cs @@ -0,0 +1,76 @@ +#if WINDOWS +using Microsoft.UI.Xaml; +using WinRT; + +namespace WinUI.TableView; + +/// +/// Describes a single group header shown by a - its display name, item +/// count, nesting level, and expanded/collapsed state. This is the object bound as a 's +/// . +/// +[GeneratedBindableCustomProperty] +public partial class TableViewGroupInfo : DependencyObject +{ + + private static void OnIsExpandedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var info = (TableViewGroupInfo)d; + info.CollectionView?.OnGroupExpandedChanged(info); + } + + /// + /// A stable identifier for this group's position in the group tree (level + key, prefixed by every + /// ancestor's own key), used to persist its expanded/collapsed state across rebuilds. + /// + internal object[] GroupPath { get; set; } = []; + + /// + /// The for this group's level, used to scope its persisted expanded/collapsed + /// override to the description instance it belongs to - see . + /// + internal GroupDescription? Description { get; set; } + + /// + /// Gets or sets the that owns this group, used to notify it when the group's + /// expanded/collapsed state changes. + /// + internal CollectionView? CollectionView { get; set; } + + /// + /// Gets the group's key, e.g. the value of the column/property being grouped by. + /// + public object? Key { get; init; } + + /// + /// Gets the number of items in this group (including all descendant subgroups/items, if any). + /// + public int Count { get; init; } + + /// + /// Gets the zero-based nesting level of this group among the active entries. + /// + internal int Level { get; init; } + + /// + /// Gets or sets a value indicating whether this group's items (and, for a non-leaf group, every descendant + /// subgroup/item) are currently shown. + /// + public bool IsExpanded + { + get => (bool)GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty IsExpandedProperty = DependencyProperty.Register(nameof(IsExpanded), typeof(bool), typeof(TableViewGroupInfo), new PropertyMetadata(true, OnIsExpandedChanged)); + + /// + public override string? ToString() + { + return $"{Key}"; + } +} +#endif diff --git a/src/TableView.Events.cs b/src/TableView.Events.cs index aa5892c1..fddd7ad8 100644 --- a/src/TableView.Events.cs +++ b/src/TableView.Events.cs @@ -147,6 +147,22 @@ protected virtual void OnCellContextFlyoutOpening(TableViewCellContextFlyoutEven CellContextFlyoutOpening?.Invoke(this, args); } +#if WINDOWS + /// + /// Occurs when a grouping is being applied to a column in the TableView. + /// + public event EventHandler? Grouping; + + /// + /// Called before the event occurs. + /// + /// Handleable event args. + protected internal virtual void OnGrouping(TableViewGroupingEventArgs args) + { + Grouping?.Invoke(this, args); + } +#endif + /// /// Occurs when a sorting is being applied to a column in the TableView. /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index df16ca3d..e59bd459 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -77,6 +77,33 @@ public partial class TableView /// public static readonly DependencyProperty CornerButtonModeProperty = DependencyProperty.Register(nameof(CornerButtonMode), typeof(TableViewCornerButtonMode), typeof(TableView), new PropertyMetadata(TableViewCornerButtonMode.Options, OnCornerButtonModeChanged)); +#if WINDOWS + /// + /// Identifies the CanGroupColumns dependency property. + /// + public static readonly DependencyProperty CanGroupColumnsProperty = DependencyProperty.Register(nameof(CanGroupColumns), typeof(bool), typeof(TableView), new PropertyMetadata(true)); + + /// + /// Identifies the AreStickyGroupHeadersEnabled dependency property. + /// + public static readonly DependencyProperty AreStickyGroupHeadersEnabledProperty = DependencyProperty.Register(nameof(AreStickyGroupHeadersEnabled), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnAreStickyGroupHeadersEnabledChanged)); + + /// + /// Identifies the DefaultGroupStyle dependency property. + /// + public static readonly DependencyProperty DefaultGroupStyleProperty = DependencyProperty.Register(nameof(DefaultGroupStyle), typeof(GroupStyle), typeof(TableView), new PropertyMetadata(null)); + + /// + /// Identifies the DefaultGroupState dependency property. + /// + public static readonly DependencyProperty DefaultGroupStateProperty = DependencyProperty.Register(nameof(DefaultGroupState), typeof(TableViewGroupState), typeof(TableView), new PropertyMetadata(TableViewGroupState.Collapsed, OnDefaultGroupStateChanged)); + + /// + /// Identifies the ShowGroupExpandCollapseButton dependency property. + /// + public static readonly DependencyProperty ShowGroupExpandCollapseButtonProperty = DependencyProperty.Register(nameof(ShowGroupExpandCollapseButton), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowGroupExpandCollapseButtonChanged)); +#endif + /// /// Identifies the CanResizeColumns dependency property. /// @@ -571,6 +598,66 @@ public TableViewCornerButtonMode CornerButtonMode set => SetValue(CornerButtonModeProperty, value); } +#if WINDOWS + /// + /// Gets a value indicating whether the TableView items are grouped. + /// + public bool IsGrouped => _collectionView.GroupDescriptions.Count > 0; + + /// + /// Gets or sets a value indicating whether columns can be grouped. This will override what is set on individual column. + /// + public bool CanGroupColumns + { + get => (bool)GetValue(CanGroupColumnsProperty); + set => SetValue(CanGroupColumnsProperty, value); + } + + /// + /// Gets or sets a value indicating whether a group header stays pinned to the top of the viewport while + /// scrolling through its items. + /// + public bool AreStickyGroupHeadersEnabled + { + get => (bool)GetValue(AreStickyGroupHeadersEnabledProperty); + set => SetValue(AreStickyGroupHeadersEnabledProperty, value); + } + + /// + /// Gets or sets the default used when the consumer hasn't + /// added one to themselves. Set from the default style; there's normally + /// no need to set this directly. It's a plain property (rather than being applied via GroupStyleSelector) + /// because GroupStyleSelector, once set, always takes priority over + /// regardless of whether the consumer added their own entries, which would make overriding it impossible. + /// + public GroupStyle? DefaultGroupStyle + { + get => (GroupStyle?)GetValue(DefaultGroupStyleProperty); + set => SetValue(DefaultGroupStyleProperty, value); + } + + /// + /// Gets or sets whether a group starts out expanded or collapsed by default, unless it's been explicitly + /// toggled away from that. + /// + public TableViewGroupState DefaultGroupState + { + get => (TableViewGroupState)GetValue(DefaultGroupStateProperty); + set => SetValue(DefaultGroupStateProperty, value); + } + + /// + /// Gets or sets a value indicating whether a group header shows its expand/collapse button. When + /// , groups can still be toggled programmatically (e.g. ) + /// or start collapsed/expanded via - only the button is hidden. + /// + public bool ShowGroupExpandCollapseButton + { + get => (bool)GetValue(ShowGroupExpandCollapseButtonProperty); + set => SetValue(ShowGroupExpandCollapseButtonProperty, value); + } +#endif + /// /// Gets or sets a value indicating whether columns can be resized. This will override what is set on individual column. /// @@ -963,6 +1050,44 @@ private static void OnCornerButtonModeChanged(DependencyObject d, DependencyProp } } +#if WINDOWS + /// + /// Handles changes to the AreStickyGroupHeadersEnabled property. + /// + private static void OnAreStickyGroupHeadersEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView.ApplyStickyGroupHeadersSetting(); + } + } + + /// + /// Handles changes to the DefaultGroupState property. + /// + private static void OnDefaultGroupStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + tableView._collectionView.DefaultGroupState = (TableViewGroupState)e.NewValue; + } + } + + /// + /// Handles changes to the ShowGroupExpandCollapseButton property. + /// + private static void OnShowGroupExpandCollapseButtonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + foreach (var groupHeaderRow in tableView._groupHeaderRows) + { + groupHeaderRow.UpdateExpandCollapseButtonVisibility(); + } + } + } +#endif + /// /// Handles changes to the IsReadOnly property. /// @@ -1253,7 +1378,10 @@ private static void OnAreRowDetailsFrozen(DependencyObject d, DependencyProperty /// private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { - throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); + if (_shouldThrowBaseProeprtyChangedException) + { + throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); + } } /// @@ -1261,7 +1389,7 @@ private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyPropert /// private void OnBaseSelectionModeChanged(DependencyObject sender, DependencyProperty dp) { - if (!_shouldThrowSelectionModeChangedException) + if (_shouldThrowBaseProeprtyChangedException) { throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.SelectionMode instead."); } diff --git a/src/TableView.cs b/src/TableView.cs index f74813df..f58e76b6 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -11,6 +11,7 @@ using System.Text; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; +using Windows.Foundation.Collections; using Windows.Storage; using Windows.Storage.Pickers; using Windows.System; @@ -30,7 +31,7 @@ public partial class TableView : ListView private TableViewHeaderRow? _headerRow; private ScrollViewer? _scrollViewer; private RowDefinition? _headerRowDefinition; - private bool _shouldThrowSelectionModeChangedException; + private bool _shouldThrowBaseProeprtyChangedException = true; private bool _ensureColumns = true; private bool _isItemsSourceSuspended; private readonly List _rows = []; @@ -55,6 +56,10 @@ public partial class TableView : ListView private readonly List _resizingPreviewCells = []; private readonly List _resizingDownstreamCells = []; private readonly List<(Panel Panel, TranslateTransform Shift)> _resizingScrollableShifts = []; + // private bool _isScrollingRowIntoView; +#if WINDOWS + private readonly List _groupHeaderRows = []; +#endif /// /// Initializes a new instance of the TableView class. @@ -77,8 +82,83 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; +#if WINDOWS + _collectionView.VectorChanged += OnCollectionViewVectorChanged; + _collectionView.PropertyChanging += OnCollectionViewPropertyChanging; + _collectionView.PropertyChanged += OnCollectionViewPropertyChanged; + ChoosingGroupHeaderContainer += OnChoosingGroupHeaderContainer; + } + + /// + /// Handles the PropertyChanging event of the CollectionView. + /// + private void OnCollectionViewPropertyChanging(object? sender, PropertyChangingEventArgs e) + { + if (e.PropertyName == nameof(CollectionView.CollectionGroups)) + { + _collectionView.CollectionGroups?.VectorChanged -= OnCollectionViewGroupDescriptionsChanged; + } + } + + /// + /// Handles the PropertyChanged event of the CollectionView. + /// + private async void OnCollectionViewPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CollectionView.CollectionGroups)) + { + _collectionView.CollectionGroups?.VectorChanged += OnCollectionViewGroupDescriptionsChanged; + + _shouldThrowBaseProeprtyChangedException = false; + base.ItemsSource = null; + await Task.Yield(); + base.ItemsSource = _collectionView; + _shouldThrowBaseProeprtyChangedException = true; + } } + /// + /// Handles the VectorChanged event of the CollectionGroups collection in the CollectionView. + /// + private void OnCollectionViewGroupDescriptionsChanged(IObservableVector sender, IVectorChangedEventArgs args) + { + if (args.CollectionChange is CollectionChange.Reset) + { + DeselectAll(); + } + } + + /// + /// Refreshes every currently realized row's group indent on a Reset - covers a regroup/collapse/expand + /// toggle, which changes GroupDescriptions.Count or which rows are visible without necessarily going + /// through PrepareContainerForItemOverride for rows that were already realized. Also re-targets + /// at the same underlying item - toggling any group reshuffles every + /// index after it, so the row a previously-set current cell points at can silently become a different + /// item once other groups expand or collapse around it. + /// + private void OnCollectionViewVectorChanged(IObservableVector sender, IVectorChangedEventArgs args) + { + if (args.CollectionChange != CollectionChange.Reset) return; + + foreach (var row in _rows) + { + row.RowPresenter?.SetGroupIndent(); + } + } + + private void OnChoosingGroupHeaderContainer(ListViewBase sender, ChoosingGroupHeaderContainerEventArgs args) + { + if (args.GroupHeaderContainer is not TableViewGroupHeaderRow) + { + var groupRow = new TableViewGroupHeaderRow { TableView = this }; + _groupHeaderRows.Add(groupRow); + args.GroupHeaderContainer = groupRow; + } + } +#else + } +#endif + /// /// Handles the SelectionChanged event of the TableView control. /// @@ -117,7 +197,12 @@ private void TableView_SelectionChanged(object sender, SelectionChangedEventArgs if (SelectedItems?.Count == 1) { - DispatcherQueue.TryEnqueue(async () => await ScrollRowIntoView(SelectedIndex)); + //// Items.IndexOf(SelectedItem), not SelectedIndex: once grouped, a grouped ListViewBase's own + //// SelectedIndex has been observed resolving against a different (and inconsistent) index space + //// than Items - passing that straight into ScrollRowIntoView's Items[index] lookup could hand it + //// an entirely unrelated item to scroll to. + //var index = Items.IndexOf(SelectedItem); + //DispatcherQueue.TryEnqueue(async () => await ScrollRowIntoView(index)); } } @@ -173,6 +258,7 @@ protected override void PrepareContainerForItemOverride(DependencyObject element } row.RowPresenter?.ApplyDetailsPaneState(item); + row.RowPresenter?.SetGroupIndent(); if (CurrentCellSlot.HasValue) { @@ -433,7 +519,7 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) || originalSource?.FindAscendant() is not null // Skip if the pointer is over a TableViewCell. || originalSource?.FindAscendant() is not null) // Skip if the pointer is over a TableViewRow. { - return; + return; } e.Handled = OnAnyPointerPressed(this, e); @@ -931,10 +1017,8 @@ private void HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool ctrlKey var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1; var column = CurrentCellSlot?.Column ?? -1; - - var numRows = CollectionView.Count; var nextRow = e.Key == VirtualKey.PageDown - ? Math.Min(numRows - 1, row + pageSize) + ? Math.Min(Items.Count - 1, row + pageSize) : Math.Max(0, row - pageSize); var newSlot = new TableViewCellSlot(nextRow, column); @@ -1021,11 +1105,35 @@ protected async override void OnApplyTemplate() while (ItemsPanelRoot is null) await Task.Yield(); EnsureAutoColumns(); +#if WINDOWS + ApplyStickyGroupHeadersSetting(); + } + + // GroupStyle is a get-only collection, so it can only be populated (never Setter-assigned); this default + // only applies when the consumer hasn't already added their own entry. + if (GroupStyle.Count == 0 && DefaultGroupStyle is { } defaultGroupStyle) + { + GroupStyle.Add(defaultGroupStyle); +#endif } SetHeadersVisibility(); } +#if WINDOWS + /// + /// Applies to the underlying , if the + /// current is one. + /// + private void ApplyStickyGroupHeadersSetting() + { + if (ItemsPanelRoot is ItemsStackPanel panel) + { + panel.AreStickyGroupHeadersEnabled = AreStickyGroupHeadersEnabled; + } + } +#endif + /// /// Handles the ViewChanged event of the ScrollViewer control, updating the position of each row when the view changes. /// @@ -1070,6 +1178,9 @@ private void OnLoaded(object sender, RoutedEventArgs e) ResumeItemsSource(); EnsureAutoColumns(); +#if WINDOWS + ApplyStickyGroupHeadersSetting(); +#endif } /// @@ -1658,6 +1769,21 @@ public void ClearAllFilters() FilterHandler.ClearFilter(null); } +#if WINDOWS + /// + /// Removes all grouping applied to the items. + /// + public void UngroupAll() + { + foreach (var column in _collectionView.GroupDescriptions.OfType().Select(x => x.Column)) + { + column.SortDirection = null; + } + + _collectionView.GroupDescriptions.Clear(); + } +#endif + /// /// Refreshes all applied filters. /// @@ -1691,7 +1817,11 @@ public void RefreshFilter() break; case ListViewSelectionMode.Multiple: case ListViewSelectionMode.Extended: - SelectRange(new ItemIndexRange(0, (uint)Items.Count)); + if (Items.Count > 0) + { + SelectRange(new ItemIndexRange(0, (uint)Items.Count)); + } + break; } } @@ -1763,10 +1893,12 @@ private void DeselectAllItems() /// private void DeselectAllCells() { - if (SelectedCellRanges.Count is 0) return; + if (SelectedCellRanges.Count > 0) + { + SelectedCellRanges.Clear(); + OnCellSelectionChanged(); + } - SelectedCellRanges.Clear(); - OnCellSelectionChanged(); CurrentCellSlot = null; } @@ -2002,6 +2134,14 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel return; } + //// This runs from CurrentCellSlot's DependencyProperty changed callback, which fires synchronously + //// as part of CurrentCellSlot's own SetValue call. Without yielding first, everything below - down + //// through ScrollCellIntoView's native ScrollIntoView call - executes re-entrantly on that same + //// native SetValue call stack, which crashes the process outright (an unhandled native exception, + //// not a catchable .NET one). Yielding lets that SetValue call fully unwind before touching XAML's + //// scroll/layout machinery again. + //await Task.Yield(); + if (oldSlot.HasValue) { var cell = GetCellFromSlot(oldSlot.Value); @@ -2601,7 +2741,7 @@ void ViewChanged(object? _, ScrollViewerViewChangedEventArgs e) /// private void UpdateBaseSelectionMode() { - _shouldThrowSelectionModeChangedException = true; + _shouldThrowBaseProeprtyChangedException = false; base.SelectionMode = SelectionUnit is TableViewSelectionUnit.Cell ? ListViewSelectionMode.None : SelectionMode; UpdateHorizontalScrollBarMargin(); @@ -2614,7 +2754,7 @@ private void UpdateBaseSelectionMode() } - _shouldThrowSelectionModeChangedException = false; + _shouldThrowBaseProeprtyChangedException = true; } /// @@ -2628,6 +2768,13 @@ private void EnsureGridLines() { row.RowPresenter?.EnsureGridLines(); } + +#if WINDOWS + foreach (var groupHeaderRow in _groupHeaderRows) + { + groupHeaderRow.EnsureGridLines(); + } +#endif } /// diff --git a/src/TableViewColumnHeader.OptionComamnds.cs b/src/TableViewColumnHeader.OptionComamnds.cs index 99d4f6a3..8e508170 100644 --- a/src/TableViewColumnHeader.OptionComamnds.cs +++ b/src/TableViewColumnHeader.OptionComamnds.cs @@ -7,10 +7,13 @@ namespace WinUI.TableView; partial class TableViewColumnHeader { private bool _commandsInitialized; +#if WINDOWS + private readonly StandardUICommand _groupCommand = new() { Label = "Group" }; +#endif private readonly StandardUICommand _sortAscendingCommand = new() { Label = TableViewLocalizedStrings.SortAscending }; private readonly StandardUICommand _sortDescendingCommand = new() { Label = TableViewLocalizedStrings.SortDescending }; private readonly StandardUICommand _clearSortingCommand = new() { Label = TableViewLocalizedStrings.ClearSorting }; - private readonly StandardUICommand _clearFilterCommand = new() { Label = TableViewLocalizedStrings.ClearFilter }; + private readonly StandardUICommand _clearFilterCommand = new() { Label = TableViewLocalizedStrings.ClearFilter }; /// /// Sets commands to option menu items. @@ -19,6 +22,10 @@ private void SetOptionCommands() { InitializeCommands(); +#if WINDOWS + if (GetTemplateChild("GroupMenuItem") is MenuFlyoutItem groupMenuItem) + groupMenuItem.Command = _groupCommand; +#endif if (GetTemplateChild("SortAscendingMenuItem") is MenuFlyoutItem sortAscendingMenuItem) sortAscendingMenuItem.Command = _sortAscendingCommand; if (GetTemplateChild("SortDescendingMenuItem") is MenuFlyoutItem sortDescendingMenuItem) @@ -39,6 +46,15 @@ private void InitializeCommands() return; } +#if WINDOWS + _groupCommand.ExecuteRequested += delegate { Group(); }; + _groupCommand.CanExecuteRequested += (_, e) => + { + e.CanExecute = CanGroup; + _groupCommand.Label = IsGrouped ? "Ungroup" : "Group"; + }; +#endif + _sortAscendingCommand.ExecuteRequested += delegate { DoSort(SD.Ascending); }; _sortAscendingCommand.CanExecuteRequested += (_, e) => e.CanExecute = CanSort && Column?.SortDirection != SD.Ascending; @@ -46,11 +62,23 @@ private void InitializeCommands() _sortDescendingCommand.CanExecuteRequested += (_, e) => e.CanExecute = CanSort && Column?.SortDirection != SD.Descending; _clearSortingCommand.ExecuteRequested += delegate { ClearSortingWithEvent(); }; - _clearSortingCommand.CanExecuteRequested += (_, e) => e.CanExecute = Column?.SortDirection is not null; + _clearSortingCommand.CanExecuteRequested += (_, e) => + { + // A grouped column with no independent sort left over is driven entirely by its group - + // "clear sorting" isn't offered for it; ungroup instead. Only three-state (asc -> desc -> + // clear) sorting is disabled, not the asc/desc toggle itself (handled by DoSort/SortGroupDescription). + // While a leftover ColumnSortDescription from before grouping is still mirroring the group's + // order, clearing it is allowed and hands ordering fully over to the group description. + var canClear = Column?.SortDirection is not null; +#if WINDOWS + canClear = canClear && (!IsGrouped || HasGroupSortCompanion); +#endif + e.CanExecute = canClear; + }; _clearFilterCommand.ExecuteRequested += delegate { ClearFilter(); }; _clearFilterCommand.CanExecuteRequested += (_, e) => e.CanExecute = Column?.IsFiltered is true; - + _commandsInitialized = true; } } diff --git a/src/TableViewColumnHeader.cs b/src/TableViewColumnHeader.cs index 0ead9bf6..54906a5e 100644 --- a/src/TableViewColumnHeader.cs +++ b/src/TableViewColumnHeader.cs @@ -96,39 +96,168 @@ private void OnRightTapped(object sender, RightTappedRoutedEventArgs e) } } +#if WINDOWS + /// + /// Gets a value indicating whether the column is currently grouped. + /// + private bool IsGrouped => + Column is not null && + _tableView?.CollectionView is CollectionView { } collectionView && + collectionView.GroupDescriptions.Any(x => x is ColumnGroupDescription columnGroup && columnGroup.Column == Column); + + /// + /// Gets a value indicating whether this grouped column still has an independent + /// left over from before it was grouped - i.e. its order is still being mirrored from that description + /// rather than owned outright by its . See + /// and . + /// + private bool HasGroupSortCompanion => + Column is not null && + _tableView?.CollectionView is CollectionView { } collectionView && + collectionView.SortDescriptions.Any(x => x is ColumnSortDescription columnSort && columnSort.Column == Column); + + /// + /// Groups the column, or removes its grouping if it's already grouped. + /// + private void Group() + { + if (!CanGroup || Column is null || _tableView is not { CollectionView: CollectionView { } collectionView }) + { + return; + } + + if (IsGrouped) + { + using var ungroupDefer = collectionView.DeferRefresh(); + collectionView.GroupDescriptions.RemoveWhere(x => x is ColumnGroupDescription columnGroup && columnGroup.Column == Column); + collectionView.SortDescriptions.RemoveWhere(x => x is ColumnSortDescription columnSort && columnSort.Column == Column); + Column.SortDirection = null; + return; + } + + var eventArgs = new TableViewGroupingEventArgs(Column); + _tableView.OnGrouping(eventArgs); + + if (eventArgs.Handled) return; + + var boundColumn = Column as TableViewBoundColumn; + + // Prefer explicit SortMemberPath if provided, otherwise use bound column's property path + var sortPath = Column.SortMemberPath ?? boundColumn?.PropertyPath; + + using var defer = collectionView.DeferRefresh(); + + // If the column already had its own sort applied, keep that ColumnSortDescription in place and + // seed the group with its direction, rather than discarding it and resetting to Ascending - see + // SortGroupDescription/HandOffToGroupDescription for how the two are kept in sync afterwards. + var direction = Column.SortDirection ?? SortDirection.Ascending; + + var groupDescription = new ColumnGroupDescription(Column, sortPath, direction); + collectionView.GroupDescriptions.Add(groupDescription); + Column.SortDirection = direction; + } + + /// + /// Changes the direction driving a grouped column's order. If the column still has an independent + /// left over from before it was grouped (), + /// that description is updated too, keeping it mirrored; otherwise only the + /// itself changes. Either way, the group's order always needs a direction, so this is the only way to + /// change it while grouped - there's no cycling to a cleared/unsorted state. + /// + private void SortGroupDescription(SD direction, CollectionView collectionView) + { + var groupDescription = collectionView.GroupDescriptions + .OfType() + .FirstOrDefault(x => x.Column == Column); + + if (groupDescription is null || groupDescription.Direction == direction) return; + + var sortDescription = collectionView.SortDescriptions + .OfType() + .FirstOrDefault(x => x.Column == Column); + + if (sortDescription is not null) + { + sortDescription.Direction = direction; + } + + groupDescription.Direction = direction; + Column!.SortDirection = direction; + collectionView.RefreshGrouping(); + } + + /// + /// Removes a grouped column's leftover from before it was grouped, + /// once it's cycled to cleared (or "Clear Sorting" is invoked) - handing its order over fully to the + /// that's been mirroring it (see ). + /// Its current direction carries over unchanged, since the group still needs one. + /// + private void HandOffToGroupDescription(CollectionView collectionView) + { + collectionView.SortDescriptions.RemoveWhere(x => x is ColumnSortDescription columnSort && columnSort.Column == Column); + + var groupDescription = collectionView.GroupDescriptions + .OfType() + .FirstOrDefault(x => x.Column == Column); + + if (groupDescription is not null) + { + Column!.SortDirection = groupDescription.Direction; + } + } +#endif + /// /// Sorts the column in the specified direction. /// private void DoSort(SD? direction, bool singleSorting = true) { - if (CanSort && Column is not null && _tableView is { CollectionView: CollectionView { } collectionView }) + if (!CanSort || Column is null || _tableView is not { CollectionView: CollectionView { } collectionView }) { - var eventArgs = new TableViewSortingEventArgs(Column); - _tableView.OnSorting(eventArgs); + return; + } - if (eventArgs.Handled) return; + var eventArgs = new TableViewSortingEventArgs(Column); + _tableView.OnSorting(eventArgs); - using var defer = collectionView.DeferRefresh(); - if (singleSorting) + if (eventArgs.Handled) return; + +#if WINDOWS + if (IsGrouped) + { + if (direction is not null) { - _tableView.ClearAllSortingWithEvent(); + SortGroupDescription(direction.Value, collectionView); } else { - ClearSortingWithEvent(); + HandOffToGroupDescription(collectionView); } - if (direction is not null) - { - var boundColumn = Column as TableViewBoundColumn; - Column.SortDirection = direction; + return; + } +#endif - // Prefer explicit SortMemberPath if provided, otherwise use bound column's property path - var sortPath = Column.SortMemberPath ?? boundColumn?.PropertyPath; + using var defer = collectionView.DeferRefresh(); + if (singleSorting) + { + _tableView.ClearAllSortingWithEvent(); + } + else + { + ClearSortingWithEvent(); + } - _tableView.SortDescriptions.Add( - new ColumnSortDescription(Column!, sortPath, direction.Value)); - } + if (direction is not null) + { + var boundColumn = Column as TableViewBoundColumn; + Column.SortDirection = direction; + + // Prefer explicit SortMemberPath if provided, otherwise use bound column's property path + var sortPath = Column.SortMemberPath ?? boundColumn?.PropertyPath; + + _tableView.SortDescriptions.Add( + new ColumnSortDescription(Column!, sortPath, direction.Value)); } } @@ -149,6 +278,15 @@ private void ClearSortingWithEvent() { using var defer = collectionView.DeferRefresh(); _tableView.DeselectAll(); + +#if WINDOWS + if (IsGrouped) + { + HandOffToGroupDescription(collectionView); + return; + } +#endif + Column.SortDirection = null; collectionView.SortDescriptions.RemoveWhere(x => x is ColumnSortDescription columnSort && columnSort.Column == Column); } @@ -225,8 +363,24 @@ protected override void OnTapped(TappedRoutedEventArgs e) base.OnTapped(e); } + /// + /// Computes the next sort direction for a tap/invoke on this header. A grouped column with no + /// independent of its own () + /// cycles between just and - its order is owned + /// entirely by its group and always needs a direction, so it never lands on the cleared/unsorted state + /// a three-state cycle's third click does. A grouped column that still has a leftover independent sort + /// from before it was grouped keeps three-state cycling until that description is cleared (see + /// ), at which point it falls into the two-state case above. + /// private SD? GetNextSortDirection() { +#if WINDOWS + if (IsGrouped && !HasGroupSortCompanion) + { + return Column?.SortDirection == SD.Ascending ? SD.Descending : SD.Ascending; + } +#endif + return Column?.SortDirection switch { SD.Ascending => SD.Descending, @@ -624,6 +778,13 @@ internal void EnsureGridLines() /// public TableViewColumn? Column { get; internal set; } +#if WINDOWS + /// + /// Gets a value indicating whether the column can be grouped. + /// + private bool CanGroup => _tableView?.CanGroupColumns == true && Column?.CanGroup == true; +#endif + /// /// Gets a value indicating whether the column can be resized. /// diff --git a/src/TableViewGroupHeaderRow.cs b/src/TableViewGroupHeaderRow.cs new file mode 100644 index 00000000..f4c685f6 --- /dev/null +++ b/src/TableViewGroupHeaderRow.cs @@ -0,0 +1,132 @@ +#if WINDOWS +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Shapes; +using Windows.Foundation; + +namespace WinUI.TableView; + +/// +/// Represents a group header row in a TableView control, used to display group information and provide expand/collapse functionality. +/// +public partial class TableViewGroupHeaderRow : ListViewHeaderItem +{ + /// + /// The per-level indent step (in pixels). Shared with so rows line up with the + /// deepest group header they belong to. + /// + internal const double GroupIndentSize = 24d; + + private Button? _expandCollapseButton; + private Border? _indentPlaceholder; + private Rectangle? _h_gridLine; + + /// + /// Initializes a new instance of the TableViewGroupHeaderRow class. + /// + public TableViewGroupHeaderRow() + { + DefaultStyleKey = typeof(TableViewGroupHeaderRow); + + Loaded += OnLoaded; + } + + /// + /// Gets or sets the owning , used to keep this row's grid line and other + /// TableView-driven appearance in sync. + /// + internal TableView? TableView { get; set; } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + _expandCollapseButton = GetTemplateChild("ExpandCollapseButton") as Button; + _expandCollapseButton?.Click -= OnExpandCollapseButtonClick; + _expandCollapseButton?.Click += OnExpandCollapseButtonClick; + _indentPlaceholder = GetTemplateChild("IndentPlaceholder") as Border; + _h_gridLine = GetTemplateChild("HorizontalGridLine") as Rectangle; + + EnsureGridLines(); + UpdateExpandCollapseButtonVisibility(); + + if (Content is TableViewGroupInfo groupInfo) + { + UpdateExpandCollapseVisualState(groupInfo.IsExpanded); + } + } + + /// + /// Registers this row with its owning so it can be refreshed (e.g. grid line + /// appearance) whenever a relevant TableView property changes. + /// + private void OnLoaded(object sender, RoutedEventArgs e) + { + EnsureGridLines(); + UpdateExpandCollapseButtonVisibility(); + } + + /// + /// Applies and + /// to the divider below this row, + /// matching every other horizontal grid line in the control. + /// + internal void EnsureGridLines() + { + if (_h_gridLine is null || TableView is null) return; + + _h_gridLine.Fill = TableView.HorizontalGridLinesStroke; + _h_gridLine.Height = TableView.HorizontalGridLinesStrokeThickness; + } + + /// + /// Shows or hides the expand/collapse button per . + /// The button's layout slot - and with it, this row's level-based indent, applied via the button's own + /// Margin - is kept either way; only its visual/interactivity is toggled, so hiding it doesn't flatten + /// every level's indentation back to zero. + /// + internal void UpdateExpandCollapseButtonVisibility() + { + if (_expandCollapseButton is null) return; + + var showButton = TableView?.ShowGroupExpandCollapseButton != false; + _expandCollapseButton.Opacity = showButton ? 1 : 0; + _expandCollapseButton.IsHitTestVisible = showButton; + } + + /// + /// Handles the Click event of the expand/collapse button, toggling the IsExpanded property of the associated TableViewGroupInfo. + /// + private void OnExpandCollapseButtonClick(object sender, RoutedEventArgs e) + { + if (Content is TableViewGroupInfo groupInfo) + { + groupInfo.IsExpanded = !groupInfo.IsExpanded; + UpdateExpandCollapseVisualState(groupInfo.IsExpanded); + } + } + + /// + protected override void OnContentChanged(object oldContent, object newContent) + { + base.OnContentChanged(oldContent, newContent); + + if (Content is TableViewGroupInfo groupInfo) + { + _indentPlaceholder?.Width = groupInfo.Level * GroupIndentSize; + UpdateExpandCollapseVisualState(groupInfo.IsExpanded); + UpdateExpandCollapseButtonVisibility(); + } + } + + /// + /// Updates the visual state of the group header row based on whether it is expanded or collapsed. + /// + private void UpdateExpandCollapseVisualState(bool isExpanded) + { + VisualStates.GoToState(this, true, isExpanded ? VisualStates.StateExpanded : VisualStates.StateCollapsed); + } +} +#endif diff --git a/src/TableViewGroupState.cs b/src/TableViewGroupState.cs new file mode 100644 index 00000000..a54f2a79 --- /dev/null +++ b/src/TableViewGroupState.cs @@ -0,0 +1,17 @@ +namespace WinUI.TableView; + +/// +/// Specifies whether a newly created group starts out expanded or collapsed. +/// +public enum TableViewGroupState +{ + /// + /// Groups start out expanded. + /// + Expanded, + + /// + /// Groups start out collapsed. + /// + Collapsed +} diff --git a/src/TableViewHeaderRow.OptionComamnds.cs b/src/TableViewHeaderRow.OptionComamnds.cs index 19fcabdb..848e6058 100644 --- a/src/TableViewHeaderRow.OptionComamnds.cs +++ b/src/TableViewHeaderRow.OptionComamnds.cs @@ -17,6 +17,9 @@ partial class TableViewHeaderRow private readonly StandardUICommand _copyWithHeadersCommand = new() { Label = TableViewLocalizedStrings.CopyWithHeaders }; private readonly StandardUICommand _clearSortingCommand = new() { Label = TableViewLocalizedStrings.ClearSorting }; private readonly StandardUICommand _clearFilterCommand = new() { Label = TableViewLocalizedStrings.ClearFilter }; +#if WINDOWS + private readonly StandardUICommand _ungroupAllCommand = new() { Label = "Ungroup All" }; +#endif private readonly StandardUICommand _exportAllToCSVCommand = new() { Label = TableViewLocalizedStrings.ExportAll }; private readonly StandardUICommand _exportSelectedToCSVCommand = new() { Label = TableViewLocalizedStrings.ExportSelected }; @@ -52,6 +55,10 @@ private void SetOptionCommands() clearSortingMenuItem.Command = _clearSortingCommand; if (GetTemplateChild("ClearFilterMenuItem") is MenuFlyoutItem clearFilterMenuItem) clearFilterMenuItem.Command = _clearFilterCommand; +#if WINDOWS + if (GetTemplateChild("UngroupAllMenuItem") is MenuFlyoutItem ungroupAllMenuItem) + ungroupAllMenuItem.Command = _ungroupAllCommand; +#endif if (GetTemplateChild("ExportAllMenuItem") is MenuFlyoutItem exportAllMenuItem) { _exportAllMenuItem = exportAllMenuItem; @@ -100,6 +107,11 @@ private void InitializeCommands() _clearFilterCommand.ExecuteRequested += delegate { TableView?.FilterHandler.ClearFilter(default); }; _clearFilterCommand.CanExecuteRequested += CanExecuteClearFilterCommand; +#if WINDOWS + _ungroupAllCommand.ExecuteRequested += delegate { TableView?.UngroupAll(); }; + _ungroupAllCommand.CanExecuteRequested += CanExecuteUngroupAllCommand; +#endif + _exportAllToCSVCommand.ExecuteRequested += delegate { TableView?.ExportAllToCSV(); }; _exportSelectedToCSVCommand.ExecuteRequested += delegate { TableView?.ExportSelectedToCSV(); }; @@ -156,6 +168,13 @@ private void CanExecuteClearFilterCommand(XamlUICommand sender, CanExecuteReques e.CanExecute = TableView?.IsEditing is false && TableView.IsFiltered; } +#if WINDOWS + private void CanExecuteUngroupAllCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) + { + e.CanExecute = TableView?.IsEditing is false && TableView.IsGrouped; + } +#endif + private void CanExecuteExportSelectedToCSVCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) { e.CanExecute = TableView?.IsEditing is false && (TableView.SelectedItems.Count > 0 || TableView.SelectedCells.Count > 0 || TableView.CurrentCellSlot.HasValue); diff --git a/src/TableViewHeaderRow.cs b/src/TableViewHeaderRow.cs index 475cc2c0..11c76636 100644 --- a/src/TableViewHeaderRow.cs +++ b/src/TableViewHeaderRow.cs @@ -33,6 +33,7 @@ public partial class TableViewHeaderRow : Control private Button? _optionsButton; private Button? _selectAllButton; private CheckBox? _selectAllCheckBox; + private bool _isSyncingSelectAllCheckBox; private Rectangle? _v_gridLine; private Rectangle? _h_gridLine; private StackPanel? _frozenHeadersPanel; @@ -375,6 +376,13 @@ private void OnTableViewSelectionChanged() { if (TableView is not null && _selectAllCheckBox is not null) { + // Setting IsChecked below can itself raise Checked/Unchecked (WinUI doesn't distinguish a + // programmatic set from a user click) - those handlers call back into TableView.SelectAll()/ + // DeselectAll(), which re-enters this same method via the resulting SelectionChanged, and so on + // forever until the stack overflows. _isSyncingSelectAllCheckBox marks this update as our own + // so OnSelectAllCheckBoxChecked/Unchecked can ignore it instead of re-triggering selection. + _isSyncingSelectAllCheckBox = true; + if (TableView.Items.Count == 0) { _selectAllCheckBox.IsChecked = null; @@ -395,6 +403,8 @@ private void OnTableViewSelectionChanged() _selectAllCheckBox.IsChecked = false; _selectAllCheckBox.IsEnabled = true; } + + _isSyncingSelectAllCheckBox = false; } } @@ -475,6 +485,11 @@ private void OnSelectAllButtonClicked(object sender, TappedRoutedEventArgs e) /// private void OnSelectAllCheckBoxChecked(object sender, RoutedEventArgs e) { + // Ignore our own programmatic update from OnTableViewSelectionChanged - only react to a real + // user click, otherwise this and DeselectAll() below recurse into each other via SelectionChanged + // until the stack overflows. + if (_isSyncingSelectAllCheckBox) return; + TableView?.SelectAll(); } @@ -483,6 +498,8 @@ private void OnSelectAllCheckBoxChecked(object sender, RoutedEventArgs e) /// private void OnSelectAllCheckBoxUnchecked(object sender, RoutedEventArgs e) { + if (_isSyncingSelectAllCheckBox) return; + TableView?.DeselectAll(); } diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index ff7e91c8..c1812c17 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -32,6 +32,7 @@ public partial class TableViewRowPresenter : Control private Panel? _detailsPanel; private ContentPresenter? _detailsPresenter; private ToggleButton? _detailsToggleButton; + private Border? _indentPlaceholder; private ListViewItemPresenter? _itemPresenter; private long? _detailsPanelVisibilityCallbackToken; @@ -67,6 +68,7 @@ protected override void OnApplyTemplate() _detailsPanel = GetTemplateChild("DetailsPanel") as Panel; _detailsPresenter = GetTemplateChild("DetailsPresenter") as ContentPresenter; _detailsToggleButton = GetTemplateChild("DetailsToggleButton") as ToggleButton; + _indentPlaceholder = GetTemplateChild("IndentPlaceholder") as Border; _itemPresenter = this.FindAscendant(); TableViewRow = this.FindAscendant(); @@ -91,6 +93,20 @@ protected override void OnApplyTemplate() SetRowHeaderWidth(); SetRowDetailsVisibility(); SetRowDetailsTemplate(); + SetGroupIndent(); + } + + /// + /// Indents this row's cells for the active grouping depth by setting a left margin on + /// - follows automatically since + /// positions it relative to the frozen panel's actual (post-margin) offset. + /// The row header, in its own column, is unaffected. Called once per row preparation (not from + /// ArrangeOverride, which runs on every scroll tick - mutating a child's Margin there would + /// re-invalidate measure every pass and risks a "Layout cycle detected" crash). + /// + internal void SetGroupIndent() + { + _indentPlaceholder?.Width = GetGroupIndent(); } /// @@ -184,6 +200,21 @@ protected override Size ArrangeOverride(Size finalSize) return finalSize; } + /// + /// The extra added for the active grouping depth, so cells (and, + /// via that shared offset, the column headers) shift right under nested group headers. A single grouping + /// level isn't indented - only levels after the first add . + /// + private double GetGroupIndent() + { +#if WINDOWS + var groupLevels = (TableView?.CollectionView as CollectionView)?.GroupDescriptions.Count ?? 0; + return Math.Max(0, groupLevels - 1) * TableViewGroupHeaderRow.GroupIndentSize; +#else + return 0d; +#endif + } + /// /// Sets the DataTemplate for the row header. /// diff --git a/src/Themes/Generic.xaml b/src/Themes/Generic.xaml index 51c789c4..c33f7e44 100644 --- a/src/Themes/Generic.xaml +++ b/src/Themes/Generic.xaml @@ -1,13 +1,15 @@ + xmlns:controls="using:WinUI.TableView.Controls" + xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> + @@ -41,4 +43,7 @@