Skip to content

Commit

Permalink
Initial VirtualizingStackPanel SpacingProperty implementation (Avalon…
Browse files Browse the repository at this point in the history
  • Loading branch information
Neakita committed Oct 26, 2024
1 parent 0944e04 commit b88472d
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 11 deletions.
1 change: 1 addition & 0 deletions src/Avalonia.Controls/Utils/RealizedStackElements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ public void ResetForReuse()
_sizes?.Clear();
}

// TODO Spacing changes should also be checked?
/// <summary>
/// Validates that <see cref="StartU"/> is still valid.
/// </summary>
Expand Down
54 changes: 43 additions & 11 deletions src/Avalonia.Controls/VirtualizingStackPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ namespace Avalonia.Controls
/// </summary>
public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
{
/// <summary>
/// Defines the <see cref="Spacing"/> property.
/// </summary>
public static readonly StyledProperty<double> SpacingProperty =
StackPanel.SpacingProperty.AddOwner<VirtualizingStackPanel>();

/// <summary>
/// Defines the <see cref="Orientation"/> property.
/// </summary>
Expand Down Expand Up @@ -83,6 +89,15 @@ public VirtualizingStackPanel()
EffectiveViewportChanged += OnEffectiveViewportChanged;
}

/// <summary>
/// Gets or sets the size of the spacing to place between child controls.
/// </summary>
public double Spacing
{
get => GetValue(SpacingProperty);
set => SetValue(SpacingProperty, value);
}

/// <summary>
/// Gets or sets the axis along which items are laid out.
/// </summary>
Expand Down Expand Up @@ -150,11 +165,12 @@ protected override Size MeasureOverride(Size availableSize)
return default;

var orientation = Orientation;
double spacing = Spacing;

// If we're bringing an item into view, ignore any layout passes until we receive a new
// effective viewport.
if (_isWaitingForViewportUpdate)
return EstimateDesiredSize(orientation, items.Count);
return EstimateDesiredSize(orientation, items.Count, spacing);

_isInLayout = true;

Expand All @@ -167,15 +183,15 @@ protected override Size MeasureOverride(Size availableSize)
// We handle horizontal and vertical layouts here so X and Y are abstracted to:
// - Horizontal layouts: U = horizontal, V = vertical
// - Vertical layouts: U = vertical, V = horizontal
var viewport = CalculateMeasureViewport(orientation, items);
var viewport = CalculateMeasureViewport(orientation, items, spacing);

// If the viewport is disjunct then we can recycle everything.
if (viewport.viewportIsDisjunct)
_realizedElements.RecycleAllElements(_recycleElement);

// Do the measure, creating/recycling elements as necessary to fill the viewport. Don't
// write to _realizedElements yet, only _measureElements.
RealizeElements(items, availableSize, ref viewport);
RealizeElements(items, availableSize, ref viewport, spacing);

// Now swap the measureElements and realizedElements collection.
(_measureElements, _realizedElements) = (_realizedElements, _measureElements);
Expand All @@ -185,7 +201,7 @@ protected override Size MeasureOverride(Size availableSize)
// _focusedElement is non-null), ensure it's measured.
_focusedElement?.Measure(availableSize);

return CalculateDesiredSize(orientation, items.Count, viewport);
return CalculateDesiredSize(orientation, items.Count, viewport, spacing);
}
finally
{
Expand All @@ -203,6 +219,7 @@ protected override Size ArrangeOverride(Size finalSize)
try
{
var orientation = Orientation;
var spacing = Spacing;
var u = _realizedElements!.StartU;

for (var i = 0; i < _realizedElements.Count; ++i)
Expand All @@ -212,6 +229,12 @@ protected override Size ArrangeOverride(Size finalSize)
if (e is not null)
{
var sizeU = _realizedElements.SizeU[i];

// TODO if condition at the beginning of the block was false for the first element then excess spacing will appear before the first visual element?
// TODO Replace the condition below with u != _realizedElements!.StartU or track first element pass with boolean variable
if (i > 0)
u += spacing;

var rect = orientation == Orientation.Horizontal ?
new Rect(u, 0, sizeU, finalSize.Height) :
new Rect(0, u, finalSize.Width, sizeU);
Expand Down Expand Up @@ -465,7 +488,7 @@ protected internal override int IndexFromContainer(Control container)
return _realizedElements?.Elements ?? Array.Empty<Control>();
}

private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadOnlyList<object?> items)
private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadOnlyList<object?> items, double spacing)
{
Debug.Assert(_realizedElements is not null);

Expand All @@ -492,6 +515,7 @@ private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadO
viewportStart,
viewportEnd,
items.Count,
spacing,
out anchorIndex,
out anchorU);
}
Expand All @@ -510,21 +534,21 @@ private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadO
};
}

