From 5919ca6acc3aa75af9771bc18fce99f1a2a90b26 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 3 Apr 2023 10:14:40 +0200 Subject: [PATCH] Modify VirtualizingStackPanel algorithm. Try to get an "anchor element" during measure, which is an existing element in the viewport that we know the position of. Start realizing elements from this anchor element. --- .../Utils/RealizedStackElements.cs | 133 +++++++++----- .../VirtualizingStackPanel.cs | 170 +++++++++--------- 2 files changed, 181 insertions(+), 122 deletions(-) diff --git a/src/Avalonia.Controls/Utils/RealizedStackElements.cs b/src/Avalonia.Controls/Utils/RealizedStackElements.cs index ba1f07a632..8dbfb2c957 100644 --- a/src/Avalonia.Controls/Utils/RealizedStackElements.cs +++ b/src/Avalonia.Controls/Utils/RealizedStackElements.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Avalonia.Utilities; namespace Avalonia.Controls.Utils { @@ -99,63 +100,120 @@ namespace Avalonia.Controls.Utils } /// - /// Gets the index and start U position of the element at the specified U position. + /// Gets or estimates the index and start U position of the anchor element for the + /// specified viewport. /// - /// The U position. + /// The U position of the start of the viewport. + /// The U position of the end of the viewport. + /// The number of items in the list. + /// The current estimated element size. /// /// A tuple containing: - /// - The index of the item at the specified U position, or -1 if the item could not be - /// determined - /// - The U position of the start of the item, if determined + /// - The index of the anchor element, or -1 if an anchor could not be determined + /// - The U position of the start of the anchor element, if determined /// - public (int index, double position) GetIndexAt(double u) + /// + /// This method tries to find an existing element in the specified viewport from which + /// element realization can start. Failing that it estimates the first element in the + /// viewport. + /// + public (int index, double position) GetOrEstimateAnchorElementForViewport( + double viewportStartU, + double viewportEndU, + int itemCount, + ref double estimatedElementSizeU) { - if (_elements is null || _sizes is null || _startU > u || _startUUnstable) + // We have no elements, nothing to do here. + if (itemCount <= 0) return (-1, 0); - var index = 0; - var position = _startU; + // If we're at 0 then display the first item. + if (MathUtilities.IsZero(viewportStartU)) + return (0, 0); - while (index < _elements.Count) + if (_sizes is not null && !_startUUnstable) { - var size = _sizes[index]; - if (double.IsNaN(size)) - break; - if (u >= position && u < position + size) - return (index + FirstIndex, position); - position += size; - ++index; + var u = _startU; + + for (var i = 0; i < _sizes.Count; ++i) + { + var size = _sizes[i]; + + if (double.IsNaN(size)) + break; + + var endU = u + size; + + if (endU > viewportStartU && u < viewportEndU) + return (FirstIndex + i, u); + + u = endU; + } } - return (-1, 0); + // 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, + double 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); } /// - /// Gets the element at the specified position on the primary axis, if realized. + /// Gets the position of the element with the requested index on the primary axis, if realized. /// - /// The position. /// - /// A tuple containing the index of the element (or -1 if not found) and the position of the element on the - /// primary axis. + /// The position of the element, or NaN if the element is not realized. /// - public (int index, double position) GetElementAt(double position) + public double GetElementU(int index) { - if (_sizes is null || position < StartU) - return (-1, 0); + if (index < FirstIndex || _sizes is null) + return double.NaN; + + var endIndex = index - FirstIndex; + + if (endIndex >= _sizes.Count) + return double.NaN; var u = StartU; - var i = FirstIndex; - foreach (var size in _sizes) + for (var i = 0; i < endIndex; ++i) + u += _sizes[i]; + + return u; + } + + public double GetOrEstimateElementU(int index, ref double estimatedElementSizeU) + { + // Return the position of the existing element if realized. + var u = GetElementU(index); + + if (!double.IsNaN(u)) + return u; + + // Estimate the element size, using defaultElementSizeU if we don't have any realized + // elements. + var estimatedSize = EstimateElementSizeU() switch { - var endU = u + size; - if (position < endU) - return (i, u); - u += size; - ++i; - } + -1 => estimatedElementSizeU, + double v => v, + }; + + // Store the estimated size for the next layout pass. + estimatedElementSizeU = estimatedSize; - return (-1, 0); + // TODO: Use _startU to work this out. + return index * estimatedSize; } /// @@ -171,13 +229,6 @@ namespace Avalonia.Controls.Utils var total = 0.0; var divisor = 0.0; - // Start by averaging the size of the elements before the first realized element. - if (FirstIndex >= 0 && !_startUUnstable) - { - total += _startU; - divisor += FirstIndex; - } - // Average the size of the realized elements. if (_sizes is not null) { diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 7cca0986ad..9378bc391d 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -60,8 +60,8 @@ namespace Avalonia.Controls private readonly Action _recycleElement; private readonly Action _recycleElementOnItemRemoved; private readonly Action _updateElementIndex; - private int _anchorIndex = -1; - private Control? _anchorElement; + private int _scrollToIndex = -1; + private Control? _scrollToElement; private bool _isInLayout; private bool _isWaitingForViewportUpdate; private double _lastEstimatedElementSizeU = 25; @@ -142,48 +142,39 @@ namespace Avalonia.Controls protected override Size MeasureOverride(Size availableSize) { - if (!IsEffectivelyVisible) + var items = Items; + + if (items.Count == 0) return default; + // If we're bringing an item into view, ignore any layout passes until we receive a new + // effective viewport. + if (_isWaitingForViewportUpdate) + return DesiredSize; + _isInLayout = true; try { - var items = Items; var orientation = Orientation; _realizedElements ??= new(); _measureElements ??= new(); - // If we're bringing an item into view, ignore any layout passes until we receive a new - // effective viewport. - if (_isWaitingForViewportUpdate) - { - var sizeV = orientation == Orientation.Horizontal ? DesiredSize.Height : DesiredSize.Width; - return CalculateDesiredSize(orientation, items, sizeV); - } - // We handle horizontal and vertical layouts here so X and Y are abstracted to: // - Horizontal layouts: U = horizontal, V = vertical // - Vertical layouts: U = vertical, V = horizontal var viewport = CalculateMeasureViewport(items); - // Recycle elements outside of the expected range. - _realizedElements.RecycleElementsBefore(viewport.firstIndex, _recycleElement); - _realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement); - // Do the measure, creating/recycling elements as necessary to fill the viewport. Don't // write to _realizedElements yet, only _measureElements. - GenerateElements(availableSize, ref viewport); - - // Now we know what definitely fits, recycle anything left over. - _realizedElements.RecycleElementsAfter(_measureElements.LastIndex, _recycleElement); + RealizeElements(items, availableSize, ref viewport); - // And swap the measureElements and realizedElements collection. + // Now swap the measureElements and realizedElements collection. (_measureElements, _realizedElements) = (_realizedElements, _measureElements); _measureElements.ResetForReuse(); - return CalculateDesiredSize(orientation, items, viewport.measuredV); + return CalculateDesiredSize(orientation, items.Count, viewport); } finally { @@ -338,7 +329,7 @@ namespace Avalonia.Controls { var items = Items; - if (_isInLayout || index < 0 || index >= items.Count) + if (_isInLayout || index < 0 || index >= items.Count || _realizedElements is null) return null; if (GetRealizedElement(index) is Control element) @@ -350,16 +341,16 @@ namespace Avalonia.Controls { // Create and measure the element to be brought into view. Store it in a field so that // it can be re-used in the layout pass. - _anchorElement = GetOrCreateElement(items, index); - _anchorElement.Measure(Size.Infinity); - _anchorIndex = index; + _scrollToElement = GetOrCreateElement(items, index); + _scrollToElement.Measure(Size.Infinity); + _scrollToIndex = index; // Get the expected position of the elment and put it in place. - var anchorU = GetOrEstimateElementPosition(index); + var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU); var rect = Orientation == Orientation.Horizontal ? - new Rect(anchorU, 0, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height) : - new Rect(0, anchorU, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height); - _anchorElement.Arrange(rect); + new Rect(anchorU, 0, _scrollToElement.DesiredSize.Width, _scrollToElement.DesiredSize.Height) : + new Rect(0, anchorU, _scrollToElement.DesiredSize.Width, _scrollToElement.DesiredSize.Height); + _scrollToElement.Arrange(rect); // If the item being brought into view was added since the last layout pass then // our bounds won't be updated, so any containing scroll viewers will not have an @@ -373,7 +364,7 @@ namespace Avalonia.Controls } // Try to bring the item into view. - _anchorElement.BringIntoView(); + _scrollToElement.BringIntoView(); // If the viewport does not contain the item to scroll to, set _isWaitingForViewportUpdate: // this should cause the following chain of events: @@ -392,9 +383,9 @@ namespace Avalonia.Controls root.LayoutManager.ExecuteLayoutPass(); } - var result = _anchorElement; - _anchorElement = null; - _anchorIndex = -1; + var result = _scrollToElement; + _scrollToElement = null; + _scrollToIndex = -1; return result; } @@ -418,46 +409,34 @@ namespace Avalonia.Controls var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y; var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom; - var (firstIndex, firstIndexU) = _realizedElements.GetIndexAt(viewportStart); - var (lastIndex, _) = _realizedElements.GetIndexAt(viewportEnd); - var estimatedElementSize = -1.0; var itemCount = items?.Count ?? 0; - var maxIndex = Math.Max(itemCount - 1, 0); - - if (firstIndex == -1) - { - estimatedElementSize = EstimateElementSizeU(); - firstIndex = Math.Min((int)(viewportStart / estimatedElementSize), maxIndex); - firstIndexU = firstIndex * estimatedElementSize; - } - - if (lastIndex == -1) - { - if (estimatedElementSize == -1) - estimatedElementSize = EstimateElementSizeU(); - lastIndex = Math.Min((int)(viewportEnd / estimatedElementSize), maxIndex); - } + var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( + viewportStart, + viewportEnd, + itemCount, + ref _lastEstimatedElementSizeU); return new MeasureViewport { - firstIndex = firstIndex, - lastIndex = lastIndex, + anchorIndex = anchorIndex, + anchorU = anchorU, viewportUStart = viewportStart, viewportUEnd = viewportEnd, - startU = firstIndexU, }; } - private Size CalculateDesiredSize(Orientation orientation, IReadOnlyList items, double sizeV) + private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport) { - var sizeU = EstimateElementSizeU() * items.Count; + var sizeU = 0.0; + var sizeV = viewport.measuredV; - if (double.IsInfinity(sizeU) || double.IsNaN(sizeU)) - throw new InvalidOperationException("Invalid calculated size."); + if (viewport.lastIndex >= 0) + { + var remaining = itemCount - viewport.lastIndex - 1; + sizeU = viewport.realizedEndU + (remaining * _lastEstimatedElementSizeU); + } - return orientation == Orientation.Horizontal ? - new Size(sizeU, sizeV) : - new Size(sizeV, sizeU); + return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU); } private double EstimateElementSizeU() @@ -498,19 +477,25 @@ namespace Avalonia.Controls return viewport; } - private void GenerateElements(Size availableSize, ref MeasureViewport viewport) + private void RealizeElements( + IReadOnlyList items, + Size availableSize, + ref MeasureViewport viewport) { Debug.Assert(_measureElements is not null); + Debug.Assert(_realizedElements is not null); + Debug.Assert(items.Count > 0); - var items = Items; + var index = viewport.anchorIndex; var horizontal = Orientation == Orientation.Horizontal; - var index = viewport.firstIndex; - var u = viewport.startU; + var u = viewport.anchorU; - // The layout is likely invalid. Don't create any elements and instead rely on our previous - // element size estimates to calculate a new desired size and trigger a new layout pass. - if (index >= items.Count) - return; + // If the anchor element is at the beginning of, or before, the start of the viewport + // then we can recycle all elements before it. + if (u <= viewport.anchorU) + _realizedElements.RecycleElementsBefore(viewport.anchorIndex, _recycleElement); + + // Start at the anchor element and move forwards, realizing elements. do { var e = GetOrCreateElement(items, index); @@ -525,6 +510,34 @@ namespace Avalonia.Controls u += sizeU; ++index; } while (u < viewport.viewportUEnd && index < items.Count); + + // Store the last index and end U position for the desired size calculation. + viewport.lastIndex = index - 1; + viewport.realizedEndU = u; + + // We can now recycle elements after the last element. + _realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement); + + // Next move backwards from the anchor element, realizing elements. + index = viewport.anchorIndex - 1; + u = viewport.anchorU; + + while (u > viewport.viewportUStart && index >= 0) + { + var e = GetOrCreateElement(items, index); + e.Measure(availableSize); + + var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; + var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; + u -= sizeU; + + _measureElements!.Add(index, e, u, sizeU); + viewport.measuredV = Math.Max(viewport.measuredV, sizeV); + --index; + } + + // We can now recycle elements before the first element. + _realizedElements.RecycleElementsBefore(index + 1, _recycleElement); } private Control GetOrCreateElement(IReadOnlyList items, int index) @@ -539,8 +552,8 @@ namespace Avalonia.Controls private Control? GetRealizedElement(int index) { - if (_anchorIndex == index) - return _anchorElement; + if (_scrollToIndex == index) + return _scrollToElement; return _realizedElements?.GetElement(index); } @@ -612,12 +625,6 @@ namespace Avalonia.Controls return container; } - private double GetOrEstimateElementPosition(int index) - { - var estimatedElementSize = EstimateElementSizeU(); - return index * estimatedElementSize; - } - private void RecycleElement(Control element, int index) { Debug.Assert(ItemContainerGenerator is not null); @@ -881,12 +888,13 @@ namespace Avalonia.Controls private struct MeasureViewport { - public int firstIndex; - public int lastIndex; + public int anchorIndex; + public double anchorU; public double viewportUStart; public double viewportUEnd; public double measuredV; - public double startU; + public double realizedEndU; + public int lastIndex; } } }