diff --git a/docs/docs/row-headers.md b/docs/docs/row-headers.md index ba917a90..83ef1e21 100644 --- a/docs/docs/row-headers.md +++ b/docs/docs/row-headers.md @@ -40,7 +40,7 @@ Provide a `DataTemplate` via [`RowHeaderTemplate`](xref:WinUI.TableView.TableVie ![Custom row header with icon](../images/row-headers-custom.png) -> **Note:** The `Tag` of the `TableViewRowHeader` is set to the row's 1-based index. Bind to it with `{Binding Tag, RelativeSource={RelativeSource Mode=TemplatedParent}}` for row numbers. +> **Note:** The `Tag` of the `TableViewRowHeader` is always set to the row's number (see [Row numbering](#row-numbering) below), whether or not a custom `RowHeaderTemplate` is set. Bind to it with `{Binding Tag, RelativeSource={RelativeSource Mode=TemplatedParent}}` to incorporate the number into a custom template. ### Selecting a template per row @@ -71,6 +71,25 @@ public class StatusHeaderSelector : DataTemplateSelector ``` +## Row numbering + +Set [`ShowRowNumbers`](xref:WinUI.TableView.TableView.ShowRowNumbers) to `true` to display each row's number at the very start of the row, in its own column ahead of the row header content and the row details expander toggle: + +```xml + +``` + +The row number is a real, stable position among all rows - it does not renumber when a [group](grouping.md) is collapsed and hides other rows, unlike a naively counted display position. It coexists with `RowHeaderTemplate`, `RowHeaderTemplateSelector`, and the row details expander toggle without taking space from any of them. + +Customize its appearance with these theme resources: + +| Resource | Type | Description | +|---|---|---| +| `TableViewRowNumberFontSize` | `double` | Font size of the row number text | +| `TableViewRowNumberForeground` | `Brush` | Foreground brush of the row number text | +| `TableViewRowNumberMargin` | `Thickness` | Margin around the row number text | +| `TableViewRowNumberMinWidth` | `double` | Minimum width of the row number column | + ## Sizing the row header | Property | Default | Description | diff --git a/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml index 83a89be9..0f25129a 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/GroupingPage.xaml @@ -12,7 +12,7 @@ + Description="Group a column via its options button (try Department or Gender), then use the buttons to add, insert, move, and delete items - a way to check those operations keep group headers, counts, and order correct. Groups sort by key by default; toggle 'Sort Groups by Count' below (or the same option in a grouped column's own flyout) to show the biggest group first instead. Turn on ShowRowNumbers and collapse a group to see row numbers stay real instead of renumbering the rows still visible."> + - + diff --git a/src/AutomationPeers/TableViewCellAutomationPeer.cs b/src/AutomationPeers/TableViewCellAutomationPeer.cs index b4e2b7f6..eb54a89b 100644 --- a/src/AutomationPeers/TableViewCellAutomationPeer.cs +++ b/src/AutomationPeers/TableViewCellAutomationPeer.cs @@ -70,8 +70,8 @@ protected override string GetNameCore() ? TableViewRowAutomationPeer.GetColumnHeaderText(col) : string.Empty; - var rowIndex = _owner.Row?.Index ?? -1; - var rowDisplay = rowIndex >= 0 ? $"Row {rowIndex + 1}" : string.Empty; + var rowNumber = _owner.Row?.RowNumber ?? 0; + var rowDisplay = rowNumber > 0 ? $"Row {rowNumber}" : string.Empty; var cellValue = _owner.Column is TableViewTemplateColumn ? string.Empty diff --git a/src/AutomationPeers/TableViewRowAutomationPeer.cs b/src/AutomationPeers/TableViewRowAutomationPeer.cs index 6a0b95f5..8d5b625d 100644 --- a/src/AutomationPeers/TableViewRowAutomationPeer.cs +++ b/src/AutomationPeers/TableViewRowAutomationPeer.cs @@ -43,8 +43,8 @@ protected override string GetNameCore() return name; } - var index = _owner.Index; - return index >= 0 ? $"Row {index + 1}" : "Row"; + var rowNumber = _owner.RowNumber; + return rowNumber > 0 ? $"Row {rowNumber}" : "Row"; } /// diff --git a/src/AutomationPeers/TableViewRowHeaderAutomationPeer.cs b/src/AutomationPeers/TableViewRowHeaderAutomationPeer.cs index aed2b94f..23e1cb10 100644 --- a/src/AutomationPeers/TableViewRowHeaderAutomationPeer.cs +++ b/src/AutomationPeers/TableViewRowHeaderAutomationPeer.cs @@ -75,8 +75,8 @@ protected override string GetNameCore() } } - // Fall back to the row index - var rowIndex = _owner.TableViewRow?.Index ?? -1; - return rowIndex >= 0 ? $"Row {rowIndex + 1}" : base.GetNameCore(); + // Fall back to the row number + var rowNumber = _owner.TableViewRow?.RowNumber ?? 0; + return rowNumber > 0 ? $"Row {rowNumber}" : base.GetNameCore(); } } diff --git a/src/ItemsSource/CollectionView.cs b/src/ItemsSource/CollectionView.cs index 820458e7..6acd2e83 100644 --- a/src/ItemsSource/CollectionView.cs +++ b/src/ItemsSource/CollectionView.cs @@ -811,6 +811,15 @@ public int IndexOf(object? item) return _view.IndexOf(item); } + /// + /// Returns the item's position among - the full filtered+sorted set, + /// in source order - unlike , unaffected by items being hidden by a collapsed group. + /// + internal int IndexOfSourceItem(object? item) + { + return item is null ? -1 : _groupingSourceItems.IndexOf(item); + } + /// /// Inserts an item to the collection at the specified index. /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 2077399d..7a939b22 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -238,6 +238,11 @@ public partial class TableView /// public static readonly DependencyProperty HeadersVisibilityProperty = DependencyProperty.Register(nameof(HeadersVisibility), typeof(TableViewHeadersVisibility), typeof(TableView), new PropertyMetadata(TableViewHeadersVisibility.All, OnRowHeadersVisibilityChanged)); + /// + /// Identifies the ShowRowNumbers dependency property. + /// + public static readonly DependencyProperty ShowRowNumbersProperty = DependencyProperty.Register(nameof(ShowRowNumbers), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnShowRowNumbersChanged)); + /// /// Identifies the RowHeaderContent dependency property. /// @@ -877,6 +882,15 @@ public TableViewHeadersVisibility HeadersVisibility set => SetValue(HeadersVisibilityProperty, value); } + /// + /// Gets or sets a value indicating whether each row shows its number in the row header area. + /// + public bool ShowRowNumbers + { + get => (bool)GetValue(ShowRowNumbersProperty); + set => SetValue(ShowRowNumbersProperty, value); + } + /// /// Gets or sets the data template for the row header. /// @@ -1303,6 +1317,21 @@ private static void OnRowHeadersVisibilityChanged(DependencyObject d, Dependency } } + /// + /// Handles changes to the ShowRowNumbers property. + /// + private static void OnShowRowNumbersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView) + { + foreach (var row in tableView._rows) + { + row.RowPresenter?.SetRowNumberVisibility(); + row.RowPresenter?.SetRowNumber(); + } + } + } + /// /// Handles changes to the RowHeaderTemplate and RowHeaderTemplateSelector properties. /// diff --git a/src/TableView.cs b/src/TableView.cs index 013dcda4..276005e4 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -143,6 +143,7 @@ private void OnCollectionViewVectorChanged(IObservableVector sender, IVe foreach (var row in _rows) { row.RowPresenter?.SetGroupIndent(); + row.RowPresenter?.SetRowNumber(); } } @@ -2811,6 +2812,33 @@ internal void EnsureAlternateRowColors() }); } + /// + /// Gets 's real, stable position among all rows - unlike , + /// unaffected by other rows being hidden by a collapsed ancestor group. Only worth the lookup cost while + /// grouping is actually active; otherwise the display index already is the real index. + /// + internal int GetRealRowIndex(TableViewRow row) + { +#if WINDOWS + if (_collectionView.GroupDescriptions.Count > 0) + { + return _collectionView.IndexOfSourceItem(row.Content); + } +#endif + return row.Index; + } + + /// + /// Refreshes the displayed row number (and ) for every realized row. + /// + internal void RefreshRowNumbers() + { + foreach (var row in _rows) + { + row.RowPresenter?.SetRowNumber(); + } + } + /// /// Resets the auto-calculated widths of the specified columns and recalculates them. /// @@ -2980,6 +3008,7 @@ private void SetHeadersVisibility() foreach (var row in _rows) { row.RowPresenter?.SetRowHeaderVisibility(); + row.RowPresenter?.SetRowNumberVisibility(); } } diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index bc7797cf..b0d501c9 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -162,6 +162,7 @@ protected override void OnContentChanged(object oldContent, object newContent) RowPresenter?.InvalidateMeasure(); // The cells presenter does not measure every time. TableView?.EnsureAlternateRowColors(); + TableView?.RefreshRowNumbers(); } /// @@ -644,6 +645,13 @@ private double GetHorizontalGridlineHeight() /// public int Index => TableView?.IndexFromContainer(this) ?? -1; + /// + /// Gets the row's 1-based number as shown in the UI. When is + /// enabled, this is the row's real, stable position among all rows - unaffected by other rows being + /// hidden by a collapsed group - rather than , which excludes them. + /// + public int RowNumber => TableView is { ShowRowNumbers: true } tableView ? tableView.GetRealRowIndex(this) + 1 : Index + 1; + /// /// Gets or sets the TableView associated with the row. /// diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index c1812c17..d4dcdfd3 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -24,6 +24,7 @@ namespace WinUI.TableView; public partial class TableViewRowPresenter : Control { private TableViewRowHeader? _rowHeader; + private TextBlock? _rowNumberText; private Panel? _rootPanel; private StackPanel? _scrollableCellsPanel; private StackPanel? _frozenCellsPanel; @@ -60,6 +61,7 @@ protected override void OnApplyTemplate() } _rowHeader = GetTemplateChild("RowHeader") as TableViewRowHeader; + _rowNumberText = GetTemplateChild("RowNumberText") as TextBlock; _rootPanel = GetTemplateChild("RootPanel") as Panel; _scrollableCellsPanel = GetTemplateChild("ScrollableCellsPanel") as StackPanel; _frozenCellsPanel = GetTemplateChild("FrozenCellsPanel") as StackPanel; @@ -94,6 +96,8 @@ protected override void OnApplyTemplate() SetRowDetailsVisibility(); SetRowDetailsTemplate(); SetGroupIndent(); + SetRowNumberVisibility(); + SetRowNumber(); } /// @@ -359,6 +363,43 @@ internal void SetRowHeaderVisibility() } } + /// + /// Sets the visibility of the row number based on the setting. + /// Deliberately independent of multi-selection, the details toggle button, and - + /// the row number lives in its own column and must never be suppressed by any of those. + /// + internal void SetRowNumberVisibility() + { + if (_rowNumberText is not null && TableView is not null) + { + var areHeadersVisible = TableView.HeadersVisibility is TableViewHeadersVisibility.All or TableViewHeadersVisibility.Rows; + + _rowNumberText.Visibility = TableView.ShowRowNumbers && areHeadersVisible ? Visibility.Visible : Visibility.Collapsed; + } + } + + /// + /// Sets the displayed row number and - the latter kept up to date + /// regardless of so a custom + /// can bind to it via {RelativeSource Mode=TemplatedParent}. + /// + internal void SetRowNumber() + { + if (TableView is null || TableViewRow is null) return; + + var rowNumber = TableViewRow.RowNumber; + + if (_rowHeader is not null) + { + _rowHeader.Tag = rowNumber; + } + + if (_rowNumberText is not null) + { + _rowNumberText.Text = rowNumber > 0 ? rowNumber.ToString() : string.Empty; + } + } + internal void SetRowHeaderBindings() { _rowHeader?.SetBinding(HeightProperty, new Binding diff --git a/src/Themes/Resources.xaml b/src/Themes/Resources.xaml index 42ae93bc..65f81efd 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -83,8 +83,12 @@ 4,0,4,0 8,0,16,0 18 + + 12 + 8,0,4,0 + 24 - + @@ -161,6 +165,10 @@ 4,0,0,0 8,0,16,0 18 + + 12 + 8,0,4,0 + 24 @@ -239,6 +247,10 @@ 4,0,0,0 8,0,16,0 18 + + 12 + 8,0,4,0 + 24 diff --git a/src/Themes/TableViewRowPresenter.xaml b/src/Themes/TableViewRowPresenter.xaml index 893a35ad..27529a9e 100644 --- a/src/Themes/TableViewRowPresenter.xaml +++ b/src/Themes/TableViewRowPresenter.xaml @@ -78,14 +78,27 @@ + - - + + + + + { + new() { Id = 1, Name = "A", Value = 1 }, + new() { Id = 2, Name = "B", Value = 2 }, + new() { Id = 3, Name = "C", Value = 1 }, + }; + var view = new CollectionView(src); + view.DefaultGroupState = TableViewGroupState.Expanded; + view.GroupDescriptions.Add(GroupByValue()); + + // Before any collapse, source order and grouped/flattened view order coincide for this dataset. + Assert.AreEqual(0, view.IndexOfSourceItem(src[0])); + Assert.AreEqual(1, view.IndexOfSourceItem(src[1])); + Assert.AreEqual(2, view.IndexOfSourceItem(src[2])); + + ToggleGroup((CollectionViewGroup)view.CollectionGroups![0]); // Collapse the Value=1 group (A, C). + + // IndexOf (the pruned/visible view) no longer finds the hidden items, and B shifted to view index 0 - + // but IndexOfSourceItem must still report every item's stable position in the full source set. + Assert.AreEqual(-1, view.IndexOf(src[0])); + Assert.AreEqual(0, view.IndexOf(src[1])); + Assert.AreEqual(0, view.IndexOfSourceItem(src[0])); + Assert.AreEqual(1, view.IndexOfSourceItem(src[1])); + Assert.AreEqual(2, view.IndexOfSourceItem(src[2])); + } + + [UITestMethod] + public void IndexOfSourceItem_Returns_Negative_One_For_Null_Or_Missing_Item() + { + var src = CreateItems(2); + var view = new CollectionView(src); + + Assert.AreEqual(-1, view.IndexOfSourceItem(null)); + Assert.AreEqual(-1, view.IndexOfSourceItem(new TestItem { Id = 99, Name = "Missing", Value = 0 })); + } + [UITestMethod] public void Collapsing_A_Parent_Group_Hides_Descendant_Subgroups_And_Items() { diff --git a/tests/TableViewRowNumberingTests.cs b/tests/TableViewRowNumberingTests.cs new file mode 100644 index 00000000..f73729d9 --- /dev/null +++ b/tests/TableViewRowNumberingTests.cs @@ -0,0 +1,159 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Linq; +using System.Threading.Tasks; +using WinUI.TableView.Extensions; + +namespace WinUI.TableView.Tests; + +/// +/// Covers and , both ungrouped and +/// grouped. is meant to stay the row's real, stable position among all +/// rows once is enabled - unlike , it +/// must not shift just because a collapsed group hid other rows. +/// +[TestClass] +public class TableViewRowNumberingTests +{ + [UITestMethod] + public async Task RowNumber_Ungrouped_MatchesDisplayIndexPlusOne() + { + var tableView = await CreateTableViewAsync(CreateItems()); + + var row = await tableView.ScrollRowIntoView(2); + + Assert.IsNotNull(row); + Assert.AreEqual(3, row!.RowNumber); + } + + [UITestMethod] + public async Task RowNumber_WithShowRowNumbersOff_UsesDisplayIndex_EvenWhileGrouped() + { + var tableView = await CreateTableViewAsync(CreateItems()); + var collectionView = await GroupByCategoryAsync(tableView); + + SetGroupExpanded(collectionView, "A", isExpanded: false); // "B" shifts down to display index 0. + + var row = await tableView.ScrollRowIntoView(0); + + Assert.IsNotNull(row); + Assert.AreEqual("B", ((RowNumberingTestItem)row!.Content!).Category); + Assert.AreEqual(1, row.RowNumber); // Same as Index + 1 - ShowRowNumbers defaults to off. + } + + [UITestMethod] + public async Task RowNumber_WithShowRowNumbersOn_StaysRealAfterACollapseHidesEarlierRows() + { + var tableView = await CreateTableViewAsync(CreateItems()); + tableView.ShowRowNumbers = true; + var collectionView = await GroupByCategoryAsync(tableView); + + SetGroupExpanded(collectionView, "A", isExpanded: false); // Hides rows 0-1 ("A"'s items). + + var row = await tableView.ScrollRowIntoView(0); // Display index 0 is now "B"'s first item. + + Assert.IsNotNull(row); + Assert.AreEqual("B", ((RowNumberingTestItem)row!.Content!).Category); + // "B"'s first item is at index 2 (0-based) in the full, ungrouped source order - its real number + // must reflect that, not the display index (0) a collapsed "A" group shifted it down to. + Assert.AreEqual(3, row.RowNumber); + } + + [UITestMethod] + public async Task ShowRowNumbers_TogglesTheRowNumberTextBlocksVisibilityAndText() + { + var tableView = await CreateTableViewAsync(CreateItems()); + var row = await tableView.ScrollRowIntoView(0); + Assert.IsNotNull(row); + + var rowNumberText = row!.FindDescendant(x => x.Name == "RowNumberText"); + Assert.IsNotNull(rowNumberText); + Assert.AreEqual(Visibility.Collapsed, rowNumberText!.Visibility); + + tableView.ShowRowNumbers = true; + await Task.Yield(); + + Assert.AreEqual(Visibility.Visible, rowNumberText.Visibility); + Assert.AreEqual("1", rowNumberText.Text); + } + + [UITestMethod] + public async Task RowHeaderTag_IsSetToTheRowNumber_RegardlessOfShowRowNumbers() + { + var tableView = await CreateTableViewAsync(CreateItems()); + + var row = await tableView.ScrollRowIntoView(1); + + Assert.IsNotNull(row); + Assert.AreEqual(row!.RowNumber, row.RowPresenter?.RowHeader?.Tag); + } + + private static async Task CreateTableViewAsync(RowNumberingTestItem[] items) + { + var tableView = new TableView(); + + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Category", + Binding = new Binding { Path = new PropertyPath(nameof(RowNumberingTestItem.Category)) } + }); + + tableView.ItemsSource = items; + + await UnitTestApp.Current.MainWindow.LoadTestContentAsync(tableView); + + return tableView; + } + + /// + /// 3 categories ("A", "B", "C"), 2 items each, laid out contiguously (rows 0-1 = A, 2-3 = B, 4-5 = C). + /// + private static RowNumberingTestItem[] CreateItems() + { + var categories = new[] { "A", "B", "C" }; + + return Enumerable.Range(0, 6) + .Select(i => new RowNumberingTestItem { Category = categories[i / 2] }) + .ToArray(); + } + + /// + /// Adding a to an already-loaded makes it + /// asynchronously unbind and rebind the base ItemsSource, so callers must await this before + /// reading - otherwise it's transiently empty. + /// + private static async Task GroupByCategoryAsync(TableView tableView, TableViewGroupState defaultGroupState = TableViewGroupState.Expanded) + { + var collectionView = (CollectionView)tableView.CollectionView; + collectionView.DefaultGroupState = defaultGroupState; + collectionView.GroupDescriptions.Add(new GroupDescription(nameof(RowNumberingTestItem.Category))); + + await Task.Yield(); + + return collectionView; + } + + private static void SetGroupExpanded(CollectionView collectionView, object key, bool isExpanded) + { + foreach (var groupObject in collectionView.CollectionGroups!) + { + var group = (CollectionViewGroup)groupObject; + var info = (TableViewGroupInfo)group.Group!; + if (Equals(info.Key, key)) + { + info.IsExpanded = isExpanded; + return; + } + } + + Assert.Fail($"No group found for key '{key}'."); + } + + private sealed class RowNumberingTestItem + { + public string Category { get; set; } = string.Empty; + } +}