Skip to content

Commit

Permalink
Make VirtualizingStackPanel better handle container size changes (#16168
Browse files Browse the repository at this point in the history
)

* Add a failing test for #15712.

* Validate StartU at the start of a measure pass.

If any container U size has changed since the last layout pass then `StartU` must be considered unstable as the average container height will have changed.

* Correctly position focused element.

If the focused element has been moved outside the visible viewport due to a realized container size change, then we need to ensure it's positioned correctly.

* We can skip check if StartU is already unstable.

* Don't invalidate virt. panels more than necessary.

* Add another virt panel test.

And revert the expected results for another test to the way they were at the beginning of this PR.

* Tweak container size estimation.

Use the desired size of _measured_ containers instead of the bounds: a layout pass may not had completed on the containers yet, so the bounds may not be up-to-date. Was easier to move the estimation methods out of `RealizedStackElements` and into `VirtualizingStackPanel` itself in order to do this, and arguably makes more sense.
  • Loading branch information
grokys committed Aug 2, 2024
1 parent 9c920b4 commit 97d7285
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 138 deletions.
163 changes: 34 additions & 129 deletions src/Avalonia.Controls/Utils/RealizedStackElements.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Avalonia.Layout;
using Avalonia.Utilities;

namespace Avalonia.Controls.Utils
Expand Down Expand Up @@ -42,16 +43,17 @@ internal class RealizedStackElements
public IReadOnlyList<double> SizeU => _sizes ??= new List<double>();

/// <summary>
/// Gets the position of the first element on the primary axis.
/// Gets the position of the first element on the primary axis, or NaN if the position is
/// unstable.
/// </summary>
public double StartU => _startU;
public double StartU => _startUUnstable ? double.NaN : _startU;

/// <summary>
/// Adds a newly realized element to the collection.
/// </summary>
/// <param name="index">The index of the element.</param>
/// <param name="element">The element.</param>
/// <param name="u">The position of the elemnt on the primary axis.</param>
/// <param name="u">The position of the element on the primary axis.</param>
/// <param name="sizeU">The size of the element on the primary axis.</param>
public void Add(int index, Control element, double u, double sizeU)
{
Expand Down Expand Up @@ -99,76 +101,6 @@ public void Add(int index, Control element, double u, double sizeU)
return null;
}

/// <summary>
/// Gets or estimates the index and start U position of the anchor element for the
/// specified viewport.
/// </summary>
/// <param name="viewportStartU">The U position of the start of the viewport.</param>
/// <param name="viewportEndU">The U position of the end of the viewport.</param>
/// <param name="itemCount">The number of items in the list.</param>
/// <param name="estimatedElementSizeU">The current estimated element size.</param>
/// <returns>
/// A tuple containing:
/// - 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
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
public (int index, double position) GetOrEstimateAnchorElementForViewport(
double viewportStartU,
double viewportEndU,
int itemCount,
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);

if (_sizes is not null && !_startUUnstable)
{
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;
}
}

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

/// <summary>
/// Gets the position of the element with the requested index on the primary axis, if realized.
/// </summary>
Expand All @@ -193,61 +125,6 @@ public double GetElementU(int index)
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
{
-1 => estimatedElementSizeU,
double v => v,
};

// Store the estimated size for the next layout pass.
estimatedElementSizeU = estimatedSize;

// TODO: Use _startU to work this out.
return index * estimatedSize;
}

/// <summary>
/// Estimates the average U size of all elements in the source collection based on the
/// realized elements.
/// </summary>
/// <returns>
/// The estimated U size of an element, or -1 if not enough information is present to make
/// an estimate.
/// </returns>
public double EstimateElementSizeU()
{
var total = 0.0;
var divisor = 0.0;

// Average the size of the realized elements.
if (_sizes is not null)
{
foreach (var size in _sizes)
{
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;
}

/// <summary>
/// Gets the index of the specified element.
/// </summary>
Expand Down Expand Up @@ -538,6 +415,34 @@ public void ResetForReuse()
_elements?.Clear();
_sizes?.Clear();
}
}

/// <summary>
/// Validates that <see cref="StartU"/> is still valid.
/// </summary>
/// <param name="orientation">The panel orientation.</param>
/// <remarks>
/// If the U size of any element in the realized elements has changed, then the value of
/// <see cref="StartU"/> should be considered unstable.
/// </remarks>
public void ValidateStartU(Orientation orientation)
{
if (_elements is null || _sizes is null || _startUUnstable)
return;

for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is not { } element)
continue;

var sizeU = orientation == Orientation.Horizontal ?
element.DesiredSize.Width : element.DesiredSize.Height;

if (sizeU != _sizes[i])
{
_startUUnstable = true;
break;
}
}
}
}
}
6 changes: 6 additions & 0 deletions src/Avalonia.Controls/VirtualizingPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ protected void RemoveInternalChildRange(int index, int count)
Children.RemoveRange(index, count);
}

