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;
+ }
+}