private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport)
private Size CalculateDesiredSize(Orientation orientation, int itemCount, in MeasureViewport viewport, double spacing)
{
var sizeU = 0.0;
var sizeV = viewport.measuredV;

if (viewport.lastIndex >= 0)
{
var remaining = itemCount - viewport.lastIndex - 1;
sizeU = viewport.realizedEndU + (remaining * _lastEstimatedElementSizeU);
sizeU = viewport.realizedEndU + (remaining * (spacing + _lastEstimatedElementSizeU));
}

return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU);
}

private Size EstimateDesiredSize(Orientation orientation, int itemCount)
private Size EstimateDesiredSize(Orientation orientation, int itemCount, double spacing)
{
if (_scrollToIndex >= 0 && _scrollToElement is not null)
{
Expand All @@ -534,7 +558,8 @@ private Size EstimateDesiredSize(Orientation orientation, int itemCount)
var u = orientation == Orientation.Horizontal ?
_scrollToElement.Bounds.Right :
_scrollToElement.Bounds.Bottom;
var sizeU = u + (remaining * _lastEstimatedElementSizeU);
var totalSpacing = (itemCount - 1) * spacing;
var sizeU = u + (remaining * _lastEstimatedElementSizeU) + totalSpacing;
return orientation == Orientation.Horizontal ?
new(sizeU, DesiredSize.Height) :
new(DesiredSize.Width, sizeU);
Expand Down Expand Up @@ -576,6 +601,7 @@ private void GetOrEstimateAnchorElementForViewport(
double viewportStartU,
double viewportEndU,
int itemCount,
double spacing,
out int index,
out double position)
{
Expand All @@ -602,6 +628,8 @@ private void GetOrEstimateAnchorElementForViewport(
element.DesiredSize.Width :
element.DesiredSize.Height;
var endU = u + sizeU;
if (i > 0)
endU += spacing;

if (endU > viewportStartU && u < viewportEndU)
{
Expand All @@ -619,7 +647,7 @@ private void GetOrEstimateAnchorElementForViewport(
var estimatedSize = EstimateElementSizeU();

// Estimate the element at the start of the viewport.
var startIndex = Math.Min((int)(viewportStartU / estimatedSize), itemCount - 1);
var startIndex = Math.Min((int)(viewportStartU / estimatedSize + spacing), itemCount - 1);
index = startIndex;
position = startIndex * estimatedSize;
}
Expand All @@ -643,7 +671,8 @@ private double GetOrEstimateElementU(int index)
private void RealizeElements(
IReadOnlyList<object?> items,
Size availableSize,
ref MeasureViewport viewport)
ref MeasureViewport viewport,
double spacing)
{
Debug.Assert(_measureElements is not null);
Debug.Assert(_realizedElements is not null);
Expand Down Expand Up @@ -672,6 +701,8 @@ private void RealizeElements(
_measureElements!.Add(index, e, u, sizeU);
viewport.measuredV = Math.Max(viewport.measuredV, sizeV);

if (index != 0)
u += spacing;
u += sizeU;
++index;
_realizingIndex = -1;
Expand All @@ -696,6 +727,7 @@ private void RealizeElements(

var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
u -= spacing;
u -= sizeU;

_measureElements!.Add(index, e, u, sizeU);
Expand Down
38 changes: 38 additions & 0 deletions tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,44 @@ public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change
Assert.Equal(new Rect(0, 140, 100, 20), container.Bounds);
}

[Fact]
public void Lays_Out_Children_Vertically_With_Spacing()
{
using var app = App();
var itemTemplate = new FuncDataTemplate<int>((x, _) => new Canvas { Width = 100, Height = x });
var (target, scroll, itemsControl) = CreateTarget([20, 30, 50], itemTemplate);

target.Spacing = 10;

// TODO for some reason Spacing setting doesn't trigger Measure and Arrange by itself even when AffectsMeasure/AffectsArrange is called in the static constructor of VirtualizingStackPanel
target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));

Assert.Equal(new Size(100, 120), target.Bounds.Size);
Assert.Equal(new Rect(0, 0, 100, 20), target.Children[0].Bounds);
Assert.Equal(new Rect(0, 30, 100, 30), target.Children[1].Bounds);
Assert.Equal(new Rect(0, 70, 100, 50), target.Children[2].Bounds);
}

[Fact]
public void Lays_Out_Children_Horizontally_With_Spacing()
{
using var app = App();
var itemTemplate = new FuncDataTemplate<int>((x, _) => new Canvas { Width = x, Height = 100 });
var (target, scroll, itemsControl) = CreateTarget([20, 30, 50], itemTemplate, orientation: Orientation.Horizontal);

target.Orientation = Orientation.Horizontal;
target.Spacing = 10;

target.Measure(Size.Infinity);
target.Arrange(new Rect(target.DesiredSize));

Assert.Equal(new Size(120, 100), target.Bounds.Size);
Assert.Equal(new Rect(0, 0, 20, 100), target.Children[0].Bounds);
Assert.Equal(new Rect(30, 0, 30, 100), target.Children[1].Bounds);
Assert.Equal(new Rect(70, 0, 50, 100), target.Children[2].Bounds);
}

private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
{
return target.GetRealizedElements()
Expand Down

0 comments on commit b88472d

Please sign in to comment.