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