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.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); 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; public VirtualizingStackPanel() { _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); } /// /// 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 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); // 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(); 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); _scrollAnchorProvider?.RegisterAnchorCandidate(e); u += orientation == Orientation.Horizontal ? rect.Width : rect.Height; } } 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(); 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: _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); _realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex); break; case NotifyCollectionChangedAction.Reset: _realizedElements.ItemsReset(_recycleElementOnItemRemoved); 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; } 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 or 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.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. var scrollToElement = GetOrCreateElement(items, index); scrollToElement.Measure(Size.Infinity); // Get the expected position of the element and put it in place. var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU); 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(); } _scrollToElement = null; _scrollToIndex = -1; return scrollToElement; } return null; } 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; // 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. var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( viewportStart, viewportEnd, items.Count, ref _lastEstimatedElementSizeU); // 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 double EstimateElementSizeU() { if (_realizedElements is null) return _lastEstimatedElementSizeU; var result = _realizedElements.EstimateElementSizeU(); if (result >= 0) _lastEstimatedElementSizeU = result; return _lastEstimatedElementSizeU; } 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; // 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); // 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; } // 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); 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) { 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); } } private void RecycleElementOnItemRemoved(Control element) { Debug.Assert(ItemContainerGenerator is not null); var recycleKey = element.GetValue(RecycleKeyProperty); if (recycleKey is null || recycleKey == s_itemIsItsOwnContainer) { RemoveInternalChild(element); } else { ItemContainerGenerator!.ClearItemContainer(element); PushToRecyclePool(recycleKey, element); element.SetCurrentValue(Visual.IsVisibleProperty, false); } } 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 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; _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); _isWaitingForViewportUpdate = false; var newViewportStart = vertical ? _viewport.Top : _viewport.Left; var newViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { 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; } } /// 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; } } }