Browse Source

add virtualized wrap panel

pull/11251/head
Emmanuel Hansen 3 years ago
parent
commit
c849bd6252
  1. 2
      samples/ControlCatalog/Pages/CompositionPage.axaml
  2. 535
      src/Avalonia.Controls/Utils/RealizedWrappedElements.cs
  3. 42
      src/Avalonia.Controls/Utils/UVSize.cs
  4. 817
      src/Avalonia.Controls/VirtualizingWrapPanel.cs
  5. 34
      src/Avalonia.Controls/WrapPanel.cs
  6. 773
      tests/Avalonia.Controls.UnitTests/VirtualizingWrapPanelTests.cs

2
samples/ControlCatalog/Pages/CompositionPage.axaml

@ -9,7 +9,7 @@
<ItemsControl x:Name="Items">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
<VirtualizingWrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.DataTemplates>

535
src/Avalonia.Controls/Utils/RealizedWrappedElements.cs

@ -0,0 +1,535 @@
using System;
using System.Collections.Generic;
using Avalonia.Layout;
using Avalonia.Utilities;
namespace Avalonia.Controls.Utils
{
/// <summary>
/// Stores the realized element state for a virtualizing panel that arranges its children
/// in a stack layout, wrapping around when layout reaches the end, such as <see cref="VirtualizingWrapPanel"/>.
/// </summary>
internal class RealizedWrappedElements
{
private int _firstIndex;
private List<Control?>? _elements;
private List<UVSize>? _sizes;
private List<UVSize>? _positions;
private UVSize _startUV;
private bool _startUUnstable;
/// <summary>
/// Gets the number of realized elements.
/// </summary>
public int Count => _elements?.Count ?? 0;
/// <summary>
/// Gets the index of the first realized element, or -1 if no elements are realized.
/// </summary>
public int FirstIndex => _elements?.Count > 0 ? _firstIndex : -1;
/// <summary>
/// Gets the index of the last realized element, or -1 if no elements are realized.
/// </summary>
public int LastIndex => _elements?.Count > 0 ? _firstIndex + _elements.Count - 1 : -1;
/// <summary>
/// Gets the elements.
/// </summary>
public IReadOnlyList<Control?> Elements => _elements ??= new List<Control?>();
/// <summary>
/// Gets the sizes of the elements on the primary axis.
/// </summary>
public IReadOnlyList<UVSize> SizeUV => _sizes ??= new List<UVSize>();
public IReadOnlyList<UVSize> PositionsUV => _positions ??= new List<UVSize>();
/// <summary>
/// Gets the position of the first element on the primary axis.
/// </summary>
public UVSize StartUV => _startUV;
/// <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="uv">The position of the elemnt.</param>
/// <param name="sizeUV">The size of the element.</param>
public void Add(int index, Control element, Orientation orientation, UVSize uv, UVSize sizeUV)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
_elements ??= new List<Control?>();
_sizes ??= new List<UVSize>();
_positions ??= new List<UVSize>();
var size = sizeUV;
if (Count == 0)
{
_elements.Add(element);
_sizes.Add(size);
_positions.Add(uv);
_startUV = uv;
_firstIndex = index;
}
else if (index == LastIndex + 1)
{
_elements.Add(element);
_sizes.Add(size);
_positions.Add(uv);
}
else if (index == FirstIndex - 1)
{
--_firstIndex;
_elements.Insert(0, element);
_sizes.Insert(0, size);
_positions.Insert(0, uv);
_startUV = uv;
}
else
{
throw new NotSupportedException("Can only add items to the beginning or end of realized elements.");
}
}
/// <summary>
/// Gets the element at the specified index, if realized.
/// </summary>
/// <param name="index">The index in the source collection of the element to get.</param>
/// <returns>The element if realized; otherwise null.</returns>
public Control? GetElement(int index)
{
var i = index - FirstIndex;
if (i >= 0 && i < _elements?.Count)
return _elements[i];
return null;
}
/// <summary>
/// Gets or estimates the index and start U position of the anchor element for the
/// specified viewport.
/// </summary>
/// <param name="viewportStart">The UV position of the start of the viewport.</param>
/// <param name="viewportEnd">The UV position of the end of the viewport.</param>
/// <param name="itemCount">The number of items in the list.</param>
/// <param name="estimatedElementSize">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, UVSize position) GetOrEstimateAnchorElementForViewport(
UVSize viewportStart,
UVSize viewportEnd,
int itemCount,
ref UVSize estimatedElementSize)
{
// We have no elements, nothing to do here.
if (itemCount <= 0)
return (-1, new UVSize(viewportStart.Orientation));
// If we're at 0 then display the first item.
if (MathUtilities.IsZero(viewportStart.U) && MathUtilities.IsZero(viewportStart.V))
return (0, new UVSize(viewportStart.Orientation));
if (_positions is not null && _sizes is not null && !_startUUnstable)
{
for (var i = 0; i < _positions.Count; ++i)
{
var position = _positions[i];
var size = _sizes[i];
if (position.IsNaN)
break;
var end = position.V + size.V;
if (end > viewportStart.V && end < viewportEnd.V)
return (FirstIndex + i, position);
}
}
// 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 = EstimateElementSize(viewportStart.Orientation) switch
{
null => estimatedElementSize,
UVSize v => v,
};
// Store the estimated size for the next layout pass.
estimatedElementSize = estimatedSize;
// Estimate the element at the start of the viewport.
var index = Math.Min((int)(viewportStart.V / estimatedSize.V) * (int)(viewportEnd.U / estimatedSize.U) + (int)(viewportStart.U / estimatedSize.U), itemCount - 1);
return (index, GetPosition(index, estimatedSize, viewportEnd));
}
private UVSize GetPosition(int index, UVSize estimate, UVSize viewportEnd)
{
var maxULength = (int)(viewportEnd.U / estimate.U) * estimate.U;
return new UVSize(viewportEnd.Orientation)
{
U = index * estimate.U % maxULength,
V = (int)(index * estimate.U) / maxULength * estimate.V
};
}
/// <summary>
/// Gets the position of the element with the requested index on the primary axis, if realized.
/// </summary>
/// <returns>
/// The position of the element, or null if the element is not realized.
/// </returns>
public UVSize? GetElementUV(int index)
{
if (index < FirstIndex || _positions is null)
return null;
var endIndex = index - FirstIndex;
if (endIndex >= _positions.Count)
return null;
return _positions[index];
}
public UVSize GetOrEstimateElementUV(int index, ref UVSize estimatedElementSizeUV, UVSize viewportEnd)
{
// Return the position of the existing element if realized.
var uv = GetElementUV(index);
if (uv != null)
return uv.Value;
// Estimate the element size, using estimatedElementSizeUV if we don't have any realized
// elements.
var estimatedSize = EstimateElementSize(estimatedElementSizeUV.Orientation) switch
{
null => estimatedElementSizeUV,
UVSize uvSize => uvSize,
};
// Store the estimated size for the next layout pass.
estimatedElementSizeUV = estimatedSize;
return GetPosition(index, estimatedSize, viewportEnd);
}
/// <summary>
/// Estimates the average UV size of all elements in the source collection based on the
/// realized elements.
/// </summary>
/// <returns>
/// The estimated UV size of an element, or null if not enough information is present to make
/// an estimate.
/// </returns>
public UVSize? EstimateElementSize(Orientation orientation)
{
var divisor = 0.0;
var u = 0.0;
var v = 0.0;
// Average the size of the realized elements.
if (_sizes is not null)
{
foreach (var size in _sizes)
{
if (size.IsNaN)
continue;
u += size.U;
v += size.V;
++divisor;
}
}
// We don't have any elements on which to base our estimate.
if (divisor == 0 || u == 0 || v == 0)
return null;
return new UVSize(orientation)
{
U = u / divisor,
V = v / divisor
};
}
/// <summary>
/// Gets the index of the specified element.
/// </summary>
/// <param name="element">The element.</param>
/// <returns>The index or -1 if the element is not present in the collection.</returns>
public int GetIndex(Control element)
{
return _elements?.IndexOf(element) is int index && index >= 0 ? index + FirstIndex : -1;
}
/// <summary>
/// Updates the elements in response to items being inserted into the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the insert.</param>
/// <param name="count">The number of items inserted.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
public void ItemsInserted(int index, int count, Action<Control, int, int> updateElementIndex)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the index within the realized _elements collection.
var first = FirstIndex;
var realizedIndex = index - first;
if (realizedIndex < Count)
{
// The insertion point affects the realized elements. Update the index of the
// elements after the insertion point.
var elementCount = _elements.Count;
var start = Math.Max(realizedIndex, 0);
var newIndex = realizedIndex + count;
for (var i = start; i < elementCount; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
if (realizedIndex < 0)
{
// The insertion point was before the first element, update the first index.
_firstIndex += count;
}
else
{
// The insertion point was within the realized elements, insert an empty space
// in _elements and _sizes.
_elements!.InsertMany(realizedIndex, null, count);
_sizes!.InsertMany(realizedIndex, new UVSize(Orientation.Horizontal, double.NaN, double.NaN), count);
_positions!.InsertMany(realizedIndex, new UVSize(Orientation.Horizontal, double.NaN, double.NaN), count);
}
}
}
/// <summary>
/// Updates the elements in response to items being removed from the source collection.
/// </summary>
/// <param name="index">The index in the source collection of the remove.</param>
/// <param name="count">The number of items removed.</param>
/// <param name="updateElementIndex">A method used to update the element indexes.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsRemoved(
int index,
int count,
Action<Control, int, int> updateElementIndex,
Action<Control> recycleElement)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (_elements is null || _elements.Count == 0)
return;
// Get the removal start and end index within the realized _elements collection.
var first = FirstIndex;
var last = LastIndex;
var startIndex = index - first;
var endIndex = index + count - first;
if (endIndex < 0)
{
// The removed range was before the realized elements. Update the first index and
// the indexes of the realized elements.
_firstIndex -= count;
_startUUnstable = true;
var newIndex = _firstIndex;
for (var i = 0; i < _elements.Count; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex - count, newIndex);
++newIndex;
}
}
else if (startIndex < _elements.Count)
{
// Recycle and remove the affected elements.
var start = Math.Max(startIndex, 0);
var end = Math.Min(endIndex, _elements.Count);
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
{
_elements[i] = null;
recycleElement(element);
}
}
_elements.RemoveRange(start, end - start);
_sizes!.RemoveRange(start, end - start);
_positions!.RemoveRange(start, end - start);
// If the remove started before and ended within our realized elements, then our new
// first index will be the index where the remove started. Mark StartU as unstable
// because we can't rely on it now to estimate element heights.
if (startIndex <= 0 && end < last)
{
_firstIndex = first = index;
_startUUnstable = true;
}
// Update the indexes of the elements after the removed range.
end = _elements.Count;
var newIndex = first + start;
for (var i = start; i < end; ++i)
{
if (_elements[i] is Control element)
updateElementIndex(element, newIndex + count, newIndex);
++newIndex;
}
}
}
/// <summary>
/// Recycles all elements in response to the source collection being reset.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void ItemsReset(Action<Control> recycleElement, Orientation orientation)
{
if (_elements is null || _elements.Count == 0)
return;
for (var i = 0; i < _elements.Count; i++)
{
if (_elements[i] is Control e)
{
_elements[i] = null;
recycleElement(e);
}
}
_firstIndex = 0;
_startUV = new UVSize(orientation);
_elements?.Clear();
_sizes?.Clear();
_positions?.Clear();
}
/// <summary>
/// Recycles elements before a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new first element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsBefore(int index, Action<Control, int> recycleElement, Orientation orientation)
{
if (index <= FirstIndex || _elements is null || _elements.Count == 0)
return;
if (index > LastIndex)
{
RecycleAllElements(recycleElement, orientation);
}
else
{
var endIndex = index - FirstIndex;
for (var i = 0; i < endIndex; ++i)
{
if (_elements[i] is Control e)
{
_elements[i] = null;
recycleElement(e, i + FirstIndex);
}
}
_elements.RemoveRange(0, endIndex);
_sizes!.RemoveRange(0, endIndex);
_positions!.RemoveRange(0, endIndex);
_firstIndex = index;
}
}
/// <summary>
/// Recycles elements after a specific index.
/// </summary>
/// <param name="index">The index in the source collection of new last element.</param>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleElementsAfter(int index, Action<Control, int> recycleElement, Orientation orientation)
{
if (index >= LastIndex || _elements is null || _elements.Count == 0)
return;
if (index < FirstIndex)
{
RecycleAllElements(recycleElement, orientation);
}
else
{
var startIndex = index + 1 - FirstIndex;
var count = _elements.Count;
for (var i = startIndex; i < count; ++i)
{
if (_elements[i] is Control e)
{
_elements[i] = null;
recycleElement(e, i + FirstIndex);
}
}
_elements.RemoveRange(startIndex, _elements.Count - startIndex);
_sizes!.RemoveRange(startIndex, _sizes.Count - startIndex);
_positions!.RemoveRange(startIndex, _positions.Count - startIndex);
}
}
/// <summary>
/// Recycles all realized elements.
/// </summary>
/// <param name="recycleElement">A method used to recycle elements.</param>
public void RecycleAllElements(Action<Control, int> recycleElement, Orientation orientation)
{
if (_elements is null || _elements.Count == 0)
return;
for (var i = 0; i < _elements.Count; i++)
{
if (_elements[i] is Control e)
{
_elements[i] = null;
recycleElement(e, i + FirstIndex);
}
}
_firstIndex = 0;
_startUV = new UVSize(orientation);
_elements?.Clear();
_sizes?.Clear();
_positions?.Clear();
}
/// <summary>
/// Resets the element list and prepares it for reuse.
/// </summary>
public void ResetForReuse(Orientation orientation)
{
_firstIndex = 0;
_startUV = new UVSize(orientation);
_startUUnstable = false;
_elements?.Clear();
_sizes?.Clear();
_positions?.Clear();
}
}
}

