Skip to content

Commit 7dcd732

Browse files
[Android] CollectionView: Defer RemainingItemsThresholdReached to avoid RecyclerView scroll callback warnings (#30907)
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Root Cause When `RemainingItemsThreshold` is met during a scroll, `OnScrolled` calls `SendRemainingItemsThresholdReached()` synchronously. This triggers `notifyItemRangeChanged` on the RecyclerView adapter while still inside the scroll callback. RecyclerView asserts against adapter modifications during layout/scroll passes, producing: ``` W/RecyclerView: Cannot call this method in a scroll callback. Scroll callbacks might be run during a measure & layout pass where you cannot change the RecyclerView data. java.lang.IllegalStateException: ...RecyclerView.assertNotInLayoutOrScroll ``` This affects any scenario where `RemainingItemsThresholdReachedCommand` (or the `RemainingItemsThresholdReached` event) modifies the bound collection — including the common pattern of clearing and reloading items followed by `ScrollTo(0)`. ### Description of Change Defers `SendRemainingItemsThresholdReached()` until the RecyclerView transitions to `ScrollStateIdle`, which is a guaranteed safe point outside any layout/scroll callback. **Changes in `RecyclerViewScrollListener.cs`:** - Added `_pendingRemainingItemsThresholdReached` boolean field - Replaced direct calls to `_itemsView.SendRemainingItemsThresholdReached()` in `OnScrolled` with calls to `HandleRemainingItemsThresholdReached()` - Added private `HandleRemainingItemsThresholdReached()` method that sets the pending flag - Added `OnScrollStateChanged` override that fires the deferred event when `newState == RecyclerView.ScrollStateIdle`, with disposal and null guards - Clears the pending flag in `Dispose(bool)` to avoid stale state **Why `OnScrollStateChanged` / `ScrollStateIdle` instead of `Post()`:** `recyclerView.Post()` posts to the next Looper tick, which can still be a layout pass and trigger the same RecyclerView assertion. Using `ScrollStateIdle` is a stronger guarantee — it signals that RecyclerView has fully settled and adapter mutations are safe. ### What NOT to Do (for future agents) - ❌ **Don't call `SendRemainingItemsThresholdReached()` directly in `OnScrolled`** — RecyclerView asserts against adapter modifications during scroll/layout passes; this causes `IllegalStateException` warnings. - ❌ **Don't use `recyclerView.Post()`** — `Post()` only defers to the next Looper tick, which may still be a layout pass and can trigger the same RecyclerView assertion. `ScrollStateIdle` is a stronger, safer guarantee. ### Issues Fixed Fixes #23030 Fixes #25010 ### Platforms Tested - [x] Android - [ ] Windows - [ ] iOS - [ ] Mac <!-- Are you targeting main? All PRs should target the main branch unless otherwise noted. --> ### Output | Before| After| |--|--| | <video src="https://github.com/user-attachments/assets/80c204ed-1657-4303-85de-df3ba998237a"> | <video src="https://github.com/user-attachments/assets/fc748a87-8419-4fa4-9286-7ba3999b6c97"> | --------- Co-authored-by: Jakub Florkowski <kubaflo123@gmail.com>
1 parent c4e883a commit 7dcd732

2 files changed

Lines changed: 29 additions & 2 deletions

File tree

src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class RecyclerViewScrollListener<TItemsView, TItemsViewSource> : Recycler
1111
bool _disposed;
1212
int _horizontalOffset, _verticalOffset;
1313
TItemsView _itemsView;
14+
bool _pendingRemainingItemsThresholdReached;
1415
readonly bool _getCenteredItemOnXAndY = false;
1516
bool _hasCompletedFirstLayout = false;
1617

@@ -91,10 +92,34 @@ public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)
9192

9293
if (isThresholdReached)
9394
{
94-
_itemsView.SendRemainingItemsThresholdReached();
95+
HandleRemainingItemsThresholdReached();
9596
}
9697
}
9798

99+
public override void OnScrollStateChanged(RecyclerView recyclerView, int newState)
100+
{
101+
base.OnScrollStateChanged(recyclerView, newState);
102+
103+
// If we have a pending threshold reached event and the RecyclerView is now idle,
104+
// it's safe to trigger the event without the risk of modifying the adapter during a scroll callback
105+
if (_pendingRemainingItemsThresholdReached && newState == RecyclerView.ScrollStateIdle)
106+
{
107+
_pendingRemainingItemsThresholdReached = false;
108+
if (!_disposed && _itemsView is not null)
109+
{
110+
_itemsView.SendRemainingItemsThresholdReached();
111+
}
112+
}
113+
}
114+
115+
void HandleRemainingItemsThresholdReached()
116+
{
117+
// Mark that we need to trigger the threshold reached event
118+
// This will be handled when the RecyclerView transitions to idle state
119+
// to avoid the "Cannot call this method in a scroll callback" exception
120+
_pendingRemainingItemsThresholdReached = true;
121+
}
122+
98123
protected virtual (int First, int Center, int Last) GetVisibleItemsIndex(RecyclerView recyclerView)
99124
{
100125
var firstVisibleItemIndex = -1;
@@ -176,6 +201,7 @@ protected override void Dispose(bool disposing)
176201
{
177202
_itemsView = null;
178203
ItemsViewAdapter = null;
204+
_pendingRemainingItemsThresholdReached = false;
179205
}
180206

181207
_disposed = true;

src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void
77
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool
88
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnTouchEvent(Android.Views.MotionEvent e) -> bool
9-
~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter<TItemsView, TItemsSource>.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool
9+
~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener<TItemsView, TItemsViewSource>.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void
10+
~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter<TItemsView, TItemsSource>.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool

0 commit comments

Comments
 (0)