Browse Source

Make VirtualizingStackPanel better handle container size changes (#16168)

* 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.
release/11.0.12
Steven Kirk 2 years ago
parent
commit
5062f9a358
  1. 163
      src/Avalonia.Controls/Utils/RealizedStackElements.cs
  2. 6
      src/Avalonia.Controls/VirtualizingPanel.cs
  3. 117
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  4. 117
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

163
src/Avalonia.Controls/Utils/RealizedStackElements.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Avalonia.Layout;
using Avalonia.Utilities;
namespace Avalonia.Controls.Utils
@ -42,16 +43,17 @@ namespace Avalonia.Controls.Utils
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)
{
@ -99,76 +101,6 @@ namespace Avalonia.Controls.Utils
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>
@ -193,61 +125,6 @@ namespace Avalonia.Controls.Utils
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>
@ -538,6 +415,34 @@ namespace Avalonia.Controls.Utils
_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
src/Avalonia.Controls/VirtualizingPanel.cs

@ -192,6 +192,12 @@ namespace Avalonia.Controls
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)

117
src/Avalonia.Controls/VirtualizingStackPanel.cs

@ -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;
@ -159,6 +160,7 @@ namespace Avalonia.Controls
try
{
_realizedElements?.ValidateStartU(Orientation);
_realizedElements ??= new();
_measureElements ??= new();
@ -179,6 +181,10 @@ namespace Avalonia.Controls
(_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
@ -215,6 +221,16 @@ namespace Avalonia.Controls
}
}
// 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
@ -389,7 +405,7 @@ namespace Avalonia.Controls
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);
@ -474,11 +490,12 @@ namespace Avalonia.Controls
}
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.
@ -533,12 +550,98 @@ namespace Avalonia.Controls
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,

117
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@ -1192,6 +1192,112 @@ namespace Avalonia.Controls.UnitTests
AssertRealizedItems(target, itemsControl, 15, 5);
}
[Fact]
public void Extent_And_Offset_Should_Be_Updated_When_Containers_Resize()
{
using var app = App();
// All containers start off with a height of 50 (2 containers fit in viewport).
var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, 50)).ToList();
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
// Scroll to the 5th item (containers 4 and 5 should be visible).
target.ScrollIntoView(5);
Assert.Equal(4, target.FirstRealizedIndex);
Assert.Equal(5, target.LastRealizedIndex);
// The extent should be 500 (10 * 50) and the offset should be 200 (4 * 50).
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
Assert.Equal(new Rect(0, 250, 100, 50), container.Bounds);
Assert.Equal(new Size(100, 100), scroll.Viewport);
Assert.Equal(new Size(100, 1000), scroll.Extent);
Assert.Equal(new Vector(0, 200), scroll.Offset);
// Update the height of all items to 25 and run a layout pass.
foreach (var item in items)
item.Height = 25;
target.UpdateLayout();
// The extent should be updated to reflect the new heights. The offset should be
// unchanged but the first realized index should be updated to 8 (200 / 25).
Assert.Equal(new Size(100, 100), scroll.Viewport);
Assert.Equal(new Size(100, 500), scroll.Extent);
Assert.Equal(new Vector(0, 200), scroll.Offset);
Assert.Equal(8, target.FirstRealizedIndex);
Assert.Equal(11, target.LastRealizedIndex);
}
[Fact]
public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change_Causes_It_To_Be_Moved_Out_Of_Visible_Viewport()
{
using var app = App();
// All containers start off with a height of 50 (2 containers fit in viewport).
var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, 50)).ToList();
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
// Scroll to the 5th item (containers 4 and 5 should be visible).
target.ScrollIntoView(5);
Assert.Equal(4, target.FirstRealizedIndex);
Assert.Equal(5, target.LastRealizedIndex);
// Focus the 5th item.
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
container.Focusable = true;
container.Focus();
// Update the height of all items to 25 and run a layout pass.
foreach (var item in items)
item.Height = 25;
target.UpdateLayout();
// The focused container should now be outside the realized range.
Assert.Equal(8, target.FirstRealizedIndex);
Assert.Equal(11, target.LastRealizedIndex);
// The container should still exist and be positioned outside the visible viewport.
container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
Assert.Equal(new Rect(0, 125, 100, 25), container.Bounds);
}
[Fact]
public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change_Causes_It_To_Be_Moved_Into_Visible_Viewport()
{
using var app = App();
// All containers start off with a height of 25 (4 containers fit in viewport).
var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, 25)).ToList();
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
// Scroll to the 5th item (containers 4-7 should be visible).
target.ScrollIntoView(7);
Assert.Equal(4, target.FirstRealizedIndex);
Assert.Equal(7, target.LastRealizedIndex);
// Focus the 7th item.
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(7));
container.Focusable = true;
container.Focus();
// Scroll up to the 3rd item (containers 3-6 should still be visible).
target.ScrollIntoView(3);
Assert.Equal(3, target.FirstRealizedIndex);
Assert.Equal(6, target.LastRealizedIndex);
// Update the height of all items to 20 and run a layout pass.
foreach (var item in items)
item.Height = 20;
target.UpdateLayout();
// The focused container should now be inside the realized range.
Assert.Equal(3, target.FirstRealizedIndex);
Assert.Equal(7, target.LastRealizedIndex);
// The container should be positioned correctly.
container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(7));
Assert.Equal(new Rect(0, 140, 100, 20), container.Bounds);
}
private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
{
return target.GetRealizedElements()
@ -1366,8 +1472,10 @@ namespace Avalonia.Controls.UnitTests
private static IDisposable App() => UnitTestApplication.Start(TestServices.RealFocus);
private class ItemWithHeight
private class ItemWithHeight : NotifyingBase
{
private double _height;
public ItemWithHeight(int index, double height = 10)
{
Caption = $"Item {index}";
@ -1375,7 +1483,12 @@ namespace Avalonia.Controls.UnitTests
}
public string Caption { get; set; }
public double Height { get; set; }
public double Height
{
get => _height;
set => SetField(ref _height, value);
}
}
private class ItemWithIsVisible : NotifyingBase

Loading…
Cancel
Save