From 4407c425780f8336850dcaab30b5859d60ffd446 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Mon, 5 May 2025 12:02:48 +0100 Subject: [PATCH 1/9] when the Source is changed and the user is horizontally scrolled try and maintain the measure information if the datasource appears to show the same data. This prevents horizontal scrolling becoming disjointed... if the data is different we scroll to the top left again. --- .../Primitives/TreeDataGridPresenterBase.cs | 10 +++++ .../Primitives/TreeDataGridRowsPresenter.cs | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index b95dd152..d88ffce3 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -90,6 +90,16 @@ public IReadOnlyList? Items protected abstract Orientation Orientation { get; } protected Rect Viewport { get; private set; } = s_invalidViewport; + public void ScrollToHome() + { + _scrollViewer?.ScrollToHome(); + } + + public void ScrollToEnd() + { + _scrollViewer?.ScrollToEnd(); + } + public Control? BringIntoView(int index, Rect? rect = null) { var items = Items; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index e946bc09..3772cbf6 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Selection; using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -97,6 +98,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (oldValue != null && newValue != null) { newValue.ViewportChanged(Viewport); + + if (!TryToMaintainColumnLayouts(oldValue, newValue)) + { + Dispatcher.UIThread.Post(ScrollToHome, DispatcherPriority.Background); + } } } @@ -111,6 +117,38 @@ internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) row.UpdateSelection(selection); } } + + /// + /// When the source has changed and the Columns are recreated, if the data is essentially the same + /// restore the column measure information. + /// + /// The previous columns + /// The new columns + private static bool TryToMaintainColumnLayouts(IColumns oldValue, IColumns newValue) + { + if (oldValue.Count == newValue.Count) + { + for (int i = 0; i < oldValue.Count; i++) + { + if (newValue[i].Header == oldValue[i].Header) + { + if (newValue[i] is IUpdateColumnLayout iucl) + { + iucl.CellMeasured(oldValue[i].ActualWidth, 0); + } + } + else + { + // The user is probably showing different data. + return false; + } + } + + return true; + } + + return false; + } private void OnColumnLayoutInvalidated(object? sender, EventArgs e) { From 9743ea8331d3c91611a256ebd0942bfd220d530a Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Mon, 5 May 2025 12:10:51 +0100 Subject: [PATCH 2/9] dont modify the columns unless we already determined that all columns are the same. --- .../Primitives/TreeDataGridRowsPresenter.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index 3772cbf6..fdfc609b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -128,19 +128,20 @@ private static bool TryToMaintainColumnLayouts(IColumns oldValue, IColumns newVa { if (oldValue.Count == newValue.Count) { + // Ensure the data is likely the same by checking the headers. for (int i = 0; i < oldValue.Count; i++) { - if (newValue[i].Header == oldValue[i].Header) + if (newValue[i].Header != oldValue[i].Header) { - if (newValue[i] is IUpdateColumnLayout iucl) - { - iucl.CellMeasured(oldValue[i].ActualWidth, 0); - } + return false; } - else + } + + for (int i = 0; i < oldValue.Count; i++) + { + if (newValue[i] is IUpdateColumnLayout iucl) { - // The user is probably showing different data. - return false; + iucl.CellMeasured(oldValue[i].ActualWidth, 0); } } From 4e41eeec21c72d3b4aacdd0d886698df110f3cc4 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:01:53 +0100 Subject: [PATCH 3/9] Add estimation methods to ColumnList --- .../Models/TreeDataGrid/ColumnList.cs | 78 +++++++++++++++++++ .../Models/TreeDataGrid/IColumns.cs | 4 + 2 files changed, 82 insertions(+) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs index 6393108c..bb15831f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs @@ -45,6 +45,84 @@ public Size CellMeasured(int columnIndex, int rowIndex, Size size) return (-1, -1); } + public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, + ref double estimatedElementSizeU) + { + // We have no elements, nothing to do here. + if (itemCount <= 0) + return (-1, 0); + + // If we're at 0 then display the first item. + if (MathUtilities.IsZero(viewportStartU)) + return (0, 0); + + int firstIndex = -1; + for (var i = 0; i < Count; ++i) + { + if (!double.IsNaN(this[i].ActualWidth)) + { + firstIndex = i; + break; + } + } + + var u = startU; + + for (var i = 0; i < Count; ++i) + { + var size = this[i].ActualWidth; + + if (double.IsNaN(size)) + break; + + var endU = u + size; + + if (endU > viewportStartU && u < viewportEndU) + return (firstIndex + i, u); + + u = endU; + } + + // We don't have any realized elements in the requested viewport, or can't rely on + // StartU being valid. Estimate the index using only the estimated size. First, + // estimate the element size, using defaultElementSizeU if we don't have any realized + // elements. + var estimatedSize = EstimateElementSizeU() switch + { + -1 => estimatedElementSizeU, + var v => v, + }; + + // Store the estimated size for the next layout pass. + estimatedElementSizeU = estimatedSize; + + // Estimate the element at the start of the viewport. + var index = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1); + return (index, index * estimatedSize); + } + + public double EstimateElementSizeU() + { + var total = 0.0; + var divisor = 0.0; + + // Average the size of the realized elements. + foreach (var column in this) + { + var size = column.ActualWidth; + if (double.IsNaN(size)) + continue; + total += size; + ++divisor; + } + + // We don't have any elements on which to base our estimate. + if (divisor == 0 || total == 0) + return -1; + + return total / divisor; + } + public double GetEstimatedWidth(double constraint) { var hasStar = false; diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs index b54d2bd0..512a65db 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs @@ -39,6 +39,10 @@ public interface IColumns : IReadOnlyList, INotifyCollectionChanged /// (int index, double x) GetColumnAt(double x); + public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, ref double estimatedElementSizeU); + + public double EstimateElementSizeU(); + /// /// Gets the estimated total width of all columns. /// From bfff17d52b4ea2955ab33f772781cc74f60f156f Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:06:32 +0100 Subject: [PATCH 4/9] defer the column position and sizing estimation to IColumnList. --- .../TreeDataGridColumnarPresenterBase.cs | 20 ++++++++++++++++++- .../Primitives/TreeDataGridPresenterBase.cs | 7 ++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs index 6e1129e5..75a29032 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs @@ -15,6 +15,8 @@ namespace Avalonia.Controls.Primitives /// public abstract class TreeDataGridColumnarPresenterBase : TreeDataGridPresenterBase { + private double _lastEstimatedElementSizeU = 25; + protected IColumns? Columns => Items as IColumns; protected sealed override Size GetInitialConstraint(Control element, int index, Size availableSize) @@ -28,11 +30,27 @@ protected override (int index, double position) GetOrEstimateAnchorElementForVie double viewportEnd, int itemCount) { - if (Columns?.GetColumnAt(viewportStart) is var (index, position) && index >= 0) + if (Columns?.GetColumnAt(viewportStart) is (var index and >= 0, var position)) return (index, position); + + if (Columns?.GetOrEstimateColumnAt(viewportStart, viewportEnd, itemCount, StartU, ref _lastEstimatedElementSizeU) is { index: >= 0 } res) + return res; + return base.GetOrEstimateAnchorElementForViewport(viewportStart, viewportEnd, itemCount); } + protected override double EstimateElementSizeU() + { + if (Columns is null) + return _lastEstimatedElementSizeU; + + var result = Columns.EstimateElementSizeU(); + if (result >= 0) + _lastEstimatedElementSizeU = result; + + return _lastEstimatedElementSizeU; + } + protected sealed override bool NeedsFinalMeasurePass(int firstIndex, IReadOnlyList elements) { var columns = Columns!; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index d88ffce3..1dcbf1b9 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -89,6 +89,11 @@ public IReadOnlyList? Items protected abstract Orientation Orientation { get; } protected Rect Viewport { get; private set; } = s_invalidViewport; + + /// + /// Gets the position of the first realized element on the primary axis. + /// + protected double StartU => _realizedElements?.StartU ?? 0; public void ScrollToHome() { @@ -651,7 +656,7 @@ private Control GetRecycledOrCreateElement(IReadOnlyList items, int index return e; } - private double EstimateElementSizeU() + protected virtual double EstimateElementSizeU() { if (_realizedElements is null) return _lastEstimatedElementSizeU; From 2ed944094d3c68b52b2be319aa7a5e1c41c21775 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:06:50 +0100 Subject: [PATCH 5/9] removed unused variable and calculation. --- .../Models/TreeDataGrid/ColumnList.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs index bb15831f..71015761 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs @@ -216,7 +216,6 @@ private void UpdateColumnSizes() { // Size the star columns. var starWidthWasConstrained = false; - var used = 0.0; availableSpace = Math.Max(0, availableSpace); @@ -228,7 +227,6 @@ private void UpdateColumnSizes() if (column.Width.IsStar) { column.CalculateStarWidth(availableSpace, totalStars); - used += NotNaN(column.ActualWidth); starWidthWasConstrained |= column.StarWidthWasConstrained; } } From 6b8e03594880d67c8e7fd04f3ba0ecdc170d1bd5 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:07:10 +0100 Subject: [PATCH 6/9] use AreClose for determining if the viewport changed. --- .../Models/TreeDataGrid/ColumnList.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs index 71015761..e9a316fc 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs @@ -180,7 +180,7 @@ public void SetColumnWidth(int columnIndex, GridLength width) public void ViewportChanged(Rect viewport) { - if (_viewportWidth != viewport.Width) + if (!MathUtilities.AreClose(_viewportWidth, viewport.Width)) { _viewportWidth = viewport.Width; if (_initialized) From 3e5f392f8398471ecb666540cb3f9a76f379412c Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:08:31 +0100 Subject: [PATCH 7/9] remove previous hack. --- .../Primitives/TreeDataGridRowsPresenter.cs | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index fdfc609b..36059b86 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -98,11 +98,6 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (oldValue != null && newValue != null) { newValue.ViewportChanged(Viewport); - - if (!TryToMaintainColumnLayouts(oldValue, newValue)) - { - Dispatcher.UIThread.Post(ScrollToHome, DispatcherPriority.Background); - } } } @@ -117,39 +112,6 @@ internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) row.UpdateSelection(selection); } } - - /// - /// When the source has changed and the Columns are recreated, if the data is essentially the same - /// restore the column measure information. - /// - /// The previous columns - /// The new columns - private static bool TryToMaintainColumnLayouts(IColumns oldValue, IColumns newValue) - { - if (oldValue.Count == newValue.Count) - { - // Ensure the data is likely the same by checking the headers. - for (int i = 0; i < oldValue.Count; i++) - { - if (newValue[i].Header != oldValue[i].Header) - { - return false; - } - } - - for (int i = 0; i < oldValue.Count; i++) - { - if (newValue[i] is IUpdateColumnLayout iucl) - { - iucl.CellMeasured(oldValue[i].ActualWidth, 0); - } - } - - return true; - } - - return false; - } private void OnColumnLayoutInvalidated(object? sender, EventArgs e) { From 8d08db717677e5e8f0cc671a397c194ea592b9e1 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 10:10:30 +0100 Subject: [PATCH 8/9] remove unused changes. --- .../Primitives/TreeDataGridPresenterBase.cs | 10 ---------- .../Primitives/TreeDataGridRowsPresenter.cs | 1 - 2 files changed, 11 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 1dcbf1b9..b6dd9fe6 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -95,16 +95,6 @@ public IReadOnlyList? Items /// protected double StartU => _realizedElements?.StartU ?? 0; - public void ScrollToHome() - { - _scrollViewer?.ScrollToHome(); - } - - public void ScrollToEnd() - { - _scrollViewer?.ScrollToEnd(); - } - public Control? BringIntoView(int index, Rect? rect = null) { var items = Items; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index 36059b86..e946bc09 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -3,7 +3,6 @@ using Avalonia.Controls.Selection; using Avalonia.Layout; using Avalonia.LogicalTree; -using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives From e4ce393cc8ebc3f6eba1735cc3d5843813d183af Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 7 May 2025 11:01:21 +0100 Subject: [PATCH 9/9] pass in the first index from RealizedStackElements when estimating the column positions. --- .../Models/TreeDataGrid/ColumnList.cs | 13 +------------ .../Models/TreeDataGrid/IColumns.cs | 2 +- .../Primitives/TreeDataGridColumnarPresenterBase.cs | 2 +- .../Primitives/TreeDataGridPresenterBase.cs | 2 ++ 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs index e9a316fc..73d8b5be 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnList.cs @@ -45,8 +45,7 @@ public Size CellMeasured(int columnIndex, int rowIndex, Size size) return (-1, -1); } - public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, - ref double estimatedElementSizeU) + public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, int firstIndex, ref double estimatedElementSizeU) { // We have no elements, nothing to do here. if (itemCount <= 0) @@ -56,16 +55,6 @@ public Size CellMeasured(int columnIndex, int rowIndex, Size size) if (MathUtilities.IsZero(viewportStartU)) return (0, 0); - int firstIndex = -1; - for (var i = 0; i < Count; ++i) - { - if (!double.IsNaN(this[i].ActualWidth)) - { - firstIndex = i; - break; - } - } - var u = startU; for (var i = 0; i < Count; ++i) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs index 512a65db..e3963abd 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumns.cs @@ -39,7 +39,7 @@ public interface IColumns : IReadOnlyList, INotifyCollectionChanged /// (int index, double x) GetColumnAt(double x); - public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, ref double estimatedElementSizeU); + public (int index, double position) GetOrEstimateColumnAt(double viewportStartU, double viewportEndU, int itemCount, double startU, int firstIndex, ref double estimatedElementSizeU); public double EstimateElementSizeU(); diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs index 75a29032..c8d11c00 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnarPresenterBase.cs @@ -33,7 +33,7 @@ protected override (int index, double position) GetOrEstimateAnchorElementForVie if (Columns?.GetColumnAt(viewportStart) is (var index and >= 0, var position)) return (index, position); - if (Columns?.GetOrEstimateColumnAt(viewportStart, viewportEnd, itemCount, StartU, ref _lastEstimatedElementSizeU) is { index: >= 0 } res) + if (Columns?.GetOrEstimateColumnAt(viewportStart, viewportEnd, itemCount, StartU, FirstIndex, ref _lastEstimatedElementSizeU) is { index: >= 0 } res) return res; return base.GetOrEstimateAnchorElementForViewport(viewportStart, viewportEnd, itemCount); diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index b6dd9fe6..16455c2d 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -94,6 +94,8 @@ public IReadOnlyList? Items /// Gets the position of the first realized element on the primary axis. /// protected double StartU => _realizedElements?.StartU ?? 0; + + protected int FirstIndex => _realizedElements?.FirstIndex ?? 0; public Control? BringIntoView(int index, Rect? rect = null) {