diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 454f7eac9d..bc86558ab3 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -427,6 +427,7 @@ namespace Avalonia.Controls.Presenters Viewport = finalSize; Extent = Child!.Bounds.Size.Inflate(Child.Margin); + Offset = ScrollViewer.CoerceOffset(Extent, finalSize, Offset); _isAnchorElementDirty = true; return finalSize; diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 634efbd699..8e7690aa6c 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -403,7 +403,7 @@ namespace Avalonia.Controls if (firstIndex == -1) { estimatedElementSize = EstimateElementSizeU(); - firstIndex = (int)(viewportStart / estimatedElementSize); + firstIndex = Math.Min((int)(viewportStart / estimatedElementSize), maxIndex); firstIndexU = firstIndex * estimatedElementSize; } @@ -411,13 +411,13 @@ namespace Avalonia.Controls { if (estimatedElementSize == -1) estimatedElementSize = EstimateElementSizeU(); - lastIndex = (int)(viewportEnd / estimatedElementSize); + lastIndex = Math.Min((int)(viewportEnd / estimatedElementSize), maxIndex); } return new MeasureViewport { - firstIndex = MathUtilities.Clamp(firstIndex, 0, maxIndex), - lastIndex = MathUtilities.Clamp(lastIndex, 0, maxIndex), + firstIndex = firstIndex, + lastIndex = lastIndex, viewportUStart = viewportStart, viewportUEnd = viewportEnd, startU = firstIndexU, @@ -1131,6 +1131,7 @@ namespace Avalonia.Controls // The removed range was before the realized elements. Update the first index and // the indexes of the realized elements. _firstIndex -= count; + _startUUnstable = true; var newIndex = _firstIndex; for (var i = 0; i < _elements.Count; ++i) diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index c3d35653cc..d3eb42f147 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs @@ -237,6 +237,40 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, raised); } + [Fact] + public void Reducing_Extent_Should_Constrain_Offset() + { + var target = new ScrollViewer + { + Template = new FuncControlTemplate(CreateTemplate), + }; + var root = new TestRoot(target); + var raised = 0; + + target.SetValue(ScrollViewer.ExtentProperty, new Size(100, 100)); + target.SetValue(ScrollViewer.ViewportProperty, new Size(50, 50)); + target.Offset = new Vector(50, 50); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.ScrollChanged += (s, e) => + { + Assert.Equal(new Vector(-30, -30), e.ExtentDelta); + Assert.Equal(new Vector(-30, -30), e.OffsetDelta); + Assert.Equal(default, e.ViewportDelta); + ++raised; + }; + + target.SetValue(ScrollViewer.ExtentProperty, new Size(70, 70)); + + Assert.Equal(0, raised); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, raised); + Assert.Equal(new Vector(20, 20), target.Offset); + } + private Control CreateTemplate(ScrollViewer control, INameScope scope) { return new Grid diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index f1dd874c71..55c43f6f96 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; +using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; @@ -278,6 +279,82 @@ namespace Avalonia.Controls.UnitTests Assert.Same(focused, target.GetRealizedElements().First()); } + [Fact] + public void Removing_Range_When_Scrolled_To_End_Updates_Viewport() + { + using var app = App(); + var items = new AvaloniaList(Enumerable.Range(0, 100).Select(x => $"Item {x}")); + var (target, scroll, itemsControl) = CreateTarget(items: items); + + scroll.Offset = new Vector(0, 900); + Layout(target); + + AssertRealizedItems(target, itemsControl, 90, 10); + + items.RemoveRange(0, 80); + Layout(target); + + AssertRealizedItems(target, itemsControl, 10, 10); + Assert.Equal(new Vector(0, 100), scroll.Offset); + } + + [Fact] + public void Removing_Range_To_Have_Less_Than_A_Page_Of_Items_When_Scrolled_To_End_Updates_Viewport() + { + using var app = App(); + var items = new AvaloniaList(Enumerable.Range(0, 100).Select(x => $"Item {x}")); + var (target, scroll, itemsControl) = CreateTarget(items: items); + + scroll.Offset = new Vector(0, 900); + Layout(target); + + AssertRealizedItems(target, itemsControl, 90, 10); + + items.RemoveRange(0, 95); + Layout(target); + + AssertRealizedItems(target, itemsControl, 0, 5); + Assert.Equal(new Vector(0, 0), scroll.Offset); + } + + [Fact] + public void Resetting_Collection_To_Have_Less_Items_When_Scrolled_To_End_Updates_Viewport() + { + using var app = App(); + var items = new ResettingCollection(Enumerable.Range(0, 100).Select(x => $"Item {x}")); + var (target, scroll, itemsControl) = CreateTarget(items: items); + + scroll.Offset = new Vector(0, 900); + Layout(target); + + AssertRealizedItems(target, itemsControl, 90, 10); + + items.Reset(Enumerable.Range(0, 20).Select(x => $"Item {x}")); + Layout(target); + + AssertRealizedItems(target, itemsControl, 10, 10); + Assert.Equal(new Vector(0, 100), scroll.Offset); + } + + [Fact] + public void Resetting_Collection_To_Have_Less_Than_A_Page_Of_Items_When_Scrolled_To_End_Updates_Viewport() + { + using var app = App(); + var items = new ResettingCollection(Enumerable.Range(0, 100).Select(x => $"Item {x}")); + var (target, scroll, itemsControl) = CreateTarget(items: items); + + scroll.Offset = new Vector(0, 900); + Layout(target); + + AssertRealizedItems(target, itemsControl, 90, 10); + + items.Reset(Enumerable.Range(0, 5).Select(x => $"Item {x}")); + Layout(target); + + AssertRealizedItems(target, itemsControl, 0, 5); + Assert.Equal(new Vector(0, 0), scroll.Offset); + } + private static IReadOnlyList GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl) { return target.GetRealizedElements() @@ -378,5 +455,24 @@ namespace Avalonia.Controls.UnitTests } private static IDisposable App() => UnitTestApplication.Start(TestServices.RealFocus); + + private class ResettingCollection : List, INotifyCollectionChanged + { + public ResettingCollection(IEnumerable items) + { + AddRange(items); + } + + public void Reset(IEnumerable items) + { + Clear(); + AddRange(items); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + } } }