Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,9 @@ public virtual void UpdateItemsSource()
UpdateAdapter();

// Set up any properties which require observing data changes in the adapter
UpdateItemsUpdatingScrollMode();

UpdateEmptyView();
AddOrUpdateScrollListener();
UpdateItemsUpdatingScrollMode();
UpdateSnapBehavior();
}

Expand All @@ -382,6 +381,15 @@ protected virtual void UpdateItemsUpdatingScrollMode()
if (ItemsViewAdapter == null || ItemsView == null)
return;

if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepScrollOffset)
{
ScrollHelper.AddScrollListener();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Android CollectionView — scroll listener lifecycleScrollHelper.AddScrollListener() can no-op after the helper has already been removed by AddOrUpdateScrollListener()/RemoveScrollListener(), because those methods call ClearOnScrollListeners() but do not reset ScrollHelper's _maintainingScrollOffsets flag. Concrete scenario: with KeepScrollOffset active, UpdateItemsSource() calls AddOrUpdateScrollListener(), clearing all listeners including ScrollHelper; then this line calls AddScrollListener(), but _maintainingScrollOffsets is still true so the helper is not re-registered and future collection updates are not tracked.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Handler lifecycleScrollHelper.AddScrollListener() can no-op after AddOrUpdateScrollListener() calls ClearOnScrollListeners(). ClearOnScrollListeners() removes the helper from RecyclerView, but _maintainingScrollOffsets remains true, so changing/replacing the ItemsSource while in KeepScrollOffset mode can leave the helper unregistered and future offset tracking disabled.

}
else
{
ScrollHelper.RemoveScrollListener();
}
Comment on lines +384 to +391

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the concern


if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepItemsInView)
{
// Keeping the current items in view is the default, so we don't need to watch for data changes
Expand Down
41 changes: 31 additions & 10 deletions src/Controls/src/Core/Handlers/Items/Android/ScrollHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal class ScrollHelper : RecyclerView.OnScrollListener

bool _undoNextScrollAdjustment;
bool _maintainingScrollOffsets;

bool _isAtScrollOrigin = true;
int _lastScrollX;
int _lastScrollY;
int _lastDeltaX;
Expand All @@ -26,13 +26,6 @@ public ScrollHelper(RecyclerView recyclerView)
// Used by the renderer to maintain scroll offset when using ItemsUpdatingScrollMode KeepScrollOffset
public void UndoNextScrollAdjustment()
{
// Don't start tracking the scroll offsets until we really need to
if (!_maintainingScrollOffsets)
{
_maintainingScrollOffsets = true;
_recyclerView.AddOnScrollListener(this);
}

_undoNextScrollAdjustment = true;

_lastScrollX = _recyclerView.ComputeHorizontalScrollOffset();
Expand Down Expand Up @@ -212,18 +205,46 @@ void TrackOffsets()
// offset to shift; since the ItemsUpdatingScrollMode is set to KeepScrollOffset; we need to undo
// that shift and stay where we were before the item was added

_undoNextScrollAdjustment = false;
_recyclerView.ScrollBy(-_lastDeltaX, -_lastDeltaY);
if (_isAtScrollOrigin)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Android CollectionView — KeepScrollOffset semantics — This gates the offset correction on _isAtScrollOrigin, so once the user is scrolled away from offset 0, inserts before the viewport are allowed to keep RecyclerView's automatic shifted offset instead of restoring the previous absolute pixel offset. Concrete scenario: set ItemsUpdatingScrollMode=KeepScrollOffset, scroll down, insert an item at index 0; RecyclerView increases ComputeVerticalScrollOffset() to keep the old item visible, but this branch skips ScrollBy(-_lastDeltaY), so the absolute scroll offset is not preserved. KeepScrollOffset should undo the adapter-induced delta regardless of whether the current offset is origin.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] CollectionView Android — Gating the offset correction on _isAtScrollOrigin changes KeepScrollOffset semantics when the user is already scrolled away from zero. In that scenario, inserting items above lets RecyclerView keep the same visible item anchored instead of restoring the absolute scroll offset, which matches KeepItemsInView more than the documented KeepScrollOffset behavior.

{
_recyclerView.ScrollBy(-_lastDeltaX, -_lastDeltaY);
}

_undoNextScrollAdjustment = false;
_lastDeltaX = 0;
_lastDeltaY = 0;
}
else
{
_isAtScrollOrigin = newXOffset == 0
&& newYOffset == 0;
}
}

public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)
{
base.OnScrolled(recyclerView, dx, dy);
TrackOffsets();
}

internal void AddScrollListener()
{
// Set up scroll listener to track the scroll offsets when we're using KeepScrollOffset.
if (!_maintainingScrollOffsets)
{
_maintainingScrollOffsets = true;
_recyclerView.AddOnScrollListener(this);
}
}

internal void RemoveScrollListener()
{
// Remove the scroll listener when we're done and no longer need to track the offsets.
if (_maintainingScrollOffsets)
{
_maintainingScrollOffsets = false;
_recyclerView.RemoveOnScrollListener(this);
}
}
}
}
85 changes: 85 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue29131.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Collections.ObjectModel;
using Maui.Controls.Sample.Issues;

namespace Controls.TestCases.HostApp.Issues;

