Skip to content

Commit 87b1996

Browse files
authored
Merge pull request #19654 from unoplatform/dev/xygu/20250307/lv-scrollintoview-mid-refresh
fix(listview): setting SelectedItem during list refresh leaves blank in items on wasm/skia
2 parents 23eab84 + 20c0e90 commit 87b1996

File tree

3 files changed

+99
-41
lines changed

3 files changed

+99
-41
lines changed

src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Controls/Given_ListViewBase.cs

+66-21
Original file line numberDiff line numberDiff line change
@@ -2243,35 +2243,52 @@ public async Task When_LargeExtent_And_Very_Large_List_Scroll_To_End_And_Back_Ha
22432243

22442244
var scroll = list.FindFirstDescendant<ScrollViewer>();
22452245
Assert.IsNotNull(scroll);
2246-
dataContextChanged.Should().BeLessThan(10, $"dataContextChanged {dataContextChanged}");
2247-
2248-
ScrollTo(list, ElementHeight);
2249-
2250-
await WindowHelper.WaitForIdle();
2251-
2252-
materialized.Should().BeLessThan(12, $"materialized {materialized}");
2253-
dataContextChanged.Should().BeLessThan(11, $"dataContextChanged {dataContextChanged}");
22542246

2255-
ScrollTo(list, ElementHeight * 3);
2256-
2257-
await WindowHelper.WaitForIdle();
2247+
int expectedMaterialized = 0, expectedDCChanged = 0;
2248+
int[] previouslyMaterializedItems = [];
2249+
async Task ScrollAndValidate(string context, double? scrollTo)
2250+
{
2251+
if (scrollTo is { } voffset)
2252+
{
2253+
ScrollTo(list, voffset);
2254+
}
2255+
await WindowHelper.WaitForIdle();
22582256

2259-
materialized.Should().BeLessThan(14, $"materialized {materialized}");
2260-
dataContextChanged.Should().BeLessThan(13, $"dataContextChanged {dataContextChanged}");
2257+
#if HAS_UNO && !(__IOS__ || __ANDROID__)
2258+
var evpScaling = (list.ItemsPanelRoot as IVirtualizingPanel).GetLayouter().CacheLength * VirtualizingPanelLayout.ExtendedViewportScaling;
2259+
#else
2260+
var evpScaling = 0.5;
2261+
#endif
22612262

2262-
ScrollTo(list, scroll.ExtentHeight / 2); // Scroll to middle
2263+
var offset = scroll.VerticalOffset;
2264+
var max = scroll.ExtentHeight;
2265+
var vp = scroll.ViewportHeight;
22632266

2264-
await WindowHelper.WaitForIdle();
2267+
var evpStart = Math.Clamp(offset - vp * evpScaling, 0, max);
2268+
var evpEnd = Math.Clamp(offset + vp + vp * evpScaling, 0, max);
22652269

2266-
materialized.Should().BeLessThan(14, $"materialized {materialized}");
2267-
dataContextChanged.Should().BeLessThan(25, $"dataContextChanged {dataContextChanged}");
2270+
var firstIndex = (int)Math.Round(evpStart / ElementHeight, 0, MidpointRounding.ToNegativeInfinity);
2271+
var lastIndex = (int)Math.Round(evpEnd / ElementHeight, 0, MidpointRounding.ToPositiveInfinity) - 1;
2272+
var itemsInEVP = Enumerable.Range(firstIndex, lastIndex - firstIndex + 1).ToArray();
2273+
var newItemsInEVP = itemsInEVP.Except(previouslyMaterializedItems).ToArray();
22682274

2269-
ScrollTo(list, scroll.ExtentHeight / 4); // Scroll to Quarter
2275+
// materialized starts with +1 extra, since we use it to determine whether the DataTemplate itself is a self-container
2276+
// Math.Max to count the historical highest, since "materialization" doesnt "unhappen" (we dont count tear-down).
2277+
expectedMaterialized = Math.Max(expectedMaterialized, 1 + itemsInEVP.Length);
2278+
// dc-changed counts the total items prepared and "re-entrancy"(out of effective-viewport and back in).
2279+
// we just need to add the new items since last time
2280+
expectedDCChanged += newItemsInEVP.Length;
2281+
previouslyMaterializedItems = itemsInEVP;
22702282

2271-
await WindowHelper.WaitForIdle();
2283+
materialized.Should().BeLessOrEqualTo(expectedMaterialized, $"[{context}] materialized {materialized}");
2284+
dataContextChanged.Should().BeLessOrEqualTo(expectedDCChanged, $"[{context}] dataContextChanged {dataContextChanged}");
2285+
}
22722286

