Skip to content
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
Expand Down Expand Up @@ -77,9 +78,7 @@ public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)

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)
{
Expand All @@ -88,63 +87,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)
);
Comment thread
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)
{
lastVisibleItemIndex -= hasHeader && hasFooter ? 2 : 1;
lastVisibleItemIndex--;
}
else

// Non-grouped items adjustment
if (hasHeader)
{
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);
}

/// <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)
{
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 (hasHeader && !hasFooter)
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 (firstVisibleItemIndex < 0)
// 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++)
{
firstVisibleItemIndex = 0;
if (!source.IsGroupHeader(index) && !source.IsGroupFooter(index) &&
!source.IsHeader(index) && !source.IsFooter(index))
{
dataCount++;
}
}

if (lastVisibleItemIndex < 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)
Comment thread
SyedAbdulAzeemSF4852 marked this conversation as resolved.
{
for (int i = start + 1; i < count; i++)
{
lastVisibleItemIndex = 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;
}
}

return (firstVisibleItemIndex, centerItemIndex, lastVisibleItemIndex);
// 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++)
{
// 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 the last valid data item found (or 0 if none)
return Math.Max(0, lastValid);
}

protected override void Dispose(bool disposing)
Expand Down
120 changes: 120 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue17664.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Collections.ObjectModel;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 17664, "Incorrect ItemsViewScrolledEventArgs in CollectionView when IsGrouped is set to true", PlatformAffected.Android)]
public class Issue17664 : ContentPage
{
CollectionView _collectionView;
Label descriptionLabel;
ObservableCollection<Issue17664_ItemModelGroup> _groupedItems;

public Issue17664()
{
Button scrollButton = new Button
{
AutomationId = "Issue17664ScrollBtn",
Text = "Scroll to Category C, Item #2"
};
scrollButton.Clicked += ScrollButton_Clicked;

descriptionLabel = new Label
{
AutomationId = "Issue17664DescriptionLabel",
Text = "Use the button above to scroll the CollectionView.",
FontSize = 14,
HorizontalOptions = LayoutOptions.Center
};

_collectionView = new CollectionView
{
IsGrouped = true,
GroupHeaderTemplate = new DataTemplate(() =>
{
Label label = new Label
{
FontAttributes = FontAttributes.Bold,
BackgroundColor = Colors.LightGray,
Padding = 10
};

label.SetBinding(Label.TextProperty, "Name");
return label;
}),
ItemTemplate = new DataTemplate(() =>
{
Label textLabel = new Label
{
FontAttributes = FontAttributes.Bold,
Padding = 30
};

textLabel.SetBinding(Label.TextProperty, ".");
return textLabel;
})
};

_collectionView.Scrolled += (s, e) =>
{
var flatItems = _groupedItems.SelectMany(group => group).ToList();
if (e.LastVisibleItemIndex < flatItems.Count)
{
descriptionLabel.Text = flatItems[e.LastVisibleItemIndex];
}
};

List<string> categories = new List<string> { "Category A", "Category B", "Category C" };

_groupedItems = new ObservableCollection<Issue17664_ItemModelGroup>();

foreach (var category in categories)
{
List<string> items = new List<string>();

for (int i = 0; i < 5; i++)
{
items.Add($"{category} item #{i}");
}

_groupedItems.Add(new Issue17664_ItemModelGroup(category, items));
}

_collectionView.ItemsSource = _groupedItems;

Grid grid = new Grid
{
RowSpacing = 10,
Padding = 10,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Star }
}
};

grid.Add(scrollButton, 0, 0);
grid.Add(descriptionLabel, 0, 1);
grid.Add(_collectionView, 0, 2);

Content = grid;
}

private void ScrollButton_Clicked(object sender, EventArgs e)
{
var targetGroup = _groupedItems.FirstOrDefault(group => group.Name == "Category C");
var targetItem = targetGroup.FirstOrDefault(item => item == "Category C item #2");

_collectionView.ScrollTo(targetItem, targetGroup, ScrollToPosition.End);
}
}

public class Issue17664_ItemModelGroup : ObservableCollection<string>
{
public string Name { get; set; }

public Issue17664_ItemModelGroup(string name, IEnumerable<string> items) : base(items)
{
Name = name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#if TEST_FAILS_ON_IOS && TEST_FAILS_ON_CATALYST && TEST_FAILS_ON_WINDOWS // iOS/MacCatalyst fix: https://github.com/dotnet/maui/pull/34240
// 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 (RecyclerViewScrollListener.cs) is Android-only and works correctly on Android.
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue => "Incorrect ItemsViewScrolledEventArgs in CollectionView when IsGrouped is set to true";

[Test]
[Category(UITestCategories.CollectionView)]
public void VerifyGroupedCollectionViewVisibleItemIndices()
Comment thread
jsuarezruiz marked this conversation as resolved.
{
App.WaitForElement("Issue17664ScrollBtn");
App.Tap("Issue17664ScrollBtn");

var resultItem = App.WaitForElement("Issue17664DescriptionLabel").GetText();
Assert.That(resultItem, Is.EqualTo("Category C item #2"));
}
}
#endif
Loading