From aef3a2c9bd6c66d4c1e69eda8bb7f46c837cca28 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 1 Jun 2019 16:20:38 +0200 Subject: [PATCH] Added UniformGridLayout. --- .../Repeaters/FlowLayoutAlgorithm.cs | 23 +- .../Repeaters/OrientationBasedMeasures.cs | 7 + .../Repeaters/StackLayout.cs | 13 +- .../Repeaters/UniformGridLayout.cs | 389 ++++++++++++++++++ .../Repeaters/UniformGridLayoutState.cs | 176 ++++++++ 5 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 src/Avalonia.Controls/Repeaters/UniformGridLayout.cs create mode 100644 src/Avalonia.Controls/Repeaters/UniformGridLayoutState.cs diff --git a/src/Avalonia.Controls/Repeaters/FlowLayoutAlgorithm.cs b/src/Avalonia.Controls/Repeaters/FlowLayoutAlgorithm.cs index f8da998201..6aaad0bdb5 100644 --- a/src/Avalonia.Controls/Repeaters/FlowLayoutAlgorithm.cs +++ b/src/Avalonia.Controls/Repeaters/FlowLayoutAlgorithm.cs @@ -656,7 +656,7 @@ namespace Avalonia.Controls.Repeaters } } - void SetLayoutOrigin() + private void SetLayoutOrigin() { if (IsVirtualizingContext) { @@ -670,6 +670,27 @@ namespace Avalonia.Controls.Repeaters } } + public IControl GetElementIfRealized(int dataIndex) + { + if (_elementManager.IsDataIndexRealized(dataIndex)) + { + return _elementManager.GetRealizedElement(dataIndex); + } + + return null; + } + + public bool TryAddElement0(IControl element) + { + if (_elementManager.GetRealizedElementCount() == 0) + { + _elementManager.Add(element, 0); + return true; + } + + return false; + } + public enum LineAlignment { Start, diff --git a/src/Avalonia.Controls/Repeaters/OrientationBasedMeasures.cs b/src/Avalonia.Controls/Repeaters/OrientationBasedMeasures.cs index 173b62842b..2f30f4c551 100644 --- a/src/Avalonia.Controls/Repeaters/OrientationBasedMeasures.cs +++ b/src/Avalonia.Controls/Repeaters/OrientationBasedMeasures.cs @@ -61,6 +61,13 @@ } } + public Rect MinorMajorRect(double minor, double major, double minorSize, double majorSize) + { + return ScrollOrientation == Orientation.Vertical ? + new Rect(minor, major, minorSize, majorSize) : + new Rect(major, minor, majorSize, minorSize); + } + public Point MinorMajorPoint(double minor, double major) { return ScrollOrientation == Orientation.Vertical ? diff --git a/src/Avalonia.Controls/Repeaters/StackLayout.cs b/src/Avalonia.Controls/Repeaters/StackLayout.cs index 379eab8675..db1900d03b 100644 --- a/src/Avalonia.Controls/Repeaters/StackLayout.cs +++ b/src/Avalonia.Controls/Repeaters/StackLayout.cs @@ -5,14 +5,19 @@ namespace Avalonia.Controls.Repeaters { public class StackLayout : VirtualizingLayout, IFlowLayoutAlgorithmDelegates { - public static readonly AvaloniaProperty OrientationProperty - = StackPanel.OrientationProperty.AddOwner(); + public static readonly StyledProperty OrientationProperty = + StackPanel.OrientationProperty.AddOwner(); - public static readonly AvaloniaProperty SpacingProperty - = StackPanel.SpacingProperty.AddOwner(); + public static readonly StyledProperty SpacingProperty = + StackPanel.SpacingProperty.AddOwner(); private readonly OrientationBasedMeasures _orientation = new OrientationBasedMeasures(); + public StackLayout() + { + LayoutId = "StackLayout"; + } + public Orientation Orientation { get => GetValue(OrientationProperty); diff --git a/src/Avalonia.Controls/Repeaters/UniformGridLayout.cs b/src/Avalonia.Controls/Repeaters/UniformGridLayout.cs new file mode 100644 index 0000000000..4d062e23ab --- /dev/null +++ b/src/Avalonia.Controls/Repeaters/UniformGridLayout.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Specialized; + +namespace Avalonia.Controls.Repeaters +{ + public enum UniformGridLayoutItemsJustification + { + Start = 0, + Center = 1, + End = 2, + SpaceAround = 3, + SpaceBetween = 4, + SpaceEvenly = 5, + }; + + public enum UniformGridLayoutItemsStretch + { + None = 0, + Fill = 1, + Uniform = 2, + }; + + public class UniformGridLayout : VirtualizingLayout, IFlowLayoutAlgorithmDelegates + { + public static readonly StyledProperty ItemsJustificationProperty = + AvaloniaProperty.Register(nameof(ItemsJustification)); + + public static readonly StyledProperty ItemsStretchProperty = + AvaloniaProperty.Register(nameof(ItemsStretch)); + + public static readonly StyledProperty MinColumnSpacingProperty = + AvaloniaProperty.Register(nameof(MinColumnSpacing)); + + public static readonly StyledProperty MinItemHeightProperty = + AvaloniaProperty.Register(nameof(MinItemHeight)); + + public static readonly StyledProperty MinItemWidthProperty = + AvaloniaProperty.Register(nameof(MinItemWidth)); + + public static readonly StyledProperty MinRowSpacingProperty = + AvaloniaProperty.Register(nameof(MinRowSpacing)); + + public static readonly StyledProperty OrientationProperty = + StackPanel.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; + + public UniformGridLayout() + { + LayoutId = "UniformGridLayout"; + } + + static UniformGridLayout() + { + OrientationProperty.OverrideDefaultValue(Orientation.Horizontal); + } + + public UniformGridLayoutItemsJustification ItemsJustification + { + get => GetValue(ItemsJustificationProperty); + set => SetValue(ItemsJustificationProperty, value); + } + + public UniformGridLayoutItemsStretch ItemsStretch + { + get => GetValue(ItemsStretchProperty); + set => SetValue(ItemsStretchProperty, value); + } + + public double MinColumnSpacing + { + get => GetValue(MinColumnSpacingProperty); + set => SetValue(MinColumnSpacingProperty, value); + } + + public double MinItemHeight + { + get => GetValue(MinItemHeightProperty); + set => SetValue(MinItemHeightProperty, value); + } + + public double MinItemWidth + { + get => GetValue(MinItemWidthProperty); + set => SetValue(MinItemWidthProperty, value); + } + + public double MinRowSpacing + { + get => GetValue(MinRowSpacingProperty); + set => SetValue(MinRowSpacingProperty, value); + } + + 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, + IControl firstRealized, + int firstRealizedItemIndex, + Rect firstRealizedLayoutBounds, + IControl 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) + { + ////MUX_ASSERT(lastRealized); + + _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(IControl 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); + + 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) ? Orientation.Vertical : Orientation.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 == Orientation.Vertical? + gridState.EffectiveItemWidth + minItemSpacing : + gridState.EffectiveItemHeight + minItemSpacing; + } + + private double GetMajorSizeWithSpacing(VirtualizingLayoutContext context) + { + var lineSpacing = LineSpacing; + var gridState = (UniformGridLayoutState)context.LayoutState; + return _orientation.ScrollOrientation == Orientation.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 == Orientation.Vertical ? gridState.EffectiveItemWidth : gridState.EffectiveItemHeight, + _orientation.ScrollOrientation == Orientation.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.Controls/Repeaters/UniformGridLayoutState.cs b/src/Avalonia.Controls/Repeaters/UniformGridLayoutState.cs new file mode 100644 index 0000000000..e62f64192d --- /dev/null +++ b/src/Avalonia.Controls/Repeaters/UniformGridLayoutState.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Text; + +namespace Avalonia.Controls.Repeaters +{ + 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 IControl _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); + 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( + IControl 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() + { + if (FlowAlgorithm.GetElementIfRealized(0) != null) + { + _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; + } + } + } + } +}