42
src/Avalonia.Controls/Utils/UVSize.cs

@ -0,0 +1,42 @@
// This source file is adapted from the Windows Presentation Foundation project.
// (https://github.com/dotnet/wpf/)
//
// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
using Avalonia.Layout;
namespace Avalonia.Controls
{
internal struct UVSize
{
internal UVSize(Orientation orientation, double width, double height)
{
U = V = 0d;
Orientation = orientation;
Width = width;
Height = height;
}
internal UVSize(Orientation orientation)
{
U = V = 0d;
Orientation = orientation;
}
internal double U;
internal double V;
internal Orientation Orientation;
internal bool IsNaN => double.IsNaN(U) || double.IsNaN(V);
internal double Width
{
get { return Orientation == Orientation.Horizontal ? U : V; }
set { if (Orientation == Orientation.Horizontal) U = value; else V = value; }
}
internal double Height
{
get { return Orientation == Orientation.Horizontal ? V : U; }
set { if (Orientation == Orientation.Horizontal) V = value; else U = value; }
}
}
}

817
src/Avalonia.Controls/VirtualizingWrapPanel.cs

@ -0,0 +1,817 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Data;
using System.Diagnostics;
using System.Linq;
using Avalonia.Controls.Utils;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Utilities;
using Avalonia.VisualTree;
namespace Avalonia.Controls
{
/// <summary>
/// Positions child elements in sequential position from left to right,
/// breaking content to the next line at the edge of the containing box.
/// Subsequent ordering happens sequentially from top to bottom or from right to left,
/// depending on the value of the <see cref="Orientation"/> property.
/// </summary>
public class VirtualizingWrapPanel : VirtualizingPanel
{
/// <summary>
/// Defines the <see cref="Orientation"/> property.
/// </summary>
public static readonly StyledProperty<Orientation> OrientationProperty =
StackPanel.OrientationProperty.AddOwner<VirtualizingWrapPanel>();
/// <summary>
/// Defines the <see cref="ItemWidth"/> property.
/// </summary>
public static readonly StyledProperty<double> ItemWidthProperty =
AvaloniaProperty.Register<VirtualizingWrapPanel, double>(nameof(ItemWidth), double.NaN);
/// <summary>
/// Defines the <see cref="ItemHeight"/> property.
/// </summary>
public static readonly StyledProperty<double> ItemHeightProperty =
AvaloniaProperty.Register<VirtualizingWrapPanel, double>(nameof(ItemHeight), double.NaN);
private static readonly AttachedProperty<bool> ItemIsOwnContainerProperty =
AvaloniaProperty.RegisterAttached<VirtualizingWrapPanel, Control, bool>("ItemIsOwnContainer");
private static readonly Rect s_invalidViewport = new(double.PositiveInfinity, double.PositiveInfinity, 0, 0);
private readonly Action<Control, int> _recycleElement;
private readonly Action<Control> _recycleElementOnItemRemoved;
private readonly Action<Control, int, int> _updateElementIndex;
private int _scrollToIndex = -1;
private Control? _scrollToElement;
private bool _isInLayout;
private bool _isWaitingForViewportUpdate;
private UVSize _lastEstimatedElementSizeUV = new UVSize(Orientation.Horizontal, 25, 25);
private RealizedWrappedElements? _measureElements;
private RealizedWrappedElements? _realizedElements;
private ScrollViewer? _scrollViewer;
private Rect _viewport = s_invalidViewport;
private Stack<Control>? _recyclePool;
private Control? _unrealizedFocusedElement;
private int _unrealizedFocusedIndex = -1;
static VirtualizingWrapPanel()
{
OrientationProperty.OverrideDefaultValue(typeof(VirtualizingWrapPanel), Orientation.Horizontal);
}
public VirtualizingWrapPanel()
{
_recycleElement = RecycleElement;
_recycleElementOnItemRemoved = RecycleElementOnItemRemoved;
_updateElementIndex = UpdateElementIndex;
EffectiveViewportChanged += OnEffectiveViewportChanged;
}
/// <summary>
/// Gets or sets the axis along which items are laid out.
/// </summary>
/// <value>
/// One of the enumeration values that specifies the axis along which items are laid out.
/// The default is Vertical.
/// </value>
public Orientation Orientation
{
get => GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
/// <summary>
/// Gets or sets the width of all items in the WrapPanel.
/// </summary>
public double ItemWidth
{
get { return GetValue(ItemWidthProperty); }
set { SetValue(ItemWidthProperty, value); }
}
/// <summary>
/// Gets or sets the height of all items in the WrapPanel.
/// </summary>
public double ItemHeight
{
get { return GetValue(ItemHeightProperty); }
set { SetValue(ItemHeightProperty, value); }
}
/// <summary>
/// Gets the index of the first realized element, or -1 if no elements are realized.
/// </summary>
public int FirstRealizedIndex => _realizedElements?.FirstIndex ?? -1;
/// <summary>
/// Gets the index of the last realized element, or -1 if no elements are realized.
/// </summary>
public int LastRealizedIndex => _realizedElements?.LastIndex ?? -1;
protected override Size MeasureOverride(Size availableSize)
{
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 orientation = Orientation;
_realizedElements ??= new();
_measureElements ??= new();
// 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);
// If the viewport is disjunct then we can recycle everything.
if (viewport.viewportIsDisjunct)
_realizedElements.RecycleAllElements(_recycleElement, orientation);
// Do the measure, creating/recycling elements as necessary to fill the viewport. Don't
// write to _realizedElements yet, only _measureElements.
RealizeElements(items, availableSize, ref viewport);
// Now swap the measureElements and realizedElements collection.
(_measureElements, _realizedElements) = (_realizedElements, _measureElements);
_measureElements.ResetForReuse(Orientation);
return CalculateDesiredSize(orientation, items.Count, viewport);
}
finally
{
_isInLayout = false;
}
}
protected override Size ArrangeOverride(Size finalSize)
{
if (_realizedElements is null)
return default;
_isInLayout = true;
try
{
var orientation = Orientation;
for (var i = 0; i < _realizedElements.Count; ++i)
{
var e = _realizedElements.Elements[i];
if (e is not null)
{
var sizeUV = _realizedElements.SizeUV[i];
var positionUV = _realizedElements.PositionsUV[i];
var rect = new Rect(positionUV.Width, positionUV.Height, sizeUV.Width, sizeUV.Height);
e.Arrange(rect);
_scrollViewer?.RegisterAnchorCandidate(e);
}
}
return finalSize;
}
finally
{
_isInLayout = false;
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_scrollViewer = this.FindAncestorOfType<ScrollViewer>();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_scrollViewer = null;
}
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
InvalidateMeasure();
if (_realizedElements is null)
return;
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
_realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex);
break;
case NotifyCollectionChangedAction.Remove:
_realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved);
break;
case NotifyCollectionChangedAction.Replace:
case NotifyCollectionChangedAction.Move:
_realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved);
_realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex);
break;
case NotifyCollectionChangedAction.Reset:
_realizedElements.ItemsReset(_recycleElementOnItemRemoved, Orientation);
break;
}
}
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
var count = Items.Count;
if (count == 0 || from is not Control fromControl)
return null;
var horiz = Orientation == Orientation.Horizontal;
var fromIndex = from != null ? IndexFromContainer(fromControl) : -1;
var toIndex = fromIndex;
switch (direction)
{
case NavigationDirection.First:
toIndex = 0;
break;
case NavigationDirection.Last:
toIndex = count - 1;
break;
case NavigationDirection.Next:
++toIndex;
break;
case NavigationDirection.Previous:
--toIndex;
break;
case NavigationDirection.Left:
if (horiz)
--toIndex;
break;
case NavigationDirection.Right:
if (horiz)
++toIndex;
break;
case NavigationDirection.Up:
if (!horiz)
--toIndex;
break;
case NavigationDirection.Down:
if (!horiz)
++toIndex;
break;
default:
return null;
}
if (fromIndex == toIndex)
return from;
if (wrap)
{
if (toIndex < 0)
toIndex = count - 1;
else if (toIndex >= count)
toIndex = 0;
}
return ScrollIntoView(toIndex);
}
protected internal override IEnumerable<Control>? GetRealizedContainers()
{
return _realizedElements?.Elements.Where(x => x is not null)!;
}
protected internal override Control? ContainerFromIndex(int index)
{
if (index < 0 || index >= Items.Count)
return null;
if (_realizedElements?.GetElement(index) is { } realized)
return realized;
if (Items[index] is Control c && c.GetValue(ItemIsOwnContainerProperty))
return c;
return null;
}
protected internal override int IndexFromContainer(Control container) => _realizedElements?.GetIndex(container) ?? -1;
protected internal override Control? ScrollIntoView(int index)
{
var items = Items;
if (_isInLayout || index < 0 || index >= items.Count || _realizedElements is null)
return null;
if (GetRealizedElement(index) is Control element)
{
element.BringIntoView();
return element;
}
else if (this.GetVisualRoot() is ILayoutRoot root)
{
// 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.
double itemWidth = ItemWidth;
double itemHeight = ItemHeight;
bool isItemWidthSet = !double.IsNaN(itemWidth);
bool isItemHeightSet = !double.IsNaN(itemHeight);
var size = new Size(isItemWidthSet ? itemWidth : double.PositiveInfinity,
isItemHeightSet ? itemHeight : double.PositiveInfinity);
_scrollToElement = GetOrCreateElement(items, index);
_scrollToElement.Measure(size);
_scrollToIndex = index;
var viewport = _viewport != s_invalidViewport ? _viewport : EstimateViewport();
var viewportEnd = Orientation == Orientation.Horizontal ? new UVSize(Orientation, viewport.Right, viewport.Bottom) : new UVSize(Orientation, viewport.Bottom, viewport.Right);
// Get the expected position of the elment and put it in place.
var anchorUV = _realizedElements.GetOrEstimateElementUV(index, ref _lastEstimatedElementSizeUV, viewportEnd);
size = new Size(isItemWidthSet ? itemWidth : _scrollToElement.DesiredSize.Width,
isItemHeightSet ? itemHeight : _scrollToElement.DesiredSize.Height);
var rect = new Rect(anchorUV.Width, anchorUV.Height, size.Width, size.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
// updated extent. Do a layout pass to ensure that the containing scroll viewers
// will be able to scroll the new item into view.
if (!Bounds.Contains(rect) && !_viewport.Contains(rect))
{
_isWaitingForViewportUpdate = true;
root.LayoutManager.ExecuteLayoutPass();
_isWaitingForViewportUpdate = false;
}
// Try to bring the item into view.
_scrollToElement.BringIntoView();
// If the viewport does not contain the item to scroll to, set _isWaitingForViewportUpdate:
// this should cause the following chain of events:
// - Measure is first done with the old viewport (which will be a no-op, see MeasureOverride)
// - The viewport is then updated by the layout system which invalidates our measure
// - Measure is then done with the new viewport.
_isWaitingForViewportUpdate = !_viewport.Contains(rect);
root.LayoutManager.ExecuteLayoutPass();
// If for some reason the layout system didn't give us a new viewport during the layout, we
// need to do another layout pass as the one that took place was a no-op.
if (_isWaitingForViewportUpdate)
{
_isWaitingForViewportUpdate = false;
InvalidateMeasure();
root.LayoutManager.ExecuteLayoutPass();
}
var result = _scrollToElement;
_scrollToElement = null;
_scrollToIndex = -1;
return result;
}
return null;
}
private UVSize EstimateElementSizeUV()
{
double itemWidth = ItemWidth;
double itemHeight = ItemHeight;
bool isItemWidthSet = !double.IsNaN(itemWidth);
bool isItemHeightSet = !double.IsNaN(itemHeight);
var estimatedSize = new UVSize(Orientation,
isItemWidthSet ? itemWidth : _lastEstimatedElementSizeUV.Width,
isItemHeightSet ? itemHeight : _lastEstimatedElementSizeUV.Height);
if ((isItemWidthSet && isItemHeightSet) || _realizedElements is null)
return estimatedSize;
var result = _realizedElements.EstimateElementSize(Orientation);
if (result != null)
{
estimatedSize = result.Value;
estimatedSize.Width = isItemWidthSet ? itemWidth : estimatedSize.Width;
estimatedSize.Height = isItemHeightSet ? itemHeight:estimatedSize.Height;
}
return estimatedSize;
}
internal IReadOnlyList<Control?> GetRealizedElements()
{
return _realizedElements?.Elements ?? Array.Empty<Control>();
}
private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> items)
{
Debug.Assert(_realizedElements is not null);
// If the control has not yet been laid out then the effective viewport won't have been set.
// Try to work it out from an ancestor control.
var viewport = _viewport != s_invalidViewport ? _viewport : EstimateViewport();
// Get the viewport in the orientation direction.
var viewportStart = new UVSize(Orientation, viewport.X, viewport.Y);
var viewportEnd = new UVSize(Orientation, viewport.Right, viewport.Bottom);
// Get or estimate the anchor element from which to start realization.
var itemCount = items?.Count ?? 0;
_lastEstimatedElementSizeUV.Orientation = Orientation;
var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport(
viewportStart,
viewportEnd,
itemCount,
ref _lastEstimatedElementSizeUV);
// Check if the anchor element is not within the currently realized elements.
var disjunct = anchorIndex < _realizedElements.FirstIndex ||
anchorIndex > _realizedElements.LastIndex;
return new MeasureViewport
{
anchorIndex = anchorIndex,
anchorUV = anchorU,
viewportUVStart = viewportStart,
viewportUVEnd = viewportEnd,
viewportIsDisjunct = disjunct,
};
}
private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport)
{
var sizeUV = new UVSize(orientation);
var estimatedSize = EstimateElementSizeUV();
if (!double.IsNaN(ItemWidth) && !double.IsNaN(ItemHeight))
{
// Since ItemWidth and ItemHeight are set, we simply compute the actual size
var uLength = viewport.viewportUVEnd.U;
var estimatedItemsPerU = (int)(uLength / estimatedSize.U);
var estimatedULanes = Math.Ceiling((double)itemCount / estimatedItemsPerU);
sizeUV.U = estimatedItemsPerU * estimatedSize.U;
sizeUV.V = estimatedULanes * estimatedSize.V;
}
else if (viewport.lastIndex >= 0)
{
var remaining = itemCount - viewport.lastIndex - 1;
sizeUV = viewport.realizedEndUV;
var u = sizeUV.U;
while (remaining > 0)
{
var newU = u + estimatedSize.U;
if (newU > viewport.viewportUVEnd.U)
{
sizeUV.V += estimatedSize.V;
newU = viewport.viewportUVStart.U + estimatedSize.U;
}
u = newU;
sizeUV.U = Math.Max(sizeUV.U, u);
remaining--;
}
sizeUV.V += estimatedSize.V;
}
return new(sizeUV.Width, sizeUV.Height);
}
private Rect EstimateViewport()
{
var c = this.GetVisualParent();
var viewport = new Rect();
if (c is null)
{
return viewport;
}
while (c is not null)
{
if ((c.Bounds.Width != 0 || c.Bounds.Height != 0) &&
c.TransformToVisual(this) is Matrix transform)
{
viewport = new Rect(0, 0, c.Bounds.Width, c.Bounds.Height)
.TransformToAABB(transform);
break;
}
c = c?.GetVisualParent();
}
return viewport;
}
private void RealizeElements(
IReadOnlyList<object?> items,
Size availableSize,
ref MeasureViewport viewport)
{
Debug.Assert(_measureElements is not null);
Debug.Assert(_realizedElements is not null);
Debug.Assert(items.Count > 0);
var index = viewport.anchorIndex;
var horizontal = Orientation == Orientation.Horizontal;
var uv = viewport.anchorUV;
var v = uv.V;
double maxSizeV = 0;
var size = new UVSize(Orientation);
double itemWidth = ItemWidth;
double itemHeight = ItemHeight;
bool isItemWidthSet = !double.IsNaN(itemWidth);
bool isItemHeightSet = !double.IsNaN(itemHeight);
var childConstraint = new Size(
isItemWidthSet ? itemWidth : availableSize.Width,
isItemHeightSet ? itemHeight : availableSize.Height);
// 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 (uv.V <= viewport.anchorUV.V)
_realizedElements.RecycleElementsBefore(viewport.anchorIndex, _recycleElement, Orientation);
// Start at the anchor element and move forwards, realizing elements.
do
{
// Predict if we will place this item in the next row, and if it's not visible, stop realizing it
if (uv.U + size.U > viewport.viewportUVEnd.U && uv.V + maxSizeV > viewport.viewportUVEnd.V)
{
break;
}
var e = GetOrCreateElement(items, index);
e.Measure(childConstraint);
size = new UVSize(Orientation,
isItemWidthSet ? itemWidth : e.DesiredSize.Width,
isItemHeightSet ? itemHeight : e.DesiredSize.Height);
maxSizeV = Math.Max(maxSizeV, size.V);
// Check if the item will exceed the viewport's bounds, and move to next row if it does
var uEnd = new UVSize(Orientation)
{
U = uv.U + size.U,
V = Math.Max(v,uv.V)
};
if (uEnd.U > viewport.viewportUVEnd.U)
{
uv.U = viewport.viewportUVStart.U;
v += maxSizeV;
maxSizeV = 0;
uv.V = v;
}
_measureElements!.Add(index, e, Orientation, uv, size);
uv = new UVSize(Orientation)
{
U = uv.U + size.U,
V = Math.Max(v, uv.V)
};
++index;
} while (uv.V < viewport.viewportUVEnd.V && index < items.Count);
// Store the last index and end U position for the desired size calculation.
viewport.lastIndex = index - 1;
viewport.realizedEndUV = uv;
// We can now recycle elements after the last element.
_realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement, Orientation);
// Next move backwards from the anchor element, realizing elements.
index = viewport.anchorIndex - 1;
uv = viewport.anchorUV;
while (index >= 0)
{
// Predict if this item will be visible, and if not, stop realizing it
if (uv.U - size.U < viewport.viewportUVStart.U && uv.V <= viewport.viewportUVStart.V)
{
break;
}
var e = GetOrCreateElement(items, index);
e.Measure(childConstraint);
size = new UVSize(Orientation,
isItemWidthSet ? itemWidth : e.DesiredSize.Width,
isItemHeightSet ? itemHeight : e.DesiredSize.Height);
uv.U -= size.U;
// Test if the item will be moved to the previous row
if (uv.U < viewport.viewportUVStart.U)
{
var uLength = viewport.viewportUVEnd.U - viewport.viewportUVStart.U;
var uConstraint = (int)(uLength / size.U) * size.U;
uv.U = uConstraint - size.U;
uv.V -= size.V;
}
_measureElements!.Add(index, e, Orientation, uv, size);
--index;
}
// We can now recycle elements before the first element.
_realizedElements.RecycleElementsBefore(index + 1, _recycleElement, Orientation);
}
private Control GetOrCreateElement(IReadOnlyList<object?> items, int index)
{
var e = GetRealizedElement(index) ??
GetItemIsOwnContainer(items, index) ??
GetRecycledElement(items, index) ??
CreateElement(items, index);
return e;
}
private Control? GetRealizedElement(int index)
{
if (_scrollToIndex == index)
return _scrollToElement;
return _realizedElements?.GetElement(index);
}
private Control? GetItemIsOwnContainer(IReadOnlyList<object?> items, int index)
{
var item = items[index];
if (item is Control controlItem)
{
var generator = ItemContainerGenerator!;
if (controlItem.IsSet(ItemIsOwnContainerProperty))
{
controlItem.IsVisible = true;
return controlItem;
}
else if (generator.IsItemItsOwnContainer(controlItem))
{
generator.PrepareItemContainer(controlItem, controlItem, index);
AddInternalChild(controlItem);
controlItem.SetValue(ItemIsOwnContainerProperty, true);
generator.ItemContainerPrepared(controlItem, item, index);
return controlItem;
}
}
return null;
}
private Control? GetRecycledElement(IReadOnlyList<object?> items, int index)
{
Debug.Assert(ItemContainerGenerator is not null);
var generator = ItemContainerGenerator!;
var item = items[index];
if (_unrealizedFocusedIndex == index && _unrealizedFocusedElement is not null)
{
var element = _unrealizedFocusedElement;
_unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
_unrealizedFocusedElement = null;
_unrealizedFocusedIndex = -1;
return element;
}
if (_recyclePool?.Count > 0)
{
var recycled = _recyclePool.Pop();
recycled.IsVisible = true;
generator.PrepareItemContainer(recycled, item, index);
generator.ItemContainerPrepared(recycled, item, index);
return recycled;
}
return null;
}
private Control CreateElement(IReadOnlyList<object?> items, int index)
{
Debug.Assert(ItemContainerGenerator is not null);
var generator = ItemContainerGenerator!;
var item = items[index];
var container = generator.CreateContainer();
generator.PrepareItemContainer(container, item, index);
AddInternalChild(container);
generator.ItemContainerPrepared(container, item, index);
return container;
}
private void RecycleElement(Control element, int index)
{
Debug.Assert(ItemContainerGenerator is not null);
_scrollViewer?.UnregisterAnchorCandidate(element);
if (element.IsSet(ItemIsOwnContainerProperty))
{
element.IsVisible = false;
}
else if (element.IsKeyboardFocusWithin)
{
_unrealizedFocusedElement = element;
_unrealizedFocusedIndex = index;
_unrealizedFocusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus;
}
else
{
ItemContainerGenerator!.ClearItemContainer(element);
_recyclePool ??= new();
_recyclePool.Push(element);
element.IsVisible = false;
}
}
private void RecycleElementOnItemRemoved(Control element)
{
Debug.Assert(ItemContainerGenerator is not null);
if (element.IsSet(ItemIsOwnContainerProperty))
{
RemoveInternalChild(element);
}
else
{
ItemContainerGenerator!.ClearItemContainer(element);
_recyclePool ??= new();
_recyclePool.Push(element);
element.IsVisible = false;
}
}
private void UpdateElementIndex(Control element, int oldIndex, int newIndex)
{
Debug.Assert(ItemContainerGenerator is not null);
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
}
private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
{
var horizontal = Orientation == Orientation.Horizontal;
var oldViewportStartU = horizontal ? _viewport.Left : _viewport.Top;
var oldViewportEndU = horizontal ? _viewport.Right : _viewport.Bottom;
var oldViewportStartV = horizontal ? _viewport.Top : _viewport.Left;
var oldViewportEndV = horizontal ? _viewport.Bottom : _viewport.Right;
_viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
_isWaitingForViewportUpdate = false;
var newViewportStartU = horizontal ? _viewport.Left : _viewport.Top;
var newViewportEndU = horizontal ? _viewport.Right : _viewport.Bottom;
var newViewportStartV = horizontal ? _viewport.Top : _viewport.Left;
var newViewportEndV = horizontal ? _viewport.Bottom : _viewport.Right;
if (!MathUtilities.AreClose(oldViewportStartU, newViewportStartU) ||
!MathUtilities.AreClose(oldViewportEndU, newViewportEndU) ||
!MathUtilities.AreClose(oldViewportStartV, newViewportStartV) ||
!MathUtilities.AreClose(oldViewportEndV, newViewportEndV))
{
InvalidateMeasure();
}
}
private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e)
{
if (_unrealizedFocusedElement is null || sender != _unrealizedFocusedElement)
return;
_unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus;
RecycleElement(_unrealizedFocusedElement, _unrealizedFocusedIndex);
_unrealizedFocusedElement = null;
_unrealizedFocusedIndex = -1;
}
private struct MeasureViewport
{
public int anchorIndex;
public UVSize anchorUV;
public UVSize viewportUVStart;
public UVSize viewportUVEnd;
public UVSize realizedEndUV;
public int lastIndex;
public bool viewportIsDisjunct;
}
}
}

