diff --git a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
index 20c3a79010a8..30558397df54 100644
--- a/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
+++ b/src/SamplesApp/UITests.Shared/UITests.Shared.projitems
@@ -2601,6 +2601,14 @@
DesignerMSBuild:Compile
+
+ Designer
+ MSBuild:Compile
+
+
+ Designer
+ MSBuild:Compile
+ DesignerMSBuild:Compile
@@ -7723,6 +7731,12 @@
ItemsRepeaterManyItems.xaml
+
+ ItemsRepeaterMultiTemplate.xaml
+
+
+ ItemsRepeaterVariableHeights.xaml
+ MyItem.xaml
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml
new file mode 100644
index 000000000000..f67fd7490fca
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ScrollViewer with ItemsRepeater using a DataTemplateSelector and 5 different templates of varying heights (32–400px). 150 items anchored to bottom. Mimics a feed-style UI with status rows, cards, expandable sections, input bubbles, and banners.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml.cs b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml.cs
new file mode 100644
index 000000000000..74c87eff91d9
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterMultiTemplate.xaml.cs
@@ -0,0 +1,319 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using Windows.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Uno.UI.Samples.Controls;
+
+namespace UITests.Windows_UI_Xaml_Controls.Repeater;
+
+[Sample(
+ "ItemsRepeater",
+ IsManualTest = true,
+ Description = "ScrollViewer + ItemsRepeater with a DataTemplateSelector dispatching to 5 templates of varying heights (32–400px). 150 items anchored to bottom. Verifies no overlap, no drift, and correct template selection across scroll jumps.")]
+public sealed partial class ItemsRepeaterMultiTemplate : Page
+{
+ private const int DefaultItemCount = 150;
+
+ private static readonly Brush s_greenBrush = new SolidColorBrush(Color.FromArgb(255, 76, 175, 80));
+ private static readonly Brush s_blueBrush = new SolidColorBrush(Color.FromArgb(255, 66, 133, 244));
+ private static readonly Brush s_orangeBrush = new SolidColorBrush(Color.FromArgb(255, 255, 152, 0));
+ private static readonly Brush s_redBrush = new SolidColorBrush(Color.FromArgb(255, 229, 57, 53));
+ private static readonly Brush s_purpleBrush = new SolidColorBrush(Color.FromArgb(255, 156, 39, 176));
+ private static readonly Brush s_tealBrush = new SolidColorBrush(Color.FromArgb(255, 0, 150, 136));
+ private static readonly Brush s_grayBrush = new SolidColorBrush(Colors.Gray);
+
+ private readonly ObservableCollection _items;
+
+ public ItemsRepeaterMultiTemplate()
+ {
+ InitializeComponent();
+ _items = new ObservableCollection(GenerateItems(0, DefaultItemCount));
+ Repeater.ItemsSource = _items;
+ UpdateStatus();
+ }
+
+ private static IEnumerable GenerateItems(int startIndex, int count)
+ {
+ for (var i = startIndex; i < startIndex + count; i++)
+ {
+ yield return CreateItem(i);
+ }
+ }
+
+ private static FeedItem CreateItem(int index)
+ {
+ // Cycle through 5 template types with a varied but deterministic pattern
+ var templateType = (FeedItemType)((index % 13) switch
+ {
+ 0 => (int)FeedItemType.StatusRow,
+ 1 => (int)FeedItemType.StatusRow,
+ 2 => (int)FeedItemType.ContentCard,
+ 3 => (int)FeedItemType.StatusRow,
+ 4 => (int)FeedItemType.StatusRow,
+ 5 => (int)FeedItemType.DetailSection,
+ 6 => (int)FeedItemType.StatusRow,
+ 7 => (int)FeedItemType.InputBubble,
+ 8 => (int)FeedItemType.StatusRow,
+ 9 => (int)FeedItemType.ContentCard,
+ 10 => (int)FeedItemType.StatusRow,
+ 11 => (int)FeedItemType.StatusRow,
+ 12 => (int)FeedItemType.Banner,
+ _ => (int)FeedItemType.StatusRow,
+ });
+
+ return templateType switch
+ {
+ FeedItemType.StatusRow => new FeedItem
+ {
+ Index = index,
+ ItemType = FeedItemType.StatusRow,
+ Title = StatusTitles[index % StatusTitles.Length],
+ Subtitle = TimeLabels[index % TimeLabels.Length],
+ AccentBrush = index % 3 == 0 ? s_greenBrush : index % 3 == 1 ? s_blueBrush : s_grayBrush,
+ },
+ FeedItemType.ContentCard => new FeedItem
+ {
+ Index = index,
+ ItemType = FeedItemType.ContentCard,
+ Title = CardTitles[index % CardTitles.Length],
+ Body = CardBodies[index % CardBodies.Length],
+ AccentBrush = s_blueBrush,
+ },
+ FeedItemType.DetailSection => new FeedItem
+ {
+ Index = index,
+ ItemType = FeedItemType.DetailSection,
+ Title = SectionTitles[index % SectionTitles.Length],
+ Body = SectionBodies[index % SectionBodies.Length],
+ IsExpanded = index % 2 == 0,
+ InnerHeight = 60 + (index % 5) * 40,
+ AccentBrush = s_purpleBrush,
+ },
+ FeedItemType.InputBubble => new FeedItem
+ {
+ Index = index,
+ ItemType = FeedItemType.InputBubble,
+ Title = InputTexts[index % InputTexts.Length],
+ AccentBrush = s_tealBrush,
+ },
+ FeedItemType.Banner => new FeedItem
+ {
+ Index = index,
+ ItemType = FeedItemType.Banner,
+ Title = BannerTitles[index % BannerTitles.Length],
+ Subtitle = BannerSubtitles[index % BannerSubtitles.Length],
+ IconGlyph = BannerGlyphs[index % BannerGlyphs.Length],
+ AccentBrush = index % 3 == 0 ? s_greenBrush : index % 3 == 1 ? s_orangeBrush : s_redBrush,
+ },
+ _ => throw new InvalidOperationException(),
+ };
+ }
+
+ private void OnTopClick(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, 0, null, disableAnimation: true);
+
+ private void OnBottomClick(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, Scroller.ScrollableHeight, null, disableAnimation: true);
+
+ private void OnAddItemsClick(object sender, RoutedEventArgs e)
+ {
+ var start = _items.Count;
+ foreach (var item in GenerateItems(start, 20))
+ {
+ _items.Add(item);
+ }
+
+ UpdateStatus();
+ }
+
+ private void OnClearClick(object sender, RoutedEventArgs e)
+ {
+ _items.Clear();
+ UpdateStatus();
+ }
+
+ private void OnResetClick(object sender, RoutedEventArgs e)
+ {
+ _items.Clear();
+ foreach (var item in GenerateItems(0, DefaultItemCount))
+ {
+ _items.Add(item);
+ }
+
+ Scroller.ChangeView(null, Scroller.ScrollableHeight, null, disableAnimation: true);
+ UpdateStatus();
+ }
+
+ private void UpdateStatus() =>
+ StatusText.Text = $"{_items.Count} items";
+
+ // --- Mock data pools ---
+
+ private static readonly string[] StatusTitles =
+ [
+ "Preheating the oven",
+ "Chopping the onions",
+ "Simmering the stock",
+ "Kneading the dough",
+ "Whisking the eggs",
+ "Toasting the spices",
+ "Marinating the chicken",
+ "Reducing the sauce",
+ "Folding in the butter",
+ "Resting the steak",
+ "Proofing the bread",
+ "Plating the dish",
+ ];
+
+ private static readonly string[] TimeLabels =
+ [
+ "2 min", "15 min", "1 hr", "45 min", "30 min", "5 min", "90 min", "20 min",
+ ];
+
+ private static readonly string[] CardTitles =
+ [
+ "Classic margherita pizza",
+ "Slow-roasted lamb shoulder",
+ "Lemon and herb roast chicken",
+ "Creamy wild mushroom risotto",
+ "Dark chocolate fondant",
+ ];
+
+ private static readonly string[] CardBodies =
+ [
+ "A thin, blistered crust topped with San Marzano tomatoes, fresh mozzarella, and basil. Bake on a preheated stone for the crispest base. Serves four as a light dinner.",
+ "Lamb rubbed with garlic and rosemary, then cooked low and slow until it falls apart at the touch of a fork. Rest under foil before carving. Pairs beautifully with roasted root vegetables.",
+ "A whole chicken brined overnight, stuffed with lemon and thyme, and roasted until the skin turns golden and crisp. Baste twice during cooking for an even colour all over.",
+ "Arborio rice cooked gently with white wine and warm stock, finished with sautéed mushrooms and a generous handful of grated parmesan. Stir often for a silky, loose texture.",
+ "An indulgent dessert with a molten centre. Bake for exactly twelve minutes so the edges set while the middle stays liquid. Serve immediately with a scoop of vanilla ice cream.",
+ ];
+
+ private static readonly string[] SectionTitles =
+ [
+ "Knife skills walkthrough",
+ "Building flavour in stews",
+ "Mastering the perfect sear",
+ "Bread proofing explained",
+ "Emulsions and sauces",
+ ];
+
+ private static readonly string[] SectionBodies =
+ [
+ "Keep the blade angled slightly outward and let the knife do the work in long, smooth strokes. Curl your guiding fingertips inward so the flat of the blade rests against your knuckles. Practise on softer vegetables before moving on to denser ones.",
+ "Start by browning the meat in batches so the pan stays hot and the surface caramelises properly. Deglaze with a splash of wine to lift the fond, then add the aromatics and let everything cook down slowly. Time is the most important ingredient here.",
+ "Pat the protein completely dry and season it generously just before it hits the pan. Use a heavy skillet and resist the urge to move the food too early. A proper crust will release on its own once it is ready.",
+ "During proofing the yeast produces gas that stretches the gluten network and gives bread its airy crumb. A warm, draught-free spot speeds things up, while a slow cold proof in the fridge develops a deeper flavour over many hours.",
+ "An emulsion suspends tiny droplets of fat in a water-based liquid, held together by an emulsifier such as egg yolk or mustard. Add the oil slowly at first and whisk constantly so the mixture thickens without splitting.",
+ ];
+
+ private static readonly string[] InputTexts =
+ [
+ "Can we swap the cream for something a little lighter?",
+ "What sides would go well with the roast lamb?",
+ "Let's try the dough with a longer overnight proof",
+ "How do I stop the sauce from splitting next time?",
+ "That risotto turned out perfectly, thank you",
+ "Could you scale the recipe up for eight guests?",
+ ];
+
+ private static readonly string[] BannerTitles =
+ [
+ "Dish ready to serve", "Oven needs attention", "Ingredient running low",
+ "Timer finished", "Taste and adjust",
+ ];
+
+ private static readonly string[] BannerSubtitles =
+ [
+ "All four courses are plated and still warm",
+ "The roast has reached its target temperature",
+ "Only a handful of fresh basil leaves left",
+ "The bread has finished its second proof",
+ "Season the soup before the final simmer",
+ ];
+
+ private static readonly string[] BannerGlyphs =
+ [
+ "\uE10B", "\uE7BA", "\uE783", "\uE930", "\uE71C",
+ ];
+}
+
+public enum FeedItemType
+{
+ StatusRow,
+ ContentCard,
+ DetailSection,
+ InputBubble,
+ Banner,
+}
+
+public sealed class FeedItem : INotifyPropertyChanged
+{
+ private bool _isExpanded;
+
+ public int Index { get; init; }
+
+ public FeedItemType ItemType { get; init; }
+
+ public string Title { get; init; } = string.Empty;
+
+ public string Subtitle { get; init; } = string.Empty;
+
+ public string Body { get; init; } = string.Empty;
+
+ public string IconGlyph { get; init; } = "\uE10B";
+
+ public Brush AccentBrush { get; init; }
+
+ public double InnerHeight { get; init; } = 80;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ if (_isExpanded != value)
+ {
+ _isExpanded = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded)));
+ }
+ }
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+}
+
+public sealed class FeedItemTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate StatusRowTemplate { get; set; }
+
+ public DataTemplate ContentCardTemplate { get; set; }
+
+ public DataTemplate DetailSectionTemplate { get; set; }
+
+ public DataTemplate InputBubbleTemplate { get; set; }
+
+ public DataTemplate BannerTemplate { get; set; }
+
+ protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
+ {
+ if (item is FeedItem feedItem)
+ {
+ return feedItem.ItemType switch
+ {
+ FeedItemType.StatusRow => StatusRowTemplate,
+ FeedItemType.ContentCard => ContentCardTemplate,
+ FeedItemType.DetailSection => DetailSectionTemplate,
+ FeedItemType.InputBubble => InputBubbleTemplate,
+ FeedItemType.Banner => BannerTemplate,
+ _ => StatusRowTemplate,
+ };
+ }
+
+ return StatusRowTemplate;
+ }
+}
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml
new file mode 100644
index 000000000000..3af62614cff6
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+ 200 items, every 10th is 1200px tall, others are 40px. Anchored to bottom (chat-style). Use the buttons to scroll to extreme offsets, walk through the list, or grow item 5 — visually verify no overlap, no drift, and that item 0 is flush at the top after walking back up.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml.cs b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml.cs
new file mode 100644
index 000000000000..0b969d7ffcaf
--- /dev/null
+++ b/src/SamplesApp/UITests.Shared/Windows_UI_Xaml_Controls/Repeater/ItemsRepeaterVariableHeights.xaml.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using Windows.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Uno.UI.Samples.Controls;
+
+namespace UITests.Windows_UI_Xaml_Controls.Repeater;
+
+[Sample(
+ "ItemsRepeater",
+ IsManualTest = true,
+ Description = "Scroll a list with very variable item heights — items must not overlap, drift, or crop the top after walking up and down. Tall items (1200px) are interleaved every 10th index between short ones (40px); the list is anchored to the bottom like a chat view.")]
+public sealed partial class ItemsRepeaterVariableHeights : Page
+{
+ private const int DefaultItemCount = 200;
+ private const double TallHeight = 1200;
+ private const double ShortHeight = 40;
+ private const double GrownHeight = 1500;
+
+ private static readonly Brush s_tallBrush = new SolidColorBrush(Colors.LightSteelBlue);
+ private static readonly Brush s_shortBrush = new SolidColorBrush(Colors.LightGray);
+
+ private readonly ObservableCollection _items;
+
+ public ItemsRepeaterVariableHeights()
+ {
+ this.InitializeComponent();
+ _items = new ObservableCollection(BuildItems(DefaultItemCount));
+ Repeater.ItemsSource = _items;
+ }
+
+ private static IEnumerable BuildItems(int count) =>
+ Enumerable.Range(0, count).Select(i => new ItemModel(
+ i,
+ IsTall(i) ? TallHeight : ShortHeight,
+ IsTall(i) ? s_tallBrush : s_shortBrush));
+
+ private static bool IsTall(int index) => index % 10 == 3;
+
+ private void OnTopClick(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, 0, null, disableAnimation: true);
+
+ private void OnJump5000Click(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, 5000, null, disableAnimation: true);
+
+ private void OnJump12000Click(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, 12000, null, disableAnimation: true);
+
+ private void OnBottomClick(object sender, RoutedEventArgs e) =>
+ Scroller.ChangeView(null, Scroller.ScrollableHeight, null, disableAnimation: true);
+
+ private async void OnWalkDownUpClick(object sender, RoutedEventArgs e)
+ {
+ const int Steps = 30;
+ var bottom = Scroller.ScrollableHeight;
+ for (var i = 1; i <= Steps; i++)
+ {
+ Scroller.ChangeView(null, bottom * i / Steps, null, disableAnimation: true);
+ await YieldAsync();
+ }
+ for (var i = Steps - 1; i >= 1; i--)
+ {
+ Scroller.ChangeView(null, bottom * i / Steps, null, disableAnimation: true);
+ await YieldAsync();
+ }
+ Scroller.ChangeView(null, 0, null, disableAnimation: true);
+ }
+
+ private void OnGrowItem5Click(object sender, RoutedEventArgs e)
+ {
+ if (_items.Count > 5)
+ {
+ _items[5].Height = GrownHeight;
+ }
+ }
+
+ private void OnResetClick(object sender, RoutedEventArgs e)
+ {
+ for (var i = 0; i < _items.Count; i++)
+ {
+ _items[i].Height = IsTall(i) ? TallHeight : ShortHeight;
+ }
+ Scroller.ChangeView(null, Scroller.ScrollableHeight, null, disableAnimation: true);
+ }
+
+ private static Task YieldAsync() => Task.Delay(16);
+
+ public sealed class ItemModel : INotifyPropertyChanged
+ {
+ private double _height;
+
+ public ItemModel(int index, double height, Brush background)
+ {
+ Index = index;
+ _height = height;
+ Background = background;
+ }
+
+ public int Index { get; }
+
+ public Brush Background { get; }
+
+ public double Height
+ {
+ get => _height;
+ set
+ {
+ if (_height != value)
+ {
+ _height = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Height)));
+ }
+ }
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ }
+}
diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater.cs
index ea2e69b23f2a..1e3934cccd2b 100644
--- a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater.cs
+++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater.cs
@@ -447,8 +447,6 @@ public async Task When_ItemSignificantlyTaller_Then_VirtualizeProperly()
sut.MaterializedItems.Should().Contain(sut.Source[3]); // Confirm that item has been materialized!
sut.Scroller.ExtentHeight.Should().BeGreaterThan(originalEstimatedExtent); // Confirm that the extent has increased due to item #3
- var item3OriginalVerticalOffset = sut.Repeater.Children.First(elt => ReferenceEquals(elt.DataContext, sut.Source[3])).ActualOffset.Y;
-
sut.Scroller.ChangeView(null, 1500, null, disableAnimation: true); // Then scroll enough for first items to be DE-materialized
await TestServices.WindowHelper.WaitForIdle();
@@ -458,11 +456,11 @@ public async Task When_ItemSignificantlyTaller_Then_VirtualizeProperly()
sut.Scroller.ChangeView(null, 2940, null, disableAnimation: true); // Then scroll enough for first items to be DE-materialized
await TestServices.WindowHelper.WaitForIdle();
- var item3UpdatedVerticalOffset = sut.Repeater.Children.First(elt => ReferenceEquals(elt.DataContext, sut.Source[3])).ActualOffset.Y;
-
- item3UpdatedVerticalOffset.Should().Be(item3OriginalVerticalOffset); // Confirm that item #3 has not been moved down
+ // The visual position of item #3 on screen is the authoritative check: when the estimated
+ // extent is corrected via IScrollAnchorProvider anchor-shift, the repeater-internal
+ // ActualOffset may change while the scroller compensates to keep the anchor visually fixed.
var result = await UITestHelper.ScreenShot(sut.Root);
- ImageAssert.HasColorAt(result, 100, 10, Colors.FromARGB("#008000")); // For safety also check it's effectively the item 3 that is visible
+ ImageAssert.HasColorAt(result, 100, 10, Colors.FromARGB("#008000")); // Confirm item 3 (green) is still visible at the top of the viewport.
}
[TestMethod]
diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater_FastScroll.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater_FastScroll.cs
new file mode 100644
index 000000000000..004a940b50e9
--- /dev/null
+++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Repeater/Given_ItemsRepeater_FastScroll.cs
@@ -0,0 +1,780 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using Windows.Foundation;
+using Windows.UI;
+using Windows.UI.Input.Preview.Injection;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Markup;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Private.Infrastructure;
+using Uno.UI.RuntimeTests.Helpers;
+using Uno.UI.Toolkit.DevTools.Input;
+
+#if HAS_UNO && !HAS_UNO_WINUI
+using Windows.UI.Xaml.Controls;
+#endif
+
+namespace Uno.UI.RuntimeTests.Tests.Windows_UI_Xaml_Controls.Repeater;
+
+[TestClass]
+[RunsOnUIThread]
+public class Given_ItemsRepeater_FastScroll
+{
+ private const double OffsetTolerance = 0.5;
+ private const double OverlapTolerance = 0.5;
+
+ private const string ItemTemplateXaml = """
+
+
+
+ """;
+
+ // Horizontal variant reuses the model's Height property as the major-axis size (Width) so
+ // vertical/horizontal tests can share the same ItemModel and helpers.
+ private const string ItemTemplateHorizontalXaml = """
+
+
+
+ """;
+
+ [TestMethod]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_FastScrollWithHighVarianceItems_Then_NoOverlap()
+ {
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ var offsets = new[] { 5000.0, 0.0, 12000.0, 300.0 };
+ foreach (var offset in offsets)
+ {
+ sut.Scroller.ChangeView(null, offset, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+
+ AssertNoOverlap(sut);
+ AssertAllMaterializedChildrenHaveFiniteOffsets(sut);
+ }
+ }
+
+ [TestMethod]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_ScrollIncrementallyToBottomAndBack_Then_FirstItemFlushAtZero()
+ {
+ // Regression for a chat-style scenario: after navigating up/down through a list of items
+ // with high-variance heights, the top of the list becomes cropped / inaccessible.
+ // Incremental ChangeView calls preserve realization-window state across scrolls, which is
+ // what drives StackLayout's average-size estimation off-course (the path that exercises
+ // the Uno workaround clamp in StackLayout.GetExtent).
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ // Walk down to the bottom, then back up to the top, in small increments. Each ChangeView is
+ // followed by WaitForIdle so the realization window tracks the scroll progression.
+ const int Steps = 30;
+ var bottomTarget = sut.Scroller.ScrollableHeight;
+ for (var i = 1; i <= Steps; i++)
+ {
+ sut.Scroller.ChangeView(null, bottomTarget * i / Steps, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+ }
+ for (var i = Steps - 1; i >= 1; i--)
+ {
+ sut.Scroller.ChangeView(null, bottomTarget * i / Steps, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+ }
+ // Final explicit scroll-to-top.
+ sut.Scroller.ChangeView(null, 0, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+
+ sut.Scroller.VerticalOffset.Should().Be(0);
+
+ var firstItem = FindMaterializedElementForIndex(sut, 0);
+ firstItem.Should().NotBeNull("Source[0] must be materialized after scrolling back to top");
+
+ var firstItemY = firstItem!.TransformToVisual(sut.Scroller).TransformPoint(new Point(0, 0)).Y;
+ firstItemY.Should().BeApproximately(0, OffsetTolerance,
+ "Source[0] must be flush at VerticalOffset=0 after an incremental scroll down and back up — content must not be cropped off the top.");
+ }
+
+ [TestMethod]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_TallItemReRealizedAfterScrollCycle_Then_PositionIsStable()
+ {
+ // Regression for the ItemsRepeater rendering corruption observed during fast scrolling
+ // through high-variance lists: when a much-taller-than-average item is realized, then
+ // virtualized out, then re-realized after a scroll cycle, its reported position must not
+ // drift. StackLayout.GetExtent estimates first-realized-item position using average size;
+ // after a tall item inflates the average, re-realizing that tall item should still
+ // resolve to its true cumulative offset. The Uno clamp in GetExtent
+ // (Math.Max(0, firstRealizedMajor)) breaks this because it prevents the compensating
+ // negative origin shift WinUI applies.
+ // Uses the same 200-item high-variance layout as Test A: every 10th item is 1200px tall,
+ // others are 40px. Item 3 is 1200px tall at Y=120 (covers Y=[120,1320]).
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ await ScrollInStepsAsync(sut, 400);
+
+ var item3Element = FindMaterializedElementForIndex(sut, 3);
+ item3Element.Should().NotBeNull("Item 3 (tall, spans Y=120..1320) must be materialized at viewport offset 400");
+ var item3YBefore = item3Element!.TransformToVisual(sut.Repeater).TransformPoint(new Point(0, 0)).Y;
+
+ // Scroll far enough that item 3 becomes dematerialized (well past its end at Y=1320).
+ await ScrollInStepsAsync(sut, 6000);
+
+ // Scroll back so item 3 is in the realization cache again.
+ await ScrollInStepsAsync(sut, 400);
+
+ var item3ElementAfter = FindMaterializedElementForIndex(sut, 3);
+ item3ElementAfter.Should().NotBeNull("Item 3 must be re-materialized after scrolling back to viewport offset 400");
+ var item3YAfter = item3ElementAfter!.TransformToVisual(sut.Repeater).TransformPoint(new Point(0, 0)).Y;
+
+ item3YAfter.Should().BeApproximately(item3YBefore, 1,
+ "Item 3 must not drift vertically across a dematerialize/re-materialize cycle.");
+ }
+
+ [TestMethod]
+ [PlatformCondition(ConditionMode.Exclude, RuntimeTestPlatforms.NativeWinUI)]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_RealizedItemGrows_Then_LayoutRemainsConsistent()
+ {
+ const double OriginalHeight = 60;
+ var items = Enumerable.Range(0, 10)
+ .Select(i => new ItemModel(i, OriginalHeight, ColorForIndex(i)))
+ .ToArray();
+ // Viewport 800 comfortably contains all 10 items (10 * 60 = 600) with 200px of headroom.
+ // The exact-fit case (viewport == content) is implementation-defined: the realization rule
+ // (elementMajorStart < rectMajorEnd) is identical on Uno and native WinUI, but the boundary
+ // outcome depends on cache-buffer growth timing — native WinUI realizes 9/10 at the exact
+ // boundary while Uno realizes all 10. A margin sidesteps that ambiguity so the test exercises
+ // the layout-consistency invariant regardless of platform virtualization timing.
+ var sut = CreateSut(items, new Size(300, 800));
+ await LoadAsync(sut);
+
+ // Items 5 and 6 must be realized so we can assert the post-growth shift on them.
+ FindMaterializedElementForIndex(sut, 5).Should().NotBeNull(
+ "Item 5 must be realized — it is the item being grown");
+ FindMaterializedElementForIndex(sut, 6).Should().NotBeNull(
+ "Item 6 must be realized — its post-growth offset is the test invariant");
+
+ const double GrownHeight = 1500;
+ sut.Source[5].Height = GrownHeight;
+ sut.Repeater.UpdateLayout();
+ await TestServices.WindowHelper.WaitForIdle();
+
+ var grownItem5 = FindMaterializedElementForIndex(sut, 5);
+ grownItem5.Should().NotBeNull("Item 5 must remain materialized after growing");
+ grownItem5!.ActualHeight.Should().BeApproximately(GrownHeight, 0.5,
+ "Item 5 must reflect its new height after the layout pass");
+
+ var expectedGrowth = GrownHeight - OriginalHeight;
+
+ AssertNoOverlap(sut);
+
+ // Items after index 5 must have shifted down by the growth delta.
+ var item6 = FindMaterializedElementForIndex(sut, 6);
+ item6.Should().NotBeNull("Item 6 must remain materialized after item 5 grows");
+ ((double)item6!.ActualOffset.Y).Should().BeApproximately(6 * OriginalHeight + expectedGrowth, 1,
+ "Item 6 must shift down by the growth delta after item 5 grows");
+ }
+
+ [TestMethod]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_FastScrollHorizontal_WithHighVarianceItems_Then_NoOverlap()
+ {
+ // Horizontal orientation counterpart of When_FastScrollWithHighVarianceItems_Then_NoOverlap.
+ // The StackLayout.GetExtent clamp applied to the major axis regardless of orientation, so the
+ // bug symptoms are equally reproducible horizontally. This guards against orientation-specific
+ // regressions in the anchor-shift pipeline.
+ var sut = CreateHighVarianceHorizontalSut(itemCount: 200, viewport: new Size(600, 300));
+ await LoadAsync(sut);
+
+ var offsets = new[] { 5000.0, 0.0, 12000.0, 300.0 };
+ foreach (var offset in offsets)
+ {
+ sut.Scroller.ChangeView(offset, null, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+
+ AssertNoOverlapHorizontal(sut);
+ AssertAllMaterializedChildrenHaveFiniteOffsets(sut);
+ }
+ }
+
+ [TestMethod]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_ScrolledThroughList_Then_ExtentAccommodatesRealContent()
+ {
+ // The pre-fix symptom: the clamp caused StackLayout to report an ExtentHeight smaller than
+ // the real cumulative content size, making the last items unreachable via scroll. Without
+ // asserting exact convergence (which varies across platforms — native WinUI can hold an
+ // over-estimated extent during rapid scrolling), this test enforces the floor invariant:
+ // after walking through the full list, ExtentHeight must be at least the real content size.
+ // The test items are 20 × 1200 + 180 × 40 = 31 200 px cumulative.
+ const double RealContentHeight = 31200;
+
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ // Walk through the entire list so every item is measured at least once.
+ const int WalkSteps = 250;
+ for (var i = 1; i <= WalkSteps; i++)
+ {
+ sut.Scroller.ChangeView(null, sut.Scroller.ScrollableHeight * i / WalkSteps, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+ }
+
+ sut.Scroller.ExtentHeight.Should().BeGreaterThanOrEqualTo(RealContentHeight - 1,
+ "ExtentHeight must accommodate the real cumulative content size after every item is measured; "
+ + "the pre-fix clamp caused an under-estimated extent that left late items unreachable.");
+ }
+
+ [TestMethod]
+ [PlatformCondition(ConditionMode.Exclude, RuntimeTestPlatforms.NativeWinUI)]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_BottomClickedFromInitialState_Then_LastItemFlushAtViewportBottom()
+ {
+ // Reproduces issue #23041 / studio.live#1333: clicking "Bottom" once on a freshly-loaded
+ // chat-style ItemsRepeater (200 items, every 10th 1200px tall, others 40px, IR
+ // VerticalAlignment=Bottom) must leave Source[199] flush against the bottom of the viewport.
+ // The observed Uno bug: the first ChangeView(ScrollableHeight) lands the offset in blank
+ // territory (UI appears empty) because StackLayout's average-size estimate is biased toward
+ // the few short items realized at the trailing edge after the jump, which shrinks
+ // ExtentHeight below the offset that was just requested. WinUI keeps the extent monotonic
+ // enough through this transition to land Source[199] at the viewport bottom on the first
+ // click.
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ var initialExtent = sut.Scroller.ExtentHeight;
+ var initialScrollableHeight = sut.Scroller.ScrollableHeight;
+
+ // First (and only) "Bottom" click — explicitly NOT preceded by ChangeView(0) so the SUT is
+ // in the same starting state as the manual repro: never scrolled, only the leading items
+ // have ever been measured.
+ sut.Scroller.ChangeView(null, sut.Scroller.ScrollableHeight, null, disableAnimation: true);
+ // Wait for the offset to actually take effect — on WinUI ChangeView is async and a single
+ // WaitForIdle may return before the SCV has settled. Poll for VO != 0 (or extent change)
+ // up to ~3s, then run a final WaitForIdle so any post-change layout cascade settles.
+ for (var attempt = 0; attempt < 30; attempt++)
+ {
+ if (sut.Scroller.VerticalOffset > 0)
+ {
+ break;
+ }
+ await Task.Delay(100);
+ }
+ await TestServices.WindowHelper.WaitForIdle();
+
+ var diagnostics = $"Initial: ExtentHeight={initialExtent:F2}, ScrollableHeight={initialScrollableHeight:F2}; "
+ + $"After Bottom click: VerticalOffset={sut.Scroller.VerticalOffset:F2}, ExtentHeight={sut.Scroller.ExtentHeight:F2}, "
+ + $"ScrollableHeight={sut.Scroller.ScrollableHeight:F2}, ViewportHeight={sut.Scroller.ViewportHeight:F2}";
+
+ var lastIndex = sut.Source.Count - 1;
+ var lastItem = FindMaterializedElementForIndex(sut, lastIndex);
+ lastItem.Should().NotBeNull(
+ $"Source[{lastIndex}] must be materialized after a single ChangeView(ScrollableHeight) from the initial state — "
+ + "if it isn't, the user sees blank UI because the offset jumped past the actual content. " + diagnostics);
+
+ var lastTop = lastItem!.TransformToVisual(sut.Scroller).TransformPoint(new Point(0, 0)).Y;
+ var lastBottom = lastTop + lastItem.ActualHeight;
+
+ lastBottom.Should().BeApproximately(sut.Scroller.ViewportHeight, 1,
+ $"Source[{lastIndex}] bottom must be flush with the viewport bottom on the FIRST 'Bottom' click — "
+ + "users should not need to click twice or scroll manually to recover from a blank viewport. "
+ + $"lastTop={lastTop:F2}, lastBottom={lastBottom:F2}. " + diagnostics);
+ }
+
+ [TestMethod]
+#if !HAS_INPUT_INJECTOR
+ [Ignore("InputInjector is not supported on this platform.")]
+#elif __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_WheelScrollDownThroughVarianceList_Then_OffsetMonotonicallyAdvances()
+ {
+ // Reproduces issue #23041 / studio.live#816: mouse-wheel scrolling down through a
+ // high-variance ItemsRepeater visibly snaps the offset backward (the user has to "fight"
+ // the scroller) when realization-driven extent shrinkage triggers TrimOverscroll mid-input.
+ // Records EVERY ViewChanged offset (intermediate and final) so we catch the visible
+ // "snap-back" frames the user perceives even when the final settled position eventually
+ // moves forward. On WinUI, the composition-thread scroll position is decoupled from the
+ // layout-thread extent estimation, so the user's wheel input is never reversed by mid-scroll
+ // layout adjustments.
+ var sut = CreateHighVarianceSut(itemCount: 200, viewport: new Size(300, 600));
+ await LoadAsync(sut);
+
+ var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init the InputInjector");
+ using var mouse = injector.GetMouse();
+
+ var bounds = sut.Scroller.GetAbsoluteBounds();
+ mouse.MoveTo(new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2));
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+
+ // Record EVERY scroll position the SV passes through, including animation intermediates.
+ // We tolerate small backward fluctuations from animation easing/quantization but a real
+ // "snap back" caused by extent shrinkage shows up as a many-pixel drop within the same
+ // wheel-down sequence.
+ var offsets = new List();
+ void OnViewChanged(object? s, ScrollViewerViewChangedEventArgs e) => offsets.Add(sut.Scroller.VerticalOffset);
+ sut.Scroller.ViewChanged += OnViewChanged;
+ try
+ {
+ offsets.Add(sut.Scroller.VerticalOffset);
+
+ // Multiple wheel-down ticks in rapid succession — typical user behavior. Each tick should
+ // nudge the offset forward; the realization rect chases the new visible region.
+ const int Ticks = 12;
+ for (var i = 0; i < Ticks; i++)
+ {
+ mouse.WheelDown();
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+ }
+ }
+ finally
+ {
+ sut.Scroller.ViewChanged -= OnViewChanged;
+ }
+
+ // Forward-progress invariant: in any monotonically-advancing forward-scroll, no recorded
+ // offset should be more than this tolerance below the cumulative max so far. A "fight"
+ // shows up as a sample 100s of pixels below the running max.
+ const double BackwardJumpTolerance = 5.0;
+ var runningMax = offsets[0];
+ for (var i = 1; i < offsets.Count; i++)
+ {
+ (offsets[i] - runningMax).Should().BeGreaterThan(-BackwardJumpTolerance,
+ $"Sample #{i} (offset {offsets[i]:F2}) jumped backward from the running max ({runningMax:F2}) "
+ + $"during a forward wheel-down sequence — the user perceives this as the scroller fighting input. "
+ + $"All recorded offsets: [{string.Join(", ", offsets.Select(o => o.ToString("F2")))}].");
+ if (offsets[i] > runningMax)
+ {
+ runningMax = offsets[i];
+ }
+ }
+
+ // Sanity: the wheel sequence must have advanced *somewhere* — if every tick stayed at 0,
+ // the monotonic check has nothing to validate. With 200-item variance content and 12
+ // wheel ticks, expect at least 200px of forward progress.
+ (offsets[^1] - offsets[0]).Should().BeGreaterThan(200,
+ $"The wheel sequence must produce non-trivial forward movement (got {offsets[^1] - offsets[0]:F2}px after {offsets.Count} samples); "
+ + "otherwise the monotonic-advance check has nothing to validate.");
+ }
+
+ [TestMethod]
+#if !HAS_INPUT_INJECTOR
+ [Ignore("InputInjector is not supported on this platform.")]
+#elif __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_SmallWheelFlicksOnMultiTemplateContent_Then_EachAdvancesOffset()
+ {
+ // Reproduces the "small flick of the scrollwheel often snap back to the previous scroll
+ // position instead of moving" symptom reported on the studio.live multi-template subagent
+ // markdown UI. The scenario: chat-style ItemsRepeater with mixed-height templates (32-400
+ // px). A single small wheel tick — typical when reading content carefully — must produce a
+ // visible forward advance. The user-reported regression was that the offset would jump
+ // somewhere, snap back to the prior position, and need a fresh wheel to register progress.
+ var sut = CreateMixedTemplateSut(itemCount: 150, viewport: new Size(360, 600));
+ await LoadAsync(sut);
+
+ var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init the InputInjector");
+ using var mouse = injector.GetMouse();
+
+ var bounds = sut.Scroller.GetAbsoluteBounds();
+ mouse.MoveTo(new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2));
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+
+ // Each iteration: one wheel tick, wait for full settle, capture offset. Assert each tick
+ // produced forward motion. We allow tiny floating-point noise (≤ 1 px) but anything bigger
+ // is the visible "snap-back" the user reports.
+ const int Ticks = 6;
+ var offsetsAfterEachTick = new List { sut.Scroller.VerticalOffset };
+ for (var i = 0; i < Ticks; i++)
+ {
+ var before = sut.Scroller.VerticalOffset;
+ mouse.WheelDown();
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+ var after = sut.Scroller.VerticalOffset;
+ offsetsAfterEachTick.Add(after);
+
+ (after - before).Should().BeGreaterThan(0.5,
+ $"Single wheel-down tick #{i + 1} on multi-template content must advance the offset, "
+ + $"but went from {before:F2} to {after:F2}. "
+ + $"All recorded offsets: [{string.Join(", ", offsetsAfterEachTick.Select(o => o.ToString("F2")))}].");
+ }
+ }
+
+ [TestMethod]
+#if !HAS_INPUT_INJECTOR
+ [Ignore("InputInjector is not supported on this platform.")]
+#elif __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_SlowWheelOnMultiTemplate_Then_ItemsDoNotJumpInIRLocalSpace()
+ {
+ // Stronger reproducer for the "jumps while scrolling slowly" symptom: tracks each
+ // realized item's IR-local position across a sequence of wheel ticks. The user-reported
+ // flicker is *items repositioning within the IR even though the user's VerticalOffset
+ // advances monotonically* — caused by StackLayout.GetExtent recomputing extent.X
+ // (layout origin Y) as the running-average element size shifts when items enter/leave
+ // the 100-slot estimation buffer. The relevant invariant: a single item's IR-local Y
+ // must not change between consecutive measures unless the user actually scrolled past
+ // the item entirely.
+ var sut = CreateMixedTemplateSut(itemCount: 150, viewport: new Size(360, 600));
+ await LoadAsync(sut);
+
+ var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init the InputInjector");
+ using var mouse = injector.GetMouse();
+
+ var bounds = sut.Scroller.GetAbsoluteBounds();
+ mouse.MoveTo(new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2));
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+
+ // For each item index we've ever seen, record its first-observed IR-local Y. Subsequent
+ // observations must match (within 0.5 px) or we have a layout-origin shift between
+ // measures, which the user observes as visible item jumping.
+ var firstObservedY = new Dictionary();
+ var report = new List();
+
+ void Snapshot(int tickIndex)
+ {
+ for (var i = 0; i < sut.Source.Count; i++)
+ {
+ if (sut.Repeater.TryGetElement(i) is not FrameworkElement fe || fe.ActualHeight <= 0)
+ {
+ continue;
+ }
+ // IR-local Y, which is what the layout origin produces; converting via TransformToVisual(Repeater)
+ // keeps the comparison platform-agnostic.
+ var y = fe.TransformToVisual(sut.Repeater).TransformPoint(new Point(0, 0)).Y;
+ if (firstObservedY.TryGetValue(i, out var prevY))
+ {
+ if (Math.Abs(prevY - y) > 0.5)
+ {
+ report.Add($"tick #{tickIndex}: item[{i}] IR-local Y changed from {prevY:F2} to {y:F2}");
+ }
+ }
+ else
+ {
+ firstObservedY[i] = y;
+ }
+ }
+ }
+
+ Snapshot(tickIndex: 0);
+ const int Ticks = 8;
+ for (var i = 0; i < Ticks; i++)
+ {
+ mouse.WheelDown();
+ await UITestHelper.WaitForIdle(waitForCompositionAnimations: true);
+ Snapshot(tickIndex: i + 1);
+ }
+
+ // Report-driven assertion: build a single error message containing every observed item
+ // jump so a failure points at the exact items + values that moved.
+ report.Should().BeEmpty(
+ "No item should change its IR-local Y between successive wheel ticks (the user perceives that as the list jumping). "
+ + $"Captured discrepancies:{Environment.NewLine}{string.Join(Environment.NewLine, report)}");
+ }
+
+ [TestMethod]
+ [PlatformCondition(ConditionMode.Exclude, RuntimeTestPlatforms.NativeWinUI)]
+#if __ANDROID__ || __IOS__ || __WASM__
+ [Ignore("Fails due to async native scrolling.")]
+#endif
+ public async Task When_ScrollBarThumbDragged_Then_OffsetTracksRequestMonotonically()
+ {
+ // Reproduces the "scrollbar drag becomes unresponsive" symptom reported on the studio.live
+ // multi-template subagent markdown UI. The scrollbar drag fires Scroll events with the
+ // requested offset; the ScrollViewer must apply each in order without snapping back.
+ // We simulate the drag at the SCV level by firing the same ChangeViewCore the
+ // scrollbar drag handler uses (line 1254 of ScrollViewer.cs), incrementing the offset by
+ // small steps the way the user moves the thumb pixel-by-pixel.
+ var sut = CreateMixedTemplateSut(itemCount: 150, viewport: new Size(360, 600));
+ await LoadAsync(sut);
+
+ // Simulate progressive scrollbar-thumb drag down by 20 px each step (typical thumb tick).
+ // The scrollbar drag handler in ScrollViewer.OnVerticalScrollBarScrolled calls
+ // ChangeViewCore with `immediate=true` (matching the e.NewValue branch). Each request
+ // must land at the requested offset — not snap back to the previous one or skip ahead.
+ const int Steps = 20;
+ const double Step = 20.0;
+ var observed = new List { sut.Scroller.VerticalOffset };
+ for (var i = 1; i <= Steps; i++)
+ {
+ var target = i * Step;
+ sut.Scroller.ChangeView(null, target, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+ observed.Add(sut.Scroller.VerticalOffset);
+ (observed[i] - observed[i - 1]).Should().BeGreaterThan(0.5,
+ $"Scrollbar drag step #{i} (requested {target:F2}) must advance the offset from "
+ + $"{observed[i - 1]:F2}, but landed at {observed[i]:F2} — the user perceives this as "
+ + $"the scrollbar 'becoming unresponsive'. "
+ + $"All recorded offsets: [{string.Join(", ", observed.Select(o => o.ToString("F2")))}].");
+ }
+ }
+
+ // ----- helpers -----
+
+ // Mixed-template sample SUT: mimics the studio.live multi-template subagent markdown UI's
+ // shape — 5 template types of varying heights cycling through 13 indices. Used by the
+ // small-wheel and scrollbar-drag regression tests.
+ private static SutHandle CreateMixedTemplateSut(int itemCount, Size viewport)
+ {
+ var items = Enumerable.Range(0, itemCount)
+ .Select(i => new ItemModel(i, MixedTemplateHeight(i), ColorForIndex(i)))
+ .ToArray();
+ return CreateSut(items, viewport);
+ }
+
+ private static double MixedTemplateHeight(int index) => (index % 13) switch
+ {
+ 0 or 1 or 3 or 4 or 6 or 8 or 10 or 11 => 32, // StatusRow
+ 2 or 9 => 120, // ContentCard
+ 5 => 200, // DetailSection
+ 7 => 80, // InputBubble
+ 12 => 60, // Banner
+ _ => 32,
+ };
+
+ private static SutHandle CreateHighVarianceSut(int itemCount, Size viewport)
+ {
+ var items = Enumerable.Range(0, itemCount)
+ .Select(i => new ItemModel(i, (i % 10 == 3) ? 1200 : 40, ColorForIndex(i)))
+ .ToArray();
+ return CreateSut(items, viewport);
+ }
+
+ private static SutHandle CreateHighVarianceHorizontalSut(int itemCount, Size viewport)
+ {
+ var items = Enumerable.Range(0, itemCount)
+ .Select(i => new ItemModel(i, (i % 10 == 3) ? 1200 : 40, ColorForIndex(i)))
+ .ToArray();
+
+ var source = new ObservableCollection(items);
+ var template = (DataTemplate)XamlReader.Load(ItemTemplateHorizontalXaml);
+
+ ItemsRepeater repeater = new()
+ {
+ ItemsSource = source,
+ Layout = new StackLayout { Orientation = Orientation.Horizontal },
+ ItemTemplate = template,
+ // Horizontal mirror of the chat-style layout anchored to the trailing edge.
+ HorizontalAlignment = HorizontalAlignment.Right,
+ };
+
+ ScrollViewer scroller = new()
+ {
+ Width = viewport.Width,
+ Height = viewport.Height,
+ Content = repeater,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Visible,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ };
+
+ return new SutHandle(scroller, repeater, source);
+ }
+
+ private static SutHandle CreateSut(IReadOnlyList items, Size viewport)
+ {
+ var source = new ObservableCollection(items);
+
+ var template = (DataTemplate)XamlReader.Load(ItemTemplateXaml);
+
+ ItemsRepeater repeater = new()
+ {
+ ItemsSource = source,
+ Layout = new StackLayout { Orientation = Orientation.Vertical },
+ ItemTemplate = template,
+ // Matches the chat-style layout that triggers the real-world bug: when short content
+ // is anchored to the bottom, the realization window evolution during scroll-up is what
+ // exercises the StackLayout.GetExtent clamp path.
+ VerticalAlignment = VerticalAlignment.Bottom,
+ };
+
+ ScrollViewer scroller = new()
+ {
+ Width = viewport.Width,
+ Height = viewport.Height,
+ Content = repeater,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Visible,
+ };
+
+ return new SutHandle(scroller, repeater, source);
+ }
+
+ private static async Task LoadAsync(SutHandle sut)
+ {
+ TestServices.WindowHelper.WindowContent = sut.Scroller;
+ await TestServices.WindowHelper.WaitForIdle();
+ await TestServices.WindowHelper.WaitForLoaded(sut.Repeater);
+ await TestServices.WindowHelper.WaitForIdle();
+ }
+
+ private static async Task ScrollInStepsAsync(SutHandle sut, double targetOffset, int steps = 6)
+ {
+ var current = sut.Scroller.VerticalOffset;
+ for (var i = 1; i <= steps; i++)
+ {
+ var next = current + (targetOffset - current) * i / steps;
+ sut.Scroller.ChangeView(null, next, null, disableAnimation: true);
+ await TestServices.WindowHelper.WaitForIdle();
+ sut.Repeater.UpdateLayout();
+ await TestServices.WindowHelper.WaitForIdle();
+ }
+ }
+
+ private static IEnumerable EnumerateRepeaterChildren(ItemsRepeater repeater)
+ {
+ // Enumerate via VisualTreeHelper (captures all rendered children) AND TryGetElement
+ // (captures materialized-but-possibly-recycled elements). Deduplicate by reference.
+ var seen = new HashSet();
+ var vtCount = VisualTreeHelper.GetChildrenCount(repeater);
+ for (var i = 0; i < vtCount; i++)
+ {
+ if (VisualTreeHelper.GetChild(repeater, i) is FrameworkElement fe && seen.Add(fe))
+ {
+ yield return fe;
+ }
+ }
+ if (repeater.ItemsSource is System.Collections.IList list)
+ {
+ for (var i = 0; i < list.Count; i++)
+ {
+ if (repeater.TryGetElement(i) is FrameworkElement fe && seen.Add(fe))
+ {
+ yield return fe;
+ }
+ }
+ }
+ }
+
+ private static FrameworkElement? FindMaterializedElementForIndex(SutHandle sut, int index)
+ {
+ var targetId = sut.Source[index].Id;
+ return EnumerateRepeaterChildren(sut.Repeater)
+ .FirstOrDefault(c => c.DataContext is ItemModel m && m.Id == targetId);
+ }
+
+ private static void AssertNoOverlap(SutHandle sut)
+ {
+ // ItemsRepeater parks recycled/unrealized elements at large negative offsets (Y ≈ -10000)
+ // as a hide trick. Filter those out before checking overlap.
+ var laidOut = EnumerateRepeaterChildren(sut.Repeater)
+ .Where(c => c.ActualHeight > 0 && c.ActualOffset.Y > -1000)
+ .Select(c => (Top: c.ActualOffset.Y, Bottom: c.ActualOffset.Y + c.ActualHeight))
+ .OrderBy(t => t.Top)
+ .ToArray();
+
+ for (var i = 1; i < laidOut.Length; i++)
+ {
+ var prev = laidOut[i - 1];
+ var curr = laidOut[i];
+ (curr.Top - prev.Bottom).Should().BeGreaterThanOrEqualTo(-OverlapTolerance,
+ $"Items must not overlap, but item@{curr.Top:F2} overlaps previous item bottom@{prev.Bottom:F2}");
+ }
+ }
+
+ private static void AssertNoOverlapHorizontal(SutHandle sut)
+ {
+ var laidOut = EnumerateRepeaterChildren(sut.Repeater)
+ .Where(c => c.ActualWidth > 0 && c.ActualOffset.X > -1000)
+ .Select(c => (Left: c.ActualOffset.X, Right: c.ActualOffset.X + c.ActualWidth))
+ .OrderBy(t => t.Left)
+ .ToArray();
+
+ for (var i = 1; i < laidOut.Length; i++)
+ {
+ var prev = laidOut[i - 1];
+ var curr = laidOut[i];
+ (curr.Left - prev.Right).Should().BeGreaterThanOrEqualTo(-OverlapTolerance,
+ $"Items must not overlap, but item@{curr.Left:F2} overlaps previous item right@{prev.Right:F2}");
+ }
+ }
+
+ private static void AssertAllMaterializedChildrenHaveFiniteOffsets(SutHandle sut)
+ {
+ foreach (var child in EnumerateRepeaterChildren(sut.Repeater))
+ {
+ if (child.ActualHeight <= 0)
+ {
+ continue;
+ }
+
+ double.IsFinite(child.ActualOffset.Y).Should().BeTrue(
+ "Materialized child must have a finite Y offset");
+ double.IsNaN(child.ActualHeight).Should().BeFalse(
+ "Materialized child must have a finite height");
+ }
+ }
+
+ private static Color ColorForIndex(int i)
+ {
+ var palette = new[]
+ {
+ Color.FromArgb(0xFF, 0xE5, 0x39, 0x35),
+ Color.FromArgb(0xFF, 0xFB, 0x8C, 0x00),
+ Color.FromArgb(0xFF, 0xFD, 0xD8, 0x35),
+ Color.FromArgb(0xFF, 0x43, 0xA0, 0x47),
+ Color.FromArgb(0xFF, 0x1E, 0x88, 0xE5),
+ Color.FromArgb(0xFF, 0x5E, 0x35, 0xB1),
+ Color.FromArgb(0xFF, 0xD8, 0x1B, 0x60),
+ };
+ return palette[i % palette.Length];
+ }
+
+ private sealed class ItemModel : INotifyPropertyChanged
+ {
+ private double _height;
+
+ public ItemModel(int id, double height, Color background)
+ {
+ Id = id;
+ _height = height;
+ Background = new SolidColorBrush(background);
+ }
+
+ public int Id { get; }
+
+ public Brush Background { get; }
+
+ public double Height
+ {
+ get => _height;
+ set
+ {
+ if (_height != value)
+ {
+ _height = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Height)));
+ }
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
+
+ private sealed record SutHandle(ScrollViewer Scroller, ItemsRepeater Repeater, ObservableCollection Source);
+}
diff --git a/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml/UIElement.cs b/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml/UIElement.cs
index 14e7fe3b99f2..b55a455d66e5 100644
--- a/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml/UIElement.cs
+++ b/src/Uno.UI/Generated/3.0.0.0/Microsoft.UI.Xaml/UIElement.cs
@@ -34,14 +34,7 @@ public partial class UIElement : global::Microsoft.UI.Composition.IAnimationObje
typeof(global::Microsoft.UI.Xaml.UIElement),
new Microsoft.UI.Xaml.FrameworkPropertyMetadata(default(global::Microsoft.UI.Xaml.Media.CacheMode)));
#endif
-#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__
- [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")]
- public static global::Microsoft.UI.Xaml.DependencyProperty CanBeScrollAnchorProperty { get; } =
- Microsoft.UI.Xaml.DependencyProperty.Register(
- nameof(CanBeScrollAnchor), typeof(bool),
- typeof(global::Microsoft.UI.Xaml.UIElement),
- new Microsoft.UI.Xaml.FrameworkPropertyMetadata(default(bool)));
-#endif
+ // Skipping already declared property CanBeScrollAnchorProperty
// Skipping already declared property CanDragProperty
#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__
[global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")]
@@ -294,20 +287,7 @@ public string AccessKey
}
}
#endif
-#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__
- [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")]
- public bool CanBeScrollAnchor
- {
- get
- {
- return (bool)this.GetValue(CanBeScrollAnchorProperty);
- }
- set
- {
- this.SetValue(CanBeScrollAnchorProperty, value);
- }
- }
-#endif
+ // Skipping already declared property CanBeScrollAnchor
// Skipping already declared property CanDrag
#if __ANDROID__ || __IOS__ || __TVOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__
[global::Uno.NotImplemented("__ANDROID__", "__IOS__", "__TVOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__")]
diff --git a/src/Uno.UI/UI/Xaml/Controls/Repeater/FlowLayoutAlgorithm.cs b/src/Uno.UI/UI/Xaml/Controls/Repeater/FlowLayoutAlgorithm.cs
index 1c98061eac28..a7fb2866d815 100644
--- a/src/Uno.UI/UI/Xaml/Controls/Repeater/FlowLayoutAlgorithm.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/Repeater/FlowLayoutAlgorithm.cs
@@ -29,7 +29,6 @@ private enum GenerateDirection
int m_firstRealizedDataIndexInsideRealizationWindow = -1;
int m_lastRealizedDataIndexInsideRealizationWindow = -1;
-
// If the scroll orientation is the same as the follow orientation
// we will only have one line since we will never wrap. In that case
// we do not want to align the line. We could potentially switch the
diff --git a/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayout.cs b/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayout.cs
index a6a35653a67c..1d2207c3a176 100644
--- a/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayout.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayout.cs
@@ -183,13 +183,53 @@ Rect GetExtent(
{
MUX_ASSERT(lastRealized != null);
- var firstRealizedMajor = (float)(MajorStart(firstRealizedLayoutBounds) - firstRealizedItemIndex * averageElementSize);
- // Uno workaround [BEGIN]: Make sure to not move items above the viewport. This can be the case if an items is significantly higher than previous items (will increase the average items size)
- firstRealizedMajor = Math.Max(0.0f, firstRealizedMajor);
- // Uno workaround [END]
- SetMajorStart(ref extent, firstRealizedMajor);
+ // WinUI parity formula for extent.MajorStart (the layout origin) is:
+ // firstRealizedLayoutBounds.MajorStart - firstRealizedItemIndex * averageElementSize
+ // This subtracts the implied size of the unrealized leading items from the first
+ // realized item's algorithm-Y position. averageElementSize is recomputed each
+ // measure from a 100-slot running mean, so the formula drifts a few pixels
+ // whenever a tall item enters or leaves the buffer. Items' IR-local Y is
+ // (algorithm Y - layout origin Y), so even a small drift in the origin shows up
+ // as items "jumping" during wheel scroll on chat-style lists with high-variance
+ // heights (issue #23042 / studio.live#816).
+ //
+ // Uno workaround: hold the previously-reported MajorStart stable across measure
+ // passes. The Uno SCV doesn't currently consume the IR's m_pendingViewportShift,
+ // so a layout-origin shift here isn't compensated by a matching SCV offset
+ // shift — it surfaces directly as a visual jump. Resetting on
+ // firstRealizedItemIndex == 0 keeps the natural origin=0 at the top of the list;
+ // otherwise the origin is held and any post-MakeAnchor algorithm-coord shift is
+ // absorbed into extent.MajorSize so realized items always fit within the IR's
+ // frame.
+ var formulaMajorStart = (float)(MajorStart(firstRealizedLayoutBounds) - firstRealizedItemIndex * averageElementSize);
+ var hasPrior = !float.IsNaN(stackState.Uno_LastReportedExtentMajorStart);
+ // Also release the stable origin when realized items extend ABOVE it
+ // (firstRealizedLayoutBounds.MajorStart < stable). This catches the wheel-up
+ // scroll cascade where Generate backward places items at negative algorithm-Y
+ // because the previous anchor was positioned at the avg-estimate offset (smaller
+ // than the true cumulative height of items 0..firstIdx-1). Holding stable=0
+ // would leave those items above the IR's frame and the user could not scroll all
+ // the way to the top — VerticalOffset clamps to 0 but the actual first items are
+ // outside the visible range. Shifting to formula re-anchors the origin so items
+ // align with their natural IR-local positions.
+ var itemsAboveStable = hasPrior
+ && (float)MajorStart(firstRealizedLayoutBounds) < stackState.Uno_LastReportedExtentMajorStart;
+ var useFormula = !hasPrior || firstRealizedItemIndex == 0 || itemsAboveStable;
+ var stableMajorStart = useFormula
+ ? formulaMajorStart
+ : stackState.Uno_LastReportedExtentMajorStart;
+ SetMajorStart(ref extent, stableMajorStart);
+
+ // extent.MajorSize spans from the stable origin to the trailing edge of the
+ // realized range, plus the avg-based estimate for trailing unrealized items.
+ // Using the stable (not formula) origin guarantees realized items always sit
+ // within [stable, stable + extent.MajorSize], so they never get clipped by the
+ // IR's frame even when MakeAnchor places anchors far from the natural
+ // firstIdx * averageElementSize position.
var remainingItems = itemsCount - lastRealizedItemIndex - 1;
- SetMajorSize(ref extent, MajorEnd(lastRealizedLayoutBounds) - MajorStart(extent) + (float)(remainingItems * averageElementSize));
+ SetMajorSize(ref extent, MajorEnd(lastRealizedLayoutBounds) - stableMajorStart + (float)(remainingItems * averageElementSize));
+
+ stackState.Uno_LastReportedExtentMajorStart = stableMajorStart;
}
else
{
diff --git a/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayoutState.cs b/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayoutState.cs
index aa6807517fa8..afd7f5f508fa 100644
--- a/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayoutState.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/Repeater/StackLayoutState.cs
@@ -30,6 +30,14 @@ public partial class StackLayoutState
internal int Uno_LastKnownRealizedElementsCount;
internal int Uno_LastKnownItemsCount;
internal Size Uno_LastKnownDesiredSize;
+ // Snapshot of the previous GetExtent result, used to keep extent.MajorStart (layout origin)
+ // stable across measure passes where the running-average element size shifted (typical when
+ // a tall item enters or leaves the 100-slot estimation buffer during wheel scroll). Without
+ // this, avg fluctuations translate directly into items repositioning in IR-local space
+ // because each item's IR-local Y = algorithm Y - layout origin Y. Released on back-to-top
+ // (firstRealizedItemIndex == 0) so the natural extent.MajorStart=0 is re-established at
+ // offset 0, and when realized items extend above the stable origin.
+ internal float Uno_LastReportedExtentMajorStart = float.NaN;
// Uno workaround [END]
public StackLayoutState()
diff --git a/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.Managed.cs b/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.Managed.cs
index 583213c8cafe..9cb0697b8dbd 100644
--- a/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.Managed.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.Managed.cs
@@ -101,6 +101,27 @@ private ScrollableOffsets GetScrollableOffsets()
internal Size? CustomContentExtent => null;
+ // True while a scroll animation is in flight on the content's AnchorPoint. Used by
+ // ScrollViewer.RecomputeOffsetsFromIntent to avoid interrupting an ongoing animation
+ // (e.g. the wheel-driven or programmatic-with-animation scroll) with an instant Set.
+ // The recompute will run again on the next layout pass after the animation completes.
+ internal bool IsScrollAnimationInProgress
+ {
+ get
+ {
+ if (Content is UIElement contentElt && contentElt.Visual is { } visual
+ && visual.TryGetAnimationController(nameof(Visual.AnchorPoint)) is { } controller)
+ {
+ // A KeyFrameAnimation that completed naturally stays in the owning
+ // CompositionObject's animation dictionary (only StopAnimation removes it),
+ // so the controller's mere presence is not a reliable "in progress" signal.
+ // Check the remaining time instead.
+ return controller.Remaining > TimeSpan.Zero;
+ }
+ return false;
+ }
+ }
+
private object RealContent => Content;
private readonly SerialDisposable _eventSubscriptions = new();
@@ -352,6 +373,10 @@ private void TryEnableDirectManipulation(object sender, PointerRoutedEventArgs a
return;
}
+ // Touch/pen press invalidates any armed offset intent so subsequent layout cascades from
+ // realization don't push the offset back toward the intent during the user's manipulation.
+ Scroller?.ClearOffsetIntents();
+
XamlRoot?.VisualTree.ContentRoot.InputManager.Pointers.RegisterDirectManipulationHandler(args.Pointer.UniqueId, this);
}
diff --git a/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.cs b/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.cs
index 15ca5dc2a178..df28fb4e8297 100644
--- a/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/ScrollContentPresenter/ScrollContentPresenter.cs
@@ -259,6 +259,12 @@ internal override bool IsViewHit()
private void PointerWheelScroll(object sender, Input.PointerRoutedEventArgs e)
{
+ // Wheel input invalidates any previously-armed offset intent so the recompute step
+ // in ScrollViewer.UpdateDimensionProperties does not push the offset back toward the
+ // intent (which would visibly fight the user's wheel direction). Mirrors the user's
+ // requirement: "mouse wheel ticks must never trigger automatic ChangeView counter-shifts".
+ Scroller?.ClearOffsetIntents();
+
var properties = e.GetCurrentPoint(null).Properties;
if (Content is UIElement)
@@ -348,10 +354,20 @@ private void PointerWheelScroll(object sender, Input.PointerRoutedEventArgs e)
}
public void SetVerticalOffset(double offset)
- => Set(verticalOffset: offset, disableAnimation: true);
+ {
+ // Direct offset setters represent an explicit programmatic request, equivalent to
+ // ScrollViewer.ChangeView. Update the Scroller's offset intent so the post-layout
+ // recompute respects this value rather than chasing a stale intent left over from a
+ // prior ChangeView/Set call.
+ Scroller?.SetVerticalOffsetIntent(offset);
+ Set(verticalOffset: offset, disableAnimation: true);
+ }
public void SetHorizontalOffset(double offset)
- => Set(horizontalOffset: offset, disableAnimation: true);
+ {
+ Scroller?.SetHorizontalOffsetIntent(offset);
+ Set(horizontalOffset: offset, disableAnimation: true);
+ }
// Ensure the offset we're scrolling to is valid.
private double ValidateInputOffset(double offset, int minOffset, double maxOffset)
diff --git a/src/Uno.UI/UI/Xaml/Controls/ScrollViewer/ScrollViewer.cs b/src/Uno.UI/UI/Xaml/Controls/ScrollViewer/ScrollViewer.cs
index 8e4ec86c0bea..6c0425936649 100644
--- a/src/Uno.UI/UI/Xaml/Controls/ScrollViewer/ScrollViewer.cs
+++ b/src/Uno.UI/UI/Xaml/Controls/ScrollViewer/ScrollViewer.cs
@@ -681,13 +681,7 @@ protected override Size ArrangeOverride(Size finalSize)
{
ViewportArrangeSize = finalSize;
- return AnchoringArrangeOverride(finalSize, size =>
- {
- var arranged = base.ArrangeOverride(size);
- TrimOverscroll(Orientation.Horizontal);
- TrimOverscroll(Orientation.Vertical);
- return arranged;
- });
+ return AnchoringArrangeOverride(finalSize, size => base.ArrangeOverride(size));
}
partial void TrimOverscroll(Orientation orientation);
@@ -825,8 +819,30 @@ static double GetEffectiveMargin(double leadingMargin, double trailingMargin)
UpdateComputedVerticalScrollability(invalidate: false);
UpdateComputedHorizontalScrollability(invalidate: false);
- TrimOverscroll(Orientation.Vertical);
- TrimOverscroll(Orientation.Horizontal);
+ // Prefer the WinUI-parity recompute when the caller has expressed an offset intent (most
+ // recent programmatic ChangeView). The recompute keeps VerticalOffset/HorizontalOffset
+ // aligned with the user's intent (clamped to the current ScrollableHeight) across the
+ // realization cascades that virtualizing content (ItemsRepeater) produces. If the
+ // recompute applied an adjustment, skip the legacy TrimOverscroll so we don't double-clamp
+ // in the same arrange cycle.
+ // When no intent is armed (e.g. wheel-driven or touch-driven scrolling), only run the
+ // legacy TrimOverscroll if the SV's *viewport* (its own size) actually changed. Pure
+ // extent shrinkage from virtualizing content's realization must NOT be auto-clamped —
+ // that's the path that produces the "wheel fight" the user reports on high-variance
+ // ItemsRepeater content (issue #23041 / studio.live#816). The viewport-resize case
+ // (When_SizeChanged_Offsets_Adjusted) still runs because that pass sees ViewportHeight
+ // or ViewportWidth different from its previous snapshot.
+ var viewportChanged =
+ !NumericExtensions.AreClose(vpHeight, _lastTrimViewportHeight) ||
+ !NumericExtensions.AreClose(vpWidth, _lastTrimViewportWidth);
+ _lastTrimViewportHeight = vpHeight;
+ _lastTrimViewportWidth = vpWidth;
+
+ if (!RecomputeOffsetsFromIntent() && viewportChanged)
+ {
+ TrimOverscroll(Orientation.Vertical);
+ TrimOverscroll(Orientation.Horizontal);
+ }
var newSize = new Size(ExtentWidth, ExtentWidth);
if (oldSize != newSize)
@@ -1500,6 +1516,25 @@ public bool ChangeView(double? horizontalOffset, double? verticalOffset, float?
this.Log().LogDebug($"ChangeView(horizontalOffset={horizontalOffset}, verticalOffset={verticalOffset}, zoomFactor={zoomFactor}, disableAnimation={disableAnimation})");
}
+ // Capture the caller's offset intent so the post-layout recompute can preserve it across
+ // realization-driven extent fluctuations. The recompute (RecomputeOffsetsFromIntent) clamps
+ // the intent to the current ScrollableHeight without overwriting the intent itself, mirroring
+ // WinUI's m_Offset.Y vs m_ComputedOffset.Y separation in ScrollContentPresenter
+ // (ScrollContentPresenter_Partial.cpp:902 SetVerticalOffsetPrivate vs :3170 CoerceOffsets).
+ // Intent is only updated when this is an external/programmatic ChangeView; internal calls
+ // (TrimOverscroll, the recompute itself) set _isInternalOffsetAdjustment to suppress this.
+ if (!_isInternalOffsetAdjustment)
+ {
+ if (horizontalOffset is double hh)
+ {
+ _horizontalOffsetIntent = hh;
+ }
+ if (verticalOffset is double vv)
+ {
+ _verticalOffsetIntent = vv;
+ }
+ }
+
if (horizontalOffset == null && verticalOffset == null && zoomFactor == null)
{
return true; // nothing to do
@@ -1524,6 +1559,104 @@ public bool ChangeView(double? horizontalOffset, double? verticalOffset, float?
}
}
+ // Tracks the caller-requested offset (the user's intent) separately from VerticalOffset/
+ // HorizontalOffset (the displayed/clamped value). Mirrors WinUI's pattern in
+ // ScrollContentPresenter where m_Offset is the requested offset (validated only against the
+ // CURRENT ScrollableHeight at request time) and m_ComputedOffset is the one returned to
+ // callers (recoerced after every layout pass).
+ // Cleared on user input (mouse wheel, touch press) so wheel-driven scrolling never gets
+ // auto-pushed back toward a stale programmatic intent.
+ private double? _verticalOffsetIntent;
+ private double? _horizontalOffsetIntent;
+ private bool _isInternalOffsetAdjustment;
+
+ // Snapshot of the SV's previous viewport (own ActualWidth/Height) at the end of each
+ // UpdateDimensionProperties pass. Used to differentiate "viewport actually shrank" (the
+ // legitimate When_SizeChanged_Offsets_Adjusted case where the legacy TrimOverscroll must
+ // run) from "only the content's extent shrank during virtualization realization" (the
+ // wheel-fight case where TrimOverscroll must stay quiet).
+ private double _lastTrimViewportHeight;
+ private double _lastTrimViewportWidth;
+
+ // Called by ScrollContentPresenter from user-input paths (mouse wheel, touch start) so the
+ // recompute does not fight subsequent input by chasing a previously-armed programmatic
+ // intent. Maintains the user's "no fighting" expectation.
+ internal void ClearOffsetIntents()
+ {
+ _verticalOffsetIntent = null;
+ _horizontalOffsetIntent = null;
+ }
+
+ // Called by ScrollContentPresenter from its public SetVerticalOffset/SetHorizontalOffset
+ // entry points which bypass ScrollViewer.ChangeView. These represent explicit programmatic
+ // requests in the same sense as ChangeView; the new value supersedes any prior intent so
+ // the recompute step does not pull the offset back to a stale intent (which the user has
+ // just explicitly overridden).
+ internal void SetVerticalOffsetIntent(double offset) => _verticalOffsetIntent = offset;
+
+ internal void SetHorizontalOffsetIntent(double offset) => _horizontalOffsetIntent = offset;
+
+ // Recomputes VerticalOffset/HorizontalOffset = clamp(intent, 0, ScrollableHeight/Width) when
+ // an intent is armed. Called from UpdateDimensionProperties after the SV's extent/viewport
+ // are refreshed. Returns true when an offset adjustment was applied so the caller can avoid
+ // an immediate redundant TrimOverscroll on top of it.
+ private bool RecomputeOffsetsFromIntent()
+ {
+ // Skip when a scroll animation is in flight: the animation already drives VO toward the
+ // target progressively, and a synchronous instant Set here would interrupt the animation
+ // and stop intermediate effects (e.g. ListViewBase IncrementalLoading triggered by the
+ // progressive viewport move). Once the animation completes the next layout pass runs
+ // the recompute and seats the offset at clamp(intent, SH).
+#if UNO_HAS_MANAGED_SCROLL_PRESENTER
+ if ((_presenter as ScrollContentPresenter)?.IsScrollAnimationInProgress == true)
+ {
+ return false;
+ }
+#endif
+
+ var changed = false;
+
+ if (_verticalOffsetIntent is double vIntent)
+ {
+ var clamped = Math.Max(0, Math.Min(vIntent, ScrollableHeight));
+ if (Math.Abs(VerticalOffset - clamped) > 0.5)
+ {
+ ChangeViewInternal(null, clamped);
+ changed = true;
+ }
+ }
+
+ if (_horizontalOffsetIntent is double hIntent)
+ {
+ var clamped = Math.Max(0, Math.Min(hIntent, ScrollableWidth));
+ if (Math.Abs(HorizontalOffset - clamped) > 0.5)
+ {
+ ChangeViewInternal(clamped, null);
+ changed = true;
+ }
+ }
+
+ return changed;
+ }
+
+ // Internal-only ChangeView used by the recompute step and TrimOverscroll. Sets the
+ // _isInternalOffsetAdjustment guard so the public ChangeView path skips updating
+ // _verticalOffsetIntent/_horizontalOffsetIntent — those represent the caller's most recent
+ // explicit request, not the framework's intermediate corrections.
+ private void ChangeViewInternal(double? horizontalOffset, double? verticalOffset)
+ {
+ var wasInternal = _isInternalOffsetAdjustment;
+ _isInternalOffsetAdjustment = true;
+ try
+ {
+ ChangeView(horizontalOffset, verticalOffset, null, disableAnimation: true);
+ }
+ finally
+ {
+ _isInternalOffsetAdjustment = wasInternal;
+ }
+ }
+
private bool ChangeViewCore(
double? horizontalOffset,
double? verticalOffset,
diff --git a/src/Uno.UI/UI/Xaml/UIElement.mux.cs b/src/Uno.UI/UI/Xaml/UIElement.mux.cs
index 511ad766c979..0e0106d582d1 100644
--- a/src/Uno.UI/UI/Xaml/UIElement.mux.cs
+++ b/src/Uno.UI/UI/Xaml/UIElement.mux.cs
@@ -1348,10 +1348,10 @@ internal virtual void EnterImpl(EnterParams @params, int depth)
//bool propViewport = GetIsViewportDirtyOrOnViewportDirtyPath();
//bool propContributesToViewport = GetWantsViewportOrContributesToViewport();
- //if (CanBeScrollAnchor)
- //{
- // UpdateAnchorCandidateOnParentScrollProvider(true /* add */);
- //}
+ if (CanBeScrollAnchor)
+ {
+ UpdateAnchorCandidateOnParentScrollProvider(add: true);
+ }
//if (EventEnabledElementAddedInfo())
//{
@@ -1997,10 +1997,10 @@ internal virtual void LeaveImpl(LeaveParams @params)
//// Discard the potential rejection viewports within this leaving element's subtree
//DiscardRejectionViewportsInSubTree();
- //if (CanBeScrollAnchor)
- //{
- // UpdateAnchorCandidateOnParentScrollProvider(false /* add */);
- //}
+ if (CanBeScrollAnchor)
+ {
+ UpdateAnchorCandidateOnParentScrollProvider(add: false);
+ }
}
}
@@ -2010,5 +2010,83 @@ internal virtual bool WantsScrollViewerToObscureAvailableSizeBasedOnScrollBarVis
=> true;
internal bool IsNonClippingSubtree { get; set; }
+
+ // Identifies the CanBeScrollAnchor dependency property. Moved out of the generated
+ // UIElement.cs (which had a [Uno.NotImplemented] stub) so we can attach a PropertyChangedCallback
+ // that mirrors WinUI's CUIElement::OnPropertyChanged dispatch for UIElement_CanBeScrollAnchor
+ // (uielement.cpp:861) and auto-registers the element with the nearest IScrollAnchorProvider.
+ public static DependencyProperty CanBeScrollAnchorProperty { get; } =
+ DependencyProperty.Register(
+ nameof(CanBeScrollAnchor),
+ typeof(bool),
+ typeof(UIElement),
+ new FrameworkPropertyMetadata(
+ defaultValue: false,
+ propertyChangedCallback: OnCanBeScrollAnchorChangedStatic));
+
+ ///
+ /// Gets or sets a value that indicates whether the UIElement can be a candidate for scroll
+ /// anchoring. The framework auto-registers / unregisters this element with the nearest
+ /// ancestor IScrollAnchorProvider when this value changes.
+ ///
+ public bool CanBeScrollAnchor
+ {
+ get => (bool)GetValue(CanBeScrollAnchorProperty);
+ set => SetValue(CanBeScrollAnchorProperty, value);
+ }
+
+ // Tracks the last IScrollAnchorProvider this element registered with, so we can unregister
+ // from the same provider on Leave even if the visual tree has been reshuffled in the meantime.
+ private global::Microsoft.UI.Xaml.Controls.IScrollAnchorProvider? _registeredScrollAnchorProvider;
+
+ private static void OnCanBeScrollAnchorChangedStatic(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ ((UIElement)d).UpdateAnchorCandidateOnParentScrollProvider(add: (bool)e.NewValue);
+ }
+
+ // Walks up the parent chain to the nearest IScrollAnchorProvider and registers/unregisters
+ // this element as an anchor candidate. Mirrors CUIElement::UpdateAnchorCandidateOnParentScrollProvider
+ // (uielement.cpp:934). Called on enter/leave (when CanBeScrollAnchor is already true) and
+ // from the property-changed callback when CanBeScrollAnchor flips.
+ private void UpdateAnchorCandidateOnParentScrollProvider(bool add)
+ {
+ if (add)
+ {
+ var provider = FindNearestScrollAnchorProvider();
+ if (provider is null)
+ {
+ return;
+ }
+ provider.RegisterAnchorCandidate(this);
+ _registeredScrollAnchorProvider = provider;
+ }
+ else
+ {
+ // Unregister from the provider we registered with, even if the tree has changed.
+ // Falls back to walking up if we never tracked one (e.g. registered before this
+ // hook was wired and only now leaving).
+ var provider = _registeredScrollAnchorProvider ?? FindNearestScrollAnchorProvider();
+ if (provider is null)
+ {
+ return;
+ }
+ provider.UnregisterAnchorCandidate(this);
+ _registeredScrollAnchorProvider = null;
+ }
+ }
+
+ private global::Microsoft.UI.Xaml.Controls.IScrollAnchorProvider? FindNearestScrollAnchorProvider()
+ {
+ DependencyObject? current = global::Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(this);
+ while (current is not null)
+ {
+ if (current is global::Microsoft.UI.Xaml.Controls.IScrollAnchorProvider provider)
+ {
+ return provider;
+ }
+ current = global::Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current);
+ }
+ return null;
+ }
}
}