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)); + } +} diff --git a/src/TableView.Grouping.cs b/src/TableView.Grouping.cs new file mode 100644 index 00000000..61db1a35 --- /dev/null +++ b/src/TableView.Grouping.cs @@ -0,0 +1,525 @@ +using Microsoft.UI.Xaml; +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 +{ + #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. + /// + 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 d90e7d26..37beb34a 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -1109,6 +1109,11 @@ private static void OnAreRowDetailsFrozen(DependencyObject d, DependencyProperty /// private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { + if (_isUpdatingBaseItemsSource) + { + return; + } + 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 e21a6d3e..616c653b 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -15,6 +15,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; @@ -34,6 +35,7 @@ public partial class TableView : ListView private ScrollViewer? _scrollViewer; private RowDefinition? _headerRowDefinition; private bool _shouldThrowSelectionModeChangedException; + private bool _isUpdatingBaseItemsSource; private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; @@ -48,7 +50,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 +63,7 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; + InitializeGrouping(); } /// @@ -403,6 +408,8 @@ private TableViewCellSlot GetNextSlot(TableViewCellSlot? currentSlot, bool isShi } } + nextRow = GetNextSelectableRowIndex(nextRow, isShiftKeyDown ? -1 : 1); + return new TableViewCellSlot(nextRow, nextColumn); } @@ -676,17 +683,27 @@ private static TableViewBoundColumn GetTableViewColumnFromType(string? propertyN /// private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e) { + if (!ReferenceEquals(e.OldValue, e.NewValue)) + { + _collapsedGroupKeys.Clear(); + } + DetailsPaneStates.Clear(); using var defer = _collectionView.DeferRefresh(); + + EnsureGroupingSortDescription(); + ClearGroupingState(); + _collectionView.Source = null!; if (e.NewValue is IEnumerable source) { EnsureAutoColumns(); - _collectionView.Source = source; } + + RebuildDisplayedItems(); } /// @@ -967,6 +984,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; @@ -1243,10 +1271,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 758c3b14..b18547bf 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -452,6 +452,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 00f382eb..f97379a8 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -23,6 +23,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; @@ -47,6 +51,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.Tapped += OnDetailsToggleButtonTapped; } + 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) { @@ -249,6 +305,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 65dd7e88..977a940e 100644 --- a/src/Themes/TableViewRowPresenter.xaml +++ b/src/Themes/TableViewRowPresenter.xaml @@ -57,11 +57,36 @@ + + + + + + + + + + + + + + @@ -105,12 +130,7 @@ - - - + Orientation="Horizontal" />