34
src/Avalonia.Controls/WrapPanel.cs

@ -17,7 +17,7 @@ namespace Avalonia.Controls
/// Subsequent ordering happens sequentially from top to bottom or from right to left,
/// depending on the value of the <see cref="Orientation"/> property.
/// </summary>
public class WrapPanel : Panel, INavigableContainer
public partial class WrapPanel : Panel, INavigableContainer
{
/// <summary>
/// Defines the <see cref="Orientation"/> property.
@ -256,37 +256,5 @@ namespace Avalonia.Controls
u += layoutSlotU;
}
}
private struct UVSize
{
internal UVSize(Orientation orientation, double width, double height)
{
U = V = 0d;
_orientation = orientation;
Width = width;
Height = height;
}
internal UVSize(Orientation orientation)
{
U = V = 0d;
_orientation = orientation;
}
internal double U;
internal double V;
private Orientation _orientation;
internal double Width
{
get { return _orientation == Orientation.Horizontal ? U : V; }
set { if (_orientation == Orientation.Horizontal) U = value; else V = value; }
}
internal double Height
{
get { return _orientation == Orientation.Horizontal ? V : U; }
set { if (_orientation == Orientation.Horizontal) V = value; else U = value; }
}
}
}
}

773
tests/Avalonia.Controls.UnitTests/VirtualizingWrapPanelTests.cs

