6 changed files with 2169 additions and 34 deletions
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue