diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 8699508320..5f24c8062e 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -26,6 +26,7 @@ + diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml new file mode 100644 index 0000000000..dfe8be2cec --- /dev/null +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml @@ -0,0 +1,25 @@ + + + + ItemsRepeater + A data-driven collection control that incorporates a flexible layout system, custom views, and virtualization. + + + + Stack - Vertical + Stack - Horizontal + UniformGrid - Vertical + UniformGrid - Horizontal + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs new file mode 100644 index 0000000000..214de89253 --- /dev/null +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs @@ -0,0 +1,71 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Markup.Xaml; + +namespace ControlCatalog.Pages +{ + public class ItemsRepeaterPage : UserControl + { + private ItemsRepeater _repeater; + private ScrollViewer _scroller; + + public ItemsRepeaterPage() + { + this.InitializeComponent(); + _repeater = this.FindControl("repeater"); + _scroller = this.FindControl("scroller"); + DataContext = Enumerable.Range(1, 100000).Select(i => $"Item {i}" ).ToArray(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void LayoutChanged(object sender, SelectionChangedEventArgs e) + { + if (_repeater == null) + { + return; + } + + var comboBox = (ComboBox)sender; + + switch (comboBox.SelectedIndex) + { + case 0: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + _repeater.Layout = new StackLayout { Orientation = Orientation.Vertical }; + break; + case 1: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + _repeater.Layout = new StackLayout { Orientation = Orientation.Horizontal }; + break; + case 2: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled; + _repeater.Layout = new UniformGridLayout + { + Orientation = Orientation.Vertical, + MinItemWidth = 200, + MinItemHeight = 200, + }; + break; + case 3: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + _repeater.Layout = new UniformGridLayout + { + Orientation = Orientation.Horizontal, + MinItemWidth = 200, + MinItemHeight = 200, + }; + break; + } + } + } +} diff --git a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs index 80e0fb2586..93fe09a156 100644 --- a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs @@ -9,6 +9,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using ReactiveUI.Legacy; using ReactiveUI; +using Avalonia.Layout; namespace VirtualizationDemo.ViewModels { diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index bcd12fbfbb..490a724eda 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -24,6 +24,7 @@ using System.Linq; using Avalonia.Input.Platform; using System.ComponentModel.DataAnnotations; using Avalonia.Controls.Utils; +using Avalonia.Layout; namespace Avalonia.Controls { diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 58b4324a3e..d0804107b3 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.LogicalTree; namespace Avalonia.Controls diff --git a/src/Avalonia.Controls/GridSplitter.cs b/src/Avalonia.Controls/GridSplitter.cs index 304a760216..28b9b3a38f 100644 --- a/src/Avalonia.Controls/GridSplitter.cs +++ b/src/Avalonia.Controls/GridSplitter.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Layout; using Avalonia.VisualTree; namespace Avalonia.Controls diff --git a/src/Avalonia.Controls/IScrollAnchorProvider.cs b/src/Avalonia.Controls/IScrollAnchorProvider.cs new file mode 100644 index 0000000000..6b5cb2ee25 --- /dev/null +++ b/src/Avalonia.Controls/IScrollAnchorProvider.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Controls +{ + public interface IScrollAnchorProvider + { + IControl CurrentAnchor { get; } + void RegisterAnchorCandidate(IControl element); + void UnregisterAnchorCandidate(IControl element); + } +} diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index b60a97e1c8..6ec97aa04e 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.Platform; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; namespace Avalonia.Controls { diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs index 46da8fe3f8..ae52e733b7 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs @@ -8,6 +8,7 @@ using System.Reactive.Linq; using Avalonia.Controls.Primitives; using Avalonia.Controls.Utils; using Avalonia.Input; +using Avalonia.Layout; namespace Avalonia.Controls.Presenters { diff --git a/src/Avalonia.Controls/Primitives/ScrollBar.cs b/src/Avalonia.Controls/Primitives/ScrollBar.cs index e1b3061b54..c6119e89dc 100644 --- a/src/Avalonia.Controls/Primitives/ScrollBar.cs +++ b/src/Avalonia.Controls/Primitives/ScrollBar.cs @@ -7,6 +7,7 @@ using System.Reactive.Linq; using Avalonia.Data; using Avalonia.Interactivity; using Avalonia.Input; +using Avalonia.Layout; namespace Avalonia.Controls.Primitives { diff --git a/src/Avalonia.Controls/Primitives/TabStrip.cs b/src/Avalonia.Controls/Primitives/TabStrip.cs index a61757e628..ec0dbd124c 100644 --- a/src/Avalonia.Controls/Primitives/TabStrip.cs +++ b/src/Avalonia.Controls/Primitives/TabStrip.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Generators; using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Layout; namespace Avalonia.Controls.Primitives { diff --git a/src/Avalonia.Controls/Primitives/Track.cs b/src/Avalonia.Controls/Primitives/Track.cs index c96fea6c25..21a7dd68f8 100644 --- a/src/Avalonia.Controls/Primitives/Track.cs +++ b/src/Avalonia.Controls/Primitives/Track.cs @@ -3,6 +3,7 @@ using System; using Avalonia.Input; +using Avalonia.Layout; using Avalonia.Metadata; namespace Avalonia.Controls.Primitives diff --git a/src/Avalonia.Controls/ProgressBar.cs b/src/Avalonia.Controls/ProgressBar.cs index a0f51099cd..29e3a17f74 100644 --- a/src/Avalonia.Controls/ProgressBar.cs +++ b/src/Avalonia.Controls/ProgressBar.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Primitives; +using Avalonia.Layout; namespace Avalonia.Controls { @@ -33,8 +34,8 @@ namespace Avalonia.Controls static ProgressBar() { - PseudoClass(OrientationProperty, o => o == Avalonia.Controls.Orientation.Vertical, ":vertical"); - PseudoClass(OrientationProperty, o => o == Avalonia.Controls.Orientation.Horizontal, ":horizontal"); + PseudoClass(OrientationProperty, o => o == Orientation.Vertical, ":vertical"); + PseudoClass(OrientationProperty, o => o == Orientation.Horizontal, ":horizontal"); PseudoClass(IsIndeterminateProperty, ":indeterminate"); ValueProperty.Changed.AddClassHandler(x => x.UpdateIndicatorWhenPropChanged); @@ -120,4 +121,4 @@ namespace Avalonia.Controls UpdateIndicator(Bounds.Size); } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Controls/Repeater/ItemTemplateWrapper.cs b/src/Avalonia.Controls/Repeater/ItemTemplateWrapper.cs new file mode 100644 index 0000000000..04d859c742 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemTemplateWrapper.cs @@ -0,0 +1,54 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using Avalonia.Controls.Templates; + +namespace Avalonia.Controls +{ + internal class ItemTemplateWrapper + { + private readonly IDataTemplate _dataTemplate; + + public ItemTemplateWrapper(IDataTemplate dataTemplate) => _dataTemplate = dataTemplate; + + public IControl GetElement(IControl parent, object data) + { + var selectedTemplate = _dataTemplate; + var recyclePool = RecyclePool.GetPoolInstance(selectedTemplate); + IControl element = null; + + if (recyclePool != null) + { + // try to get an element from the recycle pool. + element = recyclePool.TryGetElement(string.Empty, parent); + } + + if (element == null) + { + // no element was found in recycle pool, create a new element + element = selectedTemplate.Build(data); + + // Associate template with element + element.SetValue(RecyclePool.OriginTemplateProperty, selectedTemplate); + } + + return element; + } + + public void RecycleElement(IControl parent, IControl element) + { + var selectedTemplate = _dataTemplate; + var recyclePool = RecyclePool.GetPoolInstance(selectedTemplate); + if (recyclePool == null) + { + // No Recycle pool in the template, create one. + recyclePool = new RecyclePool(); + RecyclePool.SetPoolInstance(selectedTemplate, recyclePool); + } + + recyclePool.PutElement(element, "" /* key */, parent); + } + } +} diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs new file mode 100644 index 0000000000..44783e2c97 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs @@ -0,0 +1,724 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Specialized; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Layout; + +namespace Avalonia.Controls +{ + /// + /// Represents a data-driven collection control that incorporates a flexible layout system, + /// custom views, and virtualization. + /// + public class ItemsRepeater : Panel + { + /// + /// Defines the property. + /// + public static readonly AvaloniaProperty HorizontalCacheLengthProperty = + AvaloniaProperty.Register(nameof(HorizontalCacheLength), 2.0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemTemplateProperty = + ItemsControl.ItemTemplateProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ItemsProperty = + ItemsControl.ItemsProperty.AddOwner(o => o.Items, (o, v) => o.Items = v); + + /// + /// Defines the property. + /// + public static readonly AvaloniaProperty LayoutProperty = + AvaloniaProperty.Register(nameof(Layout), new StackLayout()); + + /// + /// Defines the property. + /// + public static readonly AvaloniaProperty VerticalCacheLengthProperty = + AvaloniaProperty.Register(nameof(VerticalCacheLength), 2.0); + + private static readonly AttachedProperty VirtualizationInfoProperty = + AvaloniaProperty.RegisterAttached("VirtualizationInfo"); + + internal static readonly Rect InvalidRect = new Rect(-1, -1, -1, -1); + internal static readonly Point ClearedElementsArrangePosition = new Point(-10000.0, -10000.0); + + private readonly ViewManager _viewManager; + private readonly ViewportManager _viewportManager; + private IEnumerable _items; + private VirtualizingLayoutContext _layoutContext; + private NotifyCollectionChangedEventArgs _processingItemsSourceChange; + private bool _isLayoutInProgress; + private ItemsRepeaterElementPreparedEventArgs _elementPreparedArgs; + private ItemsRepeaterElementClearingEventArgs _elementClearingArgs; + private ItemsRepeaterElementIndexChangedEventArgs _elementIndexChangedArgs; + + /// + /// Initializes a new instance of the class. + /// + public ItemsRepeater() + { + _viewManager = new ViewManager(this); + _viewportManager = new ViewportManager(this); + KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Once); + OnLayoutChanged(null, Layout); + } + + static ItemsRepeater() + { + ClipToBoundsProperty.OverrideDefaultValue(true); + } + + /// + /// Gets or sets the layout used to size and position elements in the ItemsRepeater. + /// + /// + /// The layout used to size and position elements. The default is a StackLayout with + /// vertical orientation. + /// + public AttachedLayout Layout + { + get => GetValue(LayoutProperty); + set => SetValue(LayoutProperty, value); + } + + /// + /// Gets or sets an object source used to generate the content of the ItemsRepeater. + /// + public IEnumerable Items + { + get => _items; + set => SetAndRaise(ItemsProperty, ref _items, value); + } + + /// + /// Gets or sets the template used to display each item. + /// + public IDataTemplate ItemTemplate + { + get => GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); + } + + /// + /// Gets or sets a value that indicates the size of the buffer used to realize items when + /// panning or scrolling horizontally. + /// + public double HorizontalCacheLength + { + get => GetValue(HorizontalCacheLengthProperty); + set => SetValue(HorizontalCacheLengthProperty, value); + } + + /// + /// Gets or sets a value that indicates the size of the buffer used to realize items when + /// panning or scrolling vertically. + /// + public double VerticalCacheLength + { + get => GetValue(VerticalCacheLengthProperty); + set => SetValue(VerticalCacheLengthProperty, value); + } + + /// + /// Gets a standardized view of the supported interactions between a given Items object and + /// the ItemsRepeater control and its associated components. + /// + public ItemsSourceView ItemsSourceView { get; private set; } + + internal ItemTemplateWrapper ItemTemplateShim { get; set; } + internal Point LayoutOrigin { get; set; } + internal object LayoutState { get; set; } + internal IControl MadeAnchor => _viewportManager.MadeAnchor; + internal Rect RealizationWindow => _viewportManager.GetLayoutRealizationWindow(); + internal IControl SuggestedAnchor => _viewportManager.SuggestedAnchor; + + private bool IsProcessingCollectionChange => _processingItemsSourceChange != null; + + private LayoutContext LayoutContext + { + get + { + if (_layoutContext == null) + { + _layoutContext = new RepeaterLayoutContext(this); + } + + return _layoutContext; + } + } + + /// + /// Occurs each time an element is cleared and made available to be re-used. + /// + /// + /// This event is raised immediately each time an element is cleared, such as when it falls + /// outside the range of realized items. Elements are cleared when they become available + /// for re-use. + /// + public event EventHandler ElementClearing; + + /// + /// Occurs for each realized when the index for the item it + /// represents has changed. + /// + /// + /// When you use ItemsRepeater to build a more complex control that supports specific + /// interactions on the child elements (such as selection or click), it is useful to be + /// able to keep an up-to-date identifier for the backing data item. + /// + /// This event is raised for each realized IControl where the index for the item it + /// represents has changed. For example, when another item is added or removed in the data + /// source, the index for items that come after in the ordering will be impacted. + /// + public event EventHandler ElementIndexChanged; + + /// + /// Occurs each time an element is prepared for use. + /// + /// + /// The prepared element might be newly created or an existing element that is being re- + /// used. + /// + public event EventHandler ElementPrepared; + + /// + /// Retrieves the index of the item from the data source that corresponds to the specified + /// . + /// + /// + /// The element that corresponds to the item to get the index of. + /// + /// + /// The index of the item from the data source that corresponds to the specified UIElement, + /// or -1 if the element is not supported. + /// + public int GetElementIndex(IControl element) => GetElementIndexImpl(element); + + /// + /// Retrieves the realized UIElement that corresponds to the item at the specified index in + /// the data source. + /// + /// The index of the item. + /// + /// he UIElement that corresponds to the item at the specified index if the item is + /// realized, or null if the item is not realized. + /// + public IControl TryGetElement(int index) => GetElementFromIndexImpl(index); + + internal void PinElement(IControl element) => _viewManager.UpdatePin(element, true); + + internal void UnpinElement(IControl element) => _viewManager.UpdatePin(element, false); + + internal IControl GetOrCreateElement(int index) => GetOrCreateElementImpl(index); + + internal static VirtualizationInfo TryGetVirtualizationInfo(IControl element) + { + var value = element.GetValue(VirtualizationInfoProperty); + return value; + } + + internal static VirtualizationInfo CreateAndInitializeVirtualizationInfo(IControl element) + { + if (TryGetVirtualizationInfo(element) != null) + { + throw new InvalidOperationException("VirtualizationInfo already created."); + } + + var result = new VirtualizationInfo(); + element.SetValue(VirtualizationInfoProperty, result); + return result; + } + + internal static VirtualizationInfo GetVirtualizationInfo(IControl element) + { + var result = element.GetValue(VirtualizationInfoProperty); + + if (result == null) + { + result = new VirtualizationInfo(); + element.SetValue(VirtualizationInfoProperty, result); + } + + return result; + } + + protected override Size MeasureOverride(Size availableSize) + { + if (_isLayoutInProgress) + { + throw new AvaloniaInternalException("Reentrancy detected during layout."); + } + + if (IsProcessingCollectionChange) + { + throw new NotSupportedException("Cannot run layout in the middle of a collection change."); + } + + _viewportManager.OnOwnerMeasuring(); + + _isLayoutInProgress = true; + + try + { + _viewManager.PrunePinnedElements(); + var extent = new Rect(); + var desiredSize = new Size(); + var layout = Layout; + + if (layout != null) + { + var layoutContext = GetLayoutContext(); + + desiredSize = layout.Measure(layoutContext, availableSize); + extent = new Rect(LayoutOrigin.X, LayoutOrigin.Y, desiredSize.Width, desiredSize.Height); + + // Clear auto recycle candidate elements that have not been kept alive by layout - i.e layout did not + // call GetElementAt(index). + foreach (var element in Children) + { + var virtInfo = GetVirtualizationInfo(element); + + if (virtInfo.Owner == ElementOwner.Layout && + virtInfo.AutoRecycleCandidate && + !virtInfo.KeepAlive) + { + ClearElementImpl(element); + } + } + } + + _viewportManager.SetLayoutExtent(extent); + return desiredSize; + } + finally + { + _isLayoutInProgress = false; + } + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (_isLayoutInProgress) + { + throw new AvaloniaInternalException("Reentrancy detected during layout."); + } + + if (IsProcessingCollectionChange) + { + throw new NotSupportedException("Cannot run layout in the middle of a collection change."); + } + + _isLayoutInProgress = true; + + try + { + var arrangeSize = Layout?.Arrange(GetLayoutContext(), finalSize) ?? default; + + // The view manager might clear elements during this call. + // That's why we call it before arranging cleared elements + // off screen. + _viewManager.OnOwnerArranged(); + + foreach (var element in Children) + { + var virtInfo = GetVirtualizationInfo(element); + virtInfo.KeepAlive = false; + + if (virtInfo.Owner == ElementOwner.ElementFactory || + virtInfo.Owner == ElementOwner.PinnedPool) + { + // Toss it away. And arrange it with size 0 so that XYFocus won't use it. + element.Arrange(new Rect( + ClearedElementsArrangePosition.X - element.DesiredSize.Width, + ClearedElementsArrangePosition.Y - element.DesiredSize.Height, + 0, + 0)); + } + else + { + var newBounds = element.Bounds; + virtInfo.ArrangeBounds = newBounds; + } + } + + _viewportManager.OnOwnerArranged(); + + return arrangeSize; + } + finally + { + _isLayoutInProgress = false; + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + InvalidateMeasure(); + _viewportManager.ResetScrollers(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _viewportManager.ResetScrollers(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + var property = args.Property; + + if (property == ItemsProperty) + { + var newValue = (IEnumerable)args.NewValue; + var newDataSource = newValue as ItemsSourceView; + if (newValue != null && newDataSource == null) + { + newDataSource = new ItemsSourceView(newValue); + } + + OnDataSourcePropertyChanged(ItemsSourceView, newDataSource); + } + else if (property == ItemTemplateProperty) + { + OnItemTemplateChanged((IDataTemplate)args.OldValue, (IDataTemplate)args.NewValue); + } + else if (property == LayoutProperty) + { + OnLayoutChanged((AttachedLayout)args.OldValue, (AttachedLayout)args.NewValue); + } + else if (property == HorizontalCacheLengthProperty) + { + _viewportManager.HorizontalCacheLength = (double)args.NewValue; + } + else if (property == VerticalCacheLengthProperty) + { + _viewportManager.VerticalCacheLength = (double)args.NewValue; + } + else + { + base.OnPropertyChanged(args); + } + } + + internal IControl GetElementImpl(int index, bool forceCreate, bool supressAutoRecycle) + { + var element = _viewManager.GetElement(index, forceCreate, supressAutoRecycle); + return element; + } + + internal void ClearElementImpl(IControl element) + { + // Clearing an element due to a collection change + // is more strict in that pinned elements will be forcibly + // unpinned and sent back to the view generator. + var isClearedDueToCollectionChange = + _processingItemsSourceChange != null && + (_processingItemsSourceChange.Action == NotifyCollectionChangedAction.Remove || + _processingItemsSourceChange.Action == NotifyCollectionChangedAction.Replace || + _processingItemsSourceChange.Action == NotifyCollectionChangedAction.Reset); + + _viewManager.ClearElement(element, isClearedDueToCollectionChange); + _viewportManager.OnElementCleared(element); + } + + private int GetElementIndexImpl(IControl element) + { + var virtInfo = TryGetVirtualizationInfo(element); + return _viewManager.GetElementIndex(virtInfo); + } + + private IControl GetElementFromIndexImpl(int index) + { + IControl result = null; + + var children = Children; + for (var i = 0; i < children.Count && result == null; ++i) + { + var element = children[i]; + var virtInfo = TryGetVirtualizationInfo(element); + if (virtInfo?.IsRealized == true && virtInfo.Index == index) + { + result = element; + } + } + + return result; + } + + private IControl GetOrCreateElementImpl(int index) + { + if (index >= 0 && index >= ItemsSourceView.Count) + { + throw new ArgumentException("Argument index is invalid.", "index"); + } + + if (_isLayoutInProgress) + { + throw new NotSupportedException("GetOrCreateElement invocation is not allowed during layout."); + } + + var element = GetElementFromIndexImpl(index); + bool isAnchorOutsideRealizedRange = element == null; + + if (isAnchorOutsideRealizedRange) + { + if (Layout == null) + { + throw new InvalidOperationException("Cannot make an Anchor when there is no attached layout."); + } + + element = (IControl)GetLayoutContext().GetOrCreateElementAt(index); + element.Measure(Size.Infinity); + } + + _viewportManager.OnMakeAnchor(element, isAnchorOutsideRealizedRange); + InvalidateMeasure(); + + return element; + } + + internal void OnElementPrepared(IControl element, int index) + { + _viewportManager.OnElementPrepared(element); + if (ElementPrepared != null) + { + if (_elementPreparedArgs == null) + { + _elementPreparedArgs = new ItemsRepeaterElementPreparedEventArgs(element, index); + } + else + { + _elementPreparedArgs.Update(element, index); + } + + ElementPrepared(this, _elementPreparedArgs); + } + } + + internal void OnElementClearing(IControl element) + { + if (ElementClearing != null) + { + if (_elementClearingArgs == null) + { + _elementClearingArgs = new ItemsRepeaterElementClearingEventArgs(element); + } + else + { + _elementClearingArgs.Update(element); + } + + ElementClearing(this, _elementClearingArgs); + } + } + + internal void OnElementIndexChanged(IControl element, int oldIndex, int newIndex) + { + if (ElementIndexChanged != null) + { + if (_elementIndexChangedArgs == null) + { + _elementIndexChangedArgs = new ItemsRepeaterElementIndexChangedEventArgs(element, oldIndex, newIndex); + } + else + { + _elementIndexChangedArgs.Update(element, oldIndex, newIndex); + } + + ElementIndexChanged(this, _elementIndexChangedArgs); + } + } + + private void OnDataSourcePropertyChanged(ItemsSourceView oldValue, ItemsSourceView newValue) + { + if (_isLayoutInProgress) + { + throw new AvaloniaInternalException("Cannot set ItemsSourceView during layout."); + } + + ItemsSourceView?.Dispose(); + ItemsSourceView = newValue; + + if (oldValue != null) + { + oldValue.CollectionChanged -= OnItemsSourceViewChanged; + } + + if (newValue != null) + { + newValue.CollectionChanged += OnItemsSourceViewChanged; + } + + if (Layout != null) + { + if (Layout is VirtualizingLayout virtualLayout) + { + var args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + virtualLayout.OnItemsChanged(GetLayoutContext(), newValue, args); + } + else if (Layout is NonVirtualizingLayout nonVirtualLayout) + { + // Walk through all the elements and make sure they are cleared for + // non-virtualizing layouts. + foreach (var element in Children) + { + if (GetVirtualizationInfo(element).IsRealized) + { + ClearElementImpl(element); + } + } + } + + InvalidateMeasure(); + } + } + + private void OnItemTemplateChanged(IDataTemplate oldValue, IDataTemplate newValue) + { + if (_isLayoutInProgress && oldValue != null) + { + throw new AvaloniaInternalException("ItemTemplate cannot be changed during layout."); + } + + // Since the ItemTemplate has changed, we need to re-evaluate all the items that + // have already been created and are now in the tree. The easiest way to do that + // would be to do a reset.. Note that this has to be done before we change the template + // so that the cleared elements go back into the old template. + if (Layout != null) + { + if (Layout is VirtualizingLayout virtualLayout) + { + var args = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + _processingItemsSourceChange = args; + + try + { + virtualLayout.OnItemsChanged(GetLayoutContext(), newValue, args); + } + finally + { + _processingItemsSourceChange = null; + } + } + else if (Layout is NonVirtualizingLayout) + { + // Walk through all the elements and make sure they are cleared for + // non-virtualizing layouts. + foreach (var element in Children) + { + if (GetVirtualizationInfo(element).IsRealized) + { + ClearElementImpl(element); + } + } + } + } + + ItemTemplateShim = new ItemTemplateWrapper(newValue); + + InvalidateMeasure(); + } + + private void OnLayoutChanged(AttachedLayout oldValue, AttachedLayout newValue) + { + if (_isLayoutInProgress) + { + throw new InvalidOperationException("Layout cannot be changed during layout."); + } + + _viewManager.OnLayoutChanging(); + + if (oldValue != null) + { + oldValue.UninitializeForContext(LayoutContext); + oldValue.MeasureInvalidated -= InvalidateMeasureForLayout; + oldValue.ArrangeInvalidated -= InvalidateArrangeForLayout; + + // Walk through all the elements and make sure they are cleared + foreach (var element in Children) + { + if (GetVirtualizationInfo(element).IsRealized) + { + ClearElementImpl(element); + } + } + + LayoutState = null; + } + + if (newValue != null) + { + newValue.InitializeForContext(LayoutContext); + newValue.MeasureInvalidated += InvalidateMeasureForLayout; + newValue.ArrangeInvalidated += InvalidateArrangeForLayout; + } + + bool isVirtualizingLayout = newValue != null && newValue is VirtualizingLayout; + _viewportManager.OnLayoutChanged(isVirtualizingLayout); + InvalidateMeasure(); + } + + private void OnItemsSourceViewChanged(object sender, NotifyCollectionChangedEventArgs args) + { + if (_isLayoutInProgress) + { + // Bad things will follow if the data changes while we are in the middle of a layout pass. + throw new InvalidOperationException("Changes in data source are not allowed during layout."); + } + + if (IsProcessingCollectionChange) + { + throw new InvalidOperationException("Changes in the data source are not allowed during another change in the data source."); + } + + _processingItemsSourceChange = args; + + try + { + _viewManager.OnItemsSourceChanged(sender, args); + + if (Layout != null) + { + if (Layout is VirtualizingLayout virtualLayout) + { + virtualLayout.OnItemsChanged(GetLayoutContext(), sender, args); + } + else + { + // NonVirtualizingLayout + InvalidateMeasure(); + } + } + } + finally + { + _processingItemsSourceChange = null; + } + } + + private void InvalidateArrangeForLayout(object sender, EventArgs e) => InvalidateMeasure(); + + private void InvalidateMeasureForLayout(object sender, EventArgs e) => InvalidateArrange(); + + private VirtualizingLayoutContext GetLayoutContext() + { + if (_layoutContext == null) + { + _layoutContext = new RepeaterLayoutContext(this); + } + + return _layoutContext; + } + } +} diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeaterElementClearingEventArgs.cs b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementClearingEventArgs.cs new file mode 100644 index 0000000000..75d50e52a6 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementClearingEventArgs.cs @@ -0,0 +1,24 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ItemsRepeaterElementClearingEventArgs : EventArgs + { + internal ItemsRepeaterElementClearingEventArgs(IControl element) => Element = element; + + /// + /// Gets the element that is being cleared for re-use. + /// + public IControl Element { get; private set; } + + internal void Update(IControl element) => Element = element; + } +} diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs new file mode 100644 index 0000000000..7ca68140b2 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs @@ -0,0 +1,44 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ItemsRepeaterElementIndexChangedEventArgs : EventArgs + { + internal ItemsRepeaterElementIndexChangedEventArgs(IControl element, int newIndex, int oldIndex) + { + Element = element; + NewIndex = newIndex; + OldIndex = oldIndex; + } + + /// + /// Get the element for which the index changed. + /// + public IControl Element { get; private set; } + + /// + /// Gets the index of the element after the change. + /// + public int NewIndex { get; private set; } + + /// + /// Gets the index of the element before the change. + /// + public int OldIndex { get; private set; } + + internal void Update(IControl element, int newIndex, int oldIndex) + { + Element = element; + NewIndex = newIndex; + OldIndex = oldIndex; + } + } +} diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeaterElementPreparedEventArgs.cs b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementPreparedEventArgs.cs new file mode 100644 index 0000000000..5a30dbcf2a --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementPreparedEventArgs.cs @@ -0,0 +1,35 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +namespace Avalonia.Controls +{ + /// + /// Provides data for the event. + /// + public class ItemsRepeaterElementPreparedEventArgs + { + internal ItemsRepeaterElementPreparedEventArgs(IControl element, int index) + { + Element = element; + Index = index; + } + + /// + /// Gets the prepared element. + /// + public IControl Element { get; private set; } + + /// + /// Gets the index of the item the element was prepared for. + /// + public int Index { get; private set; } + + internal void Update(IControl element, int index) + { + Element = element; + Index = index; + } + } +} diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs new file mode 100644 index 0000000000..732ba8501c --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs @@ -0,0 +1,143 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; + +namespace Avalonia.Controls +{ + /// + /// Represents a standardized view of the supported interactions between a given ItemsSource + /// object and an control. + /// + /// + /// Components written to work with ItemsRepeater should consume the + /// via ItemsSourceView since this provides a normalized + /// view of the Items. That way, each component does not need to know if the source is an + /// IEnumerable, an IList, or something else. + /// + public class ItemsSourceView : INotifyCollectionChanged, IDisposable + { + private readonly IList _inner; + private INotifyCollectionChanged _notifyCollectionChanged; + private int _cachedSize = -1; + + /// + /// Initializes a new instance of the ItemsSourceView class for the specified data source. + /// + /// The data source. + public ItemsSourceView(IEnumerable source) + { + Contract.Requires(source != null); + + _inner = source as IList; + + if (_inner == null && source is IEnumerable objectEnumerable) + { + _inner = new List(objectEnumerable); + } + else + { + _inner = new List(source.Cast()); + } + + ListenToCollectionChanges(); + } + + /// + /// Gets the number of items in the collection. + /// + public int Count + { + get + { + if (_cachedSize == -1) + { + _cachedSize = _inner.Count; + } + + return _cachedSize; + } + } + + /// + /// Gets a value that indicates whether the items source can provide a unique key for each item. + /// + /// + /// TODO: Not yet implemented in Avalonia. + /// + public bool HasKeyIndexMapping => false; + + /// + /// Occurs when the collection has changed to indicate the reason for the change and which items changed. + /// + public event NotifyCollectionChangedEventHandler CollectionChanged; + + /// + public void Dispose() + { + if (_notifyCollectionChanged != null) + { + _notifyCollectionChanged.CollectionChanged -= OnCollectionChanged; + } + } + + /// + /// Retrieves the item at the specified index. + /// + /// The index. + /// the item. + public object GetAt(int index) => _inner[index]; + + /// + /// Retrieves the index of the item that has the specified unique identifier (key). + /// + /// The index. + /// The key + /// + /// TODO: Not yet implemented in Avalonia. + /// + public string KeyFromIndex(int index) + { + throw new NotImplementedException(); + } + + /// + /// Retrieves the unique identifier (key) for the item at the specified index. + /// + /// The key. + /// The index. + /// + /// TODO: Not yet implemented in Avalonia. + /// + public int IndexFromKey(string key) + { + throw new NotImplementedException(); + } + + protected void OnItemsSourceChanged(NotifyCollectionChangedEventArgs args) + { + _cachedSize = _inner.Count; + CollectionChanged?.Invoke(this, args); + } + + private void ListenToCollectionChanges() + { + if (_inner is INotifyCollectionChanged incc) + { + incc.CollectionChanged += OnCollectionChanged; + _notifyCollectionChanged = incc; + } + } + + private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + OnItemsSourceChanged(e); + } + } +} diff --git a/src/Avalonia.Controls/Repeater/RecyclePool.cs b/src/Avalonia.Controls/Repeater/RecyclePool.cs new file mode 100644 index 0000000000..4e5950bdc5 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/RecyclePool.cs @@ -0,0 +1,106 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using Avalonia.Controls.Templates; + +namespace Avalonia.Controls +{ + internal class RecyclePool + { + public static readonly AttachedProperty OriginTemplateProperty = + AvaloniaProperty.RegisterAttached("OriginTemplate", typeof(RecyclePool)); + + private static ConditionalWeakTable s_pools = new ConditionalWeakTable(); + private readonly Dictionary> _elements = new Dictionary>(); + + public static RecyclePool GetPoolInstance(IDataTemplate dataTemplate) + { + s_pools.TryGetValue(dataTemplate, out var result); + return result; + } + + public static void SetPoolInstance(IDataTemplate dataTemplate, RecyclePool value) => s_pools.Add(dataTemplate, value); + + public void PutElement(IControl element, string key, IControl owner) + { + var ownerAsPanel = EnsureOwnerIsPanelOrNull(owner); + var elementInfo = new ElementInfo(element, ownerAsPanel); + + if (!_elements.TryGetValue(key, out var pool)) + { + pool = new List(); + _elements.Add(key, pool); + } + + pool.Add(elementInfo); + } + + public IControl TryGetElement(string key, IControl owner) + { + if (_elements.TryGetValue(key, out var elements)) + { + if (elements.Count > 0) + { + // Prefer an element from the same owner or with no owner so that we don't incur + // the enter/leave cost during recycling. + // TODO: prioritize elements with the same owner to those without an owner. + var elementInfo = elements.FirstOrDefault(x => x.Owner == owner) ?? elements.LastOrDefault(); + elements.Remove(elementInfo); + + var ownerAsPanel = EnsureOwnerIsPanelOrNull(owner); + if (elementInfo.Owner != null && elementInfo.Owner != ownerAsPanel) + { + // Element is still under its parent. remove it from its parent. + var panel = elementInfo.Owner; + if (panel != null) + { + int childIndex = panel.Children.IndexOf(elementInfo.Element); + if (childIndex == -1) + { + throw new KeyNotFoundException("ItemsRepeater's child not found in its Children collection."); + } + + panel.Children.RemoveAt(childIndex); + } + } + + return elementInfo.Element; + } + } + + return null; + } + + private IPanel EnsureOwnerIsPanelOrNull(IControl owner) + { + if (owner is IPanel panel) + { + return panel; + } + else if (owner != null) + { + throw new InvalidOperationException("Owner must be IPanel or null."); + } + + return null; + } + + private class ElementInfo + { + public ElementInfo(IControl element, IPanel owner) + { + Element = element; + Owner = owner; + } + + public IControl Element { get; } + public IPanel Owner { get;} + } + } +} diff --git a/src/Avalonia.Controls/Repeater/RepeaterLayoutContext.cs b/src/Avalonia.Controls/Repeater/RepeaterLayoutContext.cs new file mode 100644 index 0000000000..977d9d794c --- /dev/null +++ b/src/Avalonia.Controls/Repeater/RepeaterLayoutContext.cs @@ -0,0 +1,65 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Text; +using Avalonia.Layout; + +namespace Avalonia.Controls +{ + internal class RepeaterLayoutContext : VirtualizingLayoutContext + { + private readonly ItemsRepeater _owner; + + public RepeaterLayoutContext(ItemsRepeater owner) + { + _owner = owner; + } + + protected override Point LayoutOriginCore + { + get => _owner.LayoutOrigin; + set => _owner.LayoutOrigin = value; + } + + protected override object LayoutStateCore + { + get => _owner.LayoutState; + set => _owner.LayoutState = value; + } + + protected override int RecommendedAnchorIndexCore + { + get + { + int anchorIndex = -1; + var anchor = _owner.SuggestedAnchor; + if (anchor != null) + { + anchorIndex = _owner.GetElementIndex(anchor); + } + + return anchorIndex; + } + } + + protected override int ItemCountCore() => _owner.ItemsSourceView?.Count ?? 0; + + protected override ILayoutable GetOrCreateElementAtCore(int index, ElementRealizationOptions options) + { + return _owner.GetElementImpl( + index, + (options & ElementRealizationOptions.ForceCreate) != 0, + (options & ElementRealizationOptions.SuppressAutoRecycle) != 0); + } + + protected override object GetItemAtCore(int index) => _owner.ItemsSourceView.GetAt(index); + + protected override void RecycleElementCore(ILayoutable element) => _owner.ClearElementImpl((IControl)element); + + protected override Rect RealizationRectCore() => _owner.RealizationWindow; + } +} diff --git a/src/Avalonia.Controls/Repeater/UniqueIdElementPool.cs b/src/Avalonia.Controls/Repeater/UniqueIdElementPool.cs new file mode 100644 index 0000000000..775aa3f113 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/UniqueIdElementPool.cs @@ -0,0 +1,54 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + internal class UniqueIdElementPool : IEnumerable> + { + private readonly Dictionary _elementMap = new Dictionary(); + private readonly ItemsRepeater _owner; + + public UniqueIdElementPool(ItemsRepeater owner) => _owner = owner; + + public void Add(IControl element) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var key = virtInfo.UniqueId; + + if (_elementMap.ContainsKey(key)) + { + throw new InvalidOperationException($"The unique id provided ({key}) is not unique."); + } + + _elementMap.Add(key, element); + } + + public IControl Remove(int index) + { + // Check if there is already a element in the mapping and if so, use it. + string key = _owner.ItemsSourceView.KeyFromIndex(index); + + if (_elementMap.TryGetValue(key, out var element)) + { + _elementMap.Remove(key); + } + + return element; + } + + public void Clear() + { + _elementMap.Clear(); + } + + public IEnumerator> GetEnumerator() => _elementMap.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Avalonia.Controls/Repeater/ViewManager.cs b/src/Avalonia.Controls/Repeater/ViewManager.cs new file mode 100644 index 0000000000..833e708e9e --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ViewManager.cs @@ -0,0 +1,682 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + internal sealed class ViewManager + { + private const int FirstRealizedElementIndexDefault = int.MaxValue; + private const int LastRealizedElementIndexDefault = int.MinValue; + + private readonly ItemsRepeater _owner; + private readonly List _pinnedPool = new List(); + private readonly UniqueIdElementPool _resetPool; + private IControl _lastFocusedElement; + private bool _isDataSourceStableResetPending; + private int _firstRealizedElementIndexHeldByLayout = FirstRealizedElementIndexDefault; + private int _lastRealizedElementIndexHeldByLayout = LastRealizedElementIndexDefault; + private bool _eventsSubscribed; + + public ViewManager(ItemsRepeater owner) + { + _owner = owner; + _resetPool = new UniqueIdElementPool(owner); + } + + public IControl GetElement(int index, bool forceCreate, bool suppressAutoRecycle) + { + var element = forceCreate ? null : GetElementIfAlreadyHeldByLayout(index); + if (element == null) + { + // check if this is the anchor made through repeater in preparation + // for a bring into view. + var madeAnchor = _owner.MadeAnchor; + if (madeAnchor != null) + { + var anchorVirtInfo = ItemsRepeater.TryGetVirtualizationInfo(madeAnchor); + if (anchorVirtInfo.Index == index) + { + element = madeAnchor; + } + } + } + if (element == null) { element = GetElementFromUniqueIdResetPool(index); }; + if (element == null) { element = GetElementFromPinnedElements(index); } + if (element == null) { element = GetElementFromElementFactory(index); } + + var virtInfo = ItemsRepeater.TryGetVirtualizationInfo(element); + if (suppressAutoRecycle) + { + virtInfo.AutoRecycleCandidate = false; + } + else + { + virtInfo.AutoRecycleCandidate = true; + virtInfo.KeepAlive = true; + } + + return element; + } + + public void ClearElement(IControl element, bool isClearedDueToCollectionChange) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var index = virtInfo.Index; + bool cleared = + ClearElementToUniqueIdResetPool(element, virtInfo) || + ClearElementToPinnedPool(element, virtInfo, isClearedDueToCollectionChange); + + if (!cleared) + { + ClearElementToElementFactory(element); + } + + // Both First and Last indices need to be valid or default. + if (index == _firstRealizedElementIndexHeldByLayout && index == _lastRealizedElementIndexHeldByLayout) + { + // First and last were pointing to the same element and that is going away. + InvalidateRealizedIndicesHeldByLayout(); + } + else if (index == _firstRealizedElementIndexHeldByLayout) + { + // The FirstElement is going away, shrink the range by one. + ++_firstRealizedElementIndexHeldByLayout; + } + else if (index == _lastRealizedElementIndexHeldByLayout) + { + // Last element is going away, shrink the range by one at the end. + --_lastRealizedElementIndexHeldByLayout; + } + else + { + // Index is either outside the range we are keeping track of or inside the range. + // In both these cases, we just keep the range we have. If this clear was due to + // a collection change, then in the CollectionChanged event, we will invalidate these guys. + } + } + + public void ClearElementToElementFactory(IControl element) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var clearedIndex = virtInfo.Index; + _owner.OnElementClearing(element); + _owner.ItemTemplateShim.RecycleElement(_owner, element); + + virtInfo.MoveOwnershipToElementFactory(); + + if (_lastFocusedElement == element) + { + // Focused element is going away. Remove the tracked last focused element + // and pick a reasonable next focus if we can find one within the layout + // realized elements. + MoveFocusFromClearedIndex(clearedIndex); + } + + } + + private void MoveFocusFromClearedIndex(int clearedIndex) + { + IControl focusedChild = null; + var focusCandidate = FindFocusCandidate(clearedIndex, focusedChild); + if (focusCandidate != null) + { + focusCandidate.Focus(); + _lastFocusedElement = focusedChild; + + // Add pin to hold the focused element. + UpdatePin(focusedChild, true /* addPin */); + } + else + { + // We could not find a candiate. + _lastFocusedElement = null; + } + } + + IControl FindFocusCandidate(int clearedIndex, IControl focusedChild) + { + // Walk through all the children and find elements with index before and after the cleared index. + // Note that during a delete the next element would now have the same index. + int previousIndex = int.MinValue; + int nextIndex = int.MaxValue; + IControl nextElement = null; + IControl previousElement = null; + + foreach (var child in _owner.Children) + { + var virtInfo = ItemsRepeater.TryGetVirtualizationInfo(child); + if (virtInfo?.IsHeldByLayout == true) + { + int currentIndex = virtInfo.Index; + if (currentIndex < clearedIndex) + { + if (currentIndex > previousIndex) + { + previousIndex = currentIndex; + previousElement = child; + } + } + else if (currentIndex >= clearedIndex) + { + // Note that we use >= above because if we deleted the focused element, + // the next element would have the same index now. + if (currentIndex < nextIndex) + { + nextIndex = currentIndex; + nextElement = child; + } + } + } + } + + // TODO: Find the next element if one exists, if not use the previous element. + // If the container itself is not focusable, find a descendent that is. + + return nextElement; + } + + public int GetElementIndex(VirtualizationInfo virtInfo) + { + if (virtInfo == null) + { + throw new ArgumentException("Element is not a child of this ItemsRepeater."); + } + + return virtInfo.IsRealized || virtInfo.IsInUniqueIdResetPool ? virtInfo.Index : -1; + } + + public void PrunePinnedElements() + { + EnsureEventSubscriptions(); + + // Go through pinned elements and make sure they still have + // a reason to be pinned. + for (var i = 0; i < _pinnedPool.Count; ++i) + { + var elementInfo = _pinnedPool[i]; + var virtInfo = elementInfo.VirtualizationInfo; + + if (!virtInfo.IsPinned) + { + _pinnedPool.RemoveAt(i); + --i; + + // Pinning was the only thing keeping this element alive. + ClearElementToElementFactory(elementInfo.PinnedElement); + } + } + } + + public void UpdatePin(IControl element, bool addPin) + { + var parent = element.VisualParent; + var child = (IVisual)element; + + while (parent != null) + { + if (parent is ItemsRepeater repeater) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo((IControl)child); + if (virtInfo.IsRealized) + { + if (addPin) + { + virtInfo.AddPin(); + } + else if (virtInfo.IsPinned) + { + if (virtInfo.RemovePin() == 0) + { + // ElementFactory is invoked during the measure pass. + // We will clear the element then. + repeater.InvalidateMeasure(); + } + } + } + } + + child = parent; + parent = child.VisualParent; + } + } + + public void OnItemsSourceChanged(object sender, NotifyCollectionChangedEventArgs args) + { + // Note: For items that have been removed, the index will not be touched. It will hold + // the old index before it was removed. It is not valid anymore. + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + { + var newIndex = args.NewStartingIndex; + var newCount = args.NewItems.Count; + EnsureFirstLastRealizedIndices(); + if (newIndex <= _lastRealizedElementIndexHeldByLayout) + { + _lastRealizedElementIndexHeldByLayout += newCount; + foreach (var element in _owner.Children) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var dataIndex = virtInfo.Index; + + if (virtInfo.IsRealized && dataIndex >= newIndex) + { + UpdateElementIndex(element, virtInfo, dataIndex + newCount); + } + } + } + else + { + // Indices held by layout are not affected + // We could still have items in the pinned elements that need updates. This is usually a very small vector. + for (var i = 0; i < _pinnedPool.Count; ++i) + { + var elementInfo = _pinnedPool[i]; + var virtInfo = elementInfo.VirtualizationInfo; + var dataIndex = virtInfo.Index; + + if (virtInfo.IsRealized && dataIndex >= newIndex) + { + var element = elementInfo.PinnedElement; + UpdateElementIndex(element, virtInfo, dataIndex + newCount); + } + } + } + break; + } + + case NotifyCollectionChangedAction.Replace: + { + // Requirement: oldStartIndex == newStartIndex. It is not a replace if this is not true. + // Two cases here + // case 1: oldCount == newCount + // indices are not affected. nothing to do here. + // case 2: oldCount != newCount + // Replaced with less or more items. This is like an insert or remove + // depending on the counts. + var oldStartIndex = args.OldStartingIndex; + var newStartingIndex = args.NewStartingIndex; + var oldCount = args.OldItems.Count; + var newCount = args.NewItems.Count; + if (oldStartIndex != newStartingIndex) + { + throw new NotSupportedException("Replace is only allowed with OldStartingIndex equals to NewStartingIndex."); + } + + if (oldCount == 0) + { + throw new NotSupportedException("Replace notification with args.OldItemsCount value of 0 is not allowed. Use Insert action instead."); + } + + if (newCount == 0) + { + throw new NotSupportedException("Replace notification with args.NewItemCount value of 0 is not allowed. Use Remove action instead."); + } + + int countChange = newCount - oldCount; + if (countChange != 0) + { + // countChange > 0 : countChange items were added + // countChange < 0 : -countChange items were removed + foreach (var element in _owner.Children) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var dataIndex = virtInfo.Index; + + if (virtInfo.IsRealized) + { + if (dataIndex >= oldStartIndex + oldCount) + { + UpdateElementIndex(element, virtInfo, dataIndex + countChange); + } + } + } + + EnsureFirstLastRealizedIndices(); + _lastRealizedElementIndexHeldByLayout += countChange; + } + break; + } + + case NotifyCollectionChangedAction.Remove: + { + var oldStartIndex = args.OldStartingIndex; + var oldCount = args.OldItems.Count; + foreach (var element in _owner.Children) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + var dataIndex = virtInfo.Index; + + if (virtInfo.IsRealized) + { + if (virtInfo.AutoRecycleCandidate && oldStartIndex <= dataIndex && dataIndex < oldStartIndex + oldCount) + { + // If we are doing the mapping, remove the element who's data was removed. + _owner.ClearElementImpl(element); + } + else if (dataIndex >= (oldStartIndex + oldCount)) + { + UpdateElementIndex(element, virtInfo, dataIndex - oldCount); + } + } + } + + InvalidateRealizedIndicesHeldByLayout(); + break; + } + + case NotifyCollectionChangedAction.Reset: + if (_owner.ItemsSourceView.HasKeyIndexMapping) + { + _isDataSourceStableResetPending = true; + } + + // Walk through all the elements and make sure they are cleared, they will go into + // the stable id reset pool. + foreach (var element in _owner.Children) + { + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + if (virtInfo.IsRealized && virtInfo.AutoRecycleCandidate) + { + _owner.ClearElementImpl(element); + } + } + + InvalidateRealizedIndicesHeldByLayout(); + break; + } + } + + private void EnsureFirstLastRealizedIndices() + { + if (_firstRealizedElementIndexHeldByLayout == FirstRealizedElementIndexDefault) + { + // This will ensure that the indexes are updated. + GetElementIfAlreadyHeldByLayout(0); + } + } + + public void OnLayoutChanging() + { + if (_owner.ItemsSourceView?.HasKeyIndexMapping == true) + { + _isDataSourceStableResetPending = true; + } + } + + public void OnOwnerArranged() + { + if (_isDataSourceStableResetPending) + { + _isDataSourceStableResetPending = false; + + foreach (var entry in _resetPool) + { + // TODO: Task 14204306: ItemsRepeater: Find better focus candidate when focused element is deleted in the ItemsSource. + // Focused element is getting cleared. Need to figure out semantics on where + // focus should go when the focused element is removed from the data collection. + ClearElement(entry.Value, true /* isClearedDueToCollectionChange */); + } + + _resetPool.Clear(); + } + } + + // We optimize for the case where index is not realized to return null as quickly as we can. + // Flow layouts manage containers on their own and will never ask for an index that is already realized. + // If an index that is realized is requested by the layout, we unfortunately have to walk the + // children. Not ideal, but a reasonable default to provide consistent behavior between virtualizing + // and non-virtualizing hosts. + private IControl GetElementIfAlreadyHeldByLayout(int index) + { + IControl element = null; + + bool cachedFirstLastIndicesInvalid = _firstRealizedElementIndexHeldByLayout == FirstRealizedElementIndexDefault; + bool isRequestedIndexInRealizedRange = (_firstRealizedElementIndexHeldByLayout <= index && index <= _lastRealizedElementIndexHeldByLayout); + + if (cachedFirstLastIndicesInvalid || isRequestedIndexInRealizedRange) + { + foreach (var child in _owner.Children) + { + var virtInfo = ItemsRepeater.TryGetVirtualizationInfo(child); + if (virtInfo?.IsHeldByLayout == true) + { + // Only give back elements held by layout. If someone else is holding it, they will be served by other methods. + int childIndex = virtInfo.Index; + _firstRealizedElementIndexHeldByLayout = Math.Min(_firstRealizedElementIndexHeldByLayout, childIndex); + _lastRealizedElementIndexHeldByLayout = Math.Max(_lastRealizedElementIndexHeldByLayout, childIndex); + if (virtInfo.Index == index) + { + element = child; + // If we have valid first/last indices, we don't have to walk the rest, but if we + // do not, then we keep walking through the entire children collection to get accurate + // indices once. + if (!cachedFirstLastIndicesInvalid) + { + break; + } + } + } + } + } + + return element; + } + + private IControl GetElementFromUniqueIdResetPool(int index) + { + IControl element = null; + // See if you can get it from the reset pool. + if (_isDataSourceStableResetPending) + { + element = _resetPool.Remove(index); + if (element != null) + { + // Make sure that the index is updated to the current one + var virtInfo = ItemsRepeater.GetVirtualizationInfo(element); + virtInfo.MoveOwnershipToLayoutFromUniqueIdResetPool(); + UpdateElementIndex(element, virtInfo, index); + } + } + + return element; + } + + private IControl GetElementFromPinnedElements(int index) + { + IControl element = null; + + // See if you can find something among the pinned elements. + for (var i = 0; i < _pinnedPool.Count; ++i) + { + var elementInfo = _pinnedPool[i]; + var virtInfo = elementInfo.VirtualizationInfo; + + if (virtInfo.Index == index) + { + _pinnedPool.RemoveAt(i); + element = elementInfo.PinnedElement; + elementInfo.VirtualizationInfo.MoveOwnershipToLayoutFromPinnedPool(); + break; + } + } + + return element; + } + + private IControl GetElementFromElementFactory(int index) + { + // The view generator is the provider of last resort. + + var itemTemplateFactory = _owner.ItemTemplateShim; + if (itemTemplateFactory == null) + { + // If no ItemTemplate was provided, use a default + var factory = FuncDataTemplate.Default; + _owner.ItemTemplate = factory; + itemTemplateFactory = _owner.ItemTemplateShim; + } + + var data = _owner.ItemsSourceView.GetAt(index); + var element = itemTemplateFactory.GetElement(_owner, data); + + var virtInfo = ItemsRepeater.TryGetVirtualizationInfo(element); + if (virtInfo == null) + { + virtInfo = ItemsRepeater.CreateAndInitializeVirtualizationInfo(element); + } + + // Prepare the element + element.DataContext = data; + + virtInfo.MoveOwnershipToLayoutFromElementFactory( + index, + /* uniqueId: */ + _owner.ItemsSourceView.HasKeyIndexMapping ? + _owner.ItemsSourceView.KeyFromIndex(index) : + string.Empty); + + // The view generator is the only provider that prepares the element. + var repeater = _owner; + + // Add the element to the children collection here before raising OnElementPrepared so + // that handlers can walk up the tree in case they want to find their IndexPath in the + // nested case. + var children = repeater.Children; + if (element.VisualParent != repeater) + { + children.Add(element); + } + + repeater.OnElementPrepared(element, index); + + // Update realized indices + _firstRealizedElementIndexHeldByLayout = Math.Min(_firstRealizedElementIndexHeldByLayout, index); + _lastRealizedElementIndexHeldByLayout = Math.Max(_lastRealizedElementIndexHeldByLayout, index); + + return element; + } + + private bool ClearElementToUniqueIdResetPool(IControl element, VirtualizationInfo virtInfo) + { + if (_isDataSourceStableResetPending) + { + _resetPool.Add(element); + virtInfo.MoveOwnershipToUniqueIdResetPoolFromLayout(); + } + + return _isDataSourceStableResetPending; + } + + private bool ClearElementToPinnedPool(IControl element, VirtualizationInfo virtInfo, bool isClearedDueToCollectionChange) + { + if (_isDataSourceStableResetPending) + { + _resetPool.Add(element); + virtInfo.MoveOwnershipToUniqueIdResetPoolFromLayout(); + } + + return _isDataSourceStableResetPending; + } + + private void UpdateFocusedElement() + { + IControl focusedElement = null; + + var child = FocusManager.Instance.Current; + + if (child != null) + { + var parent = child.VisualParent; + var owner = _owner; + + // Find out if the focused element belongs to one of our direct + // children. + while (parent != null) + { + if (parent is ItemsRepeater repeater) + { + var element = child as IControl; + if (repeater == owner && ItemsRepeater.GetVirtualizationInfo(element).IsRealized) + { + focusedElement = element; + } + + break; + } + + child = parent as IInputElement; + parent = child.VisualParent; + } + } + + // If the focused element has changed, + // we need to unpin the old one and pin the new one. + if (_lastFocusedElement != focusedElement) + { + if (_lastFocusedElement != null) + { + UpdatePin(_lastFocusedElement, false /* addPin */); + } + + if (focusedElement != null) + { + UpdatePin(focusedElement, true /* addPin */); + } + + _lastFocusedElement = focusedElement; + } + } + + private void OnFocusChanged(object sender, RoutedEventArgs e) => UpdateFocusedElement(); + + private void EnsureEventSubscriptions() + { + if (!_eventsSubscribed) + { + _owner.GotFocus += OnFocusChanged; + _owner.LostFocus += OnFocusChanged; + } + } + + private void UpdateElementIndex(IControl element, VirtualizationInfo virtInfo, int index) + { + var oldIndex = virtInfo.Index; + if (oldIndex != index) + { + virtInfo.UpdateIndex(index); + _owner.OnElementIndexChanged(element, oldIndex, index); + } + } + + private void InvalidateRealizedIndicesHeldByLayout() + { + _firstRealizedElementIndexHeldByLayout = FirstRealizedElementIndexDefault; + _lastRealizedElementIndexHeldByLayout = LastRealizedElementIndexDefault; + } + + private struct PinnedElementInfo + { + public PinnedElementInfo(IControl element) + { + PinnedElement = element; + VirtualizationInfo = ItemsRepeater.GetVirtualizationInfo(element); + } + + public IControl PinnedElement { get; } + public VirtualizationInfo VirtualizationInfo { get; } + } + } +} diff --git a/src/Avalonia.Controls/Repeater/ViewportManager.cs b/src/Avalonia.Controls/Repeater/ViewportManager.cs new file mode 100644 index 0000000000..10c11889d0 --- /dev/null +++ b/src/Avalonia.Controls/Repeater/ViewportManager.cs @@ -0,0 +1,501 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Avalonia.Layout; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + internal class ViewportManager + { + private const double CacheBufferPerSideInflationPixelDelta = 40.0; + private readonly ItemsRepeater _owner; + private bool _ensuredScroller; + private IScrollAnchorProvider _scroller; + private IControl _makeAnchorElement; + private bool _isAnchorOutsideRealizedRange; + private Task _cacheBuildAction; + private Rect _visibleWindow; + private Rect _layoutExtent; + // This is the expected shift by the layout. + private Point _expectedViewportShift; + // This is what is pending and not been accounted for. + // Sometimes the scrolling surface cannot service a shift (for example + // it is already at the top and cannot shift anymore.) + private Point _pendingViewportShift; + // Unshiftable shift amount that this view manager can + // handle on its own to fake it to the layout as if the shift + // actually happened. This can happen in cases where no scrollviewer + // in the parent chain can scroll in the shift direction. + private Point _unshiftableShift; + private double _maximumHorizontalCacheLength = 0.0; + private double _maximumVerticalCacheLength = 0.0; + private double _horizontalCacheBufferPerSide; + private double _verticalCacheBufferPerSide; + private bool _isBringIntoViewInProgress; + // For non-virtualizing layouts, we do not need to keep + // updating viewports and invalidating measure often. So when + // a non virtualizing layout is used, we stop doing all that work. + bool _managingViewportDisabled; + private IDisposable _effectiveViewportChangedRevoker; + private bool _layoutUpdatedSubscribed; + + public ViewportManager(ItemsRepeater owner) + { + _owner = owner; + } + + public IControl SuggestedAnchor + { + get + { + // The element generated during the ItemsRepeater.MakeAnchor call has precedence over the next tick. + var suggestedAnchor = _makeAnchorElement; + var owner = _owner; + + if (suggestedAnchor == null) + { + var anchorElement = _scroller?.CurrentAnchor; + + if (anchorElement != null) + { + // We can't simply return anchorElement because, in case of nested Repeaters, it may not + // be a direct child of ours, or even an indirect child. We need to walk up the tree starting + // from anchorElement to figure out what child of ours (if any) to use as the suggested element. + var child = anchorElement; + var parent = child.VisualParent as IControl; + + while (parent != null) + { + if (parent == owner) + { + suggestedAnchor = child; + break; + } + + child = parent; + parent = parent.VisualParent as IControl; + } + } + } + + return suggestedAnchor; + } + } + + public bool HasScroller => _scroller != null; + + public IControl MadeAnchor => _makeAnchorElement; + + public double HorizontalCacheLength + { + get => _maximumHorizontalCacheLength; + set + { + if (_maximumHorizontalCacheLength != value) + { + ValidateCacheLength(value); + _maximumHorizontalCacheLength = value; + } + } + } + + public double VerticalCacheLength + { + get => _maximumVerticalCacheLength; + set + { + if (_maximumVerticalCacheLength != value) + { + ValidateCacheLength(value); + _maximumVerticalCacheLength = value; + } + } + } + + private Rect GetLayoutVisibleWindowDiscardAnchor() + { + var visibleWindow = _visibleWindow; + + if (HasScroller) + { + visibleWindow = new Rect( + visibleWindow.X + _layoutExtent.X + _expectedViewportShift.X + _unshiftableShift.X, + visibleWindow.Y + _layoutExtent.Y + _expectedViewportShift.Y + _unshiftableShift.Y, + visibleWindow.Width, + visibleWindow.Height); + } + + return visibleWindow; + } + + public Rect GetLayoutVisibleWindow() + { + var visibleWindow = _visibleWindow; + + if (_makeAnchorElement != null) + { + // The anchor is not necessarily laid out yet. Its position should default + // to zero and the layout origin is expected to change once layout is done. + // Until then, we need a window that's going to protect the anchor from + // getting recycled. + visibleWindow = visibleWindow.WithX(0).WithY(0); + } + else if (HasScroller) + { + visibleWindow = new Rect( + visibleWindow.X + _layoutExtent.X + _expectedViewportShift.X + _unshiftableShift.X, + visibleWindow.Y + _layoutExtent.Y + _expectedViewportShift.Y + _unshiftableShift.Y, + visibleWindow.Width, + visibleWindow.Height); + } + + return visibleWindow; + } + + public Rect GetLayoutRealizationWindow() + { + var realizationWindow = GetLayoutVisibleWindow(); + if (HasScroller) + { + realizationWindow = new Rect( + realizationWindow.X - _horizontalCacheBufferPerSide, + realizationWindow.Y - _verticalCacheBufferPerSide, + realizationWindow.Width + _horizontalCacheBufferPerSide * 2.0, + realizationWindow.Height + _verticalCacheBufferPerSide * 2.0); + } + + return realizationWindow; + } + + public void SetLayoutExtent(Rect extent) + { + _expectedViewportShift = new Point( + _expectedViewportShift.X + _layoutExtent.X - extent.X, + _expectedViewportShift.Y + _layoutExtent.Y - extent.Y); + + // We tolerate viewport imprecisions up to 1 pixel to avoid invaliding layout too much. + if (Math.Abs(_expectedViewportShift.X) > 1 || Math.Abs(_expectedViewportShift.Y) > 1) + { + // There are cases where we might be expecting a shift but not get it. We will + // be waiting for the effective viewport event but if the scroll viewer is not able + // to perform the shift (perhaps because it cannot scroll in negative offset), + // then we will end up not realizing elements in the visible + // window. To avoid this, we register to layout updated for this layout pass. If we + // get an effective viewport, we know we have a new viewport and we unregister from + // layout updated. If we get the layout updated handler, then we know that the + // scroller was unable to perform the shift and we invalidate measure and unregister + // from the layout updated event. + if (!_layoutUpdatedSubscribed) + { + _owner.LayoutUpdated += OnLayoutUpdated; + _layoutUpdatedSubscribed = true; + } + } + + _layoutExtent = extent; + _pendingViewportShift = _expectedViewportShift; + + // We just finished a measure pass and have a new extent. + // Let's make sure the scrollers will run its arrange so that they track the anchor. + ((IControl)_scroller)?.InvalidateArrange(); + } + + public Point GetOrigin() => _layoutExtent.TopLeft; + + public void OnLayoutChanged(bool isVirtualizing) + { + _managingViewportDisabled = !isVirtualizing; + + _layoutExtent = default; + _expectedViewportShift = default; + _pendingViewportShift = default; + _unshiftableShift = default; + + _effectiveViewportChangedRevoker?.Dispose(); + + if (!_managingViewportDisabled) + { + _effectiveViewportChangedRevoker = SubscribeToEffectiveViewportChanged(_owner); + } + } + + public void OnElementPrepared(IControl element) + { + // If we have an anchor element, we do not want the + // scroll anchor provider to start anchoring some other element. + ////element.CanBeScrollAnchor(true); + } + + public void OnElementCleared(ILayoutable element) + { + ////element.CanBeScrollAnchor(false); + } + + public void OnOwnerMeasuring() + { + // This is because of a bug that causes effective viewport to not + // fire if you register during arrange. + // Bug 17411076: EffectiveViewport: registering for effective viewport in arrange should invalidate viewport + EnsureScroller(); + } + + public void OnOwnerArranged() + { + _expectedViewportShift = default; + + if (!_managingViewportDisabled) + { + // This is because of a bug that causes effective viewport to not + // fire if you register during arrange. + // Bug 17411076: EffectiveViewport: registering for effective viewport in arrange should invalidate viewport + // EnsureScroller(); + + if (HasScroller) + { + double maximumHorizontalCacheBufferPerSide = _maximumHorizontalCacheLength * _visibleWindow.Width / 2.0; + double maximumVerticalCacheBufferPerSide = _maximumVerticalCacheLength * _visibleWindow.Height / 2.0; + + bool continueBuildingCache = + _horizontalCacheBufferPerSide < maximumHorizontalCacheBufferPerSide || + _verticalCacheBufferPerSide < maximumVerticalCacheBufferPerSide; + + if (continueBuildingCache) + { + _horizontalCacheBufferPerSide += CacheBufferPerSideInflationPixelDelta; + _verticalCacheBufferPerSide += CacheBufferPerSideInflationPixelDelta; + + _horizontalCacheBufferPerSide = Math.Min(_horizontalCacheBufferPerSide, maximumHorizontalCacheBufferPerSide); + _verticalCacheBufferPerSide = Math.Min(_verticalCacheBufferPerSide, maximumVerticalCacheBufferPerSide); + } + } + } + } + + private void OnLayoutUpdated(object sender, EventArgs args) + { + _owner.LayoutUpdated -= OnLayoutUpdated; + if (_managingViewportDisabled) + { + return; + } + + // We were expecting a viewport shift but we never got one and we are not going to in this + // layout pass. We likely will never get this shift, so lets assume that we are never going to get it and + // adjust our expected shift to track that. One case where this can happen is when there is no scrollviewer + // that can scroll in the direction where the shift is expected. + if (_pendingViewportShift.X != 0 || _pendingViewportShift.Y != 0) + { + // Assume this is never going to come. + _unshiftableShift = new Point( + _unshiftableShift.X + _pendingViewportShift.X, + _unshiftableShift.Y + _pendingViewportShift.Y); + _pendingViewportShift = default; + _expectedViewportShift = default; + + TryInvalidateMeasure(); + } + } + + public void OnMakeAnchor(IControl anchor, bool isAnchorOutsideRealizedRange) + { + _makeAnchorElement = anchor; + _isAnchorOutsideRealizedRange = isAnchorOutsideRealizedRange; + } + + public void OnBringIntoViewRequested(RequestBringIntoViewEventArgs args) + { + if (!_managingViewportDisabled) + { + // During the time between a bring into view request and the element coming into view we do not + // want the anchor provider to pick some anchor and jump to it. Instead we want to anchor on the + // element that is being brought into view. We can do this by making just that element as a potential + // anchor candidate and ensure no other element of this repeater is an anchor candidate. + // Once the layout pass is done and we render the frame, the element will be in frame and we can + // switch back to letting the anchor provider pick a suitable anchor. + + // get the targetChild - i.e the immediate child of this repeater that is being brought into view. + // Note that the element being brought into view could be a descendant. + var targetChild = GetImmediateChildOfRepeater((IControl)args.TargetObject); + + // Make sure that only the target child can be the anchor during the bring into view operation. + foreach (var child in _owner.Children) + { + ////if (child.CanBeScrollAnchor && child != targetChild) + ////{ + //// child.CanBeScrollAnchor = false; + ////} + } + + // Register to rendering event to go back to how things were before where any child can be the anchor. + _isBringIntoViewInProgress = true; + ////if (!m_renderingToken) + ////{ + //// winrt::Windows::UI::Xaml::Media::CompositionTarget compositionTarget{ nullptr }; + //// m_renderingToken = compositionTarget.Rendering(winrt::auto_revoke, { this, &ViewportManagerWithPlatformFeatures::OnCompositionTargetRendering }); + ////} + } + } + + private IControl GetImmediateChildOfRepeater(IControl descendant) + { + var targetChild = descendant; + var parent = descendant.Parent; + while (parent != null && parent != _owner) + { + targetChild = parent; + parent = (IControl)parent.VisualParent; + } + + if (parent == null) + { + throw new InvalidOperationException("OnBringIntoViewRequested called with args.target element not under the ItemsRepeater that recieved the call"); + } + + return targetChild; + } + + public void ResetScrollers() + { + _scroller = null; + _effectiveViewportChangedRevoker?.Dispose(); + _effectiveViewportChangedRevoker = null; + _ensuredScroller = false; + } + + private void OnEffectiveViewportChanged(TransformedBounds? bounds) + { + if (!bounds.HasValue) + { + return; + } + + var globalClip = bounds.Value.Clip; + var transform = _owner.GetVisualRoot().TransformToVisual(_owner).Value; + var clip = globalClip.TransformToAABB(transform); + var effectiveViewport = clip.Intersect(bounds.Value.Bounds); + + UpdateViewport(effectiveViewport); + + _pendingViewportShift = default; + _unshiftableShift = default; + if (_visibleWindow.IsEmpty) + { + // We got cleared. + _layoutExtent = default; + } + + // We got a new viewport, we dont need to wait for layout updated anymore to + // see if our request for a pending shift was handled. + if (_layoutUpdatedSubscribed) + { + _owner.LayoutUpdated -= OnLayoutUpdated; + } + } + + private void EnsureScroller() + { + if (!_ensuredScroller) + { + ResetScrollers(); + + var parent = _owner.GetVisualParent(); + while (parent != null) + { + if (parent is IScrollAnchorProvider scroller) + { + _scroller = scroller; + break; + } + + parent = parent.VisualParent; + } + + if (_scroller == null) + { + // We usually update the viewport in the post arrange handler. But, since we don't have + // a scroller, let's do it now. + UpdateViewport(Rect.Empty); + } + else if (!_managingViewportDisabled) + { + _effectiveViewportChangedRevoker?.Dispose(); + _effectiveViewportChangedRevoker = SubscribeToEffectiveViewportChanged(_owner); + } + + _ensuredScroller = true; + } + } + + private void UpdateViewport(Rect viewport) + { + var currentVisibleWindow = viewport; + + if (-currentVisibleWindow.X <= ItemsRepeater.ClearedElementsArrangePosition.X && + -currentVisibleWindow.Y <= ItemsRepeater.ClearedElementsArrangePosition.Y) + { + // We got cleared. + _visibleWindow = default; + } + else + { + _visibleWindow = currentVisibleWindow; + } + + TryInvalidateMeasure(); + } + + private static void ValidateCacheLength(double cacheLength) + { + if (cacheLength < 0.0 || double.IsInfinity(cacheLength) || double.IsNaN(cacheLength)) + { + throw new ArgumentException("The maximum cache length must be equal or superior to zero."); + } + } + + private void TryInvalidateMeasure() + { + // Don't invalidate measure if we have an invalid window. + if (!_visibleWindow.IsEmpty) + { + // We invalidate measure instead of just invalidating arrange because + // we don't invalidate measure in UpdateViewport if the view is changing to + // avoid layout cycles. + _owner.InvalidateMeasure(); + } + } + + private IDisposable SubscribeToEffectiveViewportChanged(IControl control) + { + // HACK: This is a bit of a hack. We need the effective viewport of the ItemsRepeater - + // we can get this from TransformedBounds, but this property is updated after layout has + // run, resulting in the UI being updated too late when scrolling quickly. We can + // partially remedey this by triggering also on Bounds changes, but this won't work so + // well for nested ItemsRepeaters. + // + // UWP uses the EffectiveBoundsChanged event (which I think was implemented specially + // for this case): we need to implement that in Avalonia. + return control.GetObservable(Visual.TransformedBoundsProperty) + .Merge(control.GetObservable(Visual.BoundsProperty).Select(_ => control.TransformedBounds)) + .Skip(1) + .Subscribe(OnEffectiveViewportChanged); + } + + private class ScrollerInfo + { + public ScrollerInfo(ScrollViewer scroller) + { + Scroller = scroller; + } + + public ScrollViewer Scroller { get; } + } + }; +} diff --git a/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs b/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs new file mode 100644 index 0000000000..eb30c1b7cf --- /dev/null +++ b/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs @@ -0,0 +1,118 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Controls +{ + internal enum ElementOwner + { + // All elements are originally owned by the view generator. + ElementFactory, + // Ownership is transferred to the layout when it calls GetElement. + Layout, + // Ownership is transferred to the pinned pool if the element is cleared (outside of + // a 'remove' collection change of course). + PinnedPool, + // Ownership is transfered to the reset pool if the element is cleared by a reset and + // the data source supports unique ids. + UniqueIdResetPool, + // Ownership is transfered to the animator if the element is cleared due to a + // 'remove'-like collection change. + Animator + } + + internal class VirtualizationInfo + { + private int _pinCounter; + private object _data; + + public Rect ArrangeBounds { get; set; } + public bool AutoRecycleCandidate { get; set; } + public int Index { get; private set; } + public bool IsPinned => _pinCounter > 0; + public bool IsHeldByLayout => Owner == ElementOwner.Layout; + public bool IsRealized => IsHeldByLayout || Owner == ElementOwner.PinnedPool; + public bool IsInUniqueIdResetPool => Owner == ElementOwner.UniqueIdResetPool; + public bool KeepAlive { get; set; } + public ElementOwner Owner { get; private set; } = ElementOwner.ElementFactory; + public string UniqueId { get; private set; } + + public void MoveOwnershipToLayoutFromElementFactory(int index, string uniqueId) + { + Owner = ElementOwner.Layout; + Index = index; + UniqueId = uniqueId; + } + + public void MoveOwnershipToLayoutFromUniqueIdResetPool() + { + Owner = ElementOwner.Layout; + } + + public void MoveOwnershipToLayoutFromPinnedPool() + { + Owner = ElementOwner.Layout; + } + + public void MoveOwnershipToElementFactory() + { + Owner = ElementOwner.ElementFactory; + _pinCounter = 0; + Index = -1; + UniqueId = string.Empty; + ArrangeBounds = ItemsRepeater.InvalidRect; + } + + public void MoveOwnershipToUniqueIdResetPoolFromLayout() + { + Owner = ElementOwner.UniqueIdResetPool; + // Keep the pinCounter the same. If the container survives the reset + // it can go on being pinned as if nothing happened. + } + + public void MoveOwnershipToAnimator() + { + // During a unique id reset, some elements might get removed. + // Their ownership will go from the UniqueIdResetPool to the Animator. + // The common path though is for ownership to go from Layout to Animator. + Owner = ElementOwner.Animator; + Index = -1; + _pinCounter = 0; + } + + public void MoveOwnershipToPinnedPool() + { + Owner = ElementOwner.PinnedPool; + } + + public int AddPin() + { + if (!IsRealized) + { + throw new InvalidOperationException("You can't pin an unrealized element."); + } + + return ++_pinCounter; + } + + public int RemovePin() + { + if (!IsRealized) + { + throw new InvalidOperationException("You can't unpin an unrealized element."); + } + + if (!IsPinned) + { + throw new InvalidOperationException("UnpinElement was called more often than PinElement."); + } + + return --_pinCounter; + } + + public void UpdateIndex(int newIndex) => Index = newIndex; + } +} diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 264b1fd2ce..c9b5cbb75b 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls /// /// A control scrolls its content if the content is bigger than the space available. /// - public class ScrollViewer : ContentControl, IScrollable + public class ScrollViewer : ContentControl, IScrollable, IScrollAnchorProvider { /// /// Defines the property. @@ -333,6 +333,9 @@ namespace Avalonia.Controls get { return _viewport.Height; } } + /// + IControl IScrollAnchorProvider.CurrentAnchor => null; // TODO: Implement + /// /// Gets the value of the HorizontalScrollBarVisibility attached property. /// @@ -373,6 +376,16 @@ namespace Avalonia.Controls control.SetValue(VerticalScrollBarVisibilityProperty, value); } + void IScrollAnchorProvider.RegisterAnchorCandidate(IControl element) + { + // TODO: Implement + } + + void IScrollAnchorProvider.UnregisterAnchorCandidate(IControl element) + { + // TODO: Implement + } + internal static Vector CoerceOffset(Size extent, Size viewport, Vector offset) { var maxX = Math.Max(extent.Width - viewport.Width, 0); diff --git a/src/Avalonia.Controls/Slider.cs b/src/Avalonia.Controls/Slider.cs index bc4733296b..9eaa246434 100644 --- a/src/Avalonia.Controls/Slider.cs +++ b/src/Avalonia.Controls/Slider.cs @@ -5,6 +5,7 @@ using System; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; namespace Avalonia.Controls { diff --git a/src/Avalonia.Controls/StackPanel.cs b/src/Avalonia.Controls/StackPanel.cs index c29faa1b4d..8e3b4905cf 100644 --- a/src/Avalonia.Controls/StackPanel.cs +++ b/src/Avalonia.Controls/StackPanel.cs @@ -17,13 +17,13 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly StyledProperty SpacingProperty = - AvaloniaProperty.Register(nameof(Spacing)); + StackLayout.SpacingProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty OrientationProperty = - AvaloniaProperty.Register(nameof(Orientation), Orientation.Vertical); + StackLayout.OrientationProperty.AddOwner(); /// /// Initializes static members of the class. diff --git a/src/Avalonia.Controls/WrapPanel.cs b/src/Avalonia.Controls/WrapPanel.cs index 6f53d853c7..3acf341c35 100644 --- a/src/Avalonia.Controls/WrapPanel.cs +++ b/src/Avalonia.Controls/WrapPanel.cs @@ -4,6 +4,7 @@ // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using Avalonia.Input; +using Avalonia.Layout; using Avalonia.Utilities; using static System.Math; diff --git a/src/Avalonia.Layout/AttachedLayout.cs b/src/Avalonia.Layout/AttachedLayout.cs new file mode 100644 index 0000000000..5622731a7c --- /dev/null +++ b/src/Avalonia.Layout/AttachedLayout.cs @@ -0,0 +1,106 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Layout +{ + /// + /// Represents the base class for an object that sizes and arranges child elements for a host. + /// + public abstract class AttachedLayout : AvaloniaObject + { + internal string LayoutId { get; set; } + + /// + /// Occurs when the measurement state (layout) has been invalidated. + /// + public event EventHandler MeasureInvalidated; + + /// + /// Occurs when the arrange state (layout) has been invalidated. + /// + public event EventHandler ArrangeInvalidated; + + /// + /// Initializes any per-container state the layout requires when it is attached to an + /// container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// Container elements that support attached layouts should call this method when a layout + /// instance is first assigned. The container is expected to give the attached layout + /// instance a way to store and retrieve any per-container state by way of the provided + /// context. It is also the responsibility of the container to not reuse the context, or + /// otherwise expose the state from one layout to another. + /// + /// When an attached layout is removed the container should release any reference to the + /// layout state it stored. + /// + /// Override or + /// to provide the behavior for + /// this method in a derived class. + /// + public abstract void InitializeForContext(LayoutContext context); + + /// + /// Removes any state the layout previously stored on the ILayoutable container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + public abstract void UninitializeForContext(LayoutContext context); + + /// + /// Suggests a DesiredSize for a container element. A container element that supports + /// attached layouts should call this method from their own MeasureOverride implementations + /// to form a recursive layout update. The attached layout is expected to call the Measure + /// for each of the container’s ILayoutable children. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The available space that a container can allocate to a child object. A child object can + /// request a larger space than what is available; the provided size might be accommodated + /// if scrolling or other resize behavior is possible in that particular container. + /// + /// + public abstract Size Measure(LayoutContext context, Size availableSize); + + /// + /// Positions child elements and determines a size for a container UIElement. Container + /// elements that support attached layouts should call this method from their layout + /// override implementations to form a recursive layout update. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The final size that the container computes for the child in layout. + /// + /// The actual size that is used after the element is arranged in layout. + public abstract Size Arrange(LayoutContext context, Size finalSize); + + /// + /// Invalidates the measurement state (layout) for all ILayoutable containers that reference + /// this layout. + /// + protected void InvalidateMeasure() => MeasureInvalidated?.Invoke(this, EventArgs.Empty); + + /// + /// Invalidates the arrange state (layout) for all UIElement containers that reference this + /// layout. After the invalidation, the UIElement will have its layout updated, which + /// occurs asynchronously. + /// + protected void InvalidateArrange() => ArrangeInvalidated?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/Avalonia.Layout/ElementManager.cs b/src/Avalonia.Layout/ElementManager.cs new file mode 100644 index 0000000000..1748a3be03 --- /dev/null +++ b/src/Avalonia.Layout/ElementManager.cs @@ -0,0 +1,460 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Layout.Utils; + +namespace Avalonia.Layout +{ + internal class ElementManager + { + private readonly List _realizedElements = new List(); + private readonly List _realizedElementLayoutBounds = new List(); + private int _firstRealizedDataIndex; + private VirtualizingLayoutContext _context; + + private bool IsVirtualizingContext + { + get + { + if (_context != null) + { + var rect = _context.RealizationRect; + bool hasInfiniteSize = double.IsInfinity(rect.Height) || double.IsInfinity(rect.Width); + return !hasInfiniteSize; + } + return false; + } + } + + public void SetContext(VirtualizingLayoutContext virtualContext) => _context = virtualContext; + + public void OnBeginMeasure(ScrollOrientation orientation) + { + if (_context != null) + { + if (IsVirtualizingContext) + { + // We proactively clear elements laid out outside of the realizaton + // rect so that they are available for reuse during the current + // measure pass. + // This is useful during fast panning scenarios in which the realization + // window is constantly changing and we want to reuse elements from + // the end that's opposite to the panning direction. + DiscardElementsOutsideWindow(_context.RealizationRect, orientation); + } + else + { + // If we are initialized with a non-virtualizing context, make sure that + // we have enough space to hold the bounds for all the elements. + int count = _context.ItemCount; + if (_realizedElementLayoutBounds.Count != count) + { + // Make sure there is enough space for the bounds. + // Note: We could optimize when the count becomes smaller, but keeping + // it always up to date is the simplest option for now. + _realizedElementLayoutBounds.Resize(count); + } + } + } + } + + public int GetRealizedElementCount() + { + return IsVirtualizingContext ? _realizedElements.Count : _context.ItemCount; + } + + public ILayoutable GetAt(int realizedIndex) + { + ILayoutable element; + + if (IsVirtualizingContext) + { + if (_realizedElements[realizedIndex] == null) + { + // Sentinel. Create the element now since we need it. + int dataIndex = GetDataIndexFromRealizedRangeIndex(realizedIndex); + element = _context.GetOrCreateElementAt( + dataIndex, + ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + _realizedElements[realizedIndex] = element; + } + else + { + element = _realizedElements[realizedIndex]; + } + } + else + { + // realizedIndex and dataIndex are the same (everything is realized) + element = _context.GetOrCreateElementAt( + realizedIndex, + ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + } + + return element; + } + + public void Add(ILayoutable element, int dataIndex) + { + if (_realizedElements.Count == 0) + { + _firstRealizedDataIndex = dataIndex; + } + + _realizedElements.Add(element); + _realizedElementLayoutBounds.Add(default); + } + + public void Insert(int realizedIndex, int dataIndex, ILayoutable element) + { + if (realizedIndex == 0) + { + _firstRealizedDataIndex = dataIndex; + } + + _realizedElements.Insert(realizedIndex, element); + + // Set bounds to an invalid rect since we do not know it yet. + _realizedElementLayoutBounds.Insert(realizedIndex, new Rect(-1, -1, -1, -1)); + } + + public void ClearRealizedRange(int realizedIndex, int count) + { + for (int i = 0; i < count; i++) + { + // Clear from the edges so that ItemsRepeater can optimize on maintaining + // realized indices without walking through all the children every time. + int index = realizedIndex == 0 ? realizedIndex + i : (realizedIndex + count - 1) - i; + var elementRef = _realizedElements[index]; + + if (elementRef != null) + { + _context.RecycleElement(elementRef); + } + } + + int endIndex = realizedIndex + count; + _realizedElements.RemoveRange(realizedIndex, endIndex - realizedIndex); + _realizedElementLayoutBounds.RemoveRange(realizedIndex, endIndex - realizedIndex); + + if (realizedIndex == 0) + { + _firstRealizedDataIndex = _realizedElements.Count == 0 ? + -1 : _firstRealizedDataIndex + count; + } + } + + public void DiscardElementsOutsideWindow(bool forward, int startIndex) + { + // Remove layout elements that are outside the realized range. + if (IsDataIndexRealized(startIndex)) + { + int rangeIndex = GetRealizedRangeIndexFromDataIndex(startIndex); + + if (forward) + { + ClearRealizedRange(rangeIndex, GetRealizedElementCount() - rangeIndex); + } + else + { + ClearRealizedRange(0, rangeIndex + 1); + } + } + } + + public void ClearRealizedRange() => ClearRealizedRange(0, GetRealizedElementCount()); + + public Rect GetLayoutBoundsForDataIndex(int dataIndex) + { + int realizedIndex = GetRealizedRangeIndexFromDataIndex(dataIndex); + return _realizedElementLayoutBounds[realizedIndex]; + } + + public void SetLayoutBoundsForDataIndex(int dataIndex, in Rect bounds) + { + int realizedIndex = GetRealizedRangeIndexFromDataIndex(dataIndex); + _realizedElementLayoutBounds[realizedIndex] = bounds; + } + + public Rect GetLayoutBoundsForRealizedIndex(int realizedIndex) => _realizedElementLayoutBounds[realizedIndex]; + + public void SetLayoutBoundsForRealizedIndex(int realizedIndex, in Rect bounds) + { + _realizedElementLayoutBounds[realizedIndex] = bounds; + } + + public bool IsDataIndexRealized(int index) + { + if (IsVirtualizingContext) + { + int realizedCount = GetRealizedElementCount(); + return + realizedCount > 0 && + GetDataIndexFromRealizedRangeIndex(0) <= index && + GetDataIndexFromRealizedRangeIndex(realizedCount - 1) >= index; + } + else + { + // Non virtualized - everything is realized + return index >= 0 && index < _context.ItemCount; + } + } + + public bool IsIndexValidInData(int currentIndex) => currentIndex >= 0 && currentIndex < _context.ItemCount; + + public ILayoutable GetRealizedElement(int dataIndex) + { + return IsVirtualizingContext ? + GetAt(GetRealizedRangeIndexFromDataIndex(dataIndex)) : + _context.GetOrCreateElementAt( + dataIndex, + ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + } + + public void EnsureElementRealized(bool forward, int dataIndex, string layoutId) + { + if (IsDataIndexRealized(dataIndex) == false) + { + var element = _context.GetOrCreateElementAt( + dataIndex, + ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + + if (forward) + { + Add(element, dataIndex); + } + else + { + Insert(0, dataIndex, element); + } + } + } + + public bool IsWindowConnected(in Rect window, ScrollOrientation orientation, bool scrollOrientationSameAsFlow) + { + bool intersects = false; + + if (_realizedElementLayoutBounds.Count > 0) + { + var firstElementBounds = GetLayoutBoundsForRealizedIndex(0); + var lastElementBounds = GetLayoutBoundsForRealizedIndex(GetRealizedElementCount() - 1); + + var effectiveOrientation = scrollOrientationSameAsFlow ? + (orientation == ScrollOrientation.Vertical ? ScrollOrientation.Horizontal : ScrollOrientation.Vertical) : + orientation; + + + var windowStart = effectiveOrientation == ScrollOrientation.Vertical ? window.Y : window.X; + var windowEnd = effectiveOrientation == ScrollOrientation.Vertical ? window.Y + window.Height : window.X + window.Width; + var firstElementStart = effectiveOrientation == ScrollOrientation.Vertical ? firstElementBounds.Y : firstElementBounds.X; + var lastElementEnd = effectiveOrientation == ScrollOrientation.Vertical ? lastElementBounds.Y + lastElementBounds.Height : lastElementBounds.X + lastElementBounds.Width; + + intersects = + firstElementStart <= windowEnd && + lastElementEnd >= windowStart; + } + + return intersects; + } + + public void DataSourceChanged(object source, NotifyCollectionChangedEventArgs args) + { + if (_realizedElements.Count > 0) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + { + OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); + } + break; + + case NotifyCollectionChangedAction.Replace: + { + int oldSize = args.OldItems.Count; + int newSize = args.NewItems.Count; + int oldStartIndex = args.OldStartingIndex; + int newStartIndex = args.NewStartingIndex; + + if (oldSize == newSize && + oldStartIndex == newStartIndex && + IsDataIndexRealized(oldStartIndex) && + IsDataIndexRealized(oldStartIndex + oldSize -1)) + { + // Straight up replace of n items within the realization window. + // Removing and adding might causes us to lose the anchor causing us + // to throw away all containers and start from scratch. + // Instead, we can just clear those items and set the element to + // null (sentinel) and let the next measure get new containers for them. + var startRealizedIndex = GetRealizedRangeIndexFromDataIndex(oldStartIndex); + for (int realizedIndex = startRealizedIndex; realizedIndex < startRealizedIndex + oldSize; realizedIndex++) + { + var elementRef = _realizedElements[realizedIndex]; + + if (elementRef != null) + { + _context.RecycleElement(elementRef); + _realizedElements[realizedIndex] = null; + } + } + } + else + { + OnItemsRemoved(oldStartIndex, oldSize); + OnItemsAdded(newStartIndex, newSize); + } + } + break; + + case NotifyCollectionChangedAction.Remove: + { + OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + } + break; + + case NotifyCollectionChangedAction.Reset: + ClearRealizedRange(); + break; + + case NotifyCollectionChangedAction.Move: + throw new NotImplementedException(); + } + } + } + + public int GetElementDataIndex(ILayoutable suggestedAnchor) + { + var it = _realizedElements.IndexOf(suggestedAnchor); + return it != -1 ? GetDataIndexFromRealizedRangeIndex(it) : -1; + } + + public int GetDataIndexFromRealizedRangeIndex(int rangeIndex) + { + return IsVirtualizingContext ? rangeIndex + _firstRealizedDataIndex : rangeIndex; + } + + private int GetRealizedRangeIndexFromDataIndex(int dataIndex) + { + return IsVirtualizingContext ? dataIndex - _firstRealizedDataIndex : dataIndex; + } + + private void DiscardElementsOutsideWindow(in Rect window, ScrollOrientation orientation) + { + // The following illustration explains the cutoff indices. + // We will clear all the realized elements from both ends + // up to the corresponding cutoff index. + // '-' means the element is outside the cutoff range. + // '*' means the element is inside the cutoff range and will be cleared. + // + // Window: + // |______________________________| + // Realization range: + // |*****----------------------------------*********| + // | | + // frontCutoffIndex backCutoffIndex + // + // Note that we tolerate at most one element outside of the window + // because the FlowLayoutAlgorithm.Generate routine stops *after* + // it laid out an element outside the realization window. + // This is also convenient because it protects the anchor + // during a BringIntoView operation during which the anchor may + // not be in the realization window (in fact, the realization window + // might be empty if the BringIntoView is issued before the first + // layout pass). + + int realizedRangeSize = GetRealizedElementCount(); + int frontCutoffIndex = -1; + int backCutoffIndex = realizedRangeSize; + + for (int i = 0; + i= 0 && + !Intersects(window, _realizedElementLayoutBounds[i], orientation); + --i) + { + --backCutoffIndex; + } + + if (backCutoffIndex 0) + { + ClearRealizedRange(0, Math.Min(frontCutoffIndex, GetRealizedElementCount())); + } + } + + private static bool Intersects(in Rect lhs, in Rect rhs, ScrollOrientation orientation) + { + var lhsStart = orientation == ScrollOrientation.Vertical ? lhs.Y : lhs.X; + var lhsEnd = orientation == ScrollOrientation.Vertical ? lhs.Y + lhs.Height : lhs.X + lhs.Width; + var rhsStart = orientation == ScrollOrientation.Vertical ? rhs.Y : rhs.X; + var rhsEnd = orientation == ScrollOrientation.Vertical ? rhs.Y + rhs.Height : rhs.X + rhs.Width; + + return lhsEnd >= rhsStart && lhsStart <= rhsEnd; + } + + private void OnItemsAdded(int index, int count) + { + // Using the old indices here (before it was updated by the collection change) + // if the insert data index is between the first and last realized data index, we need + // to insert items. + int lastRealizedDataIndex = _firstRealizedDataIndex + GetRealizedElementCount() - 1; + int newStartingIndex = index; + if (newStartingIndex > _firstRealizedDataIndex && + newStartingIndex <= lastRealizedDataIndex) + { + // Inserted within the realized range + int insertRangeStartIndex = newStartingIndex - _firstRealizedDataIndex; + for (int i = 0; i < count; i++) + { + // Insert null (sentinel) here instead of an element, that way we dont + // end up creating a lot of elements only to be thrown out in the next layout. + int insertRangeIndex = insertRangeStartIndex + i; + int dataIndex = newStartingIndex + i; + // This is to keep the contiguousness of the mapping + Insert(insertRangeIndex, dataIndex, null); + } + } + else if (index <= _firstRealizedDataIndex) + { + // Items were inserted before the realized range. + // We need to update m_firstRealizedDataIndex; + _firstRealizedDataIndex += count; + } + } + + private void OnItemsRemoved(int index, int count) + { + int lastRealizedDataIndex = _firstRealizedDataIndex + _realizedElements.Count - 1; + int startIndex = Math.Max(_firstRealizedDataIndex, index); + int endIndex = Math.Min(lastRealizedDataIndex, index + count - 1); + bool removeAffectsFirstRealizedDataIndex = (index <= _firstRealizedDataIndex); + + if (endIndex >= startIndex) + { + ClearRealizedRange(GetRealizedRangeIndexFromDataIndex(startIndex), endIndex - startIndex + 1); + } + + if (removeAffectsFirstRealizedDataIndex && + _firstRealizedDataIndex != -1) + { + _firstRealizedDataIndex -= count; + } + } + } +} diff --git a/src/Avalonia.Layout/FlowLayoutAlgorithm.cs b/src/Avalonia.Layout/FlowLayoutAlgorithm.cs new file mode 100644 index 0000000000..615ce725bd --- /dev/null +++ b/src/Avalonia.Layout/FlowLayoutAlgorithm.cs @@ -0,0 +1,712 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + internal class FlowLayoutAlgorithm + { + private readonly OrientationBasedMeasures _orientation = new OrientationBasedMeasures(); + private readonly ElementManager _elementManager = new ElementManager(); + private Size _lastAvailableSize; + private double _lastItemSpacing; + private bool _collectionChangePending; + private VirtualizingLayoutContext _context; + private IFlowLayoutAlgorithmDelegates _algorithmCallbacks; + private Rect _lastExtent; + private int _firstRealizedDataIndexInsideRealizationWindow = -1; + private int _lastRealizedDataIndexInsideRealizationWindow = -1; + + // If the scroll orientation is the same as the folow orientation + // we will only have one line since we will never wrap. In that case + // we do not want to align the line. We could potentially switch the + // meaning of line alignment in this case, but I'll hold off on that + // feature until someone asks for it - This is not a common scenario + // anyway. + private bool _scrollOrientationSameAsFlow; + + public Rect LastExtent => _lastExtent; + + private bool IsVirtualizingContext + { + get + { + if (_context != null) + { + var rect = _context.RealizationRect; + bool hasInfiniteSize = double.IsInfinity(rect.Height) || double.IsInfinity(rect.Width); + return !hasInfiniteSize; + } + return false; + } + } + + private Rect RealizationRect => IsVirtualizingContext ? _context.RealizationRect : new Rect(Size.Infinity); + + public void InitializeForContext(VirtualizingLayoutContext context, IFlowLayoutAlgorithmDelegates callbacks) + { + _algorithmCallbacks = callbacks; + _context = context; + _elementManager.SetContext(context); + } + + public void UninitializeForContext(VirtualizingLayoutContext context) + { + if (IsVirtualizingContext) + { + // This layout is about to be detached. Let go of all elements + // being held and remove the layout state from the context. + _elementManager.ClearRealizedRange(); + } + + context.LayoutState = null; + } + + public Size Measure( + Size availableSize, + VirtualizingLayoutContext context, + bool isWrapping, + double minItemSpacing, + double lineSpacing, + ScrollOrientation orientation, + string layoutId) + { + _orientation.ScrollOrientation = orientation; + + // If minor size is infinity, there is only one line and no need to align that line. + _scrollOrientationSameAsFlow = double.IsInfinity(_orientation.Minor(availableSize)); + var realizationRect = RealizationRect; + + var suggestedAnchorIndex = _context.RecommendedAnchorIndex; + if (_elementManager.IsIndexValidInData(suggestedAnchorIndex)) + { + var anchorRealized = _elementManager.IsDataIndexRealized(suggestedAnchorIndex); + if (!anchorRealized) + { + MakeAnchor(_context, suggestedAnchorIndex, availableSize); + } + } + + _elementManager.OnBeginMeasure(orientation); + + int anchorIndex = GetAnchorIndex(availableSize, isWrapping, minItemSpacing, layoutId); + Generate(GenerateDirection.Forward, anchorIndex, availableSize, minItemSpacing, lineSpacing, layoutId); + Generate(GenerateDirection.Backward, anchorIndex, availableSize, minItemSpacing, lineSpacing, layoutId); + if (isWrapping && IsReflowRequired()) + { + var firstElementBounds = _elementManager.GetLayoutBoundsForRealizedIndex(0); + _orientation.SetMinorStart(ref firstElementBounds, 0); + _elementManager.SetLayoutBoundsForRealizedIndex(0, firstElementBounds); + Generate(GenerateDirection.Forward, 0 /*anchorIndex*/, availableSize, minItemSpacing, lineSpacing, layoutId); + } + + RaiseLineArranged(); + _collectionChangePending = false; + _lastExtent = EstimateExtent(availableSize, layoutId); + SetLayoutOrigin(); + + return new Size(_lastExtent.Width, _lastExtent.Height); + } + + public Size Arrange( + Size finalSize, + VirtualizingLayoutContext context, + LineAlignment lineAlignment, + string layoutId) + { + ArrangeVirtualizingLayout(finalSize, lineAlignment, layoutId); + + return new Size( + Math.Max(finalSize.Width, _lastExtent.Width), + Math.Max(finalSize.Height, _lastExtent.Height)); + } + + public void OnItemsSourceChanged( + object source, + NotifyCollectionChangedEventArgs args, + VirtualizingLayoutContext context) + { + _elementManager.DataSourceChanged(source, args); + _collectionChangePending = true; + } + + public Size MeasureElement( + ILayoutable element, + int index, + Size availableSize, + VirtualizingLayoutContext context) + { + var measureSize = _algorithmCallbacks.Algorithm_GetMeasureSize(index, availableSize, context); + element.Measure(measureSize); + var provisionalArrangeSize = _algorithmCallbacks.Algorithm_GetProvisionalArrangeSize(index, measureSize, element.DesiredSize, context); + _algorithmCallbacks.Algorithm_OnElementMeasured(element, index, availableSize, measureSize, element.DesiredSize, provisionalArrangeSize, context); + + return provisionalArrangeSize; + } + + private int GetAnchorIndex( + Size availableSize, + bool isWrapping, + double minItemSpacing, + string layoutId) + { + int anchorIndex = -1; + var anchorPosition= new Point(); + var context = _context; + + if (!IsVirtualizingContext) + { + // Non virtualizing host, start generating from the element 0 + anchorIndex = context.ItemCount > 0 ? 0 : -1; + } + else + { + bool isRealizationWindowConnected = _elementManager.IsWindowConnected(RealizationRect, _orientation.ScrollOrientation, _scrollOrientationSameAsFlow); + // Item spacing and size in non-virtualizing direction change can cause elements to reflow + // and get a new column position. In that case we need the anchor to be positioned in the + // correct column. + bool needAnchorColumnRevaluation = isWrapping && ( + _orientation.Minor(_lastAvailableSize) != _orientation.Minor(availableSize) || + _lastItemSpacing != minItemSpacing || + _collectionChangePending); + + var suggestedAnchorIndex = _context.RecommendedAnchorIndex; + + var isAnchorSuggestionValid = suggestedAnchorIndex >= 0 && + _elementManager.IsDataIndexRealized(suggestedAnchorIndex); + + if (isAnchorSuggestionValid) + { + anchorIndex = _algorithmCallbacks.Algorithm_GetAnchorForTargetElement( + suggestedAnchorIndex, + availableSize, + context).Index; + + if (_elementManager.IsDataIndexRealized(anchorIndex)) + { + var anchorBounds = _elementManager.GetLayoutBoundsForDataIndex(anchorIndex); + if (needAnchorColumnRevaluation) + { + // We were provided a valid anchor, but its position might be incorrect because for example it is in + // the wrong column. We do know that the anchor is the first element in the row, so we can force the minor position + // to start at 0. + anchorPosition = _orientation.MinorMajorPoint(0, _orientation.MajorStart(anchorBounds)); + } + else + { + anchorPosition = new Point(anchorBounds.X, anchorBounds.Y); + } + } + else + { + // It is possible to end up in a situation during a collection change where GetAnchorForTargetElement returns an index + // which is not in the realized range. Eg. insert one item at index 0 for a grid layout. + // SuggestedAnchor will be 1 (used to be 0) and GetAnchorForTargetElement will return 0 (left most item in row). However 0 is not in the + // realized range yet. In this case we realize the gap between the target anchor and the suggested anchor. + int firstRealizedDataIndex = _elementManager.GetDataIndexFromRealizedRangeIndex(0); + + for (int i = firstRealizedDataIndex - 1; i >= anchorIndex; --i) + { + _elementManager.EnsureElementRealized(false /*forward*/, i, layoutId); + } + + var anchorBounds = _elementManager.GetLayoutBoundsForDataIndex(suggestedAnchorIndex); + anchorPosition = _orientation.MinorMajorPoint(0, _orientation.MajorStart(anchorBounds)); + } + } + else if (needAnchorColumnRevaluation || !isRealizationWindowConnected) + { + // The anchor is based on the realization window because a connected ItemsRepeater might intersect the realization window + // but not the visible window. In that situation, we still need to produce a valid anchor. + var anchorInfo = _algorithmCallbacks.Algorithm_GetAnchorForRealizationRect(availableSize, context); + anchorIndex = anchorInfo.Index; + anchorPosition = _orientation.MinorMajorPoint(0, anchorInfo.Offset); + } + else + { + // No suggestion - just pick first in realized range + anchorIndex = _elementManager.GetDataIndexFromRealizedRangeIndex(0); + var firstElementBounds = _elementManager.GetLayoutBoundsForRealizedIndex(0); + anchorPosition = new Point(firstElementBounds.X, firstElementBounds.Y); + } + } + + _firstRealizedDataIndexInsideRealizationWindow = _lastRealizedDataIndexInsideRealizationWindow = anchorIndex; + if (_elementManager.IsIndexValidInData(anchorIndex)) + { + if (!_elementManager.IsDataIndexRealized(anchorIndex)) + { + // Disconnected, throw everything and create new anchor + _elementManager.ClearRealizedRange(); + + var anchor = _context.GetOrCreateElementAt(anchorIndex, ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + _elementManager.Add(anchor, anchorIndex); + } + + var anchorElement = _elementManager.GetRealizedElement(anchorIndex); + var desiredSize = MeasureElement(anchorElement, anchorIndex, availableSize, _context); + var layoutBounds = new Rect(anchorPosition.X, anchorPosition.Y, desiredSize.Width, desiredSize.Height); + _elementManager.SetLayoutBoundsForDataIndex(anchorIndex, layoutBounds); + } + else + { + _elementManager.ClearRealizedRange(); + } + + // TODO: Perhaps we can track changes in the property setter + _lastAvailableSize = availableSize; + _lastItemSpacing = minItemSpacing; + + return anchorIndex; + } + + private void Generate( + GenerateDirection direction, + int anchorIndex, + Size availableSize, + double minItemSpacing, + double lineSpacing, + string layoutId) + { + if (anchorIndex != -1) + { + int step = (direction == GenerateDirection.Forward) ? 1 : -1; + int previousIndex = anchorIndex; + int currentIndex = anchorIndex + step; + var anchorBounds = _elementManager.GetLayoutBoundsForDataIndex(anchorIndex); + var lineOffset = _orientation.MajorStart(anchorBounds); + var lineMajorSize = _orientation.MajorSize(anchorBounds); + int countInLine = 1; + int count = 0; + bool lineNeedsReposition = false; + + while (_elementManager.IsIndexValidInData(currentIndex) && + ShouldContinueFillingUpSpace(previousIndex, direction)) + { + // Ensure layout element. + _elementManager.EnsureElementRealized(direction == GenerateDirection.Forward, currentIndex, layoutId); + var currentElement = _elementManager.GetRealizedElement(currentIndex); + var desiredSize = MeasureElement(currentElement, currentIndex, availableSize, _context); + ++count; + + // Lay it out. + var previousElement = _elementManager.GetRealizedElement(previousIndex); + var currentBounds = new Rect(0, 0, desiredSize.Width, desiredSize.Height); + var previousElementBounds = _elementManager.GetLayoutBoundsForDataIndex(previousIndex); + + if (direction == GenerateDirection.Forward) + { + double remainingSpace = _orientation.Minor(availableSize) - (_orientation.MinorStart(previousElementBounds) + _orientation.MinorSize(previousElementBounds) + minItemSpacing + _orientation.Minor(desiredSize)); + if (_algorithmCallbacks.Algorithm_ShouldBreakLine(currentIndex, remainingSpace)) + { + // No more space in this row. wrap to next row. + _orientation.SetMinorStart(ref currentBounds, 0); + _orientation.SetMajorStart(ref currentBounds, _orientation.MajorStart(previousElementBounds) + lineMajorSize + lineSpacing); + + if (lineNeedsReposition) + { + // reposition the previous line (countInLine items) + for (int i = 0; i < countInLine; i++) + { + var dataIndex = currentIndex - 1 - i; + var bounds = _elementManager.GetLayoutBoundsForDataIndex(dataIndex); + _orientation.SetMajorSize(ref bounds, lineMajorSize); + _elementManager.SetLayoutBoundsForDataIndex(dataIndex, bounds); + } + } + + // Setup for next line. + lineMajorSize = _orientation.MajorSize(currentBounds); + lineOffset = _orientation.MajorStart(currentBounds); + lineNeedsReposition = false; + countInLine = 1; + } + else + { + // More space is available in this row. + _orientation.SetMinorStart(ref currentBounds, _orientation.MinorStart(previousElementBounds) + _orientation.MinorSize(previousElementBounds) + minItemSpacing); + _orientation.SetMajorStart(ref currentBounds, lineOffset); + lineMajorSize = Math.Max(lineMajorSize, _orientation.MajorSize(currentBounds)); + lineNeedsReposition = _orientation.MajorSize(previousElementBounds) != _orientation.MajorSize(currentBounds); + countInLine++; + } + } + else + { + // Backward + double remainingSpace = _orientation.MinorStart(previousElementBounds) - (_orientation.Minor(desiredSize) + minItemSpacing); + if (_algorithmCallbacks.Algorithm_ShouldBreakLine(currentIndex, remainingSpace)) + { + // Does not fit, wrap to the previous row + var availableSizeMinor = _orientation.Minor(availableSize); + + _orientation.SetMinorStart(ref currentBounds, !double.IsInfinity(availableSizeMinor) ? availableSizeMinor - _orientation.Minor(desiredSize) : 0); + _orientation.SetMajorStart(ref currentBounds, lineOffset - _orientation.Major(desiredSize) - lineSpacing); + + if (lineNeedsReposition) + { + var previousLineOffset = _orientation.MajorStart(_elementManager.GetLayoutBoundsForDataIndex(currentIndex + countInLine + 1)); + // reposition the previous line (countInLine items) + for (int i = 0; i < countInLine; i++) + { + var dataIndex = currentIndex + 1 + i; + if (dataIndex != anchorIndex) + { + var bounds = _elementManager.GetLayoutBoundsForDataIndex(dataIndex); + _orientation.SetMajorStart(ref bounds, previousLineOffset - lineMajorSize - lineSpacing); + _orientation.SetMajorSize(ref bounds, lineMajorSize); + _elementManager.SetLayoutBoundsForDataIndex(dataIndex, bounds); + } + } + } + + // Setup for next line. + lineMajorSize = _orientation.MajorSize(currentBounds); + lineOffset = _orientation.MajorStart(currentBounds); + lineNeedsReposition = false; + countInLine = 1; + } + else + { + // Fits in this row. put it in the previous position + _orientation.SetMinorStart(ref currentBounds, _orientation.MinorStart(previousElementBounds) - _orientation.Minor(desiredSize) - minItemSpacing); + _orientation.SetMajorStart(ref currentBounds, lineOffset); + lineMajorSize = Math.Max(lineMajorSize, _orientation.MajorSize(currentBounds)); + lineNeedsReposition = _orientation.MajorSize(previousElementBounds) != _orientation.MajorSize(currentBounds); + countInLine++; + } + } + + _elementManager.SetLayoutBoundsForDataIndex(currentIndex, currentBounds); + previousIndex = currentIndex; + currentIndex += step; + } + + // If we did not reach the top or bottom of the extent, we realized one + // extra item before we knew we were outside the realization window. Do not + // account for that element in the indicies inside the realization window. + if (count > 0) + { + if (direction == GenerateDirection.Forward) + { + int dataCount = _context.ItemCount; + _lastRealizedDataIndexInsideRealizationWindow = previousIndex == dataCount - 1 ? dataCount - 1 : previousIndex - 1; + _lastRealizedDataIndexInsideRealizationWindow = Math.Max(0, _lastRealizedDataIndexInsideRealizationWindow); + } + else + { + int dataCount = _context.ItemCount; + _firstRealizedDataIndexInsideRealizationWindow = previousIndex == 0 ? 0 : previousIndex + 1; + _firstRealizedDataIndexInsideRealizationWindow = Math.Min(dataCount - 1, _firstRealizedDataIndexInsideRealizationWindow); + } + } + + _elementManager.DiscardElementsOutsideWindow(direction == GenerateDirection.Forward, currentIndex); + } + } + + private void MakeAnchor( + VirtualizingLayoutContext context, + int index, + Size availableSize) + { + _elementManager.ClearRealizedRange(); + // FlowLayout requires that the anchor is the first element in the row. + var internalAnchor = _algorithmCallbacks.Algorithm_GetAnchorForTargetElement(index, availableSize, context); + + // No need to set the position of the anchor. + // (0,0) is fine for now since the extent can + // grow in any direction. + for (int dataIndex = internalAnchor.Index; dataIndex < index + 1; ++dataIndex) + { + var element = context.GetOrCreateElementAt(dataIndex, ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + element.Measure(_algorithmCallbacks.Algorithm_GetMeasureSize(dataIndex, availableSize, context)); + _elementManager.Add(element, dataIndex); + } + } + + private bool IsReflowRequired() + { + // If first element is realized and is not at the very beginning we need to reflow. + return + _elementManager.GetRealizedElementCount() > 0 && + _elementManager.GetDataIndexFromRealizedRangeIndex(0) == 0 && + _orientation.MinorStart(_elementManager.GetLayoutBoundsForRealizedIndex(0)) != 0; + } + + private bool ShouldContinueFillingUpSpace( + int index, + GenerateDirection direction) + { + bool shouldContinue = false; + if (!IsVirtualizingContext) + { + shouldContinue = true; + } + else + { + var realizationRect = _context.RealizationRect; + var elementBounds = _elementManager.GetLayoutBoundsForDataIndex(index); + + var elementMajorStart = _orientation.MajorStart(elementBounds); + var elementMajorEnd = _orientation.MajorEnd(elementBounds); + var rectMajorStart = _orientation.MajorStart(realizationRect); + var rectMajorEnd = _orientation.MajorEnd(realizationRect); + + var elementMinorStart = _orientation.MinorStart(elementBounds); + var elementMinorEnd = _orientation.MinorEnd(elementBounds); + var rectMinorStart = _orientation.MinorStart(realizationRect); + var rectMinorEnd = _orientation.MinorEnd(realizationRect); + + // Ensure that both minor and major directions are taken into consideration so that if the scrolling direction + // is the same as the flow direction we still stop at the end of the viewport rectangle. + shouldContinue = + (direction == GenerateDirection.Forward && elementMajorStart < rectMajorEnd && elementMinorStart < rectMinorEnd) || + (direction == GenerateDirection.Backward && elementMajorEnd > rectMajorStart && elementMinorEnd > rectMinorStart); + } + + return shouldContinue; + } + + private Rect EstimateExtent(Size availableSize, string layoutId) + { + ILayoutable firstRealizedElement = null; + Rect firstBounds = new Rect(); + ILayoutable lastRealizedElement = null; + Rect lastBounds = new Rect(); + int firstDataIndex = -1; + int lastDataIndex = -1; + + if (_elementManager.GetRealizedElementCount() > 0) + { + firstRealizedElement = _elementManager.GetAt(0); + firstBounds = _elementManager.GetLayoutBoundsForRealizedIndex(0); + firstDataIndex = _elementManager.GetDataIndexFromRealizedRangeIndex(0);; + + int last = _elementManager.GetRealizedElementCount() - 1; + lastRealizedElement = _elementManager.GetAt(last); + lastDataIndex = _elementManager.GetDataIndexFromRealizedRangeIndex(last); + lastBounds = _elementManager.GetLayoutBoundsForRealizedIndex(last); + } + + Rect extent = _algorithmCallbacks.Algorithm_GetExtent( + availableSize, + _context, + firstRealizedElement, + firstDataIndex, + firstBounds, + lastRealizedElement, + lastDataIndex, + lastBounds); + + return extent; + } + + private void RaiseLineArranged() + { + var realizationRect = RealizationRect; + if (realizationRect.Width != 0.0f || realizationRect.Height != 0.0f) + { + int realizedElementCount = _elementManager.GetRealizedElementCount(); + if (realizedElementCount > 0) + { + int countInLine = 0; + var previousElementBounds = _elementManager.GetLayoutBoundsForDataIndex(_firstRealizedDataIndexInsideRealizationWindow); + var currentLineOffset = _orientation.MajorStart(previousElementBounds); + var currentLineSize = _orientation.MajorSize(previousElementBounds); + for (int currentDataIndex = _firstRealizedDataIndexInsideRealizationWindow; currentDataIndex <= _lastRealizedDataIndexInsideRealizationWindow; currentDataIndex++) + { + var currentBounds = _elementManager.GetLayoutBoundsForDataIndex(currentDataIndex); + if (_orientation.MajorStart(currentBounds) != currentLineOffset) + { + // Staring a new line + _algorithmCallbacks.Algorithm_OnLineArranged(currentDataIndex - countInLine, countInLine, currentLineSize, _context); + countInLine = 0; + currentLineOffset = _orientation.MajorStart(currentBounds); + currentLineSize = 0; + } + + currentLineSize = Math.Max(currentLineSize, _orientation.MajorSize(currentBounds)); + countInLine++; + previousElementBounds = currentBounds; + } + + // Raise for the last line. + _algorithmCallbacks.Algorithm_OnLineArranged(_lastRealizedDataIndexInsideRealizationWindow - countInLine + 1, countInLine, currentLineSize, _context); + } + } + } + + private void ArrangeVirtualizingLayout( + Size finalSize, + LineAlignment lineAlignment, + string layoutId) + { + // Walk through the realized elements one line at a time and + // align them, Then call element.Arrange with the arranged bounds. + int realizedElementCount = _elementManager.GetRealizedElementCount(); + if (realizedElementCount > 0) + { + var countInLine = 1; + var previousElementBounds = _elementManager.GetLayoutBoundsForRealizedIndex(0); + var currentLineOffset = _orientation.MajorStart(previousElementBounds); + var spaceAtLineStart = _orientation.MinorStart(previousElementBounds); + var spaceAtLineEnd = 0.0; + var currentLineSize = _orientation.MajorSize(previousElementBounds); + for (int i = 1; i < realizedElementCount; i++) + { + var currentBounds = _elementManager.GetLayoutBoundsForRealizedIndex(i); + if (_orientation.MajorStart(currentBounds) != currentLineOffset) + { + spaceAtLineEnd = _orientation.Minor(finalSize) - _orientation.MinorStart(previousElementBounds) - _orientation.MinorSize(previousElementBounds); + PerformLineAlignment(i - countInLine, countInLine, spaceAtLineStart, spaceAtLineEnd, currentLineSize, lineAlignment, layoutId); + spaceAtLineStart = _orientation.MinorStart(currentBounds); + countInLine = 0; + currentLineOffset = _orientation.MajorStart(currentBounds); + currentLineSize = 0; + } + + countInLine++; // for current element + currentLineSize = Math.Max(currentLineSize, _orientation.MajorSize(currentBounds)); + previousElementBounds = currentBounds; + } + + // Last line - potentially have a property to customize + // aligning the last line or not. + if (countInLine > 0) + { + var spaceAtEnd = _orientation.Minor(finalSize) - _orientation.MinorStart(previousElementBounds) - _orientation.MinorSize(previousElementBounds); + PerformLineAlignment(realizedElementCount - countInLine, countInLine, spaceAtLineStart, spaceAtEnd, currentLineSize, lineAlignment, layoutId); + } + } + } + + // Align elements within a line. Note that this does not modify LayoutBounds. So if we get + // repeated measures, the LayoutBounds remain the same in each layout. + private void PerformLineAlignment( + int lineStartIndex, + int countInLine, + double spaceAtLineStart, + double spaceAtLineEnd, + double lineSize, + LineAlignment lineAlignment, + string layoutId) + { + for (int rangeIndex = lineStartIndex; rangeIndex < lineStartIndex + countInLine; ++rangeIndex) + { + var bounds = _elementManager.GetLayoutBoundsForRealizedIndex(rangeIndex); + _orientation.SetMajorSize(ref bounds, lineSize); + + if (!_scrollOrientationSameAsFlow) + { + // Note: Space at start could potentially be negative + if (spaceAtLineStart != 0 || spaceAtLineEnd != 0) + { + var totalSpace = spaceAtLineStart + spaceAtLineEnd; + var minorStart = _orientation.MinorStart(bounds); + switch (lineAlignment) + { + case LineAlignment.Start: + { + _orientation.SetMinorStart(ref bounds, minorStart - spaceAtLineStart); + break; + } + + case LineAlignment.End: + { + _orientation.SetMinorStart(ref bounds, minorStart + spaceAtLineEnd); + break; + } + + case LineAlignment.Center: + { + _orientation.SetMinorStart(ref bounds, (minorStart - spaceAtLineStart) + (totalSpace / 2)); + break; + } + + case LineAlignment.SpaceAround: + { + var interItemSpace = countInLine >= 1 ? totalSpace / (countInLine * 2) : 0; + _orientation.SetMinorStart( + ref bounds, + (minorStart - spaceAtLineStart) + (interItemSpace * ((rangeIndex - lineStartIndex + 1) * 2 - 1))); + break; + } + + case LineAlignment.SpaceBetween: + { + var interItemSpace = countInLine > 1 ? totalSpace / (countInLine - 1) : 0; + _orientation.SetMinorStart( + ref bounds, + (minorStart - spaceAtLineStart) + (interItemSpace * (rangeIndex - lineStartIndex))); + break; + } + + case LineAlignment.SpaceEvenly: + { + var interItemSpace = countInLine >= 1 ? totalSpace / (countInLine + 1) : 0; + _orientation.SetMinorStart( + ref bounds, + (minorStart - spaceAtLineStart) + (interItemSpace * (rangeIndex - lineStartIndex + 1))); + break; + } + } + } + } + + bounds = bounds.Translate(-_lastExtent.Position); + var element = _elementManager.GetAt(rangeIndex); + element.Arrange(bounds); + } + } + + private void SetLayoutOrigin() + { + if (IsVirtualizingContext) + { + _context.LayoutOrigin = new Point(_lastExtent.X, _lastExtent.Y); + } + } + + public ILayoutable GetElementIfRealized(int dataIndex) + { + if (_elementManager.IsDataIndexRealized(dataIndex)) + { + return _elementManager.GetRealizedElement(dataIndex); + } + + return null; + } + + public bool TryAddElement0(ILayoutable element) + { + if (_elementManager.GetRealizedElementCount() == 0) + { + _elementManager.Add(element, 0); + return true; + } + + return false; + } + + public enum LineAlignment + { + Start, + Center, + End, + SpaceAround, + SpaceBetween, + SpaceEvenly, + } + + private enum GenerateDirection + { + Forward, + Backward, + } + } +} diff --git a/src/Avalonia.Layout/IFlowLayoutAlgorithmDelegates.cs b/src/Avalonia.Layout/IFlowLayoutAlgorithmDelegates.cs new file mode 100644 index 0000000000..907a3adf0f --- /dev/null +++ b/src/Avalonia.Layout/IFlowLayoutAlgorithmDelegates.cs @@ -0,0 +1,44 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +namespace Avalonia.Layout +{ + internal struct FlowLayoutAnchorInfo + { + public int Index { get; set; } + public double Offset { get; set; } + } + + internal interface IFlowLayoutAlgorithmDelegates + { + Size Algorithm_GetMeasureSize(int index, Size availableSize, VirtualizingLayoutContext context); + Size Algorithm_GetProvisionalArrangeSize(int index, Size measureSize, Size desiredSize, VirtualizingLayoutContext context); + bool Algorithm_ShouldBreakLine(int index, double remainingSpace); + FlowLayoutAnchorInfo Algorithm_GetAnchorForRealizationRect(Size availableSize, VirtualizingLayoutContext context); + FlowLayoutAnchorInfo Algorithm_GetAnchorForTargetElement(int targetIndex, Size availableSize, VirtualizingLayoutContext context); + Rect Algorithm_GetExtent( + Size availableSize, + VirtualizingLayoutContext context, + ILayoutable firstRealized, + int firstRealizedItemIndex, + Rect firstRealizedLayoutBounds, + ILayoutable lastRealized, + int lastRealizedItemIndex, + Rect lastRealizedLayoutBounds); + void Algorithm_OnElementMeasured( + ILayoutable element, + int index, + Size availableSize, + Size measureSize, + Size desiredSize, + Size provisionalArrangeSize, + VirtualizingLayoutContext context); + void Algorithm_OnLineArranged( + int startIndex, + int countInLine, + double lineSize, + VirtualizingLayoutContext context); + } +} diff --git a/src/Avalonia.Layout/LayoutContext.cs b/src/Avalonia.Layout/LayoutContext.cs new file mode 100644 index 0000000000..45a8048ea2 --- /dev/null +++ b/src/Avalonia.Layout/LayoutContext.cs @@ -0,0 +1,24 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +namespace Avalonia.Layout +{ + /// + /// Represents the base class for an object that facilitates communication between an attached + /// layout and its host container. + /// + public class LayoutContext : AvaloniaObject + { + /// + /// Gets or sets an object that represents the state of a layout. + /// + public object LayoutState { get; set; } + + /// + /// Implements the behavior of in a derived or custom LayoutContext. + /// + protected virtual object LayoutStateCore { get; set; } + } +} diff --git a/src/Avalonia.Layout/NonVirtualizingLayout.cs b/src/Avalonia.Layout/NonVirtualizingLayout.cs new file mode 100644 index 0000000000..fba91e66c7 --- /dev/null +++ b/src/Avalonia.Layout/NonVirtualizingLayout.cs @@ -0,0 +1,103 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +namespace Avalonia.Layout +{ + /// + /// Represents the base class for an object that sizes and arranges child elements for a host + /// and and does not support virtualization. + /// + /// + /// NonVirtualizingLayout is the base class for layouts that do not support virtualization. You + /// can inherit from it to create your own layout. + /// + /// A non-virtualizing layout can measure and arrange child elements. + /// + public abstract class NonVirtualizingLayout : AttachedLayout + { + /// + public sealed override void InitializeForContext(LayoutContext context) + { + InitializeForContextCore((VirtualizingLayoutContext)context); + } + + /// + public sealed override void UninitializeForContext(LayoutContext context) + { + UninitializeForContextCore((VirtualizingLayoutContext)context); + } + + /// + public sealed override Size Measure(LayoutContext context, Size availableSize) + { + return MeasureOverride((VirtualizingLayoutContext)context, availableSize); + } + + /// + public sealed override Size Arrange(LayoutContext context, Size finalSize) + { + return ArrangeOverride((VirtualizingLayoutContext)context, finalSize); + } + + /// + /// When overridden in a derived class, initializes any per-container state the layout + /// requires when it is attached to an ILayoutable container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + protected virtual void InitializeForContextCore(VirtualizingLayoutContext context) + { + } + + /// + /// When overridden in a derived class, removes any state the layout previously stored on + /// the ILayoutable container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + protected virtual void UninitializeForContextCore(VirtualizingLayoutContext context) + { + } + + /// + /// Provides the behavior for the "Measure" pass of the layout cycle. Classes can override + /// this method to define their own "Measure" pass behavior. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The available size that this object can give to child objects. Infinity can be + /// specified as a value to indicate that the object will size to whatever content is + /// available. + /// + /// + /// The size that this object determines it needs during layout, based on its calculations + /// of the allocated sizes for child objects or based on other considerations such as a + /// fixed container size. + /// + protected abstract Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize); + + /// + /// When implemented in a derived class, provides the behavior for the "Arrange" pass of + /// layout. Classes can override this method to define their own "Arrange" pass behavior. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The final area within the container that this object should use to arrange itself and + /// its children. + /// + /// The actual size that is used after the element is arranged in layout. + protected virtual Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) => finalSize; + } +} diff --git a/src/Avalonia.Controls/Orientation.cs b/src/Avalonia.Layout/Orientation.cs similarity index 94% rename from src/Avalonia.Controls/Orientation.cs rename to src/Avalonia.Layout/Orientation.cs index fe998c024a..f03b087adc 100644 --- a/src/Avalonia.Controls/Orientation.cs +++ b/src/Avalonia.Layout/Orientation.cs @@ -1,7 +1,7 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. -namespace Avalonia.Controls +namespace Avalonia.Layout { /// /// Defines vertical or horizontal orientation. diff --git a/src/Avalonia.Layout/OrientationBasedMeasures.cs b/src/Avalonia.Layout/OrientationBasedMeasures.cs new file mode 100644 index 0000000000..23a8b0e168 --- /dev/null +++ b/src/Avalonia.Layout/OrientationBasedMeasures.cs @@ -0,0 +1,96 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +namespace Avalonia.Layout +{ + internal enum ScrollOrientation + { + Vertical, + Horizontal, + } + + internal class OrientationBasedMeasures + { + public ScrollOrientation ScrollOrientation { get; set; } = ScrollOrientation.Vertical; + + public double Major(in Size size) => ScrollOrientation == ScrollOrientation.Vertical ? size.Height : size.Width; + public double Minor(in Size size) => ScrollOrientation == ScrollOrientation.Vertical ? size.Width : size.Height; + public double MajorSize(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.Height : rect.Width; + public double MinorSize(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.Width : rect.Height; + public double MajorStart(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.Y : rect.X; + public double MinorStart(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.X : rect.Y; + public double MajorEnd(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.Bottom : rect.Right; + public double MinorEnd(in Rect rect) => ScrollOrientation == ScrollOrientation.Vertical ? rect.Right : rect.Bottom; + + public void SetMajorSize(ref Rect rect, double value) + { + if (ScrollOrientation == ScrollOrientation.Vertical) + { + rect = rect.WithHeight(value); + } + else + { + rect = rect.WithWidth(value); + } + } + + public void SetMinorSize(ref Rect rect, double value) + { + if (ScrollOrientation == ScrollOrientation.Vertical) + { + rect = rect.WithWidth(value); + } + else + { + rect = rect.WithHeight(value); + } + } + + public void SetMajorStart(ref Rect rect, double value) + { + if (ScrollOrientation == ScrollOrientation.Vertical) + { + rect = rect.WithY(value); + } + else + { + rect = rect.WithX(value); + } + } + + public void SetMinorStart(ref Rect rect, double value) + { + if (ScrollOrientation == ScrollOrientation.Vertical) + { + rect = rect.WithX(value); + } + else + { + rect = rect.WithY(value); + } + } + + public Rect MinorMajorRect(double minor, double major, double minorSize, double majorSize) + { + return ScrollOrientation == ScrollOrientation.Vertical ? + new Rect(minor, major, minorSize, majorSize) : + new Rect(major, minor, majorSize, minorSize); + } + + public Point MinorMajorPoint(double minor, double major) + { + return ScrollOrientation == ScrollOrientation.Vertical ? + new Point(minor, major) : + new Point(major, minor); + } + + public Size MinorMajorSize(double minor, double major) + { + return ScrollOrientation == ScrollOrientation.Vertical ? + new Size(minor, major) : + new Size(major, minor); + } + } +} diff --git a/src/Avalonia.Layout/StackLayout.cs b/src/Avalonia.Layout/StackLayout.cs new file mode 100644 index 0000000000..e9735b9b31 --- /dev/null +++ b/src/Avalonia.Layout/StackLayout.cs @@ -0,0 +1,336 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + /// + /// Arranges elements into a single line (with spacing) that can be oriented horizontally or vertically. + /// + public class StackLayout : VirtualizingLayout, IFlowLayoutAlgorithmDelegates + { + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation), Orientation.Vertical); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SpacingProperty = + AvaloniaProperty.Register(nameof(Spacing)); + + private readonly OrientationBasedMeasures _orientation = new OrientationBasedMeasures(); + + /// + /// Initializes a new instance of the StackLayout class. + /// + public StackLayout() + { + LayoutId = "StackLayout"; + } + + /// + /// 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 a uniform distance (in pixels) between stacked items. It is applied in the + /// direction of the StackLayout's Orientation. + /// + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + internal Rect GetExtent( + Size availableSize, + VirtualizingLayoutContext context, + ILayoutable firstRealized, + int firstRealizedItemIndex, + Rect firstRealizedLayoutBounds, + ILayoutable lastRealized, + int lastRealizedItemIndex, + Rect lastRealizedLayoutBounds) + { + var extent = new Rect(); + + // Constants + int itemsCount = context.ItemCount; + var stackState = (StackLayoutState)context.LayoutState; + double averageElementSize = GetAverageElementSize(availableSize, context, stackState) + Spacing; + + _orientation.SetMinorSize(ref extent, stackState.MaxArrangeBounds); + _orientation.SetMajorSize(ref extent, Math.Max(0.0f, itemsCount * averageElementSize - Spacing)); + if (itemsCount > 0) + { + if (firstRealized != null) + { + _orientation.SetMajorStart( + ref extent, + _orientation.MajorStart(firstRealizedLayoutBounds) - firstRealizedItemIndex * averageElementSize); + var remainingItems = itemsCount - lastRealizedItemIndex - 1; + _orientation.SetMajorSize( + ref extent, + _orientation.MajorEnd(lastRealizedLayoutBounds) - + _orientation.MajorStart(extent) + + (remainingItems * averageElementSize)); + } + } + + return extent; + } + + internal void OnElementMeasured( + ILayoutable element, + int index, + Size availableSize, + Size measureSize, + Size desiredSize, + Size provisionalArrangeSize, + VirtualizingLayoutContext context) + { + if (context is VirtualizingLayoutContext virtualContext) + { + var stackState = (StackLayoutState)virtualContext.LayoutState; + var provisionalArrangeSizeWinRt = provisionalArrangeSize; + stackState.OnElementMeasured( + index, + _orientation.Major(provisionalArrangeSizeWinRt), + _orientation.Minor(provisionalArrangeSizeWinRt)); + } + } + + Size IFlowLayoutAlgorithmDelegates.Algorithm_GetMeasureSize( + int index, + Size availableSize, + VirtualizingLayoutContext context) => availableSize; + + Size IFlowLayoutAlgorithmDelegates.Algorithm_GetProvisionalArrangeSize( + int index, + Size measureSize, + Size desiredSize, + VirtualizingLayoutContext context) + { + var measureSizeMinor = _orientation.Minor(measureSize); + return _orientation.MinorMajorSize( + !double.IsInfinity(measureSizeMinor) ? + Math.Max(measureSizeMinor, _orientation.Minor(desiredSize)) : + _orientation.Minor(desiredSize), + _orientation.Major(desiredSize)); + } + + bool IFlowLayoutAlgorithmDelegates.Algorithm_ShouldBreakLine(int index, double remainingSpace) => true; + + FlowLayoutAnchorInfo IFlowLayoutAlgorithmDelegates.Algorithm_GetAnchorForRealizationRect( + Size availableSize, + VirtualizingLayoutContext context) => GetAnchorForRealizationRect(availableSize, context); + + FlowLayoutAnchorInfo IFlowLayoutAlgorithmDelegates.Algorithm_GetAnchorForTargetElement( + int targetIndex, + Size availableSize, + VirtualizingLayoutContext context) + { + double offset = double.NaN; + int index = -1; + int itemsCount = context.ItemCount; + + if (targetIndex >= 0 && targetIndex < itemsCount) + { + index = targetIndex; + var state = (StackLayoutState)context.LayoutState; + double averageElementSize = GetAverageElementSize(availableSize, context, state) + Spacing; + offset = index * averageElementSize + _orientation.MajorStart(state.FlowAlgorithm.LastExtent); + } + + return new FlowLayoutAnchorInfo { Index = index, Offset = offset }; + } + + Rect IFlowLayoutAlgorithmDelegates.Algorithm_GetExtent( + Size availableSize, + VirtualizingLayoutContext context, + ILayoutable firstRealized, + int firstRealizedItemIndex, + Rect firstRealizedLayoutBounds, + ILayoutable lastRealized, + int lastRealizedItemIndex, + Rect lastRealizedLayoutBounds) + { + return GetExtent( + availableSize, + context, + firstRealized, + firstRealizedItemIndex, + firstRealizedLayoutBounds, + lastRealized, + lastRealizedItemIndex, + lastRealizedLayoutBounds); + } + + void IFlowLayoutAlgorithmDelegates.Algorithm_OnElementMeasured(ILayoutable element, int index, Size availableSize, Size measureSize, Size desiredSize, Size provisionalArrangeSize, VirtualizingLayoutContext context) + { + OnElementMeasured( + element, + index, + availableSize, + measureSize, + desiredSize, + provisionalArrangeSize, + context); + } + + void IFlowLayoutAlgorithmDelegates.Algorithm_OnLineArranged(int startIndex, int countInLine, double lineSize, VirtualizingLayoutContext context) + { + } + + internal FlowLayoutAnchorInfo GetAnchorForRealizationRect( + Size availableSize, + VirtualizingLayoutContext context) + { + int anchorIndex = -1; + double offset = double.NaN; + + // Constants + int itemsCount = context.ItemCount; + if (itemsCount > 0) + { + var realizationRect = context.RealizationRect; + var state = (StackLayoutState)context.LayoutState; + var lastExtent = state.FlowAlgorithm.LastExtent; + + double averageElementSize = GetAverageElementSize(availableSize, context, state) + Spacing; + double realizationWindowOffsetInExtent = _orientation.MajorStart(realizationRect) - _orientation.MajorStart(lastExtent); + double majorSize = _orientation.MajorSize(lastExtent) == 0 ? Math.Max(0.0, averageElementSize * itemsCount - Spacing) : _orientation.MajorSize(lastExtent); + if (itemsCount > 0 && + _orientation.MajorSize(realizationRect) >= 0 && + // MajorSize = 0 will account for when a nested repeater is outside the realization rect but still being measured. Also, + // note that if we are measuring this repeater, then we are already realizing an element to figure out the size, so we could + // just keep that element alive. It also helps in XYFocus scenarios to have an element realized for XYFocus to find a candidate + // in the navigating direction. + realizationWindowOffsetInExtent + _orientation.MajorSize(realizationRect) >= 0 && realizationWindowOffsetInExtent <= majorSize) + { + anchorIndex = (int) (realizationWindowOffsetInExtent / averageElementSize); + offset = anchorIndex* averageElementSize + _orientation.MajorStart(lastExtent); + anchorIndex = Math.Max(0, Math.Min(itemsCount - 1, anchorIndex)); + } + } + + return new FlowLayoutAnchorInfo { Index = anchorIndex, Offset = offset, }; + } + + protected override void InitializeForContextCore(VirtualizingLayoutContext context) + { + var state = context.LayoutState; + var stackState = state as StackLayoutState; + + if (stackState == null) + { + if (state != null) + { + throw new InvalidOperationException("LayoutState must derive from StackLayoutState."); + } + + // Custom deriving layouts could potentially be stateful. + // If that is the case, we will just create the base state required by UniformGridLayout ourselves. + stackState = new StackLayoutState(); + } + + stackState.InitializeForContext(context, this); + } + + protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + { + var stackState = (StackLayoutState)context.LayoutState; + stackState.UninitializeForContext(context); + } + + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + var desiredSize = GetFlowAlgorithm(context).Measure( + availableSize, + context, + false, + 0, + Spacing, + _orientation.ScrollOrientation, + LayoutId); + + return new Size(desiredSize.Width, desiredSize.Height); + } + + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + var value = GetFlowAlgorithm(context).Arrange( + finalSize, + context, + FlowLayoutAlgorithm.LineAlignment.Start, + LayoutId); + + ((StackLayoutState)context.LayoutState).OnArrangeLayoutEnd(); + + return new Size(value.Width, value.Height); + } + + protected internal override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + GetFlowAlgorithm(context).OnItemsSourceChanged(source, args, context); + // Always invalidate layout to keep the view accurate. + InvalidateLayout(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == OrientationProperty) + { + var orientation = (Orientation)e.NewValue; + + //Note: For StackLayout Vertical Orientation means we have a Vertical ScrollOrientation. + //Horizontal Orientation means we have a Horizontal ScrollOrientation. + _orientation.ScrollOrientation = orientation == Orientation.Horizontal ? ScrollOrientation.Horizontal : ScrollOrientation.Vertical; + } + + InvalidateLayout(); + } + + private double GetAverageElementSize( + Size availableSize, + VirtualizingLayoutContext context, + StackLayoutState stackLayoutState) + { + double averageElementSize = 0; + + if (context.ItemCount > 0) + { + if (stackLayoutState.TotalElementsMeasured == 0) + { + var tmpElement = context.GetOrCreateElementAt(0, ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); + stackLayoutState.FlowAlgorithm.MeasureElement(tmpElement, 0, availableSize, context); + context.RecycleElement(tmpElement); + } + + averageElementSize = Math.Round(stackLayoutState.TotalElementSize / stackLayoutState.TotalElementsMeasured); + } + + return averageElementSize; + } + + private void InvalidateLayout() => InvalidateMeasure(); + + private FlowLayoutAlgorithm GetFlowAlgorithm(VirtualizingLayoutContext context) => ((StackLayoutState)context.LayoutState).FlowAlgorithm; + } +} diff --git a/src/Avalonia.Layout/StackLayoutState.cs b/src/Avalonia.Layout/StackLayoutState.cs new file mode 100644 index 0000000000..05ad9bca8e --- /dev/null +++ b/src/Avalonia.Layout/StackLayoutState.cs @@ -0,0 +1,61 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Layout +{ + /// + /// Represents the state of a StackLayout. + /// + public class StackLayoutState + { + private const int BufferSize = 100; + private readonly List _estimationBuffer = new List(); + + internal FlowLayoutAlgorithm FlowAlgorithm { get; } = new FlowLayoutAlgorithm(); + internal double MaxArrangeBounds { get; private set; } + internal int TotalElementsMeasured { get; private set; } + internal double TotalElementSize { get; private set; } + + internal void InitializeForContext(VirtualizingLayoutContext context, IFlowLayoutAlgorithmDelegates callbacks) + { + FlowAlgorithm.InitializeForContext(context, callbacks); + + if (_estimationBuffer.Count == 0) + { + _estimationBuffer.AddRange(Enumerable.Repeat(0.0, BufferSize)); + } + + context.LayoutState = this; + } + + internal void UninitializeForContext(VirtualizingLayoutContext context) + { + FlowAlgorithm.UninitializeForContext(context); + } + + internal void OnElementMeasured(int elementIndex, double majorSize, double minorSize) + { + int estimationBufferIndex = elementIndex % _estimationBuffer.Count; + bool alreadyMeasured = _estimationBuffer[estimationBufferIndex] != 0; + + if (!alreadyMeasured) + { + TotalElementsMeasured++; + } + + TotalElementSize -= _estimationBuffer[estimationBufferIndex]; + TotalElementSize += majorSize; + _estimationBuffer[estimationBufferIndex] = majorSize; + + MaxArrangeBounds = Math.Max(MaxArrangeBounds, minorSize); + } + + internal void OnArrangeLayoutEnd() => MaxArrangeBounds = 0; + } +} diff --git a/src/Avalonia.Layout/UniformGridLayout.cs b/src/Avalonia.Layout/UniformGridLayout.cs new file mode 100644 index 0000000000..edc2042922 --- /dev/null +++ b/src/Avalonia.Layout/UniformGridLayout.cs @@ -0,0 +1,520 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + /// + /// Defines constants that specify how items are aligned on the non-scrolling or non-virtualizing axis. + /// + public enum UniformGridLayoutItemsJustification + { + /// + /// Items are aligned with the start of the row or column, with extra space at the end. + /// Spacing between items does not change. + /// + Start = 0, + + /// + /// Items are aligned in the center of the row or column, with extra space at the start and + /// end. Spacing between items does not change. + /// + Center = 1, + + /// + /// Items are aligned with the end of the row or column, with extra space at the start. + /// Spacing between items does not change. + /// + End = 2, + + /// + /// Items are aligned so that extra space is added evenly before and after each item. + /// + SpaceAround = 3, + + /// + /// Items are aligned so that extra space is added evenly between adjacent items. No space + /// is added at the start or end. + /// + SpaceBetween = 4, + + SpaceEvenly = 5, + }; + + /// + /// Defines constants that specify how items are sized to fill the available space. + /// + public enum UniformGridLayoutItemsStretch + { + /// + /// The item retains its natural size. Use of extra space is determined by the + /// property. + /// + None = 0, + + /// + /// The item is sized to fill the available space in the non-scrolling direction. Item size + /// in the scrolling direction is not changed. + /// + Fill = 1, + + /// + /// The item is sized to both fill the available space in the non-scrolling direction and + /// maintain its aspect ratio. + /// + Uniform = 2, + }; + + /// + /// Positions elements sequentially from left to right or top to bottom in a wrapping layout. + /// + public class UniformGridLayout : VirtualizingLayout, IFlowLayoutAlgorithmDelegates + { + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemsJustificationProperty = + AvaloniaProperty.Register(nameof(ItemsJustification)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ItemsStretchProperty = + AvaloniaProperty.Register(nameof(ItemsStretch)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinColumnSpacingProperty = + AvaloniaProperty.Register(nameof(MinColumnSpacing)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinItemHeightProperty = + AvaloniaProperty.Register(nameof(MinItemHeight)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinItemWidthProperty = + AvaloniaProperty.Register(nameof(MinItemWidth)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinRowSpacingProperty = + AvaloniaProperty.Register(nameof(MinRowSpacing)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + StackLayout.OrientationProperty.AddOwner(); + + private readonly OrientationBasedMeasures _orientation = new OrientationBasedMeasures(); + private double _minItemWidth = double.NaN; + private double _minItemHeight = double.NaN; + private double _minRowSpacing; + private double _minColumnSpacing; + private UniformGridLayoutItemsJustification _itemsJustification; + private UniformGridLayoutItemsStretch _itemsStretch; + + /// + /// Initializes a new instance of the class. + /// + public UniformGridLayout() + { + LayoutId = "UniformGridLayout"; + } + + static UniformGridLayout() + { + OrientationProperty.OverrideDefaultValue(Orientation.Horizontal); + } + + /// + /// Gets or sets a value that indicates how items are aligned on the non-scrolling or non- + /// virtualizing axis. + /// + /// + /// An enumeration value that indicates how items are aligned. The default is Start. + /// + public UniformGridLayoutItemsJustification ItemsJustification + { + get => GetValue(ItemsJustificationProperty); + set => SetValue(ItemsJustificationProperty, value); + } + + /// + /// Gets or sets a value that indicates how items are sized to fill the available space. + /// + /// + /// An enumeration value that indicates how items are sized to fill the available space. + /// The default is None. + /// + /// + /// This property enables adaptive layout behavior where the items are sized to fill the + /// available space along the non-scrolling axis, and optionally maintain their aspect ratio. + /// + public UniformGridLayoutItemsStretch ItemsStretch + { + get => GetValue(ItemsStretchProperty); + set => SetValue(ItemsStretchProperty, value); + } + + /// + /// Gets or sets the minimum space between items on the horizontal axis. + /// + /// + /// The spacing may exceed this minimum value when is set + /// to SpaceEvenly, SpaceAround, or SpaceBetween. + /// + public double MinColumnSpacing + { + get => GetValue(MinColumnSpacingProperty); + set => SetValue(MinColumnSpacingProperty, value); + } + + /// + /// Gets or sets the minimum height of each item. + /// + /// + /// The minimum height (in pixels) of each item. The default is NaN, in which case the + /// height of the first item is used as the minimum. + /// + public double MinItemHeight + { + get => GetValue(MinItemHeightProperty); + set => SetValue(MinItemHeightProperty, value); + } + + /// + /// Gets or sets the minimum width of each item. + /// + /// + /// The minimum width (in pixels) of each item. The default is NaN, in which case the width + /// of the first item is used as the minimum. + /// + public double MinItemWidth + { + get => GetValue(MinItemWidthProperty); + set => SetValue(MinItemWidthProperty, value); + } + + /// + /// Gets or sets the minimum space between items on the vertical axis. + /// + /// + /// The spacing may exceed this minimum value when is set + /// to SpaceEvenly, SpaceAround, or SpaceBetween. + /// + public double MinRowSpacing + { + get => GetValue(MinRowSpacingProperty); + set => SetValue(MinRowSpacingProperty, value); + } + + /// + /// 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); + } + + internal double LineSpacing => Orientation == Orientation.Horizontal ? _minRowSpacing : _minColumnSpacing; + internal double MinItemSpacing => Orientation == Orientation.Horizontal ? _minColumnSpacing : _minRowSpacing; + + Size IFlowLayoutAlgorithmDelegates.Algorithm_GetMeasureSize( + int index, + Size availableSize, + VirtualizingLayoutContext context) + { + var gridState = (UniformGridLayoutState)context.LayoutState; + return new Size(gridState.EffectiveItemWidth, gridState.EffectiveItemHeight); + } + + Size IFlowLayoutAlgorithmDelegates.Algorithm_GetProvisionalArrangeSize( + int index, + Size measureSize, + Size desiredSize, + VirtualizingLayoutContext context) + { + var gridState = (UniformGridLayoutState)context.LayoutState; + return new Size(gridState.EffectiveItemWidth, gridState.EffectiveItemHeight); + } + + bool IFlowLayoutAlgorithmDelegates.Algorithm_ShouldBreakLine(int index, double remainingSpace) => remainingSpace < 0; + + FlowLayoutAnchorInfo IFlowLayoutAlgorithmDelegates.Algorithm_GetAnchorForRealizationRect( + Size availableSize, + VirtualizingLayoutContext context) + { + Rect bounds = new Rect(double.NaN, double.NaN, double.NaN, double.NaN); + int anchorIndex = -1; + + int itemsCount = context.ItemCount; + var realizationRect = context.RealizationRect; + if (itemsCount > 0 && _orientation.MajorSize(realizationRect) > 0) + { + var gridState = (UniformGridLayoutState)context.LayoutState; + var lastExtent = gridState.FlowAlgorithm.LastExtent; + int itemsPerLine = Math.Max(1, (int)(_orientation.Minor(availableSize) / GetMinorSizeWithSpacing(context))); + double majorSize = (itemsCount / itemsPerLine) * GetMajorSizeWithSpacing(context); + double realizationWindowStartWithinExtent = _orientation.MajorStart(realizationRect) - _orientation.MajorStart(lastExtent); + if ((realizationWindowStartWithinExtent + _orientation.MajorSize(realizationRect)) >= 0 && realizationWindowStartWithinExtent <= majorSize) + { + double offset = Math.Max(0.0, _orientation.MajorStart(realizationRect) - _orientation.MajorStart(lastExtent)); + int anchorRowIndex = (int)(offset / GetMajorSizeWithSpacing(context)); + + anchorIndex = Math.Max(0, Math.Min(itemsCount - 1, anchorRowIndex * itemsPerLine)); + bounds = GetLayoutRectForDataIndex(availableSize, anchorIndex, lastExtent, context); + } + } + + return new FlowLayoutAnchorInfo + { + Index = anchorIndex, + Offset = _orientation.MajorStart(bounds) + }; + } + + FlowLayoutAnchorInfo IFlowLayoutAlgorithmDelegates.Algorithm_GetAnchorForTargetElement( + int targetIndex, + Size availableSize, + VirtualizingLayoutContext context) + { + int index = -1; + double offset = double.NaN; + int count = context.ItemCount; + if (targetIndex >= 0 && targetIndex < count) + { + int itemsPerLine = Math.Max(1, (int)(_orientation.Minor(availableSize) / GetMinorSizeWithSpacing(context))); + int indexOfFirstInLine = (targetIndex / itemsPerLine) * itemsPerLine; + index = indexOfFirstInLine; + var state = context.LayoutState as UniformGridLayoutState; + offset = _orientation.MajorStart(GetLayoutRectForDataIndex(availableSize, indexOfFirstInLine, state.FlowAlgorithm.LastExtent, context)); + } + + return new FlowLayoutAnchorInfo + { + Index = index, + Offset = offset + }; + } + + Rect IFlowLayoutAlgorithmDelegates.Algorithm_GetExtent( + Size availableSize, + VirtualizingLayoutContext context, + ILayoutable firstRealized, + int firstRealizedItemIndex, + Rect firstRealizedLayoutBounds, + ILayoutable lastRealized, + int lastRealizedItemIndex, + Rect lastRealizedLayoutBounds) + { + var extent = new Rect(); + + + // Constants + int itemsCount = context.ItemCount; + double availableSizeMinor = _orientation.Minor(availableSize); + int itemsPerLine = Math.Max(1, !double.IsInfinity(availableSizeMinor) ? + (int)(availableSizeMinor / GetMinorSizeWithSpacing(context)) : itemsCount); + double lineSize = GetMajorSizeWithSpacing(context); + + if (itemsCount > 0) + { + _orientation.SetMinorSize( + ref extent, + !double.IsInfinity(availableSizeMinor) ? + availableSizeMinor : + Math.Max(0.0, itemsCount * GetMinorSizeWithSpacing(context) - (double)MinItemSpacing)); + _orientation.SetMajorSize( + ref extent, + Math.Max(0.0, (itemsCount / itemsPerLine) * lineSize - (double)LineSpacing)); + + if (firstRealized != null) + { + _orientation.SetMajorStart( + ref extent, + _orientation.MajorStart(firstRealizedLayoutBounds) - (firstRealizedItemIndex / itemsPerLine) * lineSize); + int remainingItems = itemsCount - lastRealizedItemIndex - 1; + _orientation.SetMajorSize( + ref extent, + _orientation.MajorEnd(lastRealizedLayoutBounds) - _orientation.MajorStart(extent) + (remainingItems / itemsPerLine) * lineSize); + } + } + + return extent; + } + + void IFlowLayoutAlgorithmDelegates.Algorithm_OnElementMeasured(ILayoutable element, int index, Size availableSize, Size measureSize, Size desiredSize, Size provisionalArrangeSize, VirtualizingLayoutContext context) + { + } + + void IFlowLayoutAlgorithmDelegates.Algorithm_OnLineArranged(int startIndex, int countInLine, double lineSize, VirtualizingLayoutContext context) + { + } + + protected override void InitializeForContextCore(VirtualizingLayoutContext context) + { + var state = context.LayoutState; + var gridState = state as UniformGridLayoutState; + + if (gridState == null) + { + if (state != null) + { + throw new InvalidOperationException("LayoutState must derive from UniformGridLayoutState."); + } + + // Custom deriving layouts could potentially be stateful. + // If that is the case, we will just create the base state required by UniformGridLayout ourselves. + gridState = new UniformGridLayoutState(); + } + + gridState.InitializeForContext(context, this); + } + + protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + { + var gridState = (UniformGridLayoutState)context.LayoutState; + gridState.UninitializeForContext(context); + } + + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + // Set the width and height on the grid state. If the user already set them then use the preset. + // If not, we have to measure the first element and get back a size which we're going to be using for the rest of the items. + var gridState = (UniformGridLayoutState)context.LayoutState; + gridState.EnsureElementSize(availableSize, context, _minItemWidth, _minItemHeight, _itemsStretch, Orientation, MinRowSpacing, MinColumnSpacing); + + var desiredSize = GetFlowAlgorithm(context).Measure( + availableSize, + context, + true, + MinItemSpacing, + LineSpacing, + _orientation.ScrollOrientation, + LayoutId); + + // If after Measure the first item is in the realization rect, then we revoke grid state's ownership, + // and only use the layout when to clear it when it's done. + gridState.EnsureFirstElementOwnership(context); + + return new Size(desiredSize.Width, desiredSize.Height); + } + + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + var value = GetFlowAlgorithm(context).Arrange( + finalSize, + context, + (FlowLayoutAlgorithm.LineAlignment)_itemsJustification, + LayoutId); + return new Size(value.Width, value.Height); + } + + protected internal override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + GetFlowAlgorithm(context).OnItemsSourceChanged(source, args, context); + // Always invalidate layout to keep the view accurate. + InvalidateLayout(); + + var gridState = (UniformGridLayoutState)context.LayoutState; + gridState.ClearElementOnDataSourceChange(context, args); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + if (args.Property == OrientationProperty) + { + var orientation = (Orientation)args.NewValue; + + //Note: For UniformGridLayout Vertical Orientation means we have a Horizontal ScrollOrientation. Horizontal Orientation means we have a Vertical ScrollOrientation. + //i.e. the properties are the inverse of each other. + var scrollOrientation = (orientation == Orientation.Horizontal) ? ScrollOrientation.Vertical : ScrollOrientation.Horizontal; + _orientation.ScrollOrientation = scrollOrientation; + } + else if (args.Property == MinColumnSpacingProperty) + { + _minColumnSpacing = (double)args.NewValue; + } + else if (args.Property == MinRowSpacingProperty) + { + _minRowSpacing = (double)args.NewValue; + } + else if (args.Property == ItemsJustificationProperty) + { + _itemsJustification = (UniformGridLayoutItemsJustification)args.NewValue; + } + else if (args.Property == ItemsStretchProperty) + { + _itemsStretch = (UniformGridLayoutItemsStretch)args.NewValue; + } + else if (args.Property == MinItemWidthProperty) + { + _minItemWidth = (double)args.NewValue; + } + else if (args.Property == MinItemHeightProperty) + { + _minItemHeight = (double)args.NewValue; + } + + InvalidateLayout(); + } + + private double GetMinorSizeWithSpacing(VirtualizingLayoutContext context) + { + var minItemSpacing = MinItemSpacing; + var gridState = (UniformGridLayoutState)context.LayoutState; + return _orientation.ScrollOrientation == ScrollOrientation.Vertical? + gridState.EffectiveItemWidth + minItemSpacing : + gridState.EffectiveItemHeight + minItemSpacing; + } + + private double GetMajorSizeWithSpacing(VirtualizingLayoutContext context) + { + var lineSpacing = LineSpacing; + var gridState = (UniformGridLayoutState)context.LayoutState; + return _orientation.ScrollOrientation == ScrollOrientation.Vertical ? + gridState.EffectiveItemHeight + lineSpacing : + gridState.EffectiveItemWidth + lineSpacing; + } + + Rect GetLayoutRectForDataIndex( + Size availableSize, + int index, + Rect lastExtent, + VirtualizingLayoutContext context) + { + int itemsPerLine = Math.Max(1, (int)(_orientation.Minor(availableSize) / GetMinorSizeWithSpacing(context))); + int rowIndex = (int)(index / itemsPerLine); + int indexInRow = index - (rowIndex * itemsPerLine); + + var gridState = (UniformGridLayoutState)context.LayoutState; + Rect bounds = _orientation.MinorMajorRect( + indexInRow * GetMinorSizeWithSpacing(context) + _orientation.MinorStart(lastExtent), + rowIndex * GetMajorSizeWithSpacing(context) + _orientation.MajorStart(lastExtent), + _orientation.ScrollOrientation == ScrollOrientation.Vertical ? gridState.EffectiveItemWidth : gridState.EffectiveItemHeight, + _orientation.ScrollOrientation == ScrollOrientation.Vertical ? gridState.EffectiveItemHeight : gridState.EffectiveItemWidth); + + return bounds; + } + + private void InvalidateLayout() => InvalidateMeasure(); + + private FlowLayoutAlgorithm GetFlowAlgorithm(VirtualizingLayoutContext context) => ((UniformGridLayoutState)context.LayoutState).FlowAlgorithm; + } +} diff --git a/src/Avalonia.Layout/UniformGridLayoutState.cs b/src/Avalonia.Layout/UniformGridLayoutState.cs new file mode 100644 index 0000000000..4557a78d37 --- /dev/null +++ b/src/Avalonia.Layout/UniformGridLayoutState.cs @@ -0,0 +1,192 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + /// + /// Represents the state of a . + /// + public class UniformGridLayoutState + { + // We need to measure the element at index 0 to know what size to measure all other items. + // If FlowlayoutAlgorithm has already realized element 0 then we can use that. + // If it does not, then we need to do context.GetElement(0) at which point we have requested an element and are on point to clear it. + // If we are responsible for clearing element 0 we keep m_cachedFirstElement valid. + // If we are not (because FlowLayoutAlgorithm is holding it for us) then we just null out this field and use the one from FlowLayoutAlgorithm. + private ILayoutable _cachedFirstElement; + + internal FlowLayoutAlgorithm FlowAlgorithm { get; } = new FlowLayoutAlgorithm(); + internal double EffectiveItemWidth { get; private set; } + internal double EffectiveItemHeight { get; private set; } + + internal void InitializeForContext(VirtualizingLayoutContext context, IFlowLayoutAlgorithmDelegates callbacks) + { + FlowAlgorithm.InitializeForContext(context, callbacks); + context.LayoutState = this; + } + + internal void UninitializeForContext(VirtualizingLayoutContext context) + { + FlowAlgorithm.UninitializeForContext(context); + + if (_cachedFirstElement != null) + { + context.RecycleElement(_cachedFirstElement); + } + } + + internal void EnsureElementSize( + Size availableSize, + VirtualizingLayoutContext context, + double layoutItemWidth, + double LayoutItemHeight, + UniformGridLayoutItemsStretch stretch, + Orientation orientation, + double minRowSpacing, + double minColumnSpacing) + { + if (context.ItemCount > 0) + { + // If the first element is realized we don't need to cache it or to get it from the context + var realizedElement = FlowAlgorithm.GetElementIfRealized(0); + if (realizedElement != null) + { + realizedElement.Measure(availableSize); + SetSize(realizedElement, layoutItemWidth, LayoutItemHeight, availableSize, stretch, orientation, minRowSpacing, minColumnSpacing); + _cachedFirstElement = null; + } + else + { + if (_cachedFirstElement == null) + { + // we only cache if we aren't realizing it + _cachedFirstElement = context.GetOrCreateElementAt( + 0, + ElementRealizationOptions.ForceCreate | ElementRealizationOptions.SuppressAutoRecycle); // expensive + } + + _cachedFirstElement.Measure(availableSize); + + // This doesn't need to be done in the UWP version and I'm not sure why. If we + // don't do this here, and we receive a recycled element then it will be shown + // at its previous arrange point, but we don't want it shown at all until its + // arranged. + _cachedFirstElement.Arrange(new Rect(-10000.0, -10000.0, 0, 0)); + + SetSize(_cachedFirstElement, layoutItemWidth, LayoutItemHeight, availableSize, stretch, orientation, minRowSpacing, minColumnSpacing); + + // See if we can move ownership to the flow algorithm. If we can, we do not need a local cache. + bool added = FlowAlgorithm.TryAddElement0(_cachedFirstElement); + if (added) + { + _cachedFirstElement = null; + } + } + } + } + + private void SetSize( + ILayoutable element, + double layoutItemWidth, + double LayoutItemHeight, + Size availableSize, + UniformGridLayoutItemsStretch stretch, + Orientation orientation, + double minRowSpacing, + double minColumnSpacing) + { + EffectiveItemWidth = (double.IsNaN(layoutItemWidth) ? element.DesiredSize.Width : layoutItemWidth); + EffectiveItemHeight = (double.IsNaN(LayoutItemHeight) ? element.DesiredSize.Height : LayoutItemHeight); + + var availableSizeMinor = orientation == Orientation.Horizontal ? availableSize.Width : availableSize.Height; + var minorItemSpacing = orientation == Orientation.Vertical ? minRowSpacing : minColumnSpacing; + + var itemSizeMinor = orientation == Orientation.Horizontal ? EffectiveItemWidth : EffectiveItemHeight; + itemSizeMinor += minorItemSpacing; + + var numItemsPerColumn = (int)(Math.Max(1.0, availableSizeMinor / itemSizeMinor)); + var remainingSpace = ((int)availableSizeMinor) % ((int)itemSizeMinor); + var extraMinorPixelsForEachItem = remainingSpace / numItemsPerColumn; + + if (stretch == UniformGridLayoutItemsStretch.Fill) + { + if (orientation == Orientation.Horizontal) + { + EffectiveItemWidth += extraMinorPixelsForEachItem; + } + else + { + EffectiveItemHeight += extraMinorPixelsForEachItem; + } + } + else if (stretch == UniformGridLayoutItemsStretch.Uniform) + { + var itemSizeMajor = orientation == Orientation.Horizontal ? EffectiveItemHeight : EffectiveItemWidth; + var extraMajorPixelsForEachItem = itemSizeMajor * (extraMinorPixelsForEachItem / itemSizeMinor); + if (orientation == Orientation.Horizontal) + { + EffectiveItemWidth += extraMinorPixelsForEachItem; + EffectiveItemHeight += extraMajorPixelsForEachItem; + } + else + { + EffectiveItemHeight += extraMinorPixelsForEachItem; + EffectiveItemWidth += extraMajorPixelsForEachItem; + } + } + } + + internal void EnsureFirstElementOwnership(VirtualizingLayoutContext context) + { + if (_cachedFirstElement != null && FlowAlgorithm.GetElementIfRealized(0) != null) + { + // We created the element, but then flowlayout algorithm took ownership, so we can clear it and + // let flowlayout algorithm do its thing. + context.RecycleElement(_cachedFirstElement); + _cachedFirstElement = null; + } + } + + internal void ClearElementOnDataSourceChange( + VirtualizingLayoutContext context, + NotifyCollectionChangedEventArgs args) + { + if (_cachedFirstElement != null) + { + bool shouldClear = false; + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + shouldClear = args.NewStartingIndex == 0; + break; + + case NotifyCollectionChangedAction.Replace: + shouldClear = args.NewStartingIndex == 0 || args.OldStartingIndex == 0; + break; + + case NotifyCollectionChangedAction.Remove: + shouldClear = args.OldStartingIndex == 0; + break; + + case NotifyCollectionChangedAction.Reset: + shouldClear = true; + break; + + case NotifyCollectionChangedAction.Move: + throw new NotImplementedException(); + } + + if (shouldClear) + { + context.RecycleElement(_cachedFirstElement); + _cachedFirstElement = null; + } + } + } + } +} diff --git a/src/Avalonia.Layout/Utils/ListUtils.cs b/src/Avalonia.Layout/Utils/ListUtils.cs new file mode 100644 index 0000000000..eb2480acd3 --- /dev/null +++ b/src/Avalonia.Layout/Utils/ListUtils.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Layout.Utils +{ + internal static class ListUtils + { + public static void Resize(this List list, int size, T value) + { + int cur = list.Count; + + if (size < cur) + { + list.RemoveRange(size, cur - size); + } + else if (size > cur) + { + if (size > list.Capacity) + { + list.Capacity = size; + } + + list.AddRange(Enumerable.Repeat(value, size - cur)); + } + } + + public static void Resize(this List list, int count) + { + Resize(list, count, default); + } + } +} diff --git a/src/Avalonia.Layout/VirtualizingLayout.cs b/src/Avalonia.Layout/VirtualizingLayout.cs new file mode 100644 index 0000000000..4c601175f3 --- /dev/null +++ b/src/Avalonia.Layout/VirtualizingLayout.cs @@ -0,0 +1,139 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + /// + /// Represents the base class for an object that sizes and arranges child elements for a host + /// and supports virtualization. + /// + /// + /// is the base class for layouts that support virtualization. + /// You can use one of the provided derived class, or inherit from it to create your own layout. + /// Provided concrete virtualizing layout classes are and + /// . + /// + public abstract class VirtualizingLayout : AttachedLayout + { + /// + public sealed override void InitializeForContext(LayoutContext context) + { + InitializeForContextCore((VirtualizingLayoutContext)context); + } + + /// + public sealed override void UninitializeForContext(LayoutContext context) + { + UninitializeForContextCore((VirtualizingLayoutContext)context); + } + + /// + public sealed override Size Measure(LayoutContext context, Size availableSize) + { + return MeasureOverride((VirtualizingLayoutContext)context, availableSize); + } + + /// + public sealed override Size Arrange(LayoutContext context, Size finalSize) + { + return ArrangeOverride((VirtualizingLayoutContext)context, finalSize); + } + + /// + /// Notifies the layout when the data collection assigned to the container element (Items) + /// has changed. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// The data source. + /// Data about the collection change. + /// + /// Override + /// to provide the behavior for this method in a derived class. + /// + public void OnItemsChanged( + VirtualizingLayoutContext context, + object source, + NotifyCollectionChangedEventArgs args) => OnItemsChangedCore(context, source, args); + + /// + /// When overridden in a derived class, initializes any per-container state the layout + /// requires when it is attached to an ILayoutable container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + protected virtual void InitializeForContextCore(VirtualizingLayoutContext context) + { + } + + /// + /// When overridden in a derived class, removes any state the layout previously stored on + /// the ILayoutable container. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + protected virtual void UninitializeForContextCore(VirtualizingLayoutContext context) + { + } + + /// + /// Provides the behavior for the "Measure" pass of the layout cycle. Classes can override + /// this method to define their own "Measure" pass behavior. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The available size that this object can give to child objects. Infinity can be + /// specified as a value to indicate that the object will size to whatever content is + /// available. + /// + /// + /// The size that this object determines it needs during layout, based on its calculations + /// of the allocated sizes for child objects or based on other considerations such as a + /// fixed container size. + /// + protected abstract Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize); + + /// + /// When implemented in a derived class, provides the behavior for the "Arrange" pass of + /// layout. Classes can override this method to define their own "Arrange" pass behavior. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// + /// The final area within the container that this object should use to arrange itself and + /// its children. + /// + /// The actual size that is used after the element is arranged in layout. + protected virtual Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) => finalSize; + + /// + /// Notifies the layout when the data collection assigned to the container element (Items) + /// has changed. + /// + /// + /// The context object that facilitates communication between the layout and its host + /// container. + /// + /// The data source. + /// Data about the collection change. + protected internal virtual void OnItemsChangedCore( + VirtualizingLayoutContext context, + object source, + NotifyCollectionChangedEventArgs args) => InvalidateMeasure(); + } +} diff --git a/src/Avalonia.Layout/VirtualizingLayoutContext.cs b/src/Avalonia.Layout/VirtualizingLayoutContext.cs new file mode 100644 index 0000000000..980daec2eb --- /dev/null +++ b/src/Avalonia.Layout/VirtualizingLayoutContext.cs @@ -0,0 +1,190 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Layout +{ + /// + /// Defines constants that specify whether to suppress automatic recycling of the retrieved + /// element or force creation of a new element. + /// + /// + /// When you call , + /// you can specify whether to suppress automatic recycling of the retrieved element or force + /// creation of a new element. Elements retrieved with automatic recycling suppressed + /// (SuppressAutoRecycle) are ignored by the automatic recycling logic that clears realized + /// elements that were not retrieved as part of the current layout pass. You must explicitly + /// recycle these elements by passing them to the RecycleElement method to avoid memory leaks. + /// + [Flags] + public enum ElementRealizationOptions + { + /// + /// No option is specified. + /// + None = 0x0, + + /// + /// Creation of a new element is forced. + /// + ForceCreate = 0x1, + + /// + /// The element is ignored by the automatic recycling logic. + /// + SuppressAutoRecycle = 0x2, + }; + + /// + /// Represents the base class for layout context types that support virtualization. + /// + public abstract class VirtualizingLayoutContext : LayoutContext + { + /// + /// Gets the number of items in the data. + /// + /// + /// This property gets the value returned by ItemCountCore, which must be implemented in + /// a derived class. + /// + public int ItemCount => ItemCountCore(); + + /// + /// Gets or sets the origin point for the estimated content size. + /// + /// + /// LayoutOrigin is used by virtualizing layouts that rely on estimations when determining + /// the size and position of content. It allows the layout to fix-up the estimated origin + /// of the content as it changes due to on-going estimation or potentially identifying the + /// actual size to use. For example, it’s possible that as a user is scrolling back to the + /// top of the content that the layout's estimates for the content size that it reports as + /// part of its MeasureOverride become increasingly accurate. If the predicted position of + /// the content does not already match the previously predicted position (for example, if + /// the size of the elements ends up being smaller than previously thought), then the + /// layout can indicate a new origin. The viewport provided to the layout on subsequent + /// passes will take into account the adjusted origin. + /// + public Point LayoutOrigin { get => LayoutOriginCore; set => LayoutOriginCore = value; } + + /// + /// Gets an area that represents the viewport and buffer that the layout should fill with + /// realized elements. + /// + public Rect RealizationRect => RealizationRectCore(); + + /// + /// Gets the recommended index from which to start the generation and layout of elements. + /// + /// + /// The recommended index might be the result of programmatically realizing an element and + /// requesting that it be brought into view. Or, it may be that a user drags the scrollbar + /// thumb so quickly that the new viewport and the viewport and buffer previously given to + /// the layout do not intersect, so a new index is suggested as the anchor from which to + /// generate and layout other elements. + /// + public int RecommendedAnchorIndex => RecommendedAnchorIndexCore; + + /// + /// Implements the behavior of LayoutOrigin in a derived or custom VirtualizingLayoutContext. + /// + protected abstract Point LayoutOriginCore { get; set; } + + /// + /// Implements the behavior for getting the return value of RecommendedAnchorIndex in a + /// derived or custom . + /// + protected virtual int RecommendedAnchorIndexCore { get; } + + /// + /// Retrieves the data item in the source found at the specified index. + /// + /// The index of the data item to retrieve. + public object GetItemAt(int index) => GetItemAtCore(index); + + /// + /// Retrieves a UIElement that represents the data item in the source found at the + /// specified index. By default, if an element already exists, it is returned; otherwise, + /// a new element is created. + /// + /// The index of the data item to retrieve a UIElement for. + /// + /// This method calls + /// with options set to None. GetElementAtCore must be implemented in a derived class. + /// + public ILayoutable GetOrCreateElementAt(int index) + => GetOrCreateElementAtCore(index, ElementRealizationOptions.None); + + /// + /// Retrieves a UIElement that represents the data item in the source found at the + /// specified index using the specified options. + /// + /// The index of the data item to retrieve a UIElement for. + /// + /// A value of that specifies whether to suppress + /// automatic recycling of the retrieved element or force creation of a new element. + /// + /// + /// This method calls , + /// which must be implemented in a derived class. When you request an element for the + /// specified index, you can optionally specify whether to suppress automatic recycling of + /// the retrieved element or force creation of a new element.Elements retrieved with + /// automatic recycling suppressed(SuppressAutoRecycle) are ignored by the automatic + /// recycling logic that clears realized elements that were not retrieved as part of the + /// current layout pass.You must explicitly recycle these elements by passing them to the + /// RecycleElement method to avoid memory leaks. These options are intended for more + /// advanced layouts that choose to explicitly manage the realization and recycling of + /// elements as a performance optimization. + /// + public ILayoutable GetOrCreateElementAt(int index, ElementRealizationOptions options) + => GetOrCreateElementAtCore(index, options); + + /// + /// Clears the specified UIElement and allows it to be either re-used or released. + /// + /// The element to clear. + /// + /// This method calls , which must be implemented + /// in a derived class. + /// + public void RecycleElement(ILayoutable element) => RecycleElementCore(element); + + /// + /// When implemented in a derived class, retrieves the number of items in the data. + /// + protected abstract int ItemCountCore(); + + /// + /// When implemented in a derived class, retrieves the data item in the source found at the + /// specified index. + /// + /// The index of the data item to retrieve. + protected abstract object GetItemAtCore(int index); + + /// + /// When implemented in a derived class, retrieves an area that represents the viewport and + /// buffer that the layout should fill with realized elements. + /// + protected abstract Rect RealizationRectCore(); + + /// + /// When implemented in a derived class, retrieves a UIElement that represents the data item + /// in the source found at the specified index using the specified options. + /// + /// The index of the data item to retrieve a UIElement for. + /// + /// A value of that specifies whether to suppress + /// automatic recycling of the retrieved element or force creation of a new element. + /// + protected abstract ILayoutable GetOrCreateElementAtCore(int index, ElementRealizationOptions options); + + /// + /// When implemented in a derived class, clears the specified UIElement and allows it to be + /// either re-used or released. + /// + /// The element to clear. + protected abstract void RecycleElementCore(ILayoutable element); + } +} diff --git a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs index 0d077d2a3a..ebaf62b2c0 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs @@ -547,7 +547,6 @@ namespace Avalonia.Rendering } } - System.Diagnostics.Debug.WriteLine("Invalidated " + rect); SceneInvalidated(this, new SceneInvalidatedEventArgs((IRenderRoot)_root, rect)); } } diff --git a/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs b/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs index eeb502d730..ecf559951a 100644 --- a/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs +++ b/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs @@ -8,6 +8,7 @@ using Xunit; using System.Windows.Input; using System; using Avalonia.Data.Converters; +using Avalonia.Layout; namespace Avalonia.Base.UnitTests.Data.Converters { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs index 2eeff4cdf9..e2eb628512 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Layout; using Avalonia.Markup.Data; using Avalonia.Styling; using Avalonia.UnitTests; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs index 5396a43f3a..dde0e0000d 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TrackTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using Avalonia.Controls.Primitives; +using Avalonia.Layout; using Avalonia.LogicalTree; using Xunit; diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index 75a2f4178b..2238175a4a 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Layout; using Xunit; namespace Avalonia.Controls.UnitTests diff --git a/tests/Avalonia.Controls.UnitTests/SliderTests.cs b/tests/Avalonia.Controls.UnitTests/SliderTests.cs index dc47d9eb89..1c3c052144 100644 --- a/tests/Avalonia.Controls.UnitTests/SliderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SliderTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using Avalonia.Layout; using Xunit; namespace Avalonia.Controls.UnitTests diff --git a/tests/Avalonia.Controls.UnitTests/WrapPanelTests.cs b/tests/Avalonia.Controls.UnitTests/WrapPanelTests.cs index cd35627064..a0511761e4 100644 --- a/tests/Avalonia.Controls.UnitTests/WrapPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WrapPanelTests.cs @@ -1,6 +1,7 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using Avalonia.Layout; using Xunit; namespace Avalonia.Controls.UnitTests @@ -93,4 +94,4 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Rect(100, 0, 100, 50), target.Children[1].Bounds); } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/NullableConverterTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/NullableConverterTests.cs index abe6fa84b0..bb44d069b5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/NullableConverterTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/NullableConverterTests.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.UnitTests; using Xunit; diff --git a/tests/Avalonia.Visuals.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs b/tests/Avalonia.Visuals.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs index 867d4d7450..139a7925b1 100644 --- a/tests/Avalonia.Visuals.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs +++ b/tests/Avalonia.Visuals.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Rendering; using Avalonia.UnitTests;