@ -0,0 +1,773 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Xunit;
#nullable enable
namespace Avalonia.Controls.UnitTests
{
public class VirtualizingWrapPanelTests
{
[Fact]
public void Creates_Initial_Items()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
Assert.Equal(5000, scroll.Extent.Height);
AssertRealizedItems(target, itemsControl, 0, 3);
}
[Fact]
public void Initializes_Initial_Control_Items()
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null);
Assert.Equal(1000, scroll.Extent.Height);
AssertRealizedControlItems<Button>(target, itemsControl, 0, 11);
}
[Fact]
public void Creates_Reassigned_Items()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget(items: Array.Empty<object>());
Assert.Empty(itemsControl.GetRealizedContainers());
itemsControl.ItemsSource = new[] { "foo", "bar" };
Layout(target);
AssertRealizedItems(target, itemsControl, 0, 2);
}
[Fact]
public void Scrolls_Down_One_Item()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.Offset = new Vector(0, 10);
Layout(target);
AssertRealizedItems(target, itemsControl, 0, 4);
}
[Fact]
public void Scrolls_Down_More_Than_A_Page()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.Offset = new Vector(0, 200);
Layout(target);
AssertRealizedItems(target, itemsControl, 4, 3);
}
[Fact]
public void Scrolls_Down_To_Index()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
target.ScrollIntoView(20);
AssertRealizedItems(target, itemsControl, 19, 3);
}
[Fact]
public void Scrolls_Up_To_Index()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.ScrollToEnd();
Layout(target);
Assert.Equal(98, target.FirstRealizedIndex);
target.ScrollIntoView(50);
AssertRealizedItems(target, itemsControl, 50, 3);
}
[Fact]
public void Scrolling_Up_To_Index_Does_Not_Create_A_Page_Of_Unrealized_Elements()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
scroll.ScrollToEnd();
Layout(target);
target.ScrollIntoView(20);
Assert.Equal(3, target.Children.Count);
}
[Fact]
public void Creates_Elements_On_Item_Insert_1()
{
using var app = App();
var (target, _, itemsControl) = CreateTarget();
var items = (IList)itemsControl.ItemsSource!;
Assert.Equal(3, target.GetRealizedElements().Count);
items.Insert(0, "new");
Assert.Equal(4, target.GetRealizedElements().Count);
var indexes = GetRealizedIndexes(target, itemsControl);
// Blank space inserted in realized elements and subsequent indexes updated.
Assert.Equal(new[] { -1, 1, 2, 3}, indexes);
var elements = target.GetRealizedElements().ToList();
Layout(target);
indexes = GetRealizedIndexes(target, itemsControl);
// After layout an element for the new element is created.
Assert.Equal(Enumerable.Range(0, 3), indexes);
// But apart from the new element and the removed last element, all existing elements
// should be the same.
elements[0] = target.GetRealizedElements().ElementAt(0);
elements.RemoveAt(elements.Count - 1);
Assert.Equal(elements, target.GetRealizedElements());
}
[Fact]
public void Creates_Elements_On_Item_Insert_2()
{
using var app = App();
var (target, _, itemsControl) = CreateTarget();
var items = (IList)itemsControl.ItemsSource!;
Assert.Equal(3, target.GetRealizedElements().Count);
items.Insert(2, "new");
Assert.Equal(4, target.GetRealizedElements().Count);
var indexes = GetRealizedIndexes(target, itemsControl);
// Blank space inserted in realized elements and subsequent indexes updated.
Assert.Equal(new[] { 0, 1, -1, 3}, indexes);
var elements = target.GetRealizedElements().ToList();
Layout(target);
indexes = GetRealizedIndexes(target, itemsControl);
// After layout an element for the new element is created.
Assert.Equal(Enumerable.Range(0, 3), indexes);
// But apart from the new element and the removed last element, all existing elements
// should be the same.
elements[2] = target.GetRealizedElements().ElementAt(2);
elements.RemoveAt(elements.Count - 1);
Assert.Equal(elements, target.GetRealizedElements());
}
[Fact]
public void Updates_Elements_On_Item_Remove()
{
using var app = App();
var (target, _, itemsControl) = CreateTarget();
var items = (IList)itemsControl.ItemsSource!;
Assert.Equal(3, target.GetRealizedElements().Count);
var toRecycle = target.GetRealizedElements().ElementAt(2);
items.RemoveAt(2);
var indexes = GetRealizedIndexes(target, itemsControl);
// Item removed from realized elements and subsequent row indexes updated.
Assert.Equal(Enumerable.Range(0, 2), indexes);
var elements = target.GetRealizedElements().ToList();
Layout(target);
indexes = GetRealizedIndexes(target, itemsControl);
// After layout an element for the newly visible last row is created and indexes updated.
Assert.Equal(Enumerable.Range(0, 3), indexes);
// And the removed row should now have been recycled as the last row.
elements.Add(toRecycle);
Assert.Equal(elements, target.GetRealizedElements());
}
[Fact]
public void Updates_Elements_On_Item_Replace()
{
using var app = App();
var (target, _, itemsControl) = CreateTarget();
var items = (ObservableCollection<string>)itemsControl.ItemsSource!;
Assert.Equal(3, target.GetRealizedElements().Count);
var toReplace = target.GetRealizedElements().ElementAt(2);
items[2] = "new";
// Container being replaced should have been recycled.
Assert.DoesNotContain(toReplace, target.GetRealizedElements());
Assert.False(toReplace!.IsVisible);
var indexes = GetRealizedIndexes(target, itemsControl);
// Item removed from realized elements at old position and space inserted at new position.
Assert.Equal(new[] { 0, 1}, indexes);
Layout(target);
indexes = GetRealizedIndexes(target, itemsControl);
// After layout the missing container should have been created.
Assert.Equal(Enumerable.Range(0, 3), indexes);
}
[Fact]
public void Updates_Elements_On_Item_Move()
{
using var app = App();
var (target, _, itemsControl) = CreateTarget();
var items = (ObservableCollection<string>)itemsControl.ItemsSource!;
Assert.Equal(3, target.GetRealizedElements().Count);
var toMove = target.GetRealizedElements().ElementAt(2);
items.Move(2, 6);
// Container being moved should have been recycled.
Assert.DoesNotContain(toMove, target.GetRealizedElements());
Assert.False(toMove!.IsVisible);
var indexes = GetRealizedIndexes(target, itemsControl);
// Item removed from realized elements at old position and space inserted at new position.
Assert.Equal(new[] { 0, 1 }, indexes);
Layout(target);
indexes = GetRealizedIndexes(target, itemsControl);
// After layout the missing container should have been created.
Assert.Equal(Enumerable.Range(0, 3), indexes);
}
[Fact]
public void Removes_Control_Items_From_Panel_On_Item_Remove()
{
using var app = App();
var items = new ObservableCollection<Button>(Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }));
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null);
Assert.Equal(1000, scroll.Extent.Height);
var removed = items[1];
items.RemoveAt(1);
Assert.Null(removed.Parent);
Assert.Null(removed.VisualParent);
}
[Fact]
public void Does_Not_Recycle_Focused_Element()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
target.GetRealizedElements().First()!.Focus();
Assert.True(target.GetRealizedElements().First()!.IsKeyboardFocusWithin);
scroll.Offset = new Vector(0, 200);
Layout(target);
Assert.All(target.GetRealizedElements(), x => Assert.False(x!.IsKeyboardFocusWithin));
}
[Fact]
public void Removing_Item_Of_Focused_Element_Clears_Focus()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var focused = target.GetRealizedElements().First()!;
focused.Focus();
Assert.True(focused.IsKeyboardFocusWithin);
scroll.Offset = new Vector(0, 200);
Layout(target);
Assert.All(target.GetRealizedElements(), x => Assert.False(x!.IsKeyboardFocusWithin));
Assert.All(target.GetRealizedElements(), x => Assert.NotSame(focused, x));
}
[Fact]
public void Scrolling_Back_To_Focused_Element_Uses_Correct_Element()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var focused = target.GetRealizedElements().First()!;
focused.Focus();
Assert.True(focused.IsKeyboardFocusWithin);
scroll.Offset = new Vector(0, 200);
Layout(target);
scroll.Offset = new Vector(0, 0);
Layout(target);
Assert.Same(focused, target.GetRealizedElements().First());
}
[Fact]
public void Focusing_Another_Element_Recycles_Original_Focus_Element()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var originalFocused = target.GetRealizedElements().First()!;
originalFocused.Focus();
scroll.Offset = new Vector(0, 500);
Layout(target);
var newFocused = target.GetRealizedElements().First()!;
newFocused.Focus();
Assert.False(originalFocused.IsVisible);
}
[Fact]
public void Removing_Range_When_Scrolled_To_End_Updates_Viewport()
{
using var app = App();
var items = new AvaloniaList<string>(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
var (target, scroll, itemsControl) = CreateTarget(items: items);
scroll.Offset = new Vector(0, 900);
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 3);
items.RemoveRange(0, 80);
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 2);
Assert.Equal(new Vector(0, 900), scroll.Offset);
}
[Fact]
public void Removing_Range_To_Have_Less_Than_A_Page_Of_Items_When_Scrolled_To_End_Updates_Viewport()
{
using var app = App();
var items = new AvaloniaList<string>(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
var (target, scroll, itemsControl) = CreateTarget(items: items);
scroll.Offset = new Vector(0, 900);
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 3);
items.RemoveRange(0, 97);
Layout(target);
AssertRealizedItems(target, itemsControl, 1, 2);
Assert.Equal(new Vector(0, 50), scroll.Offset);
}
[Fact]
public void Resetting_Collection_To_Have_Less_Items_When_Scrolled_To_End_Updates_Viewport()
{
using var app = App();
var items = new ResettingCollection(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
var (target, scroll, itemsControl) = CreateTarget(items: items);
scroll.Offset = new Vector(0, 900);
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 3);
items.Reset(Enumerable.Range(0, 20).Select(x => $"Item {x}"));
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 2);
Assert.Equal(new Vector(0, 900), scroll.Offset);
}
[Fact]
public void Resetting_Collection_To_Have_Less_Than_A_Page_Of_Items_When_Scrolled_To_End_Updates_Viewport()
{
using var app = App();
var items = new ResettingCollection(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
var (target, scroll, itemsControl) = CreateTarget(items: items);
scroll.Offset = new Vector(0, 900);
Layout(target);
AssertRealizedItems(target, itemsControl, 18, 3);
items.Reset(Enumerable.Range(0, 5).Select(x => $"Item {x}"));
Layout(target);
AssertRealizedItems(target, itemsControl, 3, 2);
Assert.Equal(new Vector(0, 150), scroll.Offset);
}
[Fact]
public void NthChild_Selector_Works()
{
using var app = App();
var style = new Style(x => x.OfType<ContentPresenter>().NthChild(5, 0))
{
Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) },
};
var (target, _, _) = CreateTarget(styles: new[] { style });
var realized = target.GetRealizedContainers()!.Cast<ContentPresenter>().ToList();
Assert.Equal(3, realized.Count);
for (var i = 0; i < 3; ++i)
{
var container = realized[i];
var index = target.IndexFromContainer(container);
var expectedBackground = (i == 4 || i == 9) ? Brushes.Red : null;
Assert.Equal(i, index);
Assert.Equal(expectedBackground, container.Background);
}
}
[Fact]
public void NthLastChild_Selector_Works()
{
using var app = App();
var style = new Style(x => x.OfType<ContentPresenter>().NthLastChild(5, 0))
{
Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) },
};
var (target, _, _) = CreateTarget(styles: new[] { style });
var realized = target.GetRealizedContainers()!.Cast<ContentPresenter>().ToList();
Assert.Equal(3, realized.Count);
for (var i = 0; i < 3; ++i)
{
var container = realized[i];
var index = target.IndexFromContainer(container);
var expectedBackground = (i == 0 || i == 5) ? Brushes.Red : null;
Assert.Equal(i, index);
Assert.Equal(expectedBackground, container.Background);
}
}
[Fact]
public void ContainerPrepared_Is_Raised_When_Scrolling()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var raised = 0;
itemsControl.ContainerPrepared += (s, e) => ++raised;
scroll.Offset = new Vector(0, 200);
Layout(target);
Assert.Equal(3, raised);
}
[Fact]
public void ContainerClearing_Is_Raised_When_Scrolling()
{
using var app = App();
var (target, scroll, itemsControl) = CreateTarget();
var raised = 0;
itemsControl.ContainerClearing += (s, e) => ++raised;
scroll.Offset = new Vector(0, 200);
Layout(target);
Assert.Equal(3, raised);
}
[Fact]
public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_At_End()
{
using var app = App();
var items = Enumerable.Range(0, 1000).Select(x => new ItemWithWidth(x)).ToList();
items[20].Width = 200;
var itemTemplate = new FuncDataTemplate<ItemWithWidth>((x, _) =>
new Canvas
{
Height = 100,
[!Canvas.WidthProperty] = new Binding("Width"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
var index = target.FirstRealizedIndex;
// Scroll down to the larger element.
while (target.LastRealizedIndex < items.Count - 1)
{
scroll.LineDown();
Layout(target);
Assert.True(
target.FirstRealizedIndex >= index,
$"{target.FirstRealizedIndex} is not greater or equal to {index}");
if (scroll.Offset.Y + scroll.Viewport.Height == scroll.Extent.Height)
Assert.Equal(items.Count - 1, target.LastRealizedIndex);
index = target.FirstRealizedIndex;
}
}
[Fact]
public void Scrolling_Up_To_Larger_Element_Does_Not_Cause_Jump()
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new ItemWithWidth(x)).ToList();
items[20].Width = 200;
var itemTemplate = new FuncDataTemplate<ItemWithWidth>((x, _) =>
new Canvas
{
Height = 100,
[!Canvas.WidthProperty] = new Binding("Width"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
// Scroll past the larger element.
scroll.Offset = new Vector(0, 600);
Layout(target);
// Precondition checks
Assert.True(target.FirstRealizedIndex > 5);
var index = target.FirstRealizedIndex;
// Scroll up to the top.
while (scroll.Offset.Y > 0)
{
scroll.LineUp();
Layout(target);
Assert.True(target.FirstRealizedIndex <= index, $"{target.FirstRealizedIndex} is not less than {index}");
index = target.FirstRealizedIndex;
}
}
[Fact]
public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump()
{
using var app = App();
var items = Enumerable.Range(0, 100).Select(x => new ItemWithWidth(x, 30)).ToList();
items[20].Width = 25;
var itemTemplate = new FuncDataTemplate<ItemWithWidth>((x, _) =>
new Canvas
{
Height = 100,
[!Canvas.WidthProperty] = new Binding("Width"),
});
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
// Scroll past the larger element.
scroll.Offset = new Vector(0, 25 * items[0].Width);
Layout(target);
// Precondition checks
Assert.True(target.FirstRealizedIndex > 6);
var index = target.FirstRealizedIndex;
// Scroll up to the top.
while (scroll.Offset.Y > 0)
{
scroll.Offset = scroll.Offset - new Vector(0, 5);
Layout(target);
Assert.True(
target.FirstRealizedIndex <= index,
$"{target.FirstRealizedIndex} is not less than {index}");
Assert.True(
index - target.FirstRealizedIndex <= 1,
$"FirstIndex changed from {index} to {target.FirstRealizedIndex}");
index = target.FirstRealizedIndex;
}
}
private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingWrapPanel target, ItemsControl itemsControl)
{
return target.GetRealizedElements()
.Select(x => x is null ? -1 : itemsControl.IndexFromContainer((Control)x))
.ToList();
}
private static void AssertRealizedItems(
VirtualizingWrapPanel target,
ItemsControl itemsControl,
int firstIndex,
int count)
{
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(itemsControl, x.Parent));
var childIndexes = target.GetRealizedContainers()!
.Select(x => itemsControl.IndexFromContainer(x))
.Where(x => x >= 0)
.OrderBy(x => x)
.ToList();
Assert.Equal(Enumerable.Range(firstIndex, count), childIndexes);
}
private static void AssertRealizedControlItems<TContainer>(
VirtualizingWrapPanel target,
ItemsControl itemsControl,
int firstIndex,
int count)
{
Assert.All(target.GetRealizedContainers()!, x => Assert.IsType<TContainer>(x));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(target, x.VisualParent));
Assert.All(target.GetRealizedContainers()!, x => Assert.Same(itemsControl, x.Parent));
var childIndexes = target.GetRealizedContainers()!
.Select(x => itemsControl.IndexFromContainer(x))
.Where(x => x >= 0)
.OrderBy(x => x)
.ToList();
Assert.Equal(Enumerable.Range(firstIndex, count), childIndexes);
}
private static (VirtualizingWrapPanel, ScrollViewer, ItemsControl) CreateTarget(
IEnumerable<object>? items = null,
Optional<IDataTemplate?> itemTemplate = default,
IEnumerable<Style>? styles = null)
{
var target = new VirtualizingWrapPanel();
items ??= new ObservableCollection<string>(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
var presenter = new ItemsPresenter
{
[~ItemsPresenter.ItemsPanelProperty] = new TemplateBinding(ItemsPresenter.ItemsPanelProperty),
};
var scroll = new ScrollViewer
{
Name = "PART_ScrollViewer",
Content = presenter,
Template = ScrollViewerTemplate(),
};
var itemsControl = new ItemsControl
{
ItemsSource = items,
Template = new FuncControlTemplate<ItemsControl>((_, ns) => scroll.RegisterInNameScope(ns)),
ItemsPanel = new FuncTemplate<Panel?>(() => target),
ItemTemplate = itemTemplate.GetValueOrDefault(DefaultItemTemplate()),
};
var root = new TestRoot(true, itemsControl);
root.ClientSize = new(50, 100);
if (styles is not null)
root.Styles.AddRange(styles);
root.LayoutManager.ExecuteInitialLayoutPass();
return (target, scroll, itemsControl);
}
private static IDataTemplate DefaultItemTemplate()
{
return new FuncDataTemplate<object>((x, _) => new Canvas { Width = 10, Height = 50 });
}
private static void Layout(Control target)
{
var root = (ILayoutRoot?)target.GetVisualRoot();
root?.LayoutManager.ExecuteLayoutPass();
}
private static IControlTemplate ScrollViewerTemplate()
{
return new FuncControlTemplate<ScrollViewer>((x, ns) =>
new ScrollContentPresenter
{
Name = "PART_ContentPresenter",
}.RegisterInNameScope(ns));
}
private static IDisposable App() => UnitTestApplication.Start(TestServices.RealFocus);
private class ItemWithWidth
{
public ItemWithWidth(int index, double width = 10)
{
Caption = $"Item {index}";
Width = width;
}
public string Caption { get; set; }
public double Width { get; set; }
}
private class ResettingCollection : List<string>, INotifyCollectionChanged
{
public ResettingCollection(IEnumerable<string> items)
{
AddRange(items);
}
public void Reset(IEnumerable<string> items)
{
Clear();
AddRange(items);
CollectionChanged?.Invoke(
this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public event NotifyCollectionChangedEventHandler? CollectionChanged;
}
}
}
Loading…
Cancel
Save