2273-
materialized.Should().BeLessThan(14, $"materialized {materialized}");
2274-
dataContextChanged.Should().BeLessThan(35, $"dataContextChanged {dataContextChanged}");
2287+
await ScrollAndValidate("initial state", null);
2288+
await ScrollAndValidate("scrolled past element#0", ElementHeight);
2289+
await ScrollAndValidate("scrolled past element#2", ElementHeight * 3);
2290+
await ScrollAndValidate("scrolled to 1/2", scroll.ExtentHeight / 2);
2291+
await ScrollAndValidate("scrolled back to 1/4", scroll.ExtentHeight / 4);
22752292
}
22762293
#endif
22772294

@@ -4868,6 +4885,34 @@ void AddItem(string item, bool select = false)
48684885

48694886
Assert.AreEqual(sv.ScrollableHeight, sv.VerticalOffset, "ListView is not scrolled to the end.");
48704887
}
4888+
4889+
[TestMethod]
4890+
public async Task When_SelectionChanged_DuringRefresh()
4891+
{
4892+
var source = new ObservableCollection<string>(Enumerable.Range(0, 4).Select(x => $"Item {x}"));
4893+
var sut = new ListView
4894+
{
4895+
Height = source.Count * 29 * 1.5, // give ample room
4896+
ItemsSource = source,
4897+
ItemTemplate = FixedSizeItemTemplate, // height=29
4898+
};
4899+
4900+
await UITestHelper.Load(sut, x => x.IsLoaded);
4901+
4902+
Assert.IsTrue(Enumerable.Range(0, 4).All(x => sut.ContainerFromIndex(x) is { }), "All containers should be materialized.");
4903+
4904+
source.Move(1, 2); // swap: 0[1]23 -> 02[1]3
4905+
sut.SelectedItem = source[2]; // select "Item 1" (at index 2)
4906+
4907+
await UITestHelper.WaitForIdle();
4908+
4909+
var tree = sut.TreeGraph();
4910+
Assert.IsTrue(Enumerable.Range(0, 4).All(x => sut.ContainerFromIndex(x) is { }), "All containers should be materialized.");
4911+
4912+
#if !(__ANDROID__ || __IOS__ || __MACOS__)
4913+
Assert.AreEqual(4, sut.ItemsPanelRoot.Children.OfType<ListViewItem>().Count(), "There should be only 4 materialized container.");
4914+
#endif
4915+
}
48714916
}
48724917

48734918
public partial class Given_ListViewBase // data class, data-context, view-model, template-selector

