-
Notifications
You must be signed in to change notification settings - Fork 2k
[Android] Fix for Incorrect ItemsViewScrolledEventArgs Values in CollectionView with Grouped Items #31437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Android] Fix for Incorrect ItemsViewScrolledEventArgs Values in CollectionView with Grouped Items #31437
Changes from all commits
de97aa1
7bb728e
8c051e4
ccd2ef5
ce3b093
e0db804
10ad5ac
d01eeb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| #nullable disable | ||
| using System; | ||
| using AndroidX.RecyclerView.Widget; | ||
|
|
||
| namespace Microsoft.Maui.Controls.Handlers.Items | ||
|
|
@@ -122,9 +123,7 @@ void HandleRemainingItemsThresholdReached() | |
|
|
||
| protected virtual (int First, int Center, int Last) GetVisibleItemsIndex(RecyclerView recyclerView) | ||
| { | ||
| var firstVisibleItemIndex = -1; | ||
| var lastVisibleItemIndex = -1; | ||
| var centerItemIndex = -1; | ||
| int firstVisibleItemIndex = -1, lastVisibleItemIndex = -1, centerItemIndex = -1; | ||
|
|
||
| if (recyclerView.GetLayoutManager() is LinearLayoutManager linearLayoutManager) | ||
| { | ||
|
|
@@ -133,63 +132,156 @@ protected virtual (int First, int Center, int Last) GetVisibleItemsIndex(Recycle | |
| centerItemIndex = recyclerView.CalculateCenterItemIndex(firstVisibleItemIndex, linearLayoutManager, _getCenteredItemOnXAndY); | ||
| } | ||
|
|
||
| bool hasHeader = ItemsViewAdapter.ItemsSource.HasHeader; | ||
| bool hasFooter = ItemsViewAdapter.ItemsSource.HasFooter; | ||
| int itemsCount = ItemsViewAdapter.ItemCount; | ||
| var adapter = ItemsViewAdapter; | ||
| var itemsSource = adapter.ItemsSource; | ||
| int itemsCount = adapter.ItemCount; | ||
| bool hasHeader = itemsSource.HasHeader; | ||
| bool hasFooter = itemsSource.HasFooter; | ||
|
|
||
| if (!hasHeader && !hasFooter) | ||
| if (itemsSource is not UngroupedItemsSource && itemsSource is IGroupableItemsViewSource groupable) | ||
| { | ||
| return (firstVisibleItemIndex, centerItemIndex, lastVisibleItemIndex); | ||
| return ( | ||
| AdjustGroupIndex(groupable, firstVisibleItemIndex, hasHeader, hasFooter, itemsCount, snapForward: true), | ||
| AdjustGroupIndex(groupable, centerItemIndex, hasHeader, hasFooter, itemsCount, snapForward: true), | ||
| AdjustGroupIndex(groupable, lastVisibleItemIndex, hasHeader, hasFooter, itemsCount, snapForward: false) | ||
| ); | ||
|
SyedAbdulAzeemSF4852 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (firstVisibleItemIndex == 0 && lastVisibleItemIndex == itemsCount - 1) | ||
| // Adjust for footer: if the last visible item is the footer, decrement to get the last data item index | ||
| if (hasFooter && lastVisibleItemIndex == itemsCount - 1) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [moderate] Logic and Correctness — The footer check only decrements Fix: also apply the footer decrement to |
||
| { | ||
| lastVisibleItemIndex -= hasHeader && hasFooter ? 2 : 1; | ||
| lastVisibleItemIndex--; | ||
| } | ||
| else | ||
|
|
||
| // Non-grouped items adjustment | ||
| if (hasHeader) | ||
| { | ||
| if (hasHeader && !hasFooter) | ||
| firstVisibleItemIndex--; | ||
| lastVisibleItemIndex--; | ||
| centerItemIndex--; | ||
| } | ||
|
|
||
| int maxValidIndex = Math.Max(0, itemsSource.Count - 1); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Logic and Correctness —
In practice, the prior footer/header decrements keep values within the correct range for int maxValidIndex = Math.Max(0, itemsSource.Count - 1 - (hasHeader ? 1 : 0) - (hasFooter ? 1 : 0)); |
||
| firstVisibleItemIndex = Math.Clamp(firstVisibleItemIndex, 0, maxValidIndex); | ||
| lastVisibleItemIndex = Math.Clamp(lastVisibleItemIndex, 0, maxValidIndex); | ||
| centerItemIndex = Math.Clamp(centerItemIndex, 0, maxValidIndex); | ||
|
|
||
| return (firstVisibleItemIndex, centerItemIndex, lastVisibleItemIndex); | ||
| } | ||
|
|
||
| /// <param name="snapForward"> | ||
| /// When the adapter position falls on a group header or group footer, | ||
| /// true = snap to the first data item in the following group (use for FirstVisible/Center), | ||
| /// false = snap to the last data item in the preceding group (use for LastVisible). | ||
| /// </param> | ||
| static int AdjustGroupIndex(IGroupableItemsViewSource source, int position, bool hasHeader, bool hasFooter, int count, bool snapForward) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] Performance-Critical Path — O(N²) per scroll event for grouped collections
Recommendation: avoid calling |
||
| { | ||
| if (position < 0) | ||
| { | ||
| return 0; | ||
| } | ||
|
|
||
| if (position >= count) | ||
| { | ||
| return Math.Max(0, GetGroupedDataCount(source) - 1); | ||
| } | ||
|
|
||
| int dataIndex = 0, currentItem = hasHeader ? 1 : 0; | ||
|
|
||
| // Iterate through items until we reach the target position | ||
| while (currentItem <= position && currentItem < count) | ||
| { | ||
| if (hasFooter && currentItem == count - 1) | ||
| { | ||
| lastVisibleItemIndex -= 1; | ||
| firstVisibleItemIndex -= 1; | ||
| break; | ||
| } | ||
| else if (!hasHeader && hasFooter) | ||
|
|
||
| bool isHeader = source.IsGroupHeader(currentItem), isFooter = source.IsGroupFooter(currentItem); | ||
|
|
||
| // If current item is a normal data item (not header/footer) | ||
| if (!isHeader && !isFooter) | ||
| { | ||
| if (lastVisibleItemIndex == itemsCount - 1) | ||
| if (currentItem == position) | ||
| { | ||
| lastVisibleItemIndex -= 1; | ||
| return dataIndex; | ||
| } | ||
|
|
||
| dataIndex++; | ||
| } | ||
| else if (hasHeader && hasFooter) | ||
| // If position is a group header/footer, find the nearest data item | ||
| else if (currentItem == position) | ||
| { | ||
| if (firstVisibleItemIndex == 0) | ||
| { | ||
| lastVisibleItemIndex -= 1; | ||
| } | ||
| else if (lastVisibleItemIndex != itemsCount - 1) | ||
| { | ||
| firstVisibleItemIndex -= 1; | ||
| lastVisibleItemIndex -= 1; | ||
| } | ||
| else | ||
| { | ||
| firstVisibleItemIndex -= 1; | ||
| lastVisibleItemIndex -= 2; | ||
| } | ||
| return snapForward | ||
| ? FindNextDataIndex(source, currentItem, hasFooter, count, dataIndex) | ||
| : FindPrevDataIndex(source, currentItem, hasHeader); | ||
| } | ||
|
|
||
| currentItem++; | ||
| } | ||
|
|
||
| // If we reach here, pos was beyond the last item | ||
| // Return the last valid data index (or 0 if empty) | ||
| return Math.Max(0, dataIndex - 1); | ||
| } | ||
|
|
||
| static int GetGroupedDataCount(IGroupableItemsViewSource source) | ||
| { | ||
| // Count data items only (excluding all headers and footers) | ||
| int dataCount = 0; | ||
| for (int index = 0; index < source.Count; index++) | ||
| { | ||
| if (!source.IsGroupHeader(index) && !source.IsGroupFooter(index) && | ||
| !source.IsHeader(index) && !source.IsFooter(index)) | ||
| { | ||
| dataCount++; | ||
| } | ||
| } | ||
|
|
||
| if (firstVisibleItemIndex < 0) | ||
| return dataCount; | ||
| } | ||
|
|
||
| // dataIndex: the 0-based data item index to assign to the next valid item found. | ||
| // Returned without incrementing because the item following a header/footer inherits this index. | ||
| static int FindNextDataIndex(IGroupableItemsViewSource source, int start, bool hasFooter, int count, int dataIndex) | ||
|
SyedAbdulAzeemSF4852 marked this conversation as resolved.
|
||
| { | ||
| for (int i = start + 1; i < count; i++) | ||
| { | ||
| firstVisibleItemIndex = 0; | ||
| // Skip footer item if present | ||
| if (hasFooter && i == count - 1) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| // If we find a regular item (not a group header or footer), | ||
| // return the current data index without incrementing | ||
| if (!source.IsGroupHeader(i) && !source.IsGroupFooter(i)) | ||
| { | ||
| return dataIndex; | ||
| } | ||
| } | ||
|
|
||
| if (lastVisibleItemIndex < 0) | ||
| // If no valid data item found ahead, return the previous data index | ||
| // (or 0 if no valid items exist) | ||
| return Math.Max(0, dataIndex - 1); | ||
| } | ||
|
|
||
| static int FindPrevDataIndex(IGroupableItemsViewSource source, int start, bool hasHeader) | ||
| { | ||
| int lastValid = -1; | ||
| int currentItem = hasHeader ? 1 : 0; | ||
|
|
||
| for (; currentItem < start; currentItem++) | ||
| { | ||
| lastVisibleItemIndex = 0; | ||
| // Increment counter only for data items (not headers/footers) | ||
| // to get accurate position for last visible item index | ||
| if (!source.IsGroupHeader(currentItem) && !source.IsGroupFooter(currentItem)) | ||
| { | ||
| lastValid++; | ||
| } | ||
| } | ||
|
|
||
| return (firstVisibleItemIndex, centerItemIndex, lastVisibleItemIndex); | ||
| // Return the last valid data item found (or 0 if none) | ||
| return Math.Max(0, lastValid); | ||
| } | ||
|
|
||
| protected override void Dispose(bool disposing) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,4 @@ | ||
| #if TEST_FAILS_ON_ANDROID && TEST_FAILS_ON_WINDOWS // Android fix: https://github.com/dotnet/maui/pull/31437 | ||
| // Windows: The Scrolled event is not consistently triggered in the CI environment during automated | ||
| // scrolling, so the label text is never updated. This is a test infrastructure limitation on Windows; | ||
| // the fix itself is iOS/MacCatalyst-only and works correctly on iOS and MacCatalyst. | ||
| #if TEST_FAILS_ON_WINDOWS // Windows: The Scrolled event is not consistently triggered in the CI environment during automated scrolling, so the label text is not updated. This is a known limitation of the test infrastructure on Windows. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Regression Prevention — test validates only The fix translates all three grouped adapter positions to logical data indices ( Consider adding a second scroll scenario that exposes |
||
| using NUnit.Framework; | ||
| using UITest.Appium; | ||
| using UITest.Core; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[major] CollectionView Android — RemainingItemsThreshold regression for grouped collections
GetVisibleItemsIndexnow returns logical data-item indices (0-based, excluding group headers/footers) for grouped sources. However,OnScrolledstill computesactualItemCount = ItemsViewAdapter.ItemCount - headerValue - footerValue, which includes group headers and group footers in the count.Concrete scenario: 3 categories × 5 items with group headers = 18 adapter items, no collection header/footer.
actualItemCount = 18Last(logical data index) is at most 14Last == actualItemCount - 1→14 == 17→ never trueRemainingItemsThresholdReachednever fires for grouped collections after this PRThis is a functional regression introduced by this fix. The
OnScrolledthreshold check at lines 78–92 must be updated to use the logical data count (e.g.,GetGroupedDataCount) when the source is grouped, rather than the raw adapter item count.