[Issue(IssueTracker.Github, 29131, "Android - KeepScrollOffset does not work as expected when new items are added in CollectionView", PlatformAffected.Android)]
public class Issue29131 : TestContentPage
{
ObservableCollection<string> items;
CollectionView collectionView;
int count = 1;

protected override void Init()
{
items = new ObservableCollection<string>(Enumerable.Range(1, 30).Select(i => $"Item {i}"));

Button keepScrollOffsetButton = CreateButton("KeepScrollOffset", "KeepScrollOffsetButton", OnKeepScrollOffsetClicked);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could include more Buttons to change the ItemsUpdatingScrollMode value https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Items/ItemsUpdatingScrollMode.cs#L8
and test the behavior with the different possibilities?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsuarezruiz,

Yes, adding tests for the various ItemsUpdatingScrollMode values would provide broader coverage. However,I would like to highlight a couple of existing platform-specific issues currently impacting these modes:

On Android: The KeepItemsInView mode currently does not work as expected, which is a known issue (#29145). This is being addressed in PR #27153, which is still under review.

On iOS : KeepLastItemInView is also not functioning correctly. This is another known issue (28716), which is also addressed in the same PR currently under review: PR #28720

Given these inconsistencies, only the KeepScrollOffset mode behaves consistently across all platforms at this time. This PR focuses on verifying that stable behavior.

Since the other two PRs handles the KeepItemsInView mode and KeepLastItemInView mode can we avoid adding tests for those modes?

Looking for your insights.

Button addButton = CreateButton("Add Item to Top", "AddNewItem", OnAddItemClicked);
Button scrollButton = CreateButton("Scroll CollectionView", "ScrollButton", OnScrollButtonClicked);

collectionView = new CollectionView
{
AutomationId = "CollectionView",
ItemsSource = items,
ItemTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, ".");
return new Border
{
Content = label,
Padding = 10,
Margin = new Thickness(5),
BackgroundColor = Colors.LightGray,
};
})
};

Grid grid = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Star }
},
RowSpacing = 5
};
grid.Add(keepScrollOffsetButton, 0, 0);
grid.Add(addButton, 0, 1);
grid.Add(scrollButton, 0, 2);
grid.Add(collectionView, 0, 3);

Content = grid;
}

Button CreateButton(string text, string automationId, EventHandler onClick)
{
return new Button
{
Text = text,
AutomationId = automationId,
Command = new Command(_ => onClick(this, EventArgs.Empty))
};
}

void OnKeepScrollOffsetClicked(object sender, EventArgs e)
{
collectionView.ItemsUpdatingScrollMode = ItemsUpdatingScrollMode.KeepScrollOffset;
}

void OnScrollButtonClicked(object sender, EventArgs e)
{
int index = (count % 2 == 0) ? 0 : items.Count - 1;
var position = (count % 2 == 0) ? ScrollToPosition.Start : ScrollToPosition.End;
collectionView.ScrollTo(index, position: position, animate: true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] UI test reliability — The page starts an animated ScrollTo and the test immediately inserts an item. On slower Android devices the insert can race the still-running scroll animation, making the test assert the animation timing rather than the CollectionView update behavior. Prefer a non-animated scroll or a deterministic wait for the target item before enabling insertion.

count++;
}

void OnAddItemClicked(object sender, EventArgs e)
{
items.Insert(0, $"Item {items.Count + 1}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public void KeepItemsInView()

#if TEST_FAILS_ON_IOS && TEST_FAILS_ON_CATALYST // The test fails on iOS and macOS because Appium is unable to locate the Picker control elements resulting in a TimeoutException. For more information, see: https://github.com/dotnet/maui/issues/28024
// KeepScrollOffset (src\Compatibility\ControlGallery\src\Issues.Shared\CollectionViewItemsUpdatingScrollMode.cs)
// After scrolling to the middle, adding items above should not cause the view to scroll.
// Previously, the view would automatically scroll to show newly added items, which broke
// KeepScrollOffset semantics. With the fix, the visible items remain stable; adding 5 items
// above shifts indices so the previously visible "Vegetables.jpg, 10" becomes "FlowerBuds.jpg, 12".
[Test]
[Category(UITestCategories.CollectionView)]
public void KeepScrollOffset()
Expand All @@ -47,8 +51,13 @@ public void KeepScrollOffset()
App.WaitForElement("ScrollToMiddle");
App.Click("ScrollToMiddle");
App.WaitForElement("Vegetables.jpg, 10");
App.Click("AddItemAbove");
App.WaitForElement("photo.jpg, 9");

for (int i = 0; i < 5; i++)
{
App.Click("AddItemAbove");
}

App.WaitForElement("FlowerBuds.jpg, 12");
}

// KeepLastItemInView(src\Compatibility\ControlGallery\src\Issues.Shared\CollectionViewItemsUpdatingScrollMode.cs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue29131 : _IssuesUITest
{
public Issue29131(TestDevice device) : base(device) { }

public override string Issue => "Android - KeepScrollOffset does not work as expected when new items are added in CollectionView";
const string AddNewItem = "AddNewItem";
const string ScrollButton = "ScrollButton";

[Test]
[Category(UITestCategories.CollectionView)]
public void KeepScrollOffsetShouldWork()
{
App.WaitForElement("CollectionView");
App.Click("KeepScrollOffsetButton");
App.Click(ScrollButton);
App.Click(AddNewItem);
App.WaitForElement("Item 30");
App.Click(ScrollButton);
App.Click(AddNewItem);
App.WaitForElement("Item 32");
App.Click(ScrollButton);
App.Click(AddNewItem);
App.WaitForElement("Item 30");
}
}
Loading