src/Uno.UI/Extensions/ViewExtensions.visual-tree.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,11 @@ static IEnumerable<string> GetDetails(object x)
152152
#if true // framework layout properties
153153
if (x is FrameworkElement fe)
154154
{
155-
yield return $"Actual={FormatSize(fe.ActualWidth, fe.ActualHeight)}";
156155
if (fe.Parent is FrameworkElement parent)
157156
{
158157
yield return $"XY={FormatPoint(fe.TransformToVisual(parent).TransformPoint(default))}";
159158
}
159+
yield return $"Actual={FormatSize(fe.ActualWidth, fe.ActualHeight)}";
160160
#if HAS_UNO
161161
//yield return $"Available={FormatSize(fe.m_previousAvailableSize)}";
162162
#endif

src/Uno.UI/UI/Xaml/Controls/ListViewBase/VirtualizingPanelLayout.managed.cs

+32-19
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,12 @@ private double ViewportExtent
165165
/// </summary>
166166
private double ViewportEnd => ScrollOffset + ViewportExtent;
167167

168+
internal const double ExtendedViewportScaling = 0.5;
169+
168170
/// <summary>
169171
/// The additional length in pixels for which to create buffered views.
170172
/// </summary>
171-
private double ViewportExtension => CacheLength * ViewportExtent * 0.5;
173+
private double ViewportExtension => CacheLength * ViewportExtent * ExtendedViewportScaling;
172174

173175
/// <summary>
174176
/// The start of the 'extended viewport,' the area of the visible viewport plus the buffer area defined by <see cref="CacheLength"/>.
@@ -459,7 +461,10 @@ private void ArrangeElements(Size finalSize, Size adjustedVisibleWindow)
459461
/// <summary>
460462
/// Update the item container layout by removing no-longer-visible views and adding visible views.
461463
/// </summary>
462-
/// <param name="extentAdjustment">Adjustment to apply when calculating fillable area.</param>
464+
/// <param name="extentAdjustment">
465+
/// Adjustment to apply when calculating fillable area.
466+
/// Used when a viewport change is not yet committed into the <see cref="ScrollOffset"/>.
467+
/// </param>
463468
private void UpdateLayout(double? extentAdjustment, bool isScroll)
464469
{
465470
ResetReorderingIndex();
@@ -501,7 +506,10 @@ private void UpdateCompleted()
501506
/// <summary>
502507
/// Fill in extended viewport with views.
503508
/// </summary>
504-
/// <param name="extentAdjustment">Adjustment to apply when calculating fillable area.</param>
509+
/// <param name="extentAdjustment">
510+
/// Adjustment to apply when calculating fillable area.
511+
/// Used when a viewport change is not yet committed into the <see cref="ScrollOffset"/>.
512+
/// </param>
505513
private void FillLayout(double extentAdjustment)
506514
{
507515
// Don't fill backward if we're doing a light rebuild (since we are starting from nearest previously-visible item)
@@ -512,6 +520,13 @@ private void FillLayout(double extentAdjustment)
512520

513521
FillForward();
514522

523+
if (_dynamicSeedStart.HasValue && GetItemsEnd() <= ExtendedViewportEnd + extentAdjustment)
524+
{
525+
// if the FillForward didnt fully fill the current viewport,
526+
// we may still need to a FillBackward, otherwise we risk having a leading blank space
527+
FillBackward();
528+
}
529+
515530
// Make sure that the reorder item has been rendered
516531
if (GetAndUpdateReorderingIndex() is { } reorderIndex && _materializedLines.None(line => line.Contains(reorderIndex)))
517532
{
@@ -520,35 +535,33 @@ private void FillLayout(double extentAdjustment)
520535

521536
void FillBackward()
522537
{
523-
if (GetItemsStart() > ExtendedViewportStart + extentAdjustment)
538+
while (
539+
GetItemsStart() is { } start && start > ExtendedViewportStart + extentAdjustment &&
540+
GetNextUnmaterializedItem(Backward, GetFirstMaterializedIndexPath()) is { } nextItem)
524541
{
525-
var nextItem = GetNextUnmaterializedItem(Backward, GetFirstMaterializedIndexPath());
526-
while (nextItem != null && GetItemsStart() > ExtendedViewportStart + extentAdjustment)
527-
{
528-
AddLine(Backward, nextItem.Value);
529-
nextItem = GetNextUnmaterializedItem(Backward, GetFirstMaterializedIndexPath());
530-
}
542+
AddLine(Backward, nextItem);
531543
}
532544
}
533-
534545
void FillForward()
535546
{
536-
if ((GetItemsEnd() ?? 0) < ExtendedViewportEnd + extentAdjustment)
547+
var seed = _dynamicSeedIndex;
548+
while (
549+
GetItemsEnd() is var end && (end ?? 0) < ExtendedViewportEnd + extentAdjustment &&
550+
GetNextUnmaterializedItem(Forward, seed ?? GetLastMaterializedIndexPath()) is { } nextItem)
537551
{
538-
var nextItem = GetNextUnmaterializedItem(Forward, _dynamicSeedIndex ?? GetLastMaterializedIndexPath());
539-
while (nextItem != null && (GetItemsEnd() ?? 0) < ExtendedViewportEnd + extentAdjustment)
540-
{
541-
AddLine(Forward, nextItem.Value);
542-
nextItem = GetNextUnmaterializedItem(Forward, GetLastMaterializedIndexPath());
543-
}
552+
AddLine(Forward, nextItem);
553+
seed = null;
544554
}
545555
}
546556
}
547557

548558
/// <summary>
549559
/// Remove views that lie entirely outside extended viewport.
550560
/// </summary>
551-
/// <param name="extentAdjustment">Adjustment to apply when calculating fillable area.</param>
561+
/// <param name="extentAdjustment">
562+
/// Adjustment to apply when calculating fillable area.
563+
/// Used when a viewport change is not yet committed into the <see cref="ScrollOffset"/>.
564+
/// </param>
552565
private void UnfillLayout(double extentAdjustment)
553566
{
554567
UnfillBackward();

0 commit comments

Comments
 (0)