diff --git a/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml index fcafdbb0..2dcc8349 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml @@ -36,6 +36,10 @@ OnContent="True" OffContent="False" IsOn="{Binding IsReadOnly, Mode=TwoWay, ElementName=tableView}" /> + diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index d90e7d26..0907bd24 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -72,6 +72,11 @@ public partial class TableView /// public static readonly DependencyProperty IsReadOnlyProperty = DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnIsReadOnlyChanged)); + /// + /// Identifies the TapToEdit dependency property. + /// + public static readonly DependencyProperty TapToEditProperty = DependencyProperty.Register(nameof(TapToEdit), typeof(bool), typeof(TableView), new PropertyMetadata(false)); + /// /// Identifies the CornerButtonMode dependency property. /// @@ -485,6 +490,15 @@ public bool IsReadOnly set => SetValue(IsReadOnlyProperty, value); } + /// + /// Gets or sets a value indicating whether tapping an already-selected cell initiates editing. + /// + public bool TapToEdit + { + get => (bool)GetValue(TapToEditProperty); + set => SetValue(TapToEditProperty, value); + } + /// /// Gets or sets the mode of the corner button. /// diff --git a/src/TableView.cs b/src/TableView.cs index e21a6d3e..bcb6e918 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -103,6 +103,11 @@ protected override void PrepareContainerForItemOverride(DependencyObject element { base.PrepareContainerForItemOverride(element, item); + if (element is TableViewRow { } recycledRow) + { + recycledRow.ApplyEditingHighlight(false); + } + DispatcherQueue.TryEnqueue(() => { if (element is TableViewRow row) @@ -111,6 +116,13 @@ protected override void PrepareContainerForItemOverride(DependencyObject element row.ApplyCellsSelectionState(); row.RowPresenter?.ApplyDetailsPaneState(item); + // Reset current cell border on all cells in recycled containers + // to clear stale "Current" visual state from previous use. + foreach (var cell in row.Cells) + { + cell.ApplyCurrentCellState(); + } + if (CurrentCellSlot.HasValue) { row.ApplyCurrentCellState(CurrentCellSlot.Value); @@ -184,7 +196,7 @@ private async Task HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool c do { - newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter); + newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter || (e.Key is VirtualKey.Tab && SelectionUnit is TableViewSelectionUnit.Row)); } while (isEditing && Columns[newSlot.Column].IsReadOnly); diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 758c3b14..6778c2a5 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -190,7 +190,8 @@ protected override void OnPointerEntered(PointerRoutedEventArgs e) if ((TableView?.SelectionMode is not ListViewSelectionMode.None && TableView?.SelectionUnit is not TableViewSelectionUnit.Row) - || !TableView.IsReadOnly) + || !TableView.IsReadOnly + || (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow && !IsReadOnly)) { VisualStates.GoToState(this, false, VisualStates.StatePointerOver); } @@ -203,7 +204,8 @@ protected override void OnPointerExited(PointerRoutedEventArgs e) if ((TableView?.SelectionMode is not ListViewSelectionMode.None && TableView?.SelectionUnit is not TableViewSelectionUnit.Row) - || !TableView.IsReadOnly) + || !TableView.IsReadOnly + || (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow && !IsReadOnly)) { VisualStates.GoToState(this, false, VisualStates.StateNormal); } @@ -224,6 +226,21 @@ protected override async void OnTapped(TappedRoutedEventArgs e) if (e.Handled) return; } + if (TableView?.TapToEdit is true + && TableView.CurrentCellSlot == Slot + && !IsReadOnly + && !TableView.IsEditing + && Column?.UseSingleElement is not true) + { + if (TableView.SelectionUnit is TableViewSelectionUnit.Row) + { + MakeSelection(); + } + + e.Handled = await BeginCellEditing(e); + return; + } + if (TableView?.CurrentCellSlot != Slot || TableView?.LastSelectionUnit is TableViewSelectionUnit.Row) { MakeSelection(); @@ -398,6 +415,11 @@ internal void PrepareForEdit(RoutedEventArgs editingArgs) TableView.UpdateCornerButtonState(); } + if (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow) + { + Row?.ApplyEditingHighlight(true); + } + if (editingElement is { IsHitTestVisible: true }) { _editingArgs = editingArgs; @@ -444,6 +466,12 @@ private void OnEditingElementLoaded(object sender, RoutedEventArgs e) internal void EndEditing(TableViewEditAction editAction) { Column?.EndCellEditing(this, Row?.Content, editAction, _uneditedValue); + + if (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow) + { + Row?.ApplyEditingHighlight(false); + } + SetElement(); } diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index d403a149..80112a0e 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -603,6 +603,14 @@ internal void UpdateSelectCheckMarkOpacity() } } + /// + /// Highlights or unhighlights the row to indicate that a cell is being edited. + /// + internal void ApplyEditingHighlight(bool isEditing) + { + RowPresenter?.ApplyEditingHighlight(isEditing); + } + /// /// Gets the height of the horizontal gridlines. /// diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index 00f382eb..f0a0c8eb 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -33,6 +33,7 @@ public partial class TableViewRowPresenter : Control private ContentPresenter? _detailsPresenter; private ToggleButton? _detailsToggleButton; private ListViewItemPresenter? _itemPresenter; + private Border? _editingHighlightOverlay; /// /// Initializes a new instance of the class. @@ -56,6 +57,7 @@ protected override void OnApplyTemplate() _detailsPanel = GetTemplateChild("DetailsPanel") as Panel; _detailsPresenter = GetTemplateChild("DetailsPresenter") as ContentPresenter; _detailsToggleButton = GetTemplateChild("DetailsToggleButton") as ToggleButton; + _editingHighlightOverlay = GetTemplateChild("EditingHighlightOverlay") as Border; _itemPresenter = this.FindAscendant(); TableViewRow = this.FindAscendant(); @@ -239,6 +241,17 @@ internal void ApplyDetailsPaneState(object? item) } } + /// + /// Toggles the editing highlight overlay visibility. + /// + internal void ApplyEditingHighlight(bool isEditing) + { + if (_editingHighlightOverlay is not null) + { + _editingHighlightOverlay.Visibility = isEditing ? Visibility.Visible : Visibility.Collapsed; + } + } + /// /// Sets the DataTemplate for the row details. /// diff --git a/src/Themes/Resources.xaml b/src/Themes/Resources.xaml index 31d47b4a..9767bae8 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -73,6 +73,7 @@ + @@ -142,6 +143,7 @@ + @@ -211,6 +213,7 @@ + diff --git a/src/Themes/TableViewRowPresenter.xaml b/src/Themes/TableViewRowPresenter.xaml index 65dd7e88..7c43aa7f 100644 --- a/src/Themes/TableViewRowPresenter.xaml +++ b/src/Themes/TableViewRowPresenter.xaml @@ -135,6 +135,11 @@ + + +/// Tests for the row-editing feature: editing highlight via cell lifecycle, +/// TapToEdit property, overlay independence from alternate colors, and virtualization. +/// +[TestClass] +public class TableViewRowEditingTests +{ + [UITestMethod] + public async Task TapToEdit_DefaultsToFalse() + { + var items = CreateTestItems(3); + var tableView = CreateTableView(TableViewSelectionUnit.Cell, items); + await LoadAsync(tableView); + + try + { + Assert.IsFalse(tableView.TapToEdit); + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task Lifecycle_BeginEditing_ShowsHighlight_RowMode() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + await LoadAsync(tableView); + + try + { + var row = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(row); + Assert.IsTrue(row.Cells.Count > 0); + + var cell = row.Cells[0]; + var overlay = row.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + + var started = await cell.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(started); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(Visibility.Visible, overlay.Visibility); + + tableView.EndCellEditing(TableViewEditAction.Cancel, cell); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task Lifecycle_BeginEditing_ShowsHighlight_CellOrRowMode() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.CellOrRow, items); + await LoadAsync(tableView); + + try + { + var row = tableView.ContainerFromIndex(2) as TableViewRow; + Assert.IsNotNull(row); + + var cell = row.Cells[0]; + var overlay = row.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + + var started = await cell.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(started); + Assert.AreEqual(Visibility.Visible, overlay.Visibility); + + tableView.EndCellEditing(TableViewEditAction.Cancel, cell); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task Lifecycle_CellMode_NoHighlight() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Cell, items); + await LoadAsync(tableView); + + try + { + var row = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(row); + + var cell = row.Cells[0]; + var overlay = row.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay); + + var started = await cell.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(started); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + + tableView.EndCellEditing(TableViewEditAction.Cancel, cell); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task AlternateColors_PreservedDuringEditingHighlight() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + var alternateBrush = new SolidColorBrush(Colors.LightGray); + tableView.AlternateRowBackground = alternateBrush; + await LoadAsync(tableView); + + try + { + var oddRow = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(oddRow); + + oddRow.EnsureAlternateColors(); + Assert.AreEqual(alternateBrush, oddRow.RowPresenter?.Background); + + var cell = oddRow.Cells[0]; + var overlay = oddRow.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay); + + var started = await cell.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(started); + Assert.AreEqual(Visibility.Visible, overlay.Visibility); + Assert.AreEqual(alternateBrush, oddRow.RowPresenter?.Background); + + tableView.EndCellEditing(TableViewEditAction.Cancel, cell); + Assert.AreEqual(Visibility.Collapsed, overlay.Visibility); + Assert.AreEqual(alternateBrush, oddRow.RowPresenter?.Background); + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task CellOrRowMode_TapToEdit_Prerequisites() + { + var items = CreateTestItems(3); + var tableView = CreateTableView(TableViewSelectionUnit.CellOrRow, items); + await LoadAsync(tableView); + + try + { + Assert.AreEqual(TableViewSelectionUnit.CellOrRow, tableView.SelectionUnit); + Assert.IsFalse(tableView.TapToEdit); + Assert.IsFalse(tableView.IsReadOnly); + Assert.IsFalse(tableView.IsEditing); + + foreach (var column in tableView.Columns) + { + Assert.IsFalse(column.UseSingleElement); + } + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task Virtualization_CellCurrentState_ResetOnPrepare() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + await LoadAsync(tableView); + + try + { + tableView.CurrentCellSlot = new TableViewCellSlot(1, 0); + tableView.CurrentCellSlot = null; + + var row = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(row); + + foreach (var cell in row.Cells) + { + cell.ApplyCurrentCellState(); + } + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task EditingState_SwitchBetweenRows() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + await LoadAsync(tableView); + + try + { + var row0 = tableView.ContainerFromIndex(0) as TableViewRow; + var row3 = tableView.ContainerFromIndex(3) as TableViewRow; + Assert.IsNotNull(row0); + Assert.IsNotNull(row3); + + var cell0 = row0.Cells[0]; + var overlay0 = row0.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay0); + + await cell0.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(Visibility.Visible, overlay0.Visibility); + + tableView.EndCellEditing(TableViewEditAction.Commit, cell0); + tableView.SetIsEditing(false); + Assert.IsFalse(tableView.IsEditing); + Assert.AreEqual(Visibility.Collapsed, overlay0.Visibility); + + var cell3 = row3.Cells[1]; + var overlay3 = row3.FindDescendant(b => b.Name is "EditingHighlightOverlay"); + Assert.IsNotNull(overlay3); + + await cell3.BeginCellEditing(new RoutedEventArgs()); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(Visibility.Visible, overlay3.Visibility); + Assert.AreEqual(Visibility.Collapsed, overlay0.Visibility); + + tableView.EndCellEditing(TableViewEditAction.Commit, cell3); + tableView.SetIsEditing(false); + Assert.IsFalse(tableView.IsEditing); + Assert.AreEqual(Visibility.Collapsed, overlay3.Visibility); + } + finally + { + await UnloadAsync(tableView); + } + } + + private static TableView CreateTableView( + TableViewSelectionUnit selectionUnit, + ObservableCollection? items = null) + { + var tableView = new TableView + { + AutoGenerateColumns = false, + SelectionMode = ListViewSelectionMode.Single, + SelectionUnit = selectionUnit, + }; + + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath("Name") } + }); + + tableView.Columns.Add(new TableViewNumberColumn + { + Header = "Value", + Binding = new Binding { Path = new PropertyPath("Value") } + }); + + if (items is not null) + { + tableView.ItemsSource = items; + } + + return tableView; + } + + private static ObservableCollection CreateTestItems(int count) + { + var list = new ObservableCollection(); + for (int i = 0; i < count; i++) + { + list.Add(new TestItem { Id = i, Name = $"Item{i}", Value = count - i }); + } + return list; + } + + private static Task LoadAsync(FrameworkElement content) + { + return UnitTestApp.Current.MainWindow.LoadTestContentAsync(content); + } + + private static Task UnloadAsync(FrameworkElement content) + { + return UnitTestApp.Current.MainWindow.UnloadTestContentAsync(content); + } +}