using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using Avalonia.Controls.Primitives; using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Logging; using Avalonia.Utilities; using Avalonia.VisualTree; namespace Avalonia.Controls { /// /// Arranges and virtualizes content on a single line that is oriented either horizontally or vertically. /// public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo { /// /// Defines the property. /// public static readonly StyledProperty OrientationProperty = StackPanel.OrientationProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty AreHorizontalSnapPointsRegularProperty = AvaloniaProperty.Register(nameof(AreHorizontalSnapPointsRegular)); /// /// Defines the property. /// public static readonly StyledProperty AreVerticalSnapPointsRegularProperty = AvaloniaProperty.Register(nameof(AreVerticalSnapPointsRegular)); /// /// Defines the event. /// public static readonly RoutedEvent HorizontalSnapPointsChangedEvent = RoutedEvent.Register( nameof(HorizontalSnapPointsChanged), RoutingStrategies.Bubble); /// /// Defines the event. /// public static readonly RoutedEvent VerticalSnapPointsChangedEvent = RoutedEvent.Register( nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); /// /// Defines the property. /// public static readonly StyledProperty CacheLengthProperty = AvaloniaProperty.Register(nameof(CacheLength), 0.0, validate: v => v is >= 0 and <= 2); private static readonly AttachedProperty RecycleKeyProperty = AvaloniaProperty.RegisterAttached("RecycleKey"); private static readonly object s_itemIsItsOwnContainer = new object(); 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 double _lastEstimatedElementSizeU = 25; private RealizedStackElements? _measureElements; private RealizedStackElements? _realizedElements; private IScrollAnchorProvider? _scrollAnchorProvider; private Rect _viewport; private Dictionary>? _recyclePool; private Control? _focusedElement; private int _focusedIndex = -1; private Control? _realizingElement; private int _realizingIndex = -1; private double _bufferFactor; private bool _hasReachedStart = false; private bool _hasReachedEnd = false; private Rect _lastMeasuredExtendedViewport; private Rect _lastKnownExtendedViewport; static VirtualizingStackPanel() { CacheLengthProperty.Changed.AddClassHandler((x, e) => x.OnCacheLengthChanged(e)); } public VirtualizingStackPanel() { _recycleElement = RecycleElement; _recycleElementOnItemRemoved = RecycleElementOnItemRemoved; _updateElementIndex = UpdateElementIndex; _bufferFactor = Math.Max(0, CacheLength); 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); } /// /// Occurs when the measurements for horizontal snap points change. /// public event EventHandler? HorizontalSnapPointsChanged { add => AddHandler(HorizontalSnapPointsChangedEvent, value); remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value); } /// /// Occurs when the measurements for vertical snap points change. /// public event EventHandler? VerticalSnapPointsChanged { add => AddHandler(VerticalSnapPointsChangedEvent, value); remove => RemoveHandler(VerticalSnapPointsChangedEvent, value); } /// /// Gets or sets whether the horizontal snap points for the are equidistant from each other. /// public bool AreHorizontalSnapPointsRegular { get => GetValue(AreHorizontalSnapPointsRegularProperty); set => SetValue(AreHorizontalSnapPointsRegularProperty, value); } /// /// Gets or sets whether the vertical snap points for the are equidistant from each other. /// public bool AreVerticalSnapPointsRegular { get => GetValue(AreVerticalSnapPointsRegularProperty); set => SetValue(AreVerticalSnapPointsRegularProperty, value); } /// /// Gets or sets the CacheLength. /// /// The factor determines how much additional space to maintain above and below the viewport. /// A value of 0.5 means half the viewport size will be buffered on each side (up-down or left-right) /// This uses more memory as more UI elements are realized, but greatly reduces the number of Measure-Arrange /// cycles which can cause heavy GC pressure depending on the complexity of the item layouts. /// public double CacheLength { get => GetValue(CacheLengthProperty); set => SetValue(CacheLengthProperty, 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; /// /// Returns the viewport that contains any visible elements /// internal Rect ViewPort => _viewport; /// /// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2) /// internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport; protected override Size MeasureOverride(Size availableSize) { var items = Items; if (items.Count == 0) return default; var orientation = Orientation; // If we're bringing an item into view, ignore any layout passes until we receive a new // effective viewport. if (_isWaitingForViewportUpdate) return EstimateDesiredSize(orientation, items.Count); _isInLayout = true; try { _realizedElements?.ValidateStartU(Orientation); _realizedElements ??= new(); _measureElements ??= new(); // We need to set the lastEstimatedElementSizeU before calling CalculateDesiredSize() _ = EstimateElementSizeU(); // 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(orientation, items); // If the viewport is disjunct then we can recycle everything. if (viewport.viewportIsDisjunct) _realizedElements.RecycleAllElements(_recycleElement); // 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(); // If there is a focused element is outside the visible viewport (i.e. // _focusedElement is non-null), ensure it's measured. _focusedElement?.Measure(availableSize); return CalculateDesiredSize(orientation, items.Count, viewport); } finally { _isInLayout = false; } } protected override Size ArrangeOverride(Size finalSize) { if (_realizedElements is null) return default; _isInLayout = true; try { var orientation = Orientation; var u = _realizedElements!.StartU; for (var i = 0; i < _realizedElements.Count; ++i) { var e = _realizedElements.Elements[i]; if (e is not null) { var sizeU = _realizedElements.SizeU[i]; var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, sizeU, finalSize.Height) : new Rect(0, u, finalSize.Width, sizeU); e.Arrange(rect); if (e.IsVisible && _viewport.Intersects(rect)) { try { _scrollAnchorProvider?.RegisterAnchorCandidate(e); } catch (InvalidOperationException ex) { // Element might have been removed/reparented during virtualization; ignore but log for diagnostics. Logger.TryGet(LogEventLevel.Verbose, LogArea.Layout)?.Log(this, "RegisterAnchorCandidate ignored for {Element}: not a descendant of ScrollAnchorProvider. {Message}", e, ex.Message); } } u += orientation == Orientation.Horizontal ? rect.Width : rect.Height; } } // Ensure that the focused element is in the correct position. if (_focusedElement is not null && _focusedIndex >= 0) { u = GetOrEstimateElementU(_focusedIndex); var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) : new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height); _focusedElement.Arrange(rect); } return finalSize; } finally { _isInLayout = false; RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent)); } } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); _scrollAnchorProvider = this.FindAncestorOfType(); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); _scrollAnchorProvider = null; } protected override void OnItemsChanged(IReadOnlyList items, NotifyCollectionChangedEventArgs e) { InvalidateMeasure(); // Always update special elements UpdateSpecialElementsOnItemsChanged(e); 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: _realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved); break; case NotifyCollectionChangedAction.Move: if (e.OldStartingIndex < 0) { goto case NotifyCollectionChangedAction.Reset; } _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); var insertIndex = e.NewStartingIndex; if (e.NewStartingIndex > e.OldStartingIndex) { insertIndex -= e.OldItems!.Count - 1; } _realizedElements.ItemsInserted(insertIndex, e.NewItems!.Count, _updateElementIndex); break; case NotifyCollectionChangedAction.Reset: _realizedElements.ItemsReset(_recycleElementOnItemRemoved); break; } } private void UpdateSpecialElementsOnItemsChanged(NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: if (_focusedElement is not null && e.NewStartingIndex <= _focusedIndex) { var oldIndex = _focusedIndex; _focusedIndex += e.NewItems!.Count; _updateElementIndex(_focusedElement, oldIndex, _focusedIndex); } if (_scrollToElement is not null && e.NewStartingIndex <= _scrollToIndex) { _scrollToIndex += e.NewItems!.Count; } break; case NotifyCollectionChangedAction.Remove: if (_focusedElement is not null) { if (e.OldStartingIndex <= _focusedIndex && _focusedIndex < e.OldStartingIndex + e.OldItems!.Count) { RecycleFocusedElement(); } else if (e.OldStartingIndex < _focusedIndex) { var oldIndex = _focusedIndex; _focusedIndex -= e.OldItems!.Count; _updateElementIndex(_focusedElement, oldIndex, _focusedIndex); } } if (_scrollToElement is not null) { if (e.OldStartingIndex <= _scrollToIndex && _scrollToIndex < e.OldStartingIndex + e.OldItems!.Count) { RecycleScrollToElement(); } else if (e.OldStartingIndex < _scrollToIndex) { _scrollToIndex -= e.OldItems!.Count; } } break; case NotifyCollectionChangedAction.Replace: if (_focusedElement is not null && e.OldStartingIndex <= _focusedIndex && _focusedIndex < e.OldStartingIndex + e.OldItems!.Count) { RecycleFocusedElement(); } if (_scrollToElement is not null && e.OldStartingIndex <= _scrollToIndex && _scrollToIndex < e.OldStartingIndex + e.OldItems!.Count) { RecycleScrollToElement(); } break; case NotifyCollectionChangedAction.Move: if (e.OldStartingIndex < 0) { goto case NotifyCollectionChangedAction.Reset; } if (_focusedElement is not null) { if (e.OldStartingIndex <= _focusedIndex && _focusedIndex < e.OldStartingIndex + e.OldItems!.Count) { var oldIndex = _focusedIndex; _focusedIndex = e.NewStartingIndex + (_focusedIndex - e.OldStartingIndex); _updateElementIndex(_focusedElement, oldIndex, _focusedIndex); } else { var newFocusedIndex = _focusedIndex; if (e.OldStartingIndex < _focusedIndex) { newFocusedIndex -= e.OldItems!.Count; } if (e.NewStartingIndex <= newFocusedIndex) { newFocusedIndex += e.NewItems!.Count; } if (newFocusedIndex != _focusedIndex) { var oldIndex = _focusedIndex; _focusedIndex = newFocusedIndex; _updateElementIndex(_focusedElement, oldIndex, _focusedIndex); } } } if (_scrollToElement is not null) { if (e.OldStartingIndex <= _scrollToIndex && _scrollToIndex < e.OldStartingIndex + e.OldItems!.Count) { _scrollToIndex = e.NewStartingIndex + (_scrollToIndex - e.OldStartingIndex); } else { var newScrollToIndex = _scrollToIndex; if (e.OldStartingIndex < _scrollToIndex) { newScrollToIndex -= e.OldItems!.Count; } if (e.NewStartingIndex <= newScrollToIndex) { newScrollToIndex += e.NewItems!.Count; } _scrollToIndex = newScrollToIndex; } } break; case NotifyCollectionChangedAction.Reset: if (_focusedElement is not null) { RecycleFocusedElement(); } if (_scrollToElement is not null) { RecycleScrollToElement(); } break; } } protected override void OnItemsControlChanged(ItemsControl? oldValue) { base.OnItemsControlChanged(oldValue); if (oldValue is not null) oldValue.PropertyChanged -= OnItemsControlPropertyChanged; if (ItemsControl is not null) ItemsControl.PropertyChanged += OnItemsControlPropertyChanged; _realizedElements?.ResetForReuse(); _measureElements?.ResetForReuse(); if (ItemsControl is not null && _focusedElement is not null) { RecycleFocusedElement(); } if (ItemsControl is not null && _scrollToElement is not null) { RecycleScrollToElement(); } if (ItemsControl is null) { _focusedElement = null; _scrollToElement = null; } _focusedIndex = -1; _scrollToIndex = -1; } protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) { var count = Items.Count; var fromControl = from as Control; if (count == 0 || (fromControl is null && direction is not NavigationDirection.First and not NavigationDirection.Last)) return null; var horiz = Orientation == Orientation.Horizontal; var fromIndex = fromControl != 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 (_scrollToIndex == index) return _scrollToElement; if (_focusedIndex == index) return _focusedElement; if (index == _realizingIndex) return _realizingElement; if (GetRealizedElement(index) is { } realized) return realized; if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) return c; return null; } protected internal override int IndexFromContainer(Control container) { if (container == _scrollToElement) return _scrollToIndex; if (container == _focusedElement) return _focusedIndex; if (container == _realizingElement) return _realizingIndex; return _realizedElements?.GetIndex(container) ?? -1; } protected internal override Control? ScrollIntoView(int index) { var items = Items; if (_isInLayout || index < 0 || index >= items.Count || _realizedElements is null || !IsEffectivelyVisible) return null; if (GetRealizedElement(index) is Control element) { element.BringIntoView(); return element; } else if (this.GetLayoutRoot() is {} 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. var scrollToElement = GetOrCreateElement(items, index); scrollToElement.Measure(Size.Infinity); // Get the expected position of the element and put it in place. var anchorU = GetOrEstimateElementU(index); var rect = Orientation == Orientation.Horizontal ? new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) : new Rect(0, anchorU, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height); scrollToElement.Arrange(rect); // Store the element and index so that they can be used in the layout pass. _scrollToElement = scrollToElement; _scrollToIndex = index; // 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(); } // During the previous BringIntoView, the scroll width extent might have been out of date if // elements have different widths. Because of that, the ScrollViewer might not scroll to the correct offset. // After the previous BringIntoView, Y offset should be correct and an extra layout pass has been executed, // hence the width extent should be correct now, and we can try to scroll again. scrollToElement.BringIntoView(); _scrollToElement = null; _scrollToIndex = -1; return scrollToElement; } return null; } internal IReadOnlyList GetRealizedElements() { return _realizedElements?.Elements ?? Array.Empty(); } private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadOnlyList items) { Debug.Assert(_realizedElements is not null); // Use the extended viewport for calculations var viewport = _lastMeasuredExtendedViewport; // Get the viewport in the orientation direction. var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y; var viewportEnd = orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom; // Get or estimate the anchor element from which to start realization. If we are // scrolling to an element, use that as the anchor element. Otherwise, estimate the // anchor element based on the current viewport. int anchorIndex; double anchorU; if (_scrollToIndex >= 0 && _scrollToElement is not null) { anchorIndex = _scrollToIndex; anchorU = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Left : _scrollToElement.Bounds.Top; } else { GetOrEstimateAnchorElementForViewport( viewportStart, viewportEnd, items.Count, out anchorIndex, out anchorU); } // 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, anchorU = anchorU, viewportUStart = viewportStart, viewportUEnd = viewportEnd, viewportIsDisjunct = disjunct, }; } private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport) { var sizeU = 0.0; var sizeV = viewport.measuredV; if (viewport.lastIndex >= 0) { var remaining = itemCount - viewport.lastIndex - 1; sizeU = viewport.realizedEndU + (remaining * _lastEstimatedElementSizeU); } return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU); } private Size EstimateDesiredSize(Orientation orientation, int itemCount) { if (_scrollToIndex >= 0 && _scrollToElement is not null) { // We have an element to scroll to, so we can estimate the desired size based on the // element's position and the remaining elements. var remaining = itemCount - _scrollToIndex - 1; var u = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Right : _scrollToElement.Bounds.Bottom; var sizeU = u + (remaining * _lastEstimatedElementSizeU); return orientation == Orientation.Horizontal ? new(sizeU, DesiredSize.Height) : new(DesiredSize.Width, sizeU); } return DesiredSize; } private double EstimateElementSizeU() { if (_realizedElements is null) return _lastEstimatedElementSizeU; var orientation = Orientation; var total = 0.0; var divisor = 0.0; // Average the desired size of the realized, measured elements. foreach (var element in _realizedElements.Elements) { if (element is null || !element.IsMeasureValid) continue; var sizeU = orientation == Orientation.Horizontal ? element.DesiredSize.Width : element.DesiredSize.Height; total += sizeU; ++divisor; } // Check we have enough information on which to base our estimate. if (divisor == 0 || total == 0) return _lastEstimatedElementSizeU; // Store and return the estimate. return _lastEstimatedElementSizeU = total / divisor; } private void GetOrEstimateAnchorElementForViewport( double viewportStartU, double viewportEndU, int itemCount, out int index, out double position) { // We have no elements, or we're at the start of the viewport. if (itemCount <= 0 || MathUtilities.IsZero(viewportStartU)) { index = 0; position = 0; return; } // If we have realised elements and a valid StartU then try to use this information to // get the anchor element. if (_realizedElements?.StartU is { } u && !double.IsNaN(u)) { var orientation = Orientation; for (var i = 0; i < _realizedElements.Elements.Count; ++i) { if (_realizedElements.Elements[i] is not { } element) continue; var sizeU = orientation == Orientation.Horizontal ? element.DesiredSize.Width : element.DesiredSize.Height; var endU = u + sizeU; if (endU > viewportStartU && u < viewportEndU) { index = _realizedElements.FirstIndex + i; position = u; return; } u = endU; } } // We don't have any realized elements in the requested viewport, or can't rely on // StartU being valid. Estimate the index using only the estimated element size. var estimatedSize = EstimateElementSizeU(); // Estimate the element at the start of the viewport. var startIndex = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1); index = startIndex; position = startIndex * estimatedSize; } private double GetOrEstimateElementU(int index) { // Return the position of the existing element if realized. var u = _realizedElements?.GetElementU(index) ?? double.NaN; if (!double.IsNaN(u)) return u; // Estimate the element size. var estimatedSize = EstimateElementSizeU(); // If we have a valid StartU, use it to anchor estimates relative to the realized range. if (_realizedElements is { } realized && !double.IsNaN(realized.StartU)) { var first = realized.FirstIndex; var last = realized.LastIndex; if (index < first) { return realized.StartU - ((first - index) * estimatedSize); } if (index > last) { var sizes = realized.SizeU; var realizedSpan = 0.0; for (var i = 0; i < sizes.Count; ++i) { var sizeU = sizes[i]; realizedSpan += double.IsNaN(sizeU) ? estimatedSize : sizeU; } return realized.StartU + realizedSpan + ((index - last - 1) * estimatedSize); } } return index * estimatedSize; } 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 u = viewport.anchorU; // Reset boundary flags _hasReachedStart = false; _hasReachedEnd = false; // 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 (u <= viewport.anchorU) _realizedElements.RecycleElementsBefore(viewport.anchorIndex, _recycleElement); // Start at the anchor element and move forwards, realizing elements. do { _realizingIndex = index; var e = GetOrCreateElement(items, index); _realizingElement = e; e.Measure(availableSize); var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; _measureElements!.Add(index, e, u, sizeU); viewport.measuredV = Math.Max(viewport.measuredV, sizeV); u += sizeU; ++index; _realizingIndex = -1; _realizingElement = null; } while (u < viewport.viewportUEnd && index < items.Count); // Check if we reached the end of the collection _hasReachedEnd = index >= items.Count; // Store the last index and end U position for the desired size calculation. viewport.lastIndex = index - 1; viewport.realizedEndU = u; // We can now recycle elements after the last element. _realizedElements.RecycleElementsAfter(viewport.lastIndex, _recycleElement); // Next move backwards from the anchor element, realizing elements. index = viewport.anchorIndex - 1; u = viewport.anchorU; while (u > viewport.viewportUStart && index >= 0) { var e = GetOrCreateElement(items, index); e.Measure(availableSize); var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; u -= sizeU; _measureElements!.Add(index, e, u, sizeU); viewport.measuredV = Math.Max(viewport.measuredV, sizeV); --index; } // Check if we reached the start of the collection _hasReachedStart = index < 0; // We can now recycle elements before the first element. _realizedElements.RecycleElementsBefore(index + 1, _recycleElement); } private Control GetOrCreateElement(IReadOnlyList items, int index) { Debug.Assert(ItemContainerGenerator is not null); if ((GetRealizedElement(index) ?? GetRealizedElement(index, ref _focusedIndex, ref _focusedElement) ?? GetRealizedElement(index, ref _scrollToIndex, ref _scrollToElement)) is { } realized) return realized; var item = items[index]; var generator = ItemContainerGenerator!; if (generator.NeedsContainer(item, index, out var recycleKey)) { return GetRecycledElement(item, index, recycleKey) ?? CreateElement(item, index, recycleKey); } else { return GetItemAsOwnContainer(item, index); } } private Control? GetRealizedElement(int index) { return _realizedElements?.GetElement(index); } private static Control? GetRealizedElement( int index, ref int specialIndex, ref Control? specialElement) { if (specialIndex == index) { Debug.Assert(specialElement is not null); var result = specialElement; specialIndex = -1; specialElement = null; return result; } return null; } private Control GetItemAsOwnContainer(object? item, int index) { Debug.Assert(ItemContainerGenerator is not null); var controlItem = (Control)item!; var generator = ItemContainerGenerator!; if (!controlItem.IsSet(RecycleKeyProperty)) { generator.PrepareItemContainer(controlItem, controlItem, index); AddInternalChild(controlItem); controlItem.SetValue(RecycleKeyProperty, s_itemIsItsOwnContainer); generator.ItemContainerPrepared(controlItem, item, index); } controlItem.SetCurrentValue(Visual.IsVisibleProperty, true); return controlItem; } private Control? GetRecycledElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); if (recycleKey is null) return null; var generator = ItemContainerGenerator!; if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0) { var recycled = recyclePool.Pop(); recycled.SetCurrentValue(Visual.IsVisibleProperty, true); generator.PrepareItemContainer(recycled, item, index); AddInternalChild(recycled); generator.ItemContainerPrepared(recycled, item, index); return recycled; } return null; } private Control CreateElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); var generator = ItemContainerGenerator!; var container = generator.CreateContainer(item, index, recycleKey); container.SetValue(RecycleKeyProperty, recycleKey); generator.PrepareItemContainer(container, item, index); AddInternalChild(container); generator.ItemContainerPrepared(container, item, index); return container; } private void RecycleElement(Control element, int index) { Debug.Assert(ItemsControl is not null); Debug.Assert(ItemContainerGenerator is not null); _scrollAnchorProvider?.UnregisterAnchorCandidate(element); var recycleKey = element.GetValue(RecycleKeyProperty); if (recycleKey is null) { ItemContainerGenerator!.ClearItemContainer(element); RemoveInternalChild(element); } else if (recycleKey == s_itemIsItsOwnContainer) { element.SetCurrentValue(Visual.IsVisibleProperty, false); } else if (KeyboardNavigation.GetTabOnceActiveElement(ItemsControl) == element) { _focusedElement = element; _focusedIndex = index; } else { ItemContainerGenerator!.ClearItemContainer(element); PushToRecyclePool(recycleKey, element); element.SetCurrentValue(Visual.IsVisibleProperty, false); RemoveInternalChild(element); } } private void RecycleElementOnItemRemoved(Control element) { Debug.Assert(ItemContainerGenerator is not null); _scrollAnchorProvider?.UnregisterAnchorCandidate(element); var recycleKey = element.GetValue(RecycleKeyProperty); if (recycleKey is null) { ItemContainerGenerator!.ClearItemContainer(element); RemoveInternalChild(element); } else if (recycleKey == s_itemIsItsOwnContainer) { RemoveInternalChild(element); } else { ItemContainerGenerator!.ClearItemContainer(element); PushToRecyclePool(recycleKey, element); element.SetCurrentValue(Visual.IsVisibleProperty, false); RemoveInternalChild(element); } } private void RecycleFocusedElement() { if (_focusedElement != null) { RecycleElementOnItemRemoved(_focusedElement); } _focusedElement = null; _focusedIndex = -1; } private void RecycleScrollToElement() { if (_scrollToElement != null) { RecycleElementOnItemRemoved(_scrollToElement); } _scrollToElement = null; _scrollToIndex = -1; } private void PushToRecyclePool(object recycleKey, Control element) { _recyclePool ??= new(); if (!_recyclePool.TryGetValue(recycleKey, out var pool)) { pool = new(); _recyclePool.Add(recycleKey, pool); } pool.Push(element); } private void UpdateElementIndex(Control element, int oldIndex, int newIndex) { Debug.Assert(ItemContainerGenerator is not null); ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex); } private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize) { var extendedViewportStart = vertical ? Math.Max(0, _viewport.Top - bufferSize) : Math.Max(0, _viewport.Left - bufferSize); var extendedViewportEnd = vertical ? Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : Math.Min(Bounds.Width, _viewport.Right + bufferSize); // If we are at the start of the list, append 2 * CacheLength additional items // If we are at the end of the list, prepend 2 * CacheLength additional items // - this way we always maintain "2 * CacheLength * element" items. if (vertical) { var spaceAbove = _viewport.Top - bufferSize; var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize); if (spaceAbove < 0 && spaceBelow >= 0) extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove)); if (spaceAbove >= 0 && spaceBelow < 0) extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceBelow)); } else { var spaceLeft = _viewport.Left - bufferSize; var spaceRight = Bounds.Width - (_viewport.Right + bufferSize); if (spaceLeft < 0 && spaceRight >= 0) extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft)); if (spaceLeft >= 0 && spaceRight < 0) extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight)); } if (vertical) { return new Rect( _viewport.X, extendedViewportStart, _viewport.Width, extendedViewportEnd - extendedViewportStart); } else { return new Rect( extendedViewportStart, _viewport.Y, extendedViewportEnd - extendedViewportStart, _viewport.Height); } } private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) { var vertical = Orientation == Orientation.Vertical; var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left; var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right; // Update current viewport _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); _isWaitingForViewportUpdate = false; // Calculate buffer sizes based on viewport dimensions var viewportSize = vertical ? _viewport.Height : _viewport.Width; var bufferSize = viewportSize * _bufferFactor; var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize); // Determine if we need a new measure var newViewportStart = vertical ? _viewport.Top : _viewport.Left; var newViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; var newExtendedViewportStart = vertical ? extendedViewPort.Top : extendedViewPort.Left; var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right; var needsMeasure = false; // Case 1: Viewport has changed significantly if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { // Case 1a: The new viewport exceeds the old extended viewport if (newViewportStart < oldExtendedViewportStart || newViewportEnd > oldExtendedViewportEnd) { needsMeasure = true; } // Case 1b: The extended viewport has changed significantly else if (!MathUtilities.AreClose(oldExtendedViewportStart, newExtendedViewportStart) || !MathUtilities.AreClose(oldExtendedViewportEnd, newExtendedViewportEnd)) { // Check if we're about to scroll into an area where we don't have realized elements // This would be the case if we're near the edge of our current extended viewport var nearingEdge = false; if (_realizedElements != null) { var firstRealizedElementU = _realizedElements.StartU; var lastRealizedElementU = _realizedElements.StartU; for (var i = 0; i < _realizedElements.Count; i++) { lastRealizedElementU += _realizedElements.SizeU[i]; } // If scrolling up/left and nearing the top/left edge of realized elements if (newViewportStart < oldViewportStart && newViewportStart - newExtendedViewportStart < bufferSize) { // Edge case: We're at item 0 with excess measurement space. // Skip re-measuring since we're at the list start and it won't change the result. // This prevents redundant Measure-Arrange cycles when at list beginning. nearingEdge = !_hasReachedStart; } // If scrolling down/right and nearing the bottom/right edge of realized elements if (newViewportEnd > oldViewportEnd && newExtendedViewportEnd - newViewportEnd < bufferSize) { // Edge case: We're at the last item with excess measurement space. // Skip re-measuring since we're at the list end and it won't change the result. // This prevents redundant Measure-Arrange cycles when at list beginning. nearingEdge = !_hasReachedEnd; } } else { nearingEdge = true; } needsMeasure = nearingEdge; } } // Supplementary check: detect viewport growth after a previous shrink. // The main comparison (Cases 1a/1b) uses _extendedViewport which only updates // on measure. When the viewport shrinks (e.g. ComboBox popup during filtering), // _extendedViewport stays stale-large, masking subsequent growth. Compare against // _lastKnownExtendedViewport (always updated) to catch this case. if (!needsMeasure) { var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left; var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right; if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd) { needsMeasure = true; } } _lastKnownExtendedViewport = extendedViewPort; if (needsMeasure) { // Only update the measure viewport when triggering a measure. This keeps the // wider realization range available for externally-triggered measures (e.g. from // OnItemsChanged), ensuring enough items are realized. _lastMeasuredExtendedViewport = extendedViewPort; InvalidateMeasure(); } } private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if (_focusedElement is not null && e.Property == KeyboardNavigation.TabOnceActiveElementProperty && e.GetOldValue() == _focusedElement) { // TabOnceActiveElement has moved away from _focusedElement so we can recycle it. RecycleElement(_focusedElement, _focusedIndex); _focusedElement = null; _focusedIndex = -1; } } private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e) { var newValue = e.GetNewValue(); _bufferFactor = newValue; // Force a recalculation of the extended viewport on the next layout pass InvalidateMeasure(); } /// public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) { if(_realizedElements == null) return new List(); return new VirtualizingSnapPointsList(_realizedElements, ItemsControl?.ItemsSource?.Count() ?? 0, orientation, Orientation, snapPointsAlignment, EstimateElementSizeU()); } /// public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset) { offset = 0f; var firstRealizedChild = _realizedElements?.Elements.FirstOrDefault(); if (firstRealizedChild == null) { return 0; } double snapPoint = 0; switch (Orientation) { case Orientation.Horizontal: if (!AreHorizontalSnapPointsRegular) throw new InvalidOperationException(); snapPoint = firstRealizedChild.Bounds.Width; switch (snapPointsAlignment) { case SnapPointsAlignment.Near: offset = 0; break; case SnapPointsAlignment.Center: offset = (firstRealizedChild.Bounds.Right - firstRealizedChild.Bounds.Left) / 2; break; case SnapPointsAlignment.Far: offset = firstRealizedChild.Bounds.Width; break; } break; case Orientation.Vertical: if (!AreVerticalSnapPointsRegular) throw new InvalidOperationException(); snapPoint = firstRealizedChild.Bounds.Height; switch (snapPointsAlignment) { case SnapPointsAlignment.Near: offset = 0; break; case SnapPointsAlignment.Center: offset = (firstRealizedChild.Bounds.Bottom - firstRealizedChild.Bounds.Top) / 2; break; case SnapPointsAlignment.Far: offset = firstRealizedChild.Bounds.Height; break; } break; } return snapPoint; } private struct MeasureViewport { public int anchorIndex; public double anchorU; public double viewportUStart; public double viewportUEnd; public double measuredV; public double realizedEndU; public int lastIndex; public bool viewportIsDisjunct; } } }