From 06cf8efa0e01ee8645b513f251966b36505f1b1c Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 28 Jul 2026 13:44:41 +0200 Subject: [PATCH] Handle Padding correctly in ScrollContentPresenter (#21872) * Add failing ScrollViewer tests * Handle Padding correctly in ScrollContentPresenter --- .../Presenters/ScrollContentPresenter.cs | 37 +++++-- .../Presenters/ScrollContentPresenterTests.cs | 102 +++++++++++++++++- .../ScrollViewerTests.cs | 64 +++++++++++ 3 files changed, 193 insertions(+), 10 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 395c24824f..7bd32dcee4 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -403,9 +403,12 @@ namespace Avalonia.Controls.Presenters return base.MeasureOverride(availableSize); } + var padding = GetChildPadding(); + var deflated = availableSize.Deflate(padding); + var constraint = new Size( - CanHorizontallyScroll ? double.PositiveInfinity : availableSize.Width, - CanVerticallyScroll ? double.PositiveInfinity : availableSize.Height); + CanHorizontallyScroll ? double.PositiveInfinity : deflated.Width, + CanVerticallyScroll ? double.PositiveInfinity : deflated.Height); Child.Measure(constraint); @@ -415,7 +418,7 @@ namespace Avalonia.Controls.Presenters UpdateSnapPoints(); } - return Child.DesiredSize.Constrain(availableSize); + return Child.DesiredSize.Inflate(padding).Constrain(availableSize); } /// @@ -431,9 +434,12 @@ namespace Avalonia.Controls.Presenters private Size ArrangeWithAnchoring(Size finalSize) { + var padding = GetChildPadding(); + var desiredSize = Child!.DesiredSize.Inflate(padding); + var size = new Size( - CanHorizontallyScroll ? Math.Max(Child!.DesiredSize.Width, finalSize.Width) : finalSize.Width, - CanVerticallyScroll ? Math.Max(Child!.DesiredSize.Height, finalSize.Height) : finalSize.Height); + CanHorizontallyScroll ? Math.Max(desiredSize.Width, finalSize.Width) : finalSize.Width, + CanVerticallyScroll ? Math.Max(desiredSize.Height, finalSize.Height) : finalSize.Height); Vector TrackAnchor() { @@ -501,13 +507,28 @@ namespace Avalonia.Controls.Presenters } Viewport = finalSize; - Extent = ComputeExtent(finalSize); + Extent = ComputeExtent(finalSize, padding); _isAnchorElementDirty = true; return finalSize; } - private Size ComputeExtent(Size viewportSize) + private Thickness GetChildPadding() + { + var padding = Padding; + var borderThickness = BorderThickness; + + if (UseLayoutRounding) + { + var scale = LayoutHelper.GetLayoutScale(this); + padding = LayoutHelper.RoundLayoutThickness(padding, scale); + borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, scale); + } + + return padding + borderThickness; + } + + private Size ComputeExtent(Size viewportSize, Thickness padding) { var childMargin = Child!.Margin; @@ -517,7 +538,7 @@ namespace Avalonia.Controls.Presenters childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale); } - var extent = Child!.Bounds.Size.Inflate(childMargin); + var extent = Child!.Bounds.Size.Inflate(childMargin).Inflate(padding); if (MathUtilities.AreClose(extent.Width, viewportSize.Width, LayoutHelper.LayoutEpsilon)) extent = extent.WithWidth(viewportSize.Width); diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs index 58906c1127..d616f23fa1 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs @@ -49,7 +49,7 @@ namespace Avalonia.Controls.UnitTests.Presenters } [Fact] - public void DesiredSize_Is_Content_Size_When_Smaller_Than_AvailableSize() + public void DesiredSize_Is_Content_Size_Plus_Padding_When_Smaller_Than_AvailableSize() { var target = new ScrollContentPresenter { @@ -65,7 +65,7 @@ namespace Avalonia.Controls.UnitTests.Presenters target.Measure(new Size(100, 100)); target.Arrange(new Rect(0, 0, 100, 100)); - Assert.Equal(new Size(16, 16), target.DesiredSize); + Assert.Equal(new Size(36, 36), target.DesiredSize); } [Fact] @@ -226,6 +226,104 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(100, child.Bounds.Width); } + [Fact] + public void Measure_Should_Deflate_AvailableSize_By_Padding() + { + var child = new TestControl(); + var target = new ScrollContentPresenter + { + Padding = new Thickness(10), + Content = child + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + + Assert.Equal(new Size(80, 80), child.AvailableSize); + } + + [Fact] + public void Extent_Should_Include_Padding() + { + var target = new ScrollContentPresenter + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + Padding = new Thickness(10), + Content = new Border + { + Width = 100, + Height = 100 + } + }; + + target.UpdateChild(); + target.Measure(new Size(50, 50)); + target.Arrange(new Rect(0, 0, 50, 50)); + + Assert.Equal(new Size(50, 50), target.Viewport); + Assert.Equal(new Size(120, 120), target.Extent); + } + + [Fact] + public void Content_Larger_Than_Viewport_Should_Not_Be_Squashed_By_Padding() + { + StackPanel content; + var target = new ScrollContentPresenter + { + CanVerticallyScroll = true, + Padding = new Thickness(10), + Content = content = new StackPanel + { + Children = + { + new Border { Height = 50 }, + new Border { Height = 50 } + } + }, + }; + + target.UpdateChild(); + target.Measure(new Size(50, 50)); + target.Arrange(new Rect(0, 0, 50, 50)); + + Assert.Equal(new Rect(10, 10, 30, 100), content.Bounds); + Assert.Equal(new Size(50, 120), target.Extent); + } + + [Fact] + public void Bottom_Padding_Should_Be_Visible_When_Scrolled_To_End() + { + StackPanel content; + var target = new ScrollContentPresenter + { + CanVerticallyScroll = true, + Padding = new Thickness(10), + Content = content = new StackPanel + { + Children = + { + new Border { Height = 50 }, + new Border { Height = 50 } + } + } + }; + + target.UpdateChild(); + target.Measure(new Size(50, 50)); + target.Arrange(new Rect(0, 0, 50, 50)); + + // Scroll to the end: extent (120) - viewport (50). + target.Offset = new Vector(0, 70); + target.Arrange(new Rect(0, 0, 50, 50)); + + Assert.Equal(new Vector(0, 70), target.Offset); + + // The whole content is scrolled into view and the bottom padding is still visible. + Assert.Equal(-60, content.Bounds.Top); + Assert.Equal(40, content.Bounds.Bottom); + } + [Fact] public void Extent_Should_Include_Content_Margin() { diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index 35e25f6538..752f2ce567 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs @@ -273,6 +273,69 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Vector(20, 20), target.Offset); } + [Fact] + public void Padding_Should_Be_Included_In_Extent() + { + const int itemCount = 19; + const double itemHeight = 32; + const double padding = 50; + const double viewportHeight = 200; + + var content = new StackPanel(); + + for (var i = 0; i < itemCount; ++i) + { + content.Children.Add(new Border { Height = itemHeight }); + } + + var target = new ScrollViewer + { + Template = new FuncControlTemplate(CreateTemplate), + Padding = new Thickness(padding), + Height = viewportHeight, + Content = content + }; + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Equal(viewportHeight, target.Viewport.Height); + Assert.Equal(itemCount * itemHeight + 2 * padding, target.Extent.Height); + + // The content is arranged below the top padding and isn't squashed by it. + Assert.Equal(padding, content.Bounds.Top); + Assert.Equal(itemCount * itemHeight, content.Bounds.Height); + + target.ScrollToEnd(); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(itemCount * itemHeight + 2 * padding - viewportHeight, target.Offset.Y); + + // Once scrolled to the end, the last item is fully visible and the bottom padding is still displayed below it. + Assert.Equal(viewportHeight - padding, content.Bounds.Bottom); + } + + [Fact] + public void Padding_Should_Not_Make_Content_Scrollable_When_ScrollViewer_Is_Sized_To_Content() + { + var target = new ScrollViewer + { + Template = new FuncControlTemplate(CreateTemplate), + Padding = new Thickness(50), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Content = new Border { Width = 100, Height = 100 }, + }; + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + // The padding is included in the desired size, so the content still fits exactly. + Assert.Equal(new Size(200, 200), target.Bounds.Size); + Assert.Equal(new Size(200, 200), target.Viewport); + Assert.Equal(new Size(200, 200), target.Extent); + } + [Fact] public void Scroll_Does_Not_Jump_When_Viewport_Becomes_Smaller_While_Dragging_ScrollBar_Thumb() { @@ -575,6 +638,7 @@ namespace Avalonia.Controls.UnitTests new ScrollContentPresenter { Name = "PART_ContentPresenter", + [~ScrollContentPresenter.PaddingProperty] = control[~ScrollViewer.PaddingProperty], }.RegisterInNameScope(scope), new ScrollBar {