diff --git a/docs/docs/column-sizing.md b/docs/docs/column-sizing.md index 7a81bad1..d76d1e18 100644 --- a/docs/docs/column-sizing.md +++ b/docs/docs/column-sizing.md @@ -94,6 +94,21 @@ Disable resizing for a specific column: ``` +### ColumnResizeMode + +[`ColumnResizeMode`](xref:WinUI.TableView.TableView.ColumnResizeMode) controls what happens to cells *while* the user is dragging a column divider, as distinct from the final committed width: + +| Value | Description | +|---|---| +| [`Live`](xref:WinUI.TableView.TableViewColumnResizeMode.Live) (default) | Every visible row's cells resize for real on every pointer-move frame. Fully accurate at all times, but on grids with many visible rows the drag can feel less smooth. | +| [`Preview`](xref:WinUI.TableView.TableViewColumnResizeMode.Preview) | Cells appear to resize live via a lightweight visual preview; no row layout runs until the drag ends, so the drag stays smooth regardless of row count. The real width is committed in a single layout pass when the pointer is released. | + +```xml + +``` + +Prefer `Preview` if users report a slow or stuttery resize drag on grids with many visible rows; keep the default `Live` if you need cells to reflect the in-progress width at every instant (for example, a template column that reacts to its own width while dragging). + ## Row and header row heights Control row heights with the following `TableView` properties: diff --git a/docs/docs/performance.md b/docs/docs/performance.md index 7a3ef3ff..463fce48 100644 --- a/docs/docs/performance.md +++ b/docs/docs/performance.md @@ -90,6 +90,10 @@ tableView.RefreshFilter(); > **Tip**: Prefer `ObservableCollection` with `INotifyPropertyChanged` models over manual refresh calls whenever possible, as it is more efficient and requires less code. +## Column resize drag performance + +By default, dragging a column divider ([`ColumnResizeMode="Live"`](xref:WinUI.TableView.TableView.ColumnResizeMode)) relayouts every visible row's cells on every pointer-move frame. On grids with many visible rows this can make the drag itself feel less smooth, even though the final committed width is unaffected. Set `ColumnResizeMode="Preview"` to use a lightweight visual preview during the drag instead — no row layout runs until the pointer is released, so the drag stays smooth regardless of row count. See [Column sizing](column-sizing.md#columnresizemode). + ## Horizontal scrolling and column count Unlike rows, columns are not virtualized — all column headers are instantiated regardless of whether they are visible. A very large number of columns (100+) may affect horizontal scroll performance. In practice, most data grids have far fewer columns than rows. diff --git a/samples/WinUI.TableView.SampleApp/Pages/ColumnSizingPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/ColumnSizingPage.xaml index 48ee037e..91d91284 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/ColumnSizingPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/ColumnSizingPage.xaml @@ -26,17 +26,25 @@ Header="Column Auto Width Mode" SelectedItem="{Binding ColumnAutoWidthMode, Mode=TwoWay, ElementName=tableView}" ItemsSource="{ui:EnumValues Type=tv:TableViewColumnAutoWidthMode}" /> + <tv:TableView ItemsSource="{Binding Items}" - ColumnAutoWidthMode="$(ColumnAutoWidthMode)"/> + ColumnAutoWidthMode="$(ColumnAutoWidthMode)" + ColumnResizeMode="$(ColumnResizeMode)"/> + diff --git a/src/Columns/TableViewColumn.cs b/src/Columns/TableViewColumn.cs index 9b1bc830..c0c75009 100644 --- a/src/Columns/TableViewColumn.cs +++ b/src/Columns/TableViewColumn.cs @@ -255,6 +255,12 @@ public bool CanResize set => SetValue(CanResizeProperty, value); } + /// + /// Gets or sets a value indicating whether the column is currently being resized by the user. + /// Used to skip expensive auto-width measurement while a manual pixel resize preview is active. + /// + internal bool IsResizing { get; set; } + /// /// Gets or sets a value indicating whether the column is read-only. /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 05f9697b..df16ca3d 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -226,6 +226,11 @@ public partial class TableView /// public static readonly DependencyProperty ColumnAutoWidthModeProperty = DependencyProperty.Register(nameof(ColumnAutoWidthMode), typeof(TableViewColumnAutoWidthMode), typeof(TableView), new PropertyMetadata(TableViewColumnAutoWidthMode.Both, OnColumnAutoWidthModeChanged)); + /// + /// Identifies the ColumnResizeMode dependency property. + /// + public static readonly DependencyProperty ColumnResizeModeProperty = DependencyProperty.Register(nameof(ColumnResizeMode), typeof(TableViewColumnResizeMode), typeof(TableView), new PropertyMetadata(TableViewColumnResizeMode.Live)); + /// /// Identifies the FrozenColumnCount dependency property. /// @@ -806,6 +811,16 @@ public TableViewColumnAutoWidthMode ColumnAutoWidthMode set => SetValue(ColumnAutoWidthModeProperty, value); } + /// + /// Gets or sets how a column behaves while the user drags to resize it — a real-time resize where + /// every visible row's cell relayouts every frame (default), or a fast composition-only preview. + /// + public TableViewColumnResizeMode ColumnResizeMode + { + get => (TableViewColumnResizeMode)GetValue(ColumnResizeModeProperty); + set => SetValue(ColumnResizeModeProperty, value); + } + /// /// Gets or sets the number of columns that stays in view on horizontal scroll. /// diff --git a/src/TableView.cs b/src/TableView.cs index e5dd9ac3..b981bbb9 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; using System.Collections; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -49,6 +50,11 @@ public partial class TableView : ListView private ItemIndexRange? _lastDragSelectionRowRange; private bool _cellStateDispatchPending; private readonly HashSet _pendingCellStateRows = []; + private TableViewColumn? _resizingColumn; + private double _resizingOriginalWidth; + private readonly List _resizingPreviewCells = []; + private readonly List _resizingDownstreamCells = []; + private readonly List<(Panel Panel, TranslateTransform Shift)> _resizingScrollableShifts = []; /// /// Initializes a new instance of the TableView class. @@ -204,6 +210,206 @@ protected override DependencyObject GetContainerForItemOverride() return row; } + /// + /// Gets a value indicating whether a column is currently being resized by the user via a live + /// drag preview (see ). + /// + internal bool IsColumnResizing { get; private set; } + + /// + /// Starts a live resize-drag preview for : generously (re)measures the + /// column's currently-realized cells once, and creates the per-cell composition-only clip/shift + /// state that will mutate on every subsequent pointer-move + /// frame. No real layout (Width/ActualWidth) is touched here or during the drag — that only + /// happens once, in . + /// + internal void BeginColumnResizePreview(TableViewColumn column) + { + var visibleColumns = Columns.VisibleColumns; + var columnIndex = visibleColumns.IndexOf(column); + + if (columnIndex < 0) + { + return; + } + + _resizingColumn = column; + _resizingOriginalWidth = column.ActualWidth; + IsColumnResizing = true; + column.IsResizing = true; + + var effectiveMax = column.MaxWidth ?? MaxColumnWidth; + if (double.IsPositiveInfinity(effectiveMax)) + { + effectiveMax = 4000d; + } + + _resizingPreviewCells.Clear(); + _resizingDownstreamCells.Clear(); + _resizingScrollableShifts.Clear(); + + foreach (var row in _rows) + { + foreach (var cell in row.Cells) + { + if (cell.Column is null || cell.Column.IsFrozen != column.IsFrozen) + { + continue; + } + + if (cell.Column == column) + { + cell.BeginResizePreview(effectiveMax); + _resizingPreviewCells.Add(cell); + } + else if (visibleColumns.IndexOf(cell.Column) > columnIndex) + { + cell.ApplyDownstreamShift(); + _resizingDownstreamCells.Add(cell); + } + } + + if (column.IsFrozen && row.RowPresenter?.ScrollableCellsPanel is { } scrollablePanel) + { + var shift = new TranslateTransform(); + scrollablePanel.RenderTransform = shift; + _resizingScrollableShifts.Add((scrollablePanel, shift)); + } + } + } + + /// + /// Updates the live resize-drag preview to . The only work done per + /// pointer-move frame: mutate each touched cell's own (not shared — see + /// ) clip/shift in place. No Measure/Arrange, just + /// cheap composition-only property writes, still nowhere near the cost of the real layout cascade + /// this preview mechanism replaces. + /// + internal void UpdateColumnResizePreview(double liveWidth) + { + if (_resizingColumn is null) + { + return; + } + + var delta = liveWidth - _resizingOriginalWidth; + + foreach (var cell in _resizingPreviewCells) + { + cell.UpdateResizePreviewClip(liveWidth, cell.ActualHeight); + cell.UpdateGridLineShift(delta); + } + + foreach (var cell in _resizingDownstreamCells) + { + cell.UpdateDownstreamShift(delta); + } + + foreach (var (_, shift) in _resizingScrollableShifts) + { + shift.X = delta; + } + } + + /// + /// Ends the live resize-drag preview, synchronously: clears every clip/transform the preview + /// touched, then — if is not null — performs the single real width + /// commit ( then ), + /// cascading into a normal, one-time layout pass. Doing this all in one synchronous call (no + /// await/DispatcherQueue in between) means the compositor never presents an intermediate frame — + /// the preview's last shown state and the committed real state are numerically identical. + /// + internal void EndColumnResizePreview(double? commitWidth) + { + if (_resizingColumn is null) + { + return; + } + + var column = _resizingColumn; + _resizingColumn = null; + IsColumnResizing = false; + column.IsResizing = false; + + foreach (var cell in _resizingPreviewCells) + { + cell.EndResizePreview(); + } + + foreach (var cell in _resizingDownstreamCells) + { + cell.EndResizePreview(); + } + + foreach (var (panel, _) in _resizingScrollableShifts) + { + panel.RenderTransform = null; + } + + _resizingPreviewCells.Clear(); + _resizingDownstreamCells.Clear(); + _resizingScrollableShifts.Clear(); + + if (commitWidth is double width) + { + column.ActualWidth = width; + column.Width = new GridLength(width, GridUnitType.Pixel); + } + } + + /// + /// Starts a resize drag for : + /// unlike , no cell state is touched here — every frame's + /// width change goes through the normal, real cascade + /// instead (see ). + /// + internal void BeginColumnResizeLive(TableViewColumn column) + { + _resizingColumn = column; + IsColumnResizing = true; + column.IsResizing = true; + } + + /// + /// Updates a resize drag to + /// by setting directly — every visible row's cell + /// relayouts for real on every call. Deliberately does not touch + /// (which would additionally re-run CalculateHeaderWidths for every column on every frame); + /// that commit happens once, in . + /// + internal void UpdateColumnResizeLive(double liveWidth) + { + if (_resizingColumn is null) + { + return; + } + + _resizingColumn.ActualWidth = liveWidth; + } + + /// + /// Ends a resize drag. + /// already reflects the live width from , so only the + /// GridLength commit is left to do here. + /// + internal void EndColumnResizeLive(double? commitWidth) + { + if (_resizingColumn is null) + { + return; + } + + var column = _resizingColumn; + _resizingColumn = null; + IsColumnResizing = false; + column.IsResizing = false; + + if (commitWidth is double width) + { + column.Width = new GridLength(width, GridUnitType.Pixel); + } + } + /// protected override void OnKeyDown(KeyRoutedEventArgs e) { @@ -2604,7 +2810,8 @@ internal void UpdateHorizontalScrollBarMargin() { if (_scrollViewer is null) return; - var offset = CellsHorizontalOffset + Columns.VisibleColumns.Where(c => c.IsFrozen).Sum(c => c.ActualWidth); + var frozenColumns = Columns.VisibleColumns.Where(c => c.IsFrozen); + var offset = CellsHorizontalOffset + frozenColumns.Sum(c => c.ActualWidth); AttachedPropertiesHelper.SetFrozenColumnScrollBarSpace(_scrollViewer, offset); } } diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index d1955299..8dad3e01 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -27,10 +27,18 @@ public partial class TableViewCell : ContentControl { private ContentPresenter? _contentPresenter; private Border? _selectionBorder; + private Border? _backgroundBorder; + private Border? _rootBorder; private Rectangle? _v_gridLine; private object? _uneditedValue; private RoutedEventArgs? _editingArgs; private IList? _cellStyles; + private bool _resizePreviewActive; + private double _resizePreviewWidth; + private double _resizePreviewMaxWidth; + private RectangleGeometry? _resizeClipGeometry; + private TranslateTransform? _gridLineShiftTransform; + private TranslateTransform? _downstreamShiftTransform; /// /// Initializes a new instance of the TableViewCell class. @@ -87,6 +95,8 @@ protected override void OnApplyTemplate() _contentPresenter = GetTemplateChild("Content") as ContentPresenter; _selectionBorder = GetTemplateChild("SelectionBorder") as Border; + _backgroundBorder = GetTemplateChild("BackgroundBorder") as Border; + _rootBorder = GetTemplateChild("RootBorder") as Border; _v_gridLine = GetTemplateChild("VerticalGridLine") as Rectangle; EnsureGridLines(); @@ -127,30 +137,41 @@ protected override Size MeasureOverride(Size availableSize) return base.MeasureOverride(availableSize); } - #region TEMP_FIX_FOR_ISSUE https://github.com/microsoft/microsoft-ui-xaml/issues/9860 + #region TEMP_FIX_FOR_ISSUE https://github.com/microsoft/microsoft-ui-xaml/issues/9860 element.MaxWidth = double.PositiveInfinity; element.MaxHeight = double.PositiveInfinity; #endregion - element.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - - var autoSizeMode = Column.ColumnAutoWidthMode ?? TableView.ColumnAutoWidthMode; - if (autoSizeMode is TableViewColumnAutoWidthMode.Cells or TableViewColumnAutoWidthMode.Both) + // Skip the unconstrained auto-width measurement while the column is being manually + // resized — it only feeds Column.DesiredWidth, which is irrelevant to a pixel-width drag, + // and this cell doesn't get remeasured on every drag frame anyway (see BeginResizePreview). + if (!Column.IsResizing) { - var desiredWidth = element.DesiredSize.Width; - desiredWidth += Padding.Left; - desiredWidth += Padding.Right; - desiredWidth += BorderThickness.Left; - desiredWidth += BorderThickness.Right; - desiredWidth += _selectionBorder?.BorderThickness.Right ?? 0; - desiredWidth += _selectionBorder?.BorderThickness.Left ?? 0; - desiredWidth += _v_gridLine?.ActualWidth ?? 0d; - - Column.DesiredWidth = Math.Max(Column.DesiredWidth, desiredWidth); + element.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + var autoSizeMode = Column.ColumnAutoWidthMode ?? TableView.ColumnAutoWidthMode; + if (autoSizeMode is TableViewColumnAutoWidthMode.Cells or TableViewColumnAutoWidthMode.Both) + { + var desiredWidth = element.DesiredSize.Width; + desiredWidth += Padding.Left; + desiredWidth += Padding.Right; + desiredWidth += BorderThickness.Left; + desiredWidth += BorderThickness.Right; + desiredWidth += _selectionBorder?.BorderThickness.Right ?? 0; + desiredWidth += _selectionBorder?.BorderThickness.Left ?? 0; + desiredWidth += _v_gridLine?.ActualWidth ?? 0d; + + Column.DesiredWidth = Math.Max(Column.DesiredWidth, desiredWidth); + } } #region TEMP_FIX_FOR_ISSUE https://github.com/microsoft/microsoft-ui-xaml/issues/9860 - var contentWidth = Column.ActualWidth; + // While a resize preview is active, the content was already generously (re)measured once + // in BeginResizePreview and must keep that width so Clip can freely reveal/hide it every + // frame without another Measure pass — using the live Column.ActualWidth here (which is + // intentionally frozen during the drag, see TableView.UpdateColumnResizePreview) would + // re-clamp the content straight back to the pre-drag size. + var contentWidth = _resizePreviewActive ? _resizePreviewWidth : Column.ActualWidth; contentWidth -= element.Margin.Left; contentWidth -= element.Margin.Right; contentWidth -= Padding.Left; @@ -189,6 +210,170 @@ protected override Size MeasureOverride(Size availableSize) return base.MeasureOverride(availableSize); } + /// + protected override Size ArrangeOverride(Size finalSize) + { + finalSize = base.ArrangeOverride(finalSize); + + // During a resize-drag preview, manually re-arrange the overlapping template borders wider + // than the Grid's own column-based sizing would give them (the Grid still thinks this cell is + // its pre-drag width, since Width itself is left untouched for the whole drag) — this is what + // lets the generously-premeasured content in BeginResizePreview actually render past the old + // boundary; Clip then reveals/hides it every frame. Same "arrange a child beyond what the + // framework gave it" technique already used in TableViewRow.ArrangeOverride for _itemPresenter. + if (_resizePreviewActive) + { + var bordersRect = new Rect(0, 0, _resizePreviewMaxWidth, finalSize.Height); + _backgroundBorder?.Arrange(bordersRect); + _selectionBorder?.Arrange(bordersRect); + _rootBorder?.Arrange(new Rect(0, 0, _resizePreviewWidth, finalSize.Height)); + } + + return finalSize; + } + + /// + /// Begins a live resize-drag preview for this cell: generously (re)measures its content once so + /// widening can freely reveal more of it, and creates this cell's own geometry + /// and gridline shift transform. These are per-cell instances (not shared across cells — WinUI + /// throws if the same is assigned as on more + /// than one element at a time), mutated in place every frame by + /// / — still no Measure/Arrange + /// per frame, just not a single shared instance across every row. + /// + internal void BeginResizePreview(double maxPreviewWidth) + { + _resizePreviewWidth = ActualWidth; + + if (Content is FrameworkElement element) + { + if (Column is TableViewTemplateColumn) + { +#if WINDOWS + if (element is ContentControl { ContentTemplateRoot: FrameworkElement root }) +#else + if (element.FindDescendant() is { ContentTemplateRoot: FrameworkElement root }) +#endif + element = root; + else + element = null!; + } + + if (element is not null) + { + element.MaxWidth = maxPreviewWidth; + element.MaxHeight = double.PositiveInfinity; + element.Measure(new Size(maxPreviewWidth, double.PositiveInfinity)); + + var desiredWidth = element.DesiredSize.Width; + desiredWidth += element.Margin.Left; + desiredWidth += element.Margin.Right; + desiredWidth += Padding.Left; + desiredWidth += Padding.Right; + desiredWidth += BorderThickness.Left; + desiredWidth += BorderThickness.Right; + desiredWidth += _selectionBorder?.BorderThickness.Left ?? 0; + desiredWidth += _selectionBorder?.BorderThickness.Right ?? 0; + desiredWidth += _v_gridLine?.ActualWidth ?? 0d; + + _resizePreviewWidth = Math.Min(maxPreviewWidth, Math.Max(ActualWidth, desiredWidth)); + } + } + + _resizePreviewActive = true; + _resizePreviewMaxWidth = maxPreviewWidth; + + _resizeClipGeometry = new RectangleGeometry { Rect = ComputeClipRect(ActualWidth, ActualHeight) }; + Clip = _resizeClipGeometry; + + if (_v_gridLine is not null) + { + _gridLineShiftTransform = new TranslateTransform(); + _v_gridLine.RenderTransform = _gridLineShiftTransform; + } + + InvalidateArrange(); + } + + /// + /// Shifts this cell sideways to visually make room for the column being resized, without any + /// real layout — creates this cell's own , mutated in place every + /// frame by . + /// + internal void ApplyDownstreamShift() + { + _downstreamShiftTransform = new TranslateTransform(); + RenderTransform = _downstreamShiftTransform; + } + + /// + /// Updates this resize-preview cell's clip to the given live drag width. No-op if this cell + /// isn't the one being resized (i.e. was never called on it). + /// + internal void UpdateResizePreviewClip(double liveWidth, double height) + { + if (_resizeClipGeometry is not null) + { + _resizeClipGeometry.Rect = ComputeClipRect(liveWidth, height); + } + } + + /// + /// Shifts this resize-preview cell's own gridline to track the live drag boundary. No-op if this + /// cell isn't the one being resized. + /// + internal void UpdateGridLineShift(double deltaX) + { + if (_gridLineShiftTransform is not null) + { + _gridLineShiftTransform.X = deltaX; + } + } + + /// + /// Updates this downstream cell's shift to the given delta. No-op if + /// was never called on this cell. + /// + internal void UpdateDownstreamShift(double deltaX) + { + if (_downstreamShiftTransform is not null) + { + _downstreamShiftTransform.X = deltaX; + } + } + + /// + /// Ends a resize-drag preview started by or + /// , reverting this cell to normal layout-driven sizing. + /// + internal void EndResizePreview() + { + _resizePreviewActive = false; + _resizePreviewMaxWidth = 0d; + Clip = null; + RenderTransform = null; + _resizeClipGeometry = null; + _gridLineShiftTransform = null; + _downstreamShiftTransform = null; + + if (_v_gridLine is not null) + { + _v_gridLine.RenderTransform = null; + } + + InvalidateMeasure(); + InvalidateArrange(); + } + + /// + /// Computes the clip rect that reveals/hides a resize-preview cell's content for a given live + /// drag width. Pure function — no side effects — so it's directly unit-testable. + /// + internal static Rect ComputeClipRect(double liveWidth, double height) + { + return new Rect(0, 0, Math.Max(0, liveWidth), Math.Max(0, height)); + } + /// protected override void OnPointerEntered(PointerRoutedEventArgs e) { diff --git a/src/TableViewColumnHeader.cs b/src/TableViewColumnHeader.cs index d03562f6..7365d032 100644 --- a/src/TableViewColumnHeader.cs +++ b/src/TableViewColumnHeader.cs @@ -40,7 +40,12 @@ public partial class TableViewColumnHeader : ContentControl private Rectangle? _v_gridLine; private bool _resizeStarted; private double _resizeStartingWidth; + private double _resizeStartPointerX; + private bool _resizeWidthChanged; private bool _resizePreviousStarted; + private TableViewColumn? _resizingColumn; + private TableViewColumnHeader? _resizeTargetHeader; + private TableViewColumnResizeMode _activeResizeMode; private double _reorderStartingPosition; private bool _reorderStarted; private RenderTargetBitmap? _dragVisuals; @@ -61,7 +66,12 @@ public TableViewColumnHeader() /// private void OnWidthChanged(DependencyObject sender, DependencyProperty dp) { - if (!double.IsNaN(Width)) + // While a resize-drag preview is active for this column, Width tracks the pointer live on + // just this one header element (cheap), but must NOT cascade into Column.ActualWidth — that + // would push a width change into every row's cell on every frame, which is exactly what the + // preview mechanism (TableView.Begin/Update/EndColumnResizePreview) exists to avoid. The real + // commit happens once, in CommitResize, when the drag ends. + if (!double.IsNaN(Width) && Column?.IsResizing != true) { Column?.ActualWidth = Width; } @@ -362,6 +372,33 @@ protected override void OnPointerMoved(PointerRoutedEventArgs e) { base.OnPointerMoved(e); + if ((_resizeStarted || _resizePreviousStarted) && _resizingColumn is not null + && _resizeTargetHeader is not null && _tableView is not null) + { + var delta = e.GetCurrentPoint(_headerRow).Position.X - _resizeStartPointerX; + var minWidth = _resizingColumn.MinWidth ?? _tableView.MinColumnWidth; + var maxWidth = _resizingColumn.MaxWidth ?? _tableView.MaxColumnWidth; + var width = ClampWidth(_resizeStartingWidth + delta, minWidth, maxWidth); + + _resizeTargetHeader.Width = width; + _resizeWidthChanged = true; + + // Explicitly re-assert the resize cursor on every move — without this, something else + // (layout invalidation elsewhere, a hover state change) can reset it mid-drag. + ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.SizeWestEast); + + if (_activeResizeMode == TableViewColumnResizeMode.Preview) + { + _tableView.UpdateColumnResizePreview(width); + } + else + { + _tableView.UpdateColumnResizeLive(width); + } + + return; + } + if (CanResize && IsCursorInRightResizeArea(e) && !_reorderStarted) { ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.SizeWestEast); @@ -376,21 +413,51 @@ protected override void OnPointerMoved(PointerRoutedEventArgs e) } } + /// + /// Clamps a candidate column width to the given bounds. Internal (not private) so it's directly + /// unit-testable as a pure function. + /// + internal static double ClampWidth(double width, double minWidth, double maxWidth) + { + if (width < minWidth) + { + return minWidth; + } + + if (width > maxWidth) + { + return maxWidth; + } + + return width; + } + /// protected override async void OnPointerPressed(PointerRoutedEventArgs e) { base.OnPointerPressed(e); - if (IsSizingCursor && CanResize && IsCursorInRightResizeArea(e)) + if (IsSizingCursor && CanResize && IsCursorInRightResizeArea(e) && Column is not null && _tableView is not null) { _resizeStarted = true; + _resizingColumn = Column; + _resizeTargetHeader = this; _resizeStartingWidth = ActualWidth; + _resizeStartPointerX = e.GetCurrentPoint(_headerRow).Position.X; + _activeResizeMode = _tableView.ColumnResizeMode; + BeginResize(Column); CapturePointer(e.Pointer); } - else if (IsSizingCursor && IsCursorInLeftResizeArea(e) && _headerRow?.GetPreviousHeader(this) is { Column: { } } header) + else if (IsSizingCursor && IsCursorInLeftResizeArea(e) && _tableView is not null + && _headerRow?.GetPreviousHeader(this) is { Column: { } } header) { _resizePreviousStarted = true; + _resizingColumn = header.Column; + _resizeTargetHeader = header; _resizeStartingWidth = header.ActualWidth; + _resizeStartPointerX = e.GetCurrentPoint(_headerRow).Position.X; + _activeResizeMode = _tableView.ColumnResizeMode; + BeginResize(header.Column); CapturePointer(e.Pointer); } else if (_tableView?.CanReorderColumns is true && Column?.CanReorder is true) @@ -408,35 +475,7 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) { base.OnManipulationDelta(e); - if (Column is null || _tableView is null) - { - return; - } - - if (_resizeStarted) - { - var width = _resizeStartingWidth + e.Cumulative.Translation.X; - - var minWidth = Column.MinWidth ?? _tableView.MinColumnWidth; - var maxWidth = Column.MaxWidth ?? _tableView.MaxColumnWidth; - - width = width < minWidth ? minWidth : width; - width = width > maxWidth ? maxWidth : width; - - Column.Width = new GridLength(width, GridUnitType.Pixel); - } - else if (_resizePreviousStarted && _headerRow?.GetPreviousHeader(this) is { Column: { } } header) - { - var minWidth = header.Column.MinWidth ?? _tableView.MinColumnWidth; - var maxWidth = header.Column.MaxWidth ?? _tableView.MaxColumnWidth; - var width = _resizeStartingWidth + e.Cumulative.Translation.X; - - width = width < minWidth ? minWidth : width; - width = width > maxWidth ? maxWidth : width; - - header.Column.Width = new GridLength(width, GridUnitType.Pixel); - } - else if (_reorderStarted && _dragVisuals is not null) + if (_reorderStarted && _dragVisuals is not null) { var position = _reorderStartingPosition + e.Cumulative.Translation.X; _headerRow?.ShowColumnDropIndicator(position, _dragVisuals); @@ -454,10 +493,8 @@ private async Task CreateDragVisualsAsync() protected override void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) { base.OnManipulationCompleted(e); + CommitResize(); CompleteColumnDrop(true); - - _resizeStarted = false; - _resizePreviousStarted = false; } /// @@ -466,8 +503,6 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) base.OnPointerReleased(e); ReleasePointerCaptures(); - _resizeStarted = false; - _resizePreviousStarted = false; _reorderStarted = false; } @@ -475,9 +510,63 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) { base.OnPointerCaptureLost(e); + CommitResize(); CompleteColumnDrop(false); } + /// + /// Starts a resize-drag on , using whichever mode was captured into + /// at the start of this gesture. + /// + private void BeginResize(TableViewColumn column) + { + if (_tableView is null) + { + return; + } + + if (_activeResizeMode == TableViewColumnResizeMode.Preview) + { + _tableView.BeginColumnResizePreview(column); + } + else + { + _tableView.BeginColumnResizeLive(column); + } + } + + /// + /// Ends an in-progress resize drag, synchronously: ends the active resize mode (which, if the + /// width actually changed, performs the single real width commit) and resets gesture state. Safe + /// to call more than once per drag (both and + /// call it, in case a manipulation gesture never started) — + /// it no-ops if no resize is in progress. + /// + private void CommitResize() + { + if (!_resizeStarted && !_resizePreviousStarted) + { + return; + } + + var finalWidth = _resizeWidthChanged ? _resizeTargetHeader?.Width : null; + + if (_activeResizeMode == TableViewColumnResizeMode.Preview) + { + _tableView?.EndColumnResizePreview(finalWidth); + } + else + { + _tableView?.EndColumnResizeLive(finalWidth); + } + + _resizeStarted = false; + _resizePreviousStarted = false; + _resizeWidthChanged = false; + _resizingColumn = null; + _resizeTargetHeader = null; + } + private void CompleteColumnDrop(bool applyDrop) { if (_reorderStarted && Column is not null) @@ -491,12 +580,13 @@ private void CompleteColumnDrop(bool applyDrop) /// protected override Size MeasureOverride(Size availableSize) { - if (Column is not null && _tableView is not null) + if (Column is not null && _tableView is not null && !Column.IsResizing) { var autoWidthMode = Column.ColumnAutoWidthMode ?? _tableView.ColumnAutoWidthMode; if (autoWidthMode is TableViewColumnAutoWidthMode.Header or TableViewColumnAutoWidthMode.Both) { var desiredHeaderSize = base.MeasureOverride(new Size(double.PositiveInfinity, double.PositiveInfinity)); + CachedDesiredWidth = desiredHeaderSize.Width; Column.DesiredWidth = Math.Max(Column.DesiredWidth, desiredHeaderSize.Width); } } @@ -520,6 +610,12 @@ internal void EnsureGridLines() } } + /// + /// Caches this header's own natural (unconstrained) desired width, last computed in + /// . + /// + internal double? CachedDesiredWidth { get; private set; } + /// /// Gets or sets the column associated with the header. /// diff --git a/src/TableViewColumnResizeMode.cs b/src/TableViewColumnResizeMode.cs new file mode 100644 index 00000000..76642e89 --- /dev/null +++ b/src/TableViewColumnResizeMode.cs @@ -0,0 +1,21 @@ +namespace WinUI.TableView; + +/// +/// Specifies how a column behaves while the user drags to resize it. +/// +public enum TableViewColumnResizeMode +{ + /// + /// Cells appear to resize live via a composition-only preview (Clip/RenderTransform) — no real + /// layout runs until the drag ends, keeping the drag smooth regardless of row count. The real + /// width is committed once, when the pointer is released. + /// + Preview, + + /// + /// The column's real width updates on every pointer-move frame, so every visible row's cell goes + /// through a real layout pass during the drag. Simpler and fully "real", at the cost of frame + /// rate on grids with many visible rows. This is the default. + /// + Live +} diff --git a/src/TableViewHeaderRow.cs b/src/TableViewHeaderRow.cs index 9e6501c7..475cc2c0 100644 --- a/src/TableViewHeaderRow.cs +++ b/src/TableViewHeaderRow.cs @@ -282,10 +282,10 @@ internal void CalculateHeaderWidths() var absoluteColumns = allColumns.Where(x => x.Width.IsAbsolute).ToList(); var availableWidth = TableView.ActualWidth - 32; - var starUnitWeight = starColumns.Select(x => x.Width.Value).Sum(); + var starUnitWeight = starColumns.Sum(x => x.Width.Value); - var fixedWidth = autoColumns.Select(GetColumnDesiredWidth).Sum(); - fixedWidth += absoluteColumns.Select(x => x.ActualWidth).Sum(); + var fixedWidth = autoColumns.Sum(GetColumnDesiredWidth); + fixedWidth += absoluteColumns.Sum(x => x.ActualWidth); availableWidth -= fixedWidth; var starUnitWidth = starUnitWeight > 0 ? availableWidth / starUnitWeight : 0; @@ -337,11 +337,6 @@ internal void CalculateHeaderWidths() width = width < minWidth ? minWidth : width; width = width > maxWidth ? maxWidth : width; header.Width = width; - - DispatcherQueue.TryEnqueue(() => - header.Measure( - new Size(header.Width, - _scrollableHeadersPanel?.ActualHeight ?? ActualHeight))); } } @@ -354,16 +349,20 @@ internal void CalculateHeaderWidths() /// /// Gets the desired width of a column based on its header and cells. /// - private double GetColumnDesiredWidth(TableViewColumn column) + internal double GetColumnDesiredWidth(TableViewColumn column) { var autoWidthMode = column.ColumnAutoWidthMode ?? TableView?.ColumnAutoWidthMode; var width = column.DesiredWidth; if (column.HeaderControl is { } header && autoWidthMode is not TableViewColumnAutoWidthMode.Cells) { - header.Width = double.NaN; - header.Measure(new Size(double.PositiveInfinity, ActualHeight)); - width = Math.Max(width, header.DesiredSize.Width); + if (header.CachedDesiredWidth is null) + { + header.Width = double.NaN; + header.Measure(new Size(double.PositiveInfinity, ActualHeight)); + } + + width = Math.Max(width, header.CachedDesiredWidth ?? 0d); } return width; diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index 6d1eaccc..53f964c5 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -148,6 +148,14 @@ protected override void OnContentChanged(object oldContent, object newContent) { foreach (var cell in Cells) { + // Defensively resync width on reuse — a recycled container can otherwise keep a + // stale Width if it missed a Column.ActualWidth change while off-screen (e.g. an + // auto-width recalculation triggered by a sort), leaving cells misaligned with headers. + if (cell.Column is not null) + { + cell.Width = cell.Column.ActualWidth; + } + cell.RefreshElement(); } } @@ -175,8 +183,14 @@ protected override Size ArrangeOverride(Size finalSize) var left = Math.Max(cornerRadius.TopLeft, cornerRadius.BottomLeft); _itemPresenter?.Arrange(new Rect(-left, 0, _itemPresenter.ActualWidth + left, _itemPresenter.ActualHeight)); - - UpdatePosition(); + + // Position feeds drag-selection hit testing only; a column-width change never moves a row + // relative to the drag canvas, so recomputing it (a visual-tree transform walk) on every + // row on every frame of a resize drag is pure waste. + if (TableView?.IsColumnResizing != true) + { + UpdatePosition(); + } return finalSize; } diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index 95e5ee34..ff7e91c8 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -112,7 +112,14 @@ private void OnDetailsPanelVisibilityChanged(DependencyObject sender, Dependency /// protected override Size MeasureOverride(Size availableSize) { - _rowHeader?.InvalidateMeasure(); // The row header does not measure every time. + // The row header's size never depends on a data column's width, but this presenter still + // measures every visible row every frame during a Live-mode resize drag (a cell's width + // really did change), so forcing this remeasure here too is pure per-frame waste during a drag. + if (TableView?.IsColumnResizing != true) + { + _rowHeader?.InvalidateMeasure(); // The row header does not measure every time. + } + return base.MeasureOverride(availableSize); } @@ -159,7 +166,11 @@ protected override Size ArrangeOverride(Size finalSize) } - if (_v_gridLine is not null && TableView is not null) + // CellsHorizontalOffset is the boundary between the row header and the data cells — it's + // positioned purely by HeaderColumn's width (see TableViewRowPresenter.xaml's ColumnDefinitions), + // so it never depends on any data column's width and is safe to skip recomputing (via a real + // TransformToVisual walk, on every visible row) during a resize drag. + if (TableView is not null && !TableView.IsColumnResizing && _v_gridLine is not null) { var transform = _v_gridLine.TransformToVisual(this); var relativePosition = transform.TransformPoint(new Point(0, 0)); @@ -483,6 +494,13 @@ public void ClearCells() [.. _frozenCellsPanel?.Children.OfType() ?? [], .. _scrollableCellsPanel?.Children.OfType() ?? []]; + /// + /// Gets the panel hosting scrollable (non-frozen) cells. Used to shift the whole scrollable + /// region in one shot when a frozen column is being resized, instead of shifting every + /// scrollable cell individually. + /// + internal Panel? ScrollableCellsPanel => _scrollableCellsPanel; + /// /// Gets or sets the TableViewRow associated with the presenter. /// diff --git a/tests/TableViewColumnResizingTests.cs b/tests/TableViewColumnResizingTests.cs new file mode 100644 index 00000000..ce406aab --- /dev/null +++ b/tests/TableViewColumnResizingTests.cs @@ -0,0 +1,501 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Linq; +using System.Threading.Tasks; +using Windows.Foundation; +using WinUI.TableView.Extensions; + +namespace WinUI.TableView.Tests; + +[TestClass] +public class TableViewColumnResizingTests +{ + // ── Direct (non-drag) header/column width propagation ─────────────────── + + [UITestMethod] + public async Task HeaderWidthChange_UpdatesColumnActualWidth_Immediately() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + var originalWidth = header.Width; + var newWidth = originalWidth + 50; + + header.Width = newWidth; + + Assert.AreEqual(newWidth, column.ActualWidth, 0.01, + "Column.ActualWidth should update immediately via OnWidthChanged when header.Width changes outside of a resize-drag preview"); + } + + [UITestMethod] + public async Task HeaderWidthChange_DoesNotChange_ColumnGridLengthType() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + Assert.IsTrue(column.Width.IsAuto, "Precondition: column starts with Auto width"); + + header.Width = 250; + + Assert.IsTrue(column.Width.IsAuto, + "Column.Width GridLength must remain Auto until a resize is actually committed"); + } + + [UITestMethod] + public async Task HeaderWidthChange_Propagates_ToCellWidths() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + const double newWidth = 220d; + header.Width = newWidth; + + var rows = tableView.FindDescendants().OfType().Where(r => r.IsLoaded).ToList(); + Assert.IsTrue(rows.Count > 0, "Precondition: at least one rendered row exists"); + + foreach (var row in rows) + { + var cell = row.Cells.FirstOrDefault(c => c.Column == column); + if (cell is null) continue; + + Assert.AreEqual(newWidth, cell.Width, 0.01, + $"Cell width must match new header width for row index {row.Index}"); + } + } + + [UITestMethod] + public async Task CommittedResize_Stores_PixelGridLength() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + Assert.IsTrue(column.Width.IsAuto, "Precondition: column starts with Auto width"); + + const double finalWidth = 300d; + header.Width = finalWidth; + column.Width = new GridLength(finalWidth, GridUnitType.Pixel); + + await Task.Yield(); + + Assert.IsTrue(column.Width.IsAbsolute, "Column.Width must be Absolute (Pixel) after resize commit"); + Assert.AreEqual(finalWidth, column.Width.Value, 0.01); + Assert.AreEqual(finalWidth, header.Width, 0.01); + } + + // ── Resize-drag preview: layout must stay frozen while active ─────────── + + [UITestMethod] + public async Task WhileResizePreviewActive_ColumnLayout_DoesNotChange() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var originalActualWidth = column.ActualWidth; + var originalGridLength = column.Width; + + var rows = tableView.FindDescendants().OfType().Where(r => r.IsLoaded).ToList(); + Assert.IsTrue(rows.Count > 0, "Precondition: at least one rendered row exists"); + var originalCellWidths = rows + .Select(r => r.Cells.FirstOrDefault(c => c.Column == column)) + .Where(c => c is not null) + .ToDictionary(c => c!, c => c!.Width); + Assert.IsTrue(originalCellWidths.Count > 0, "Precondition: at least one realized cell for the column"); + + tableView.BeginColumnResizePreview(column); + try + { + foreach (var w in new[] { originalActualWidth + 40, originalActualWidth - 20, originalActualWidth + 100 }) + { + tableView.UpdateColumnResizePreview(w); + + Assert.AreEqual(originalActualWidth, column.ActualWidth, 0.01, + "Column.ActualWidth must not change while a resize preview is active"); + Assert.AreEqual(originalGridLength.GridUnitType, column.Width.GridUnitType, + "Column.Width's GridLength type must not change while a resize preview is active"); + + foreach (var (cell, originalWidth) in originalCellWidths) + { + Assert.AreEqual(originalWidth, cell.Width, 0.01, + "A cell's real Width DP must not change while a resize preview is active — " + + "the live look comes entirely from Clip/RenderTransform, not real layout"); + } + } + } + finally + { + tableView.EndColumnResizePreview(null); + } + } + + [UITestMethod] + public async Task ResizePreview_Cancel_LeavesColumnCompletelyUnchanged() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var originalActualWidth = column.ActualWidth; + var originalGridLength = column.Width; + + tableView.BeginColumnResizePreview(column); + tableView.UpdateColumnResizePreview(originalActualWidth + 75); + tableView.EndColumnResizePreview(null); // cancel — e.g. a click without an actual drag + + Assert.AreEqual(originalActualWidth, column.ActualWidth, 0.01, + "Cancelling a preview (no commit width) must leave ActualWidth untouched"); + Assert.AreEqual(originalGridLength.GridUnitType, column.Width.GridUnitType, + "Cancelling a preview must not convert an Auto column to Pixel"); + Assert.IsFalse(tableView.IsColumnResizing, "IsColumnResizing must be cleared after End, even on cancel"); + Assert.IsFalse(column.IsResizing, "Column.IsResizing must be cleared after End, even on cancel"); + } + + // ── Resize-drag preview: the illusion's numbers must be correct ───────── + + [UITestMethod] + public async Task ResizePreview_UpdatesClipAndDownstreamShift_ToMatchLiveWidth() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; // "Name" — has a downstream column ("Value") in the same row + var originalWidth = column.ActualWidth; + const double liveWidth = 260d; + + var row = tableView.FindDescendants().OfType().First(r => r.IsLoaded); + var resizedCell = row.Cells.First(c => c.Column == column); + var downstreamCell = row.Cells.First(c => c.Column == tableView.Columns[1]); + + tableView.BeginColumnResizePreview(column); + tableView.UpdateColumnResizePreview(liveWidth); + + Assert.IsInstanceOfType(resizedCell.Clip); + var clip = (RectangleGeometry)resizedCell.Clip; + var expectedClip = TableViewCell.ComputeClipRect(liveWidth, clip.Rect.Height); + Assert.AreEqual(expectedClip.Width, clip.Rect.Width, 0.01, + "The resized cell's Clip width must track the live drag width via ComputeClipRect"); + + Assert.IsInstanceOfType(downstreamCell.RenderTransform); + var shift = (TranslateTransform)downstreamCell.RenderTransform; + Assert.AreEqual(liveWidth - originalWidth, shift.X, 0.01, + "A downstream cell's shift must equal (liveWidth - originalWidth)"); + + tableView.EndColumnResizePreview(null); + } + + [UITestMethod] + public async Task ResizePreview_EachRowGetsItsOwnClipAndShiftInstance() + { + // WinUI throws if the same Clip (RectangleGeometry) or RenderTransform instance is assigned + // to more than one UIElement at a time, so each row's cells must get their own instance — + // this test guards against reintroducing the shared-instance crash. + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + + var rows = tableView.FindDescendants().OfType().Where(r => r.IsLoaded).ToList(); + Assert.IsTrue(rows.Count > 1, "Precondition: more than one realized row"); + + tableView.BeginColumnResizePreview(column); + tableView.UpdateColumnResizePreview(column.ActualWidth + 30); + + var clips = rows.Select(r => r.Cells.First(c => c.Column == column).Clip).ToList(); + Assert.AreEqual(clips.Count, clips.Distinct().Count(), + "Every realized row's resized cell must have its OWN Clip instance, not a shared one"); + + var shifts = rows + .SelectMany(r => r.Cells.Where(c => c.Column == tableView.Columns[1])) + .Select(c => c.RenderTransform) + .ToList(); + Assert.AreEqual(shifts.Count, shifts.Distinct().Count(), + "Every realized row's downstream cell must have its OWN RenderTransform instance, not a shared one"); + + tableView.EndColumnResizePreview(null); + } + + // ── ColumnResizeMode toggle: Live mode relayouts for real, every frame ── + + [UITestMethod] + public async Task ColumnResizeMode_DefaultsToLive() + { + var tableView = await CreateTableViewAsync(); + Assert.AreEqual(TableViewColumnResizeMode.Live, tableView.ColumnResizeMode); + } + + [UITestMethod] + public async Task LiveResize_UpdatesColumnActualWidth_OnEveryFrame() + { + var tableView = await CreateTableViewAsync(); + tableView.ColumnResizeMode = TableViewColumnResizeMode.Live; + var column = tableView.Columns[0]; + var originalWidth = column.ActualWidth; + + tableView.BeginColumnResizeLive(column); + try + { + foreach (var w in new[] { originalWidth + 40, originalWidth - 10, originalWidth + 90 }) + { + tableView.UpdateColumnResizeLive(w); + + Assert.AreEqual(w, column.ActualWidth, 0.01, + "Unlike Preview mode, Live mode must update Column.ActualWidth on every frame"); + Assert.IsTrue(column.Width.IsAuto, + "Live mode must not touch Column.Width (GridLength) until commit"); + } + } + finally + { + tableView.EndColumnResizeLive(null); + } + } + + [UITestMethod] + public async Task LiveResize_Commit_StoresPixelGridLength() + { + var tableView = await CreateTableViewAsync(); + tableView.ColumnResizeMode = TableViewColumnResizeMode.Live; + var column = tableView.Columns[0]; + const double finalWidth = 245d; + + tableView.BeginColumnResizeLive(column); + tableView.UpdateColumnResizeLive(finalWidth); + tableView.EndColumnResizeLive(finalWidth); + + Assert.IsTrue(column.Width.IsAbsolute && column.Width.Value == finalWidth, + "Live mode must commit the same Pixel GridLength as Preview mode does"); + Assert.AreEqual(finalWidth, column.ActualWidth, 0.01); + Assert.IsFalse(tableView.IsColumnResizing); + Assert.IsFalse(column.IsResizing); + } + + [UITestMethod] + public async Task LiveResize_Cancel_LeavesColumnWidthUnchanged() + { + var tableView = await CreateTableViewAsync(); + tableView.ColumnResizeMode = TableViewColumnResizeMode.Live; + var column = tableView.Columns[0]; + var originalGridLength = column.Width; + + tableView.BeginColumnResizeLive(column); + tableView.UpdateColumnResizeLive(column.ActualWidth + 55); + tableView.EndColumnResizeLive(null); + + Assert.AreEqual(originalGridLength.GridUnitType, column.Width.GridUnitType, + "Cancelling a Live resize must not convert an Auto column to Pixel"); + } + + // ── Resize-drag preview: commit must match a direct (non-drag) resize ─── + + [UITestMethod] + public async Task ResizePreview_Commit_MatchesDirectResizeExactly() + { + const double finalWidth = 275d; + + var dragged = await CreateTableViewAsync(); + var draggedColumn = dragged.Columns[0]; + dragged.BeginColumnResizePreview(draggedColumn); + dragged.UpdateColumnResizePreview(draggedColumn.ActualWidth + 10); + dragged.UpdateColumnResizePreview(finalWidth); + dragged.EndColumnResizePreview(finalWidth); + + var direct = await CreateTableViewAsync(); + var directColumn = direct.Columns[0]; + directColumn.Width = new GridLength(finalWidth, GridUnitType.Pixel); + await Task.Yield(); + + Assert.AreEqual(directColumn.HeaderControl!.Width, draggedColumn.HeaderControl!.Width, 0.01, + "A committed drag must produce the same header width as a direct resize"); + Assert.AreEqual(directColumn.ActualWidth, draggedColumn.ActualWidth, 0.01, + "A committed drag must produce the same Column.ActualWidth as a direct resize"); + Assert.IsTrue(draggedColumn.Width.IsAbsolute && draggedColumn.Width.Value == finalWidth, + "A committed drag must store the same Pixel GridLength as a direct resize"); + + var draggedRows = dragged.FindDescendants().OfType().Where(r => r.IsLoaded); + foreach (var row in draggedRows) + { + var cell = row.Cells.FirstOrDefault(c => c.Column == draggedColumn); + if (cell is null) continue; + + Assert.AreEqual(finalWidth, cell.Width, 0.01, $"Cell width mismatch after commit, row {row.Index}"); + Assert.IsNull(cell.Clip, "Clip must be cleared once the drag commits"); + Assert.IsNull(cell.RenderTransform, "RenderTransform must be cleared once the drag commits"); + } + } + + // ── Pure-function helpers (directly testable, no UI/pointer simulation needed) ── + + [UITestMethod] + public void ClampWidth_ClampsToMinAndMax() + { + Assert.AreEqual(50d, TableViewColumnHeader.ClampWidth(10, 50, 500), 0.01, "Below min clamps to min"); + Assert.AreEqual(500d, TableViewColumnHeader.ClampWidth(900, 50, 500), 0.01, "Above max clamps to max"); + Assert.AreEqual(200d, TableViewColumnHeader.ClampWidth(200, 50, 500), 0.01, "In-range passes through unchanged"); + Assert.AreEqual(50d, TableViewColumnHeader.ClampWidth(50, 50, 500), 0.01, "Exactly at min stays at min"); + Assert.AreEqual(500d, TableViewColumnHeader.ClampWidth(500, 50, 500), 0.01, "Exactly at max stays at max"); + } + + [UITestMethod] + public void ComputeClipRect_MatchesLiveWidthAndHeight() + { + var rect = TableViewCell.ComputeClipRect(180d, 32d); + Assert.AreEqual(0d, rect.X, 0.01); + Assert.AreEqual(0d, rect.Y, 0.01); + Assert.AreEqual(180d, rect.Width, 0.01); + Assert.AreEqual(32d, rect.Height, 0.01); + } + + [UITestMethod] + public void ComputeClipRect_NeverReturnsNegativeSize() + { + var rect = TableViewCell.ComputeClipRect(-10d, -5d); + Assert.AreEqual(0d, rect.Width, 0.01, "A negative live width must clamp to zero, not a negative Rect size"); + Assert.AreEqual(0d, rect.Height, 0.01, "A negative height must clamp to zero, not a negative Rect size"); + } + + // ── Bug 3 regression: CalculateHeaderWidths stability / alignment after sort ──── + + [UITestMethod] + public async Task CalculateHeaderWidths_IsIdempotent() + { + var tableView = await CreateTableViewAsync(); + var headerRow = tableView.FindDescendant(); + + Assert.IsNotNull(headerRow, "TableViewHeaderRow must be present in the visual tree"); + + headerRow.CalculateHeaderWidths(); + var widths1 = tableView.Columns.Select(c => c.HeaderControl!.Width).ToArray(); + + headerRow.CalculateHeaderWidths(); + var widths2 = tableView.Columns.Select(c => c.HeaderControl!.Width).ToArray(); + + for (var i = 0; i < widths1.Length; i++) + { + Assert.AreEqual(widths1[i], widths2[i], 0.01, + $"Column[{i}] width changed between consecutive CalculateHeaderWidths calls — indicates oscillation"); + } + } + + [UITestMethod] + public async Task ColumnHeader_CachesDesiredWidth_AfterMeasure() + { + var tableView = await CreateTableViewAsync(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + Assert.IsTrue(column.Width.IsAuto, "Precondition: column starts with Auto width"); + Assert.IsNotNull(header.CachedDesiredWidth, + "A header that's been through at least one layout pass must have a cached desired width " + + "(set in TableViewColumnHeader.MeasureOverride) — this is what lets GetColumnDesiredWidth " + + "skip a redundant remeasure."); + Assert.IsTrue(header.CachedDesiredWidth > 0, + "The cached desired width should reflect real header content, not a default/zero value"); + } + + [UITestMethod] + public async Task GetColumnDesiredWidth_UsesCachedHeaderWidth() + { + var tableView = await CreateTableViewAsync(); + var headerRow = tableView.FindDescendant(); + var column = tableView.Columns[0]; + var header = column.HeaderControl!; + + Assert.IsNotNull(headerRow, "TableViewHeaderRow must be present in the visual tree"); + Assert.IsNotNull(header.CachedDesiredWidth, "Precondition: header has already been measured once"); + + var desiredWidth = headerRow.GetColumnDesiredWidth(column); + + Assert.AreEqual(Math.Max(column.DesiredWidth, header.CachedDesiredWidth.Value), desiredWidth, 0.01, + "GetColumnDesiredWidth must resolve to the max of Column.DesiredWidth and the header's " + + "cached desired width — proving it consulted the cache rather than only column.DesiredWidth"); + } + + [UITestMethod] + public async Task AfterSort_CellWidths_MatchColumnActualWidths() + { + var tableView = await CreateTableViewAsync(); + + // Seed a stale-width state: explicitly set the Name column to a non-auto width so that + // containers carry a specific width before recycling happens. + var nameColumn = tableView.Columns.OfType() + .First(c => c.PropertyPath == nameof(ResizingTestItem.Name)); + const double seededWidth = 200d; + nameColumn.Width = new GridLength(seededWidth, GridUnitType.Pixel); + + // Wait for layout to apply the seeded width to all realized cells. + await Task.Delay(200); + + // Sort the Value column (data is seeded 3/1/2, so ascending sort truly reorders rows), + // causing the ListView to recycle and rebind containers — this exercises OnContentChanged + // width resync in TableViewRow. + var valueColumn = tableView.Columns.OfType() + .First(c => c.PropertyPath == nameof(ResizingTestItem.Value)); + tableView.SortDescriptions.Add( + new ColumnSortDescription(valueColumn, valueColumn.PropertyPath, SortDirection.Ascending)); + + // Wait for the 250ms debounce timer + at least one layout pass to settle. This also exercises + // the container-recycling width resync (TableViewRow.OnContentChanged) since sorting reorders + // items behind already-realized row containers. + await Task.Delay(600); + + var rows = tableView.FindDescendants().OfType().Where(r => r.IsLoaded).ToList(); + Assert.IsTrue(rows.Count > 0, "Precondition: at least one rendered row exists after sort"); + + foreach (var column in tableView.Columns) + { + foreach (var row in rows) + { + var cell = row.Cells.FirstOrDefault(c => c.Column == column); + if (cell is null) continue; + + Assert.AreEqual(column.ActualWidth, cell.Width, 0.01, + $"Cell width mismatch after sort: column '{column.Header}', row {row.Index}"); + } + } + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private static async Task CreateTableViewAsync() + { + var tableView = new TableView + { + Width = 800, + Height = 400, + AutoGenerateColumns = false + }; + + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath(nameof(ResizingTestItem.Name)) } + }); + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Value", + Binding = new Binding { Path = new PropertyPath(nameof(ResizingTestItem.Value)) } + }); + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Description", + Binding = new Binding { Path = new PropertyPath(nameof(ResizingTestItem.Description)) } + }); + + tableView.ItemsSource = new[] + { + new ResizingTestItem { Name = "Alpha", Value = 3, Description = "First item" }, + new ResizingTestItem { Name = "Beta", Value = 1, Description = "Second item" }, + new ResizingTestItem { Name = "Gamma", Value = 2, Description = "Third item" } + }; + + await UnitTestApp.Current.MainWindow.LoadTestContentAsync(tableView); + + return tableView; + } + + private sealed class ResizingTestItem + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + public string Description { get; set; } = string.Empty; + } +}