diff --git a/demo/Ursa.Demo/Pages/ElasticWrapPanelDemo.axaml b/demo/Ursa.Demo/Pages/ElasticWrapPanelDemo.axaml index 10040d3..d439086 100644 --- a/demo/Ursa.Demo/Pages/ElasticWrapPanelDemo.axaml +++ b/demo/Ursa.Demo/Pages/ElasticWrapPanelDemo.axaml @@ -64,6 +64,22 @@ Minimum="0" Value="{Binding ItemHeight}" /> + + + + + + @@ -108,6 +124,8 @@ IsFillVertical="{Binding IsFillVertical}" ItemHeight="{Binding ItemHeight}" ItemWidth="{Binding ItemWidth}" + ItemSpacing="{Binding ItemSpacing}" + LineSpacing="{Binding LineSpacing}" Orientation="{Binding SelectedOrientation}"> @@ -134,6 +152,8 @@ IsFillVertical="{Binding IsFillVertical}" ItemHeight="{Binding ItemHeight}" ItemWidth="{Binding ItemWidth}" + ItemSpacing="{Binding ItemSpacing}" + LineSpacing="{Binding LineSpacing}" Orientation="{Binding SelectedOrientation}"> @@ -165,6 +185,8 @@ IsFillVertical="{Binding IsFillVertical}" ItemHeight="{Binding ItemHeight}" ItemWidth="{Binding ItemWidth}" + ItemSpacing="{Binding ItemSpacing}" + LineSpacing="{Binding LineSpacing}" Orientation="{Binding SelectedOrientation}"> @@ -200,6 +222,8 @@ @@ -223,6 +247,8 @@ IsFillVertical="{Binding IsFillVertical}" ItemHeight="{Binding ItemHeight}" ItemWidth="{Binding ItemWidth}" + ItemSpacing="{Binding ItemSpacing}" + LineSpacing="{Binding LineSpacing}" Orientation="{Binding SelectedOrientation}"> @@ -249,6 +275,8 @@ @@ -285,6 +313,8 @@ IsFillVertical="{Binding IsFillVertical}" ItemHeight="{Binding ItemHeight}" ItemWidth="{Binding ItemWidth}" + ItemSpacing="{Binding ItemSpacing}" + LineSpacing="{Binding LineSpacing}" Orientation="{Binding SelectedOrientation}"> diff --git a/demo/Ursa.Demo/ViewModels/ElasticWrapPanelDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/ElasticWrapPanelDemoViewModel.cs index 4964f36..a3f0b2e 100644 --- a/demo/Ursa.Demo/ViewModels/ElasticWrapPanelDemoViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/ElasticWrapPanelDemoViewModel.cs @@ -14,6 +14,8 @@ public partial class ElasticWrapPanelDemoViewModel : ObservableObject [ObservableProperty] private bool _isFillVertical; [ObservableProperty] private double _itemWidth = 40d; [ObservableProperty] private double _itemHeight = 40d; + [ObservableProperty] private double _itemSpacing; + [ObservableProperty] private double _lineSpacing; [ObservableProperty] private bool _autoWidth = true; [ObservableProperty] private bool _autoHeight = true; diff --git a/src/Ursa/Controls/Panels/ElasticWrapPanel.cs b/src/Ursa/Controls/Panels/ElasticWrapPanel.cs index ac603a5..87751af 100644 --- a/src/Ursa/Controls/Panels/ElasticWrapPanel.cs +++ b/src/Ursa/Controls/Panels/ElasticWrapPanel.cs @@ -12,8 +12,8 @@ public class ElasticWrapPanel : WrapPanel { static ElasticWrapPanel() { - AffectsMeasure(IsFillHorizontalProperty, IsFillVerticalProperty); - AffectsArrange(IsFillHorizontalProperty, IsFillVerticalProperty); + AffectsMeasure(IsFillHorizontalProperty, IsFillVerticalProperty, ItemSpacingProperty, LineSpacingProperty); + AffectsArrange(IsFillHorizontalProperty, IsFillVerticalProperty, ItemSpacingProperty, LineSpacingProperty); } #region AttachedProperty @@ -78,6 +78,8 @@ public class ElasticWrapPanel : WrapPanel double itemHeight = ItemHeight; var orientation = Orientation; var children = Children; + double itemSpacing = ItemSpacing; + double lineSpacing = LineSpacing; // Determine the space required for items in the same row/column based on horizontal/vertical arrangement var curLineSize = new UVSize(orientation); @@ -111,6 +113,8 @@ public class ElasticWrapPanel : WrapPanel itemWidthSet ? itemWidth : 0, itemHeightSet ? itemHeight : 0); + bool isFirstItemInLine = true; + foreach (var child in children) { UVSize sz; @@ -138,21 +142,38 @@ public class ElasticWrapPanel : WrapPanel sz.V = itemSetSize.V; } - if (MathHelpers.GreaterThan(curLineSize.U + sz.U, uvConstraint.U)) + double itemUWithSpacing = isFirstItemInLine ? sz.U : sz.U + itemSpacing; + + if (MathHelpers.GreaterThan(curLineSize.U + itemUWithSpacing, uvConstraint.U)) { panelSize.U = Max(curLineSize.U, panelSize.U); + if (panelSize.V > 0) + { + panelSize.V += lineSpacing; + } panelSize.V += curLineSize.V; curLineSize = sz; + isFirstItemInLine = false; } else { + if (!isFirstItemInLine) + { + curLineSize.U += itemSpacing; + } curLineSize.U += sz.U; curLineSize.V = Max(sz.V, curLineSize.V); panelSize.U = Max(curLineSize.U, panelSize.U); + if (panelSize.V > 0) + { + panelSize.V += lineSpacing; + } panelSize.V += curLineSize.V; + isFirstItemInLine = false; } curLineSize = new UVSize(orientation); + isFirstItemInLine = true; } else { @@ -164,23 +185,40 @@ public class ElasticWrapPanel : WrapPanel itemWidthSet ? itemWidth : child.DesiredSize.Width, itemHeightSet ? itemHeight : child.DesiredSize.Height); - if (MathHelpers.GreaterThan(curLineSize.U + sz.U, uvConstraint.U)) // Need to switch to another line + double itemUWithSpacing = isFirstItemInLine ? sz.U : sz.U + itemSpacing; + + if (MathHelpers.GreaterThan(curLineSize.U + itemUWithSpacing, uvConstraint.U)) // Need to switch to another line { panelSize.U = Max(curLineSize.U, panelSize.U); + if (panelSize.V > 0) + { + panelSize.V += lineSpacing; + } panelSize.V += curLineSize.V; curLineSize = sz; + isFirstItemInLine = false; if (MathHelpers.GreaterThan(sz.U, uvConstraint.U)) // The element is wider than the constraint - give it a separate line { panelSize.U = Max(sz.U, panelSize.U); + if (panelSize.V > 0) + { + panelSize.V += lineSpacing; + } panelSize.V += sz.V; curLineSize = new UVSize(orientation); + isFirstItemInLine = true; } } else // Continue to accumulate a line { + if (!isFirstItemInLine) + { + curLineSize.U += itemSpacing; + } curLineSize.U += sz.U; curLineSize.V = Max(sz.V, curLineSize.V); + isFirstItemInLine = false; } } } @@ -197,6 +235,8 @@ public class ElasticWrapPanel : WrapPanel { bool itemWidthSet = !double.IsNaN(ItemWidth); bool itemHeightSet = !double.IsNaN(ItemHeight); + double itemSpacing = ItemSpacing; + double lineSpacing = LineSpacing; // This is the size for non-space measurement UVSize itemSetSize = new UVSize(Orientation, @@ -212,7 +252,7 @@ public class ElasticWrapPanel : WrapPanel #region Get the collection of elements in the same direction // Current collection of elements in a row/column - UVCollection curLineUIs = new UVCollection(Orientation, itemSetSize); + UVCollection curLineUIs = new UVCollection(Orientation, itemSetSize, itemSpacing); // Iterate over the child elements var children = Children; @@ -242,14 +282,16 @@ public class ElasticWrapPanel : WrapPanel sz.V = itemSetSize.V; } - if (MathHelpers.GreaterThan(curLineUIs.TotalU + sz.U, uvFinalSize.U)) + // Account for spacing before this item if it's not the first + double spacingBeforeItem = curLineUIs.Count > 0 ? itemSpacing : 0; + if (MathHelpers.GreaterThan(curLineUIs.TotalU + spacingBeforeItem + sz.U, uvFinalSize.U)) { if (curLineUIs.Count > 0) { lineUVCollection.Add(curLineUIs); } - curLineUIs = new UVCollection(Orientation, itemSetSize); + curLineUIs = new UVCollection(Orientation, itemSetSize, itemSpacing); curLineUIs.Add(child, sz, Convert.ToInt32(lengthCount)); } else @@ -258,7 +300,7 @@ public class ElasticWrapPanel : WrapPanel } lineUVCollection.Add(curLineUIs); - curLineUIs = new UVCollection(Orientation, itemSetSize); + curLineUIs = new UVCollection(Orientation, itemSetSize, itemSpacing); } else { @@ -266,19 +308,21 @@ public class ElasticWrapPanel : WrapPanel itemWidthSet ? ItemWidth : child.DesiredSize.Width, itemHeightSet ? ItemHeight : child.DesiredSize.Height); - if (MathHelpers.GreaterThan(curLineUIs.TotalU + sz.U, uvFinalSize.U)) // Need to switch to another line + // Account for spacing before this item if it's not the first + double spacingBeforeItem = curLineUIs.Count > 0 ? itemSpacing : 0; + if (MathHelpers.GreaterThan(curLineUIs.TotalU + spacingBeforeItem + sz.U, uvFinalSize.U)) // Need to switch to another line { if (curLineUIs.Count > 0) { lineUVCollection.Add(curLineUIs); } - curLineUIs = new UVCollection(Orientation, itemSetSize); + curLineUIs = new UVCollection(Orientation, itemSetSize, itemSpacing); curLineUIs.Add(child, sz); if (MathHelpers.GreaterThan(sz.U, uvFinalSize.U)) { lineUVCollection.Add(curLineUIs); - curLineUIs = new UVCollection(Orientation, itemSetSize); + curLineUIs = new UVCollection(Orientation, itemSetSize, itemSpacing); } } else @@ -332,7 +376,8 @@ public class ElasticWrapPanel : WrapPanel } maxElementCount = Max(sum, maxElementCount); } - adaptULength = (uvFinalSize.U - maxElementCount * itemSetSize.U) / maxElementCount; + double totalItemSpacing = maxElementCount > 1 ? (maxElementCount - 1) * itemSpacing : 0; + adaptULength = (uvFinalSize.U - maxElementCount * itemSetSize.U - totalItemSpacing) / maxElementCount; adaptULength = Max(adaptULength, 0); } } @@ -342,16 +387,19 @@ public class ElasticWrapPanel : WrapPanel if (itemSetSize.V > 0) { isAdaptV = true; - adaptVLength = uvFinalSize.V / lineUVCollection.Count; + double totalLineSpacing = lineUVCollection.Count > 1 ? (lineUVCollection.Count - 1) * lineSpacing : 0; + adaptVLength = (uvFinalSize.V - totalLineSpacing) / lineUVCollection.Count; } } bool isHorizontal = Orientation == Orientation.Horizontal; + int lineIndex = 0; foreach (var uvCollection in lineUVCollection) { double u = 0; var lineUIEles = uvCollection.UICollection.Keys.ToList(); double linevV = isAdaptV ? adaptVLength : uvCollection.LineV; + int itemIndex = 0; foreach (var child in lineUIEles) { UVLengthSize childSize = uvCollection.UICollection[child]; @@ -384,9 +432,19 @@ public class ElasticWrapPanel : WrapPanel } u += layoutSlotU; + if (itemIndex < lineUIEles.Count - 1) + { + u += itemSpacing; + } + itemIndex++; } accumulatedV += linevV; + if (lineIndex < lineUVCollection.Count - 1) + { + accumulatedV += lineSpacing; + } + lineIndex++; lineUIEles.Clear(); } } @@ -462,15 +520,26 @@ public class ElasticWrapPanel : WrapPanel private UVSize LineDesireUVSize; private UVSize ItemSetSize; + + private double ItemSpacing; - public UVCollection(Orientation orientation, UVSize itemSetSize) + public UVCollection(Orientation orientation, UVSize itemSetSize, double itemSpacing = 0) { UICollection = new Dictionary(); LineDesireUVSize = new UVSize(orientation); ItemSetSize = itemSetSize; + ItemSpacing = itemSpacing; } - public double TotalU => LineDesireUVSize.U; + public double TotalU + { + get + { + // TotalU includes spacing between items (not before first or after last) + double spacingTotal = UICollection.Count > 1 ? (UICollection.Count - 1) * ItemSpacing : 0; + return LineDesireUVSize.U + spacingTotal; + } + } public double LineV => LineDesireUVSize.V; diff --git a/tests/HeadlessTest.Ursa/Controls/ElasticWrapPanelTests/Tests.cs b/tests/HeadlessTest.Ursa/Controls/ElasticWrapPanelTests/Tests.cs index 8b20e87..0069399 100644 --- a/tests/HeadlessTest.Ursa/Controls/ElasticWrapPanelTests/Tests.cs +++ b/tests/HeadlessTest.Ursa/Controls/ElasticWrapPanelTests/Tests.cs @@ -69,4 +69,151 @@ public class Tests window.Show(); Assert.Equal(2, panel.LineCount); } + + [AvaloniaFact] + public void ItemSpacing_Applied_Correctly() + { + var window = new Window() { }; + var panel = new ElasticWrapPanel + { + Width = 400, + Height = 200, + Orientation = Orientation.Horizontal, + ItemSpacing = 10, + }; + + // Add 3 rectangles of 100x100 with 10px spacing + // Total width should be: 100 + 10 + 100 + 10 + 100 = 320 + for (int i = 0; i < 3; i++) + { + var rect = new Rectangle + { + Width = 100, + Height = 100, + }; + panel.Children.Add(rect); + } + + window.Content = panel; + window.Show(); + + // All 3 should fit on one line + Assert.Equal(1, panel.LineCount); + + // Check that the desired size accounts for spacing + Assert.True(panel.DesiredSize.Width >= 320); + } + + [AvaloniaFact] + public void LineSpacing_Applied_Correctly() + { + var window = new Window() { }; + var panel = new ElasticWrapPanel + { + Width = 200, + Height = 400, + Orientation = Orientation.Horizontal, + LineSpacing = 20, + }; + + // Add 4 rectangles of 100x100, should wrap to 2 lines + for (int i = 0; i < 4; i++) + { + var rect = new Rectangle + { + Width = 100, + Height = 100, + }; + panel.Children.Add(rect); + } + + window.Content = panel; + window.Show(); + + // Should have 2 lines + Assert.Equal(2, panel.LineCount); + + // Total height should be: 100 + 20 + 100 = 220 + Assert.True(panel.DesiredSize.Height >= 220); + } + + [AvaloniaFact] + public void ItemSpacing_And_LineSpacing_Together() + { + var window = new Window() { }; + var panel = new ElasticWrapPanel + { + Width = 250, + Height = 400, + Orientation = Orientation.Horizontal, + ItemSpacing = 10, + LineSpacing = 20, + }; + + // Add 4 rectangles of 100x100 + // With ItemSpacing=10, two items per line need 100 + 10 + 100 = 210 width + // With LineSpacing=20, two lines need 100 + 20 + 100 = 220 height + for (int i = 0; i < 4; i++) + { + var rect = new Rectangle + { + Width = 100, + Height = 100, + }; + panel.Children.Add(rect); + } + + window.Content = panel; + window.Show(); + + // Should have 2 lines + Assert.Equal(2, panel.LineCount); + + // Check dimensions include spacing + Assert.True(panel.DesiredSize.Width >= 210); + Assert.True(panel.DesiredSize.Height >= 220); + } + + [AvaloniaFact] + public void ItemSpacing_Does_Not_Affect_LineCount() + { + var window = new Window() { }; + + // Panel without spacing - should fit 2 items per line (200 width / 100 per item = 2) + var panel1 = new ElasticWrapPanel + { + Width = 200, + Height = 400, + Orientation = Orientation.Horizontal, + ItemSpacing = 0, + }; + + for (int i = 0; i < 4; i++) + { + panel1.Children.Add(new Rectangle { Width = 100, Height = 100 }); + } + + // Panel with spacing - items with spacing should fit fewer per line + // 100 + 10 + 100 = 210 > 200, so only 1 item per line + var panel2 = new ElasticWrapPanel + { + Width = 200, + Height = 400, + Orientation = Orientation.Horizontal, + ItemSpacing = 10, + }; + + for (int i = 0; i < 4; i++) + { + panel2.Children.Add(new Rectangle { Width = 100, Height = 100 }); + } + + window.Content = panel1; + window.Show(); + Assert.Equal(2, panel1.LineCount); // 2 items per line, 4 items total = 2 lines + + window.Content = panel2; + window.UpdateLayout(); + Assert.Equal(4, panel2.LineCount); // 1 item per line, 4 items total = 4 lines + } } \ No newline at end of file