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 Rect s_invalidViewport = new(double.PositiveInfinity, double.PositiveInfinity, 0, 0); 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 ScrollViewer? _scrollViewer; private Rect _viewport = s_invalidViewport; private Dictionary>? _recyclePool; private Control? _unrealizedFocusedElement; private int _unrealizedFocusedIndex = -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 { return 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 { return 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); _scrollViewer?.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); _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); 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(RecycleKeyProperty) == s_itemIsItsOwnContainer) 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. _scrollToElement = GetOrCreateElement(items, index); _scrollToElement.Measure(Size.Infinity); _scrollToIndex = index; // Get the expected position of the elment 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); // 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; } 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 = 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 itemCount = items?.Count ?? 0; var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( viewportStart, viewportEnd, itemCount, 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 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.Intersect(new Rect(0, 0, double.PositiveInfinity, double.PositiveInfinity)); } 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 { 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; _measureElements!.Add(index, e, u, sizeU); viewport.measuredV = Math.Max(viewport.measuredV, sizeV); u += sizeU; ++index; } 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); var e = GetRealizedElement(index); if (e is null) { var item = items[index]; var generator = ItemContainerGenerator!; if (generator.NeedsContainer(item, index, out var recycleKey)) { e = GetRecycledElement(item, index, recycleKey) ?? CreateElement(item, index, recycleKey); } else { e = GetItemAsOwnContainer(item, index); } } return e; } private Control? GetRealizedElement(int index) { if (_scrollToIndex == index) return _scrollToElement; return _realizedElements?.GetElement(index); } 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.IsVisible = 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 (_unrealizedFocusedIndex == index && _unrealizedFocusedElement is not null) { var element = _unrealizedFocusedElement; _unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus; _unrealizedFocusedElement = null; _unrealizedFocusedIndex = -1; return element; } if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && 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(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(ItemContainerGenerator is not null); _scrollViewer?.UnregisterAnchorCandidate(element); var recycleKey = element.GetValue(RecycleKeyProperty); Debug.Assert(recycleKey is not null); if (recycleKey == s_itemIsItsOwnContainer) { element.IsVisible = false; } else if (element.IsKeyboardFocusWithin) { _unrealizedFocusedElement = element; _unrealizedFocusedIndex = index; _unrealizedFocusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus; } else { ItemContainerGenerator!.ClearItemContainer(element); PushToRecyclePool(recycleKey, element); element.IsVisible = false; } } private void RecycleElementOnItemRemoved(Control element) { Debug.Assert(ItemContainerGenerator is not null); var recycleKey = element.GetValue(RecycleKeyProperty); Debug.Assert(recycleKey is not null); if (recycleKey == s_itemIsItsOwnContainer) { RemoveInternalChild(element); } else { ItemContainerGenerator!.ClearItemContainer(element); PushToRecyclePool(recycleKey, element); element.IsVisible = 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 OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e) { if (_unrealizedFocusedElement is null || sender != _unrealizedFocusedElement) return; _unrealizedFocusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus; RecycleElement(_unrealizedFocusedElement, _unrealizedFocusedIndex); _unrealizedFocusedElement = null; _unrealizedFocusedIndex = -1; } /// public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) { var snapPoints = new List(); switch (orientation) { case Orientation.Horizontal: if (AreHorizontalSnapPointsRegular) throw new InvalidOperationException(); if (Orientation == Orientation.Horizontal) { var averageElementSize = EstimateElementSizeU(); double snapPoint = 0; for (var i = 0; i < Items.Count; i++) { var container = ContainerFromIndex(i); if (container != null) { switch (snapPointsAlignment) { case SnapPointsAlignment.Near: snapPoint = container.Bounds.Left; break; case SnapPointsAlignment.Center: snapPoint = container.Bounds.Center.X; break; case SnapPointsAlignment.Far: snapPoint = container.Bounds.Right; break; } } else { if (snapPoint == 0) { switch (snapPointsAlignment) { case SnapPointsAlignment.Center: snapPoint = averageElementSize / 2; break; case SnapPointsAlignment.Far: snapPoint = averageElementSize; break; } } else snapPoint += averageElementSize; } snapPoints.Add(snapPoint); } } break; case Orientation.Vertical: if (AreVerticalSnapPointsRegular) throw new InvalidOperationException(); if (Orientation == Orientation.Vertical) { var averageElementSize = EstimateElementSizeU(); double snapPoint = 0; for (var i = 0; i < Items.Count; i++) { var container = ContainerFromIndex(i); if (container != null) { switch (snapPointsAlignment) { case SnapPointsAlignment.Near: snapPoint = container.Bounds.Top; break; case SnapPointsAlignment.Center: snapPoint = container.Bounds.Center.Y; break; case SnapPointsAlignment.Far: snapPoint = container.Bounds.Bottom; break; } } else { if (snapPoint == 0) { switch (snapPointsAlignment) { case SnapPointsAlignment.Center: snapPoint = averageElementSize / 2; break; case SnapPointsAlignment.Far: snapPoint = averageElementSize; break; } } else snapPoint += averageElementSize; } snapPoints.Add(snapPoint); } } break; } return snapPoints; } /// 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; } } }