From 33b288ffd68b623830f28fcf6a55b6edbc02eab5 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 27 Aug 2026 09:27:52 +0000 Subject: [PATCH] Fix multiple ScrollContentPresenter.BringDescendantIntoView calls (#22001) * Add failing test for ScrollContentPresenter * Fix ScrollContentPresenter multiple BringDescendantIntoView * Update src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs Co-authored-by: Steven Kirk --------- Co-authored-by: Steven Kirk --- .../Presenters/ScrollContentPresenter.cs | 33 +++++++++++++------ .../Presenters/ScrollContentPresenterTests.cs | 31 +++++++++++++++++ .../VirtualizingStackPanelTests.cs | 2 +- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 7bd32dcee4..078467e228 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -251,17 +251,20 @@ namespace Avalonia.Controls.Presenters return scrollable.BringIntoView(control, targetRect); } - var transform = target.TransformToVisual(this); - - if (transform == null) + // The `viewport` rectangle computed below is in extent coordinates, so transform + // `targetRect` into that space too. Going via Child rather than via this + Offset + // keeps the result independent of Offset, which may have changed since the last + // arrange. + if (target.TransformToVisual(Child) is not { } transform) { return false; } - transform *= Matrix.CreateTranslation(Offset); - - var rectangle = targetRect.TransformToAABB(transform.Value); - Rect viewport = new Rect(Offset.X, Offset.Y, Viewport.Width, Viewport.Height); + var childPadding = GetChildPadding(); + var childMargin = GetChildMargin(); + var childContentOrigin = new Vector(childPadding.Left + childMargin.Left, childPadding.Top + childMargin.Top); + var rectangle = targetRect.TransformToAABB(transform).Translate(childContentOrigin); + var viewport = new Rect(Offset.X, Offset.Y, Viewport.Width, Viewport.Height); double minX = ComputeScrollOffsetWithMinimalScroll(viewport.Left, viewport.Right, rectangle.Left, rectangle.Right); double minY = ComputeScrollOffsetWithMinimalScroll(viewport.Top, viewport.Bottom, rectangle.Top, rectangle.Bottom); @@ -528,16 +531,26 @@ namespace Avalonia.Controls.Presenters return padding + borderThickness; } - private Size ComputeExtent(Size viewportSize, Thickness padding) + private Thickness GetChildMargin() { - var childMargin = Child!.Margin; + if (Child is not { } child) + return default; + + var margin = child.Margin; if (Child.UseLayoutRounding) { var scale = LayoutHelper.GetLayoutScale(Child); - childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale); + margin = LayoutHelper.RoundLayoutThickness(margin, scale); } + return margin; + } + + private Size ComputeExtent(Size viewportSize, Thickness padding) + { + var childMargin = GetChildMargin(); + var extent = Child!.Bounds.Size.Inflate(childMargin).Inflate(padding); if (MathUtilities.AreClose(extent.Width, viewportSize.Width, LayoutHelper.LayoutEpsilon)) diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs index d616f23fa1..28ee029925 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs @@ -475,6 +475,37 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(new Vector(100, 100), target.Offset); } + [Fact] + public void BringDescendantIntoView_Should_Be_Idempotent_Before_Next_Layout_Pass() + { + var panel = new StackPanel(); + + for (var i = 0; i < 100; ++i) + panel.Children.Add(new Border { Height = 20 }); + + var target = new ScrollContentPresenter + { + Width = 50, + Height = 100, + CanVerticallyScroll = true, + Content = panel, + }; + + target.UpdateChild(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(0, 0, 50, 100)); + + // The 50th child spans 1000..1020, so with a 100px viewport it is brought into view by scrolling to 920. + var child = panel.Children[50]; + + target.BringDescendantIntoView(child, new Rect(child.Bounds.Size)); + Assert.Equal(920, target.Offset.Y); + Assert.False(target.IsArrangeValid); + + target.BringDescendantIntoView(child, new Rect(child.Bounds.Size)); + Assert.Equal(920, target.Offset.Y); + } + [Fact] public void BringDescendantIntoView_Should_Handle_Child_Margin() { diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 10584cd3f9..aefae1fce7 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -1724,7 +1724,7 @@ namespace Avalonia.Controls.UnitTests [InlineData(0.5d, 0, 7, 0, 7, - 7, 17)] + 0, 9)] public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change_Causes_It_To_Be_Moved_Into_Visible_Viewport(double bufferFactor, int firstIndex1, int lastIndex1, int firstIndex2, int lastIndex2,