diff --git a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs index e23134fb4fd7..e816322cffbe 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs @@ -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) + ); } - 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) { - 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); + firstVisibleItemIndex = Math.Clamp(firstVisibleItemIndex, 0, maxValidIndex); + lastVisibleItemIndex = Math.Clamp(lastVisibleItemIndex, 0, maxValidIndex); + centerItemIndex = Math.Clamp(centerItemIndex, 0, maxValidIndex); + + return (firstVisibleItemIndex, centerItemIndex, lastVisibleItemIndex); + } + + /// + /// 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). + /// + static int AdjustGroupIndex(IGroupableItemsViewSource source, int position, bool hasHeader, bool hasFooter, int count, bool snapForward) + { + 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) + { + 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) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue17664.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue17664.cs index 75fafeb1b9ab..7fd0a8426363 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue17664.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue17664.cs @@ -2,7 +2,7 @@ namespace Maui.Controls.Sample.Issues; -[Issue(IssueTracker.Github, 17664, "Incorrect ItemsViewScrolledEventArgs in CollectionView when IsGrouped is set to true", PlatformAffected.iOS)] +[Issue(IssueTracker.Github, 17664, "Incorrect ItemsViewScrolledEventArgs in CollectionView when IsGrouped is set to true", PlatformAffected.iOS | PlatformAffected.Android)] public class Issue17664 : ContentPage { CollectionView _collectionView; diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17664.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17664.cs index 71874bd0c082..13b94a8d1d4a 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17664.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue17664.cs @@ -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. using NUnit.Framework; using UITest.Appium; using UITest.Core;