private protected override void InvalidateMeasureOnChildrenChanged()
{
// Don't invalidate measure when children are added or removed: the panel is responsible
// for managing its children.
}

internal void Attach(ItemsControl itemsControl)
{
if (ItemsControl is not null)
Expand Down
117 changes: 110 additions & 7 deletions src/Avalonia.Controls/VirtualizingStackPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Utils;
Expand Down Expand Up @@ -159,6 +160,7 @@ protected override Size MeasureOverride(Size availableSize)

try
{
_realizedElements?.ValidateStartU(Orientation);
_realizedElements ??= new();
_measureElements ??= new();

Expand All @@ -179,6 +181,10 @@ protected override Size MeasureOverride(Size availableSize)
(_measureElements, _realizedElements) = (_realizedElements, _measureElements);
_measureElements.ResetForReuse();

// If there is a focused element is outside the visible viewport (i.e.
// _focusedElement is non-null), ensure it's measured.
_focusedElement?.Measure(availableSize);

return CalculateDesiredSize(orientation, items.Count, viewport);
}
finally
Expand Down Expand Up @@ -215,6 +221,16 @@ protected override Size ArrangeOverride(Size finalSize)
}
}

// Ensure that the focused element is in the correct position.
if (_focusedElement is not null && _focusedIndex >= 0)
{
u = GetOrEstimateElementU(_focusedIndex);
var rect = orientation == Orientation.Horizontal ?
new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) :
new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height);
_focusedElement.Arrange(rect);
}

return finalSize;
}
finally
Expand Down Expand Up @@ -389,7 +405,7 @@ protected internal override int IndexFromContainer(Control container)
scrollToElement.Measure(Size.Infinity);

// Get the expected position of the element and put it in place.
var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU);
var anchorU = GetOrEstimateElementU(index);
var rect = Orientation == Orientation.Horizontal ?
new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) :
new Rect(0, anchorU, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height);
Expand Down Expand Up @@ -472,11 +488,12 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> items)
}
else
{
(anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport(
GetOrEstimateAnchorElementForViewport(
viewportStart,
viewportEnd,
items.Count,
ref _lastEstimatedElementSizeU);
out anchorIndex,
out anchorU);
}

// Check if the anchor element is not within the currently realized elements.
Expand Down Expand Up @@ -531,12 +548,98 @@ private double EstimateElementSizeU()
if (_realizedElements is null)
return _lastEstimatedElementSizeU;

var result = _realizedElements.EstimateElementSizeU();
if (result >= 0)
_lastEstimatedElementSizeU = result;
return _lastEstimatedElementSizeU;
var orientation = Orientation;
var total = 0.0;
var divisor = 0.0;

// Average the desired size of the realized, measured elements.
foreach (var element in _realizedElements.Elements)
{
if (element is null || !element.IsMeasureValid)
continue;
var sizeU = orientation == Orientation.Horizontal ?
element.DesiredSize.Width :
element.DesiredSize.Height;
total += sizeU;
++divisor;
}

// Check we have enough information on which to base our estimate.
if (divisor == 0 || total == 0)
return _lastEstimatedElementSizeU;

// Store and return the estimate.
return _lastEstimatedElementSizeU = total / divisor;
}

private void GetOrEstimateAnchorElementForViewport(
double viewportStartU,
double viewportEndU,
int itemCount,
out int index,
out double position)
{
// We have no elements, or we're at the start of the viewport.
if (itemCount <= 0 || MathUtilities.IsZero(viewportStartU))
{
index = 0;
position = 0;
return;
}

// If we have realised elements and a valid StartU then try to use this information to
// get the anchor element.
if (_realizedElements?.StartU is { } u && !double.IsNaN(u))
{
var orientation = Orientation;

for (var i = 0; i < _realizedElements.Elements.Count; ++i)
{
if (_realizedElements.Elements[i] is not { } element)
continue;

var sizeU = orientation == Orientation.Horizontal ?
element.DesiredSize.Width :
element.DesiredSize.Height;
var endU = u + sizeU;

if (endU > viewportStartU && u < viewportEndU)
{
index = _realizedElements.FirstIndex + i;
position = u;
return;
}

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 element size.
var estimatedSize = EstimateElementSizeU();

// Estimate the element at the start of the viewport.
var startIndex = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1);
index = startIndex;
position = startIndex * estimatedSize;
}

private double GetOrEstimateElementU(int index)
{
// Return the position of the existing element if realized.
var u = _realizedElements?.GetElementU(index) ?? double.NaN;

if (!double.IsNaN(u))
return u;

// Estimate the element size.
var estimatedSize = EstimateElementSizeU();

// TODO: Use _startU to work this out.
return index * estimatedSize;
}


private void RealizeElements(
IReadOnlyList<object?> items,
Size availableSize,
Expand Down
Loading

0 comments on commit 97d7285

Please sign in to comment.