From c849bd625289ed47376eb7d3842aa45d5148a718 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Fri, 5 May 2023 11:13:50 +0000 Subject: [PATCH] add virtualized wrap panel --- .../Pages/CompositionPage.axaml | 2 +- .../Utils/RealizedWrappedElements.cs | 535 ++++++++++++ src/Avalonia.Controls/Utils/UVSize.cs | 42 + .../VirtualizingWrapPanel.cs | 817 ++++++++++++++++++ src/Avalonia.Controls/WrapPanel.cs | 34 +- .../VirtualizingWrapPanelTests.cs | 773 +++++++++++++++++ 6 files changed, 2169 insertions(+), 34 deletions(-) create mode 100644 src/Avalonia.Controls/Utils/RealizedWrappedElements.cs create mode 100644 src/Avalonia.Controls/Utils/UVSize.cs create mode 100644 src/Avalonia.Controls/VirtualizingWrapPanel.cs create mode 100644 tests/Avalonia.Controls.UnitTests/VirtualizingWrapPanelTests.cs diff --git a/samples/ControlCatalog/Pages/CompositionPage.axaml b/samples/ControlCatalog/Pages/CompositionPage.axaml index 602b9b768d..9e03d4b351 100644 --- a/samples/ControlCatalog/Pages/CompositionPage.axaml +++ b/samples/ControlCatalog/Pages/CompositionPage.axaml @@ -9,7 +9,7 @@ - + diff --git a/src/Avalonia.Controls/Utils/RealizedWrappedElements.cs b/src/Avalonia.Controls/Utils/RealizedWrappedElements.cs new file mode 100644 index 0000000000..29b86b2aa3 --- /dev/null +++ b/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 +{ + /// + /// 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 . + /// + internal class RealizedWrappedElements + { + private int _firstIndex; + private List? _elements; + private List? _sizes; + private List? _positions; + private UVSize _startUV; + private bool _startUUnstable; + + /// + /// Gets the number of realized elements. + /// + public int Count => _elements?.Count ?? 0; + + /// + /// Gets the index of the first realized element, or -1 if no elements are realized. + /// + public int FirstIndex => _elements?.Count > 0 ? _firstIndex : -1; + + /// + /// Gets the index of the last realized element, or -1 if no elements are realized. + /// + public int LastIndex => _elements?.Count > 0 ? _firstIndex + _elements.Count - 1 : -1; + + /// + /// Gets the elements. + /// + public IReadOnlyList Elements => _elements ??= new List(); + + /// + /// Gets the sizes of the elements on the primary axis. + /// + public IReadOnlyList SizeUV => _sizes ??= new List(); + public IReadOnlyList PositionsUV => _positions ??= new List(); + + /// + /// Gets the position of the first element on the primary axis. + /// + public UVSize StartUV => _startUV; + + /// + /// Adds a newly realized element to the collection. + /// + /// The index of the element. + /// The element. + /// The position of the elemnt. + /// The size of the element. + public void Add(int index, Control element, Orientation orientation, UVSize uv, UVSize sizeUV) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + + _elements ??= new List(); + _sizes ??= new List(); + _positions ??= new List(); + 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."); + } + } + + /// + /// Gets the element at the specified index, if realized. + /// + /// The index in the source collection of the element to get. + /// The element if realized; otherwise null. + public Control? GetElement(int index) + { + var i = index - FirstIndex; + if (i >= 0 && i < _elements?.Count) + return _elements[i]; + return null; + } + + /// + /// Gets or estimates the index and start U position of the anchor element for the + /// specified viewport. + /// + /// The UV position of the start of the viewport. + /// The UV position of the end of the viewport. + /// The number of items in the list. + /// The current estimated element size. + /// + /// A tuple containing: + /// - The index of the anchor element, or -1 if an anchor could not be determined + /// - The U position of the start of the anchor element, if determined + /// + /// + /// This method tries to find an existing element in the specified viewport from which + /// element realization can start. Failing that it estimates the first element in the + /// viewport. + /// + public (int index, 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 + }; + } + + /// + /// Gets the position of the element with the requested index on the primary axis, if realized. + /// + /// + /// The position of the element, or null if the element is not realized. + /// + 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); + } + + /// + /// Estimates the average UV size of all elements in the source collection based on the + /// realized elements. + /// + /// + /// The estimated UV size of an element, or null if not enough information is present to make + /// an estimate. + /// + 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 + }; + } + + /// + /// Gets the index of the specified element. + /// + /// The element. + /// The index or -1 if the element is not present in the collection. + public int GetIndex(Control element) + { + return _elements?.IndexOf(element) is int index && index >= 0 ? index + FirstIndex : -1; + } + + /// + /// Updates the elements in response to items being inserted into the source collection. + /// + /// The index in the source collection of the insert. + /// The number of items inserted. + /// A method used to update the element indexes. + public void ItemsInserted(int index, int count, Action 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); + } + } + } + + /// + /// Updates the elements in response to items being removed from the source collection. + /// + /// The index in the source collection of the remove. + /// The number of items removed. + /// A method used to update the element indexes. + /// A method used to recycle elements. + public void ItemsRemoved( + int index, + int count, + Action updateElementIndex, + Action 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; + } + } + } + + /// + /// Recycles all elements in response to the source collection being reset. + /// + /// A method used to recycle elements. + public void ItemsReset(Action 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(); + } + + /// + /// Recycles elements before a specific index. + /// + /// The index in the source collection of new first element. + /// A method used to recycle elements. + public void RecycleElementsBefore(int index, Action 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; + } + } + + /// + /// Recycles elements after a specific index. + /// + /// The index in the source collection of new last element. + /// A method used to recycle elements. + public void RecycleElementsAfter(int index, Action 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); + } + } + + /// + /// Recycles all realized elements. + /// + /// A method used to recycle elements. + public void RecycleAllElements(Action 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(); + } + + /// + /// Resets the element list and prepares it for reuse. + /// + public void ResetForReuse(Orientation orientation) + { + _firstIndex = 0; + _startUV = new UVSize(orientation); + _startUUnstable = false; + _elements?.Clear(); + _sizes?.Clear(); + _positions?.Clear(); + } + } +} diff --git a/src/Avalonia.Controls/Utils/UVSize.cs b/src/Avalonia.Controls/Utils/UVSize.cs new file mode 100644 index 0000000000..7289af1d2c --- /dev/null +++ b/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; } + } + } +} diff --git a/src/Avalonia.Controls/VirtualizingWrapPanel.cs b/src/Avalonia.Controls/VirtualizingWrapPanel.cs new file mode 100644 index 0000000000..6fad762770 --- /dev/null +++ b/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 +{ + /// + /// 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 property. + /// + public class VirtualizingWrapPanel : VirtualizingPanel + { + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + StackPanel.OrientationProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemWidthProperty = + AvaloniaProperty.Register(nameof(ItemWidth), double.NaN); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemHeightProperty = + AvaloniaProperty.Register(nameof(ItemHeight), double.NaN); + + private static readonly AttachedProperty ItemIsOwnContainerProperty = + AvaloniaProperty.RegisterAttached("ItemIsOwnContainer"); + + private static readonly Rect s_invalidViewport = new(double.PositiveInfinity, double.PositiveInfinity, 0, 0); + private readonly Action _recycleElement; + private readonly Action _recycleElementOnItemRemoved; + private readonly Action _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? _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; + } + + /// + /// Gets or sets the axis along which items are laid out. + /// + /// + /// One of the enumeration values that specifies the axis along which items are laid out. + /// The default is Vertical. + /// + public Orientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + /// + /// Gets or sets the width of all items in the WrapPanel. + /// + public double ItemWidth + { + get { return GetValue(ItemWidthProperty); } + set { SetValue(ItemWidthProperty, value); } + } + + /// + /// Gets or sets the height of all items in the WrapPanel. + /// + public double ItemHeight + { + get { return GetValue(ItemHeightProperty); } + set { SetValue(ItemHeightProperty, value); } + } + + /// + /// Gets the index of the first realized element, or -1 if no elements are realized. + /// + public int FirstRealizedIndex => _realizedElements?.FirstIndex ?? -1; + + /// + /// Gets the index of the last realized element, or -1 if no elements are realized. + /// + 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(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _scrollViewer = null; + } + + protected override void OnItemsChanged(IReadOnlyList 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? 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 GetRealizedElements() + { + return _realizedElements?.Elements ?? Array.Empty(); + } + + private MeasureViewport CalculateMeasureViewport(IReadOnlyList 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 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 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 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 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 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; + } + } +} diff --git a/src/Avalonia.Controls/WrapPanel.cs b/src/Avalonia.Controls/WrapPanel.cs index 71b3234aff..c123b6f4af 100644 --- a/src/Avalonia.Controls/WrapPanel.cs +++ b/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 property. /// - public class WrapPanel : Panel, INavigableContainer + public partial class WrapPanel : Panel, INavigableContainer { /// /// Defines the 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; } - } - } } } diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingWrapPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingWrapPanelTests.cs new file mode 100644 index 0000000000..1d1c4056b9 --- /dev/null +++ b/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