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
45 changes: 44 additions & 1 deletion src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using CoreGraphics;
using Foundation;
Expand Down Expand Up @@ -124,7 +125,7 @@ protected virtual (bool VisibleItems, NSIndexPath First, NSIndexPath Center, NSI

if (visibleItems)
{
firstVisibleItemIndex = indexPathsForVisibleItems.First();
firstVisibleItemIndex = GetFirstVisibleIndexPathUsingLayoutAttributes(collectionView, indexPathsForVisibleItems);
centerItemIndex = GetCenteredIndexPath(collectionView);
lastVisibleItemIndex = indexPathsForVisibleItems.Last();
}
Expand Down Expand Up @@ -162,6 +163,48 @@ static int GetItemIndex(NSIndexPath indexPath, IItemsViewSource itemSource)
return index;
}

static NSIndexPath GetFirstVisibleIndexPathUsingLayoutAttributes(UICollectionView collectionView, IEnumerable<NSIndexPath> indexPathsForVisibleItems)
{
if (!indexPathsForVisibleItems.Any())
return null;

var layout = collectionView.CollectionViewLayout;
if (layout is null)
return indexPathsForVisibleItems.First();

var visibleRect = new CGRect(collectionView.ContentOffset, collectionView.Bounds.Size);
var layoutAttributes = layout.LayoutAttributesForElementsInRect(visibleRect);
if (layoutAttributes is null || layoutAttributes.Length == 0)
return indexPathsForVisibleItems.First();

var flowLayout = layout as UICollectionViewFlowLayout;
bool isVertical = flowLayout?.ScrollDirection != UICollectionViewScrollDirection.Horizontal;
// Find the first visible cell (not headers/footers) based on scroll direction
NSIndexPath firstVisibleIndexPath = null;
nfloat minPosition = nfloat.MaxValue;

for (int i = 0; i < layoutAttributes.Length; i++)
{
var attr = layoutAttributes[i];
// Skip non-cell elements (headers, footers, decorations)
if (attr.RepresentedElementCategory != UICollectionElementCategory.Cell)
continue;

// Skip items that don't intersect with visible rect
if (!attr.Frame.IntersectsWith(visibleRect))
continue;

nfloat position = isVertical ? attr.Frame.Y : attr.Frame.X;
if (position < minPosition)
{
minPosition = position;
firstVisibleIndexPath = attr.IndexPath;
}
}

return firstVisibleIndexPath ?? indexPathsForVisibleItems.First();
}

static NSIndexPath GetCenteredIndexPath(UICollectionView collectionView)
{
NSIndexPath centerItemIndex = null;
Expand Down
45 changes: 44 additions & 1 deletion src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using CoreGraphics;
using Foundation;
Expand Down Expand Up @@ -125,7 +126,7 @@ protected virtual (bool VisibleItems, NSIndexPath First, NSIndexPath Center, NSI

if (visibleItems)
{
firstVisibleItemIndex = indexPathsForVisibleItems.First();
firstVisibleItemIndex = GetFirstVisibleIndexPathUsingLayoutAttributes(collectionView, indexPathsForVisibleItems);
centerItemIndex = GetCenteredIndexPath(collectionView);
lastVisibleItemIndex = indexPathsForVisibleItems.Last();
}
Expand Down Expand Up @@ -163,6 +164,48 @@ static int GetItemIndex(NSIndexPath indexPath, IItemsViewSource itemSource)
return index;
}

static NSIndexPath GetFirstVisibleIndexPathUsingLayoutAttributes(UICollectionView collectionView, IEnumerable<NSIndexPath> indexPathsForVisibleItems)
{
if (!indexPathsForVisibleItems.Any())
return null;

var layout = collectionView.CollectionViewLayout;
if (layout is null)
return indexPathsForVisibleItems.First();

var visibleRect = new CGRect(collectionView.ContentOffset, collectionView.Bounds.Size);
var layoutAttributes = layout.LayoutAttributesForElementsInRect(visibleRect);
if (layoutAttributes is null || layoutAttributes.Length == 0)
return indexPathsForVisibleItems.First();

var flowLayout = layout as UICollectionViewFlowLayout;
bool isVertical = flowLayout?.ScrollDirection != UICollectionViewScrollDirection.Horizontal;
// Find the first visible cell (not headers/footers) based on scroll direction
NSIndexPath firstVisibleIndexPath = null;
nfloat minPosition = nfloat.MaxValue;

for (int i = 0; i < layoutAttributes.Length; i++)
{
var attr = layoutAttributes[i];
// Skip non-cell elements (headers, footers, decorations)
if (attr.RepresentedElementCategory != UICollectionElementCategory.Cell)
continue;

// Skip items that don't intersect with visible rect
if (!attr.Frame.IntersectsWith(visibleRect))
continue;

nfloat position = isVertical ? attr.Frame.Y : attr.Frame.X;
if (position < minPosition)
{
minPosition = position;
firstVisibleIndexPath = attr.IndexPath;
}
}

return firstVisibleIndexPath ?? indexPathsForVisibleItems.First();
}

static NSIndexPath GetCenteredIndexPath(UICollectionView collectionView)
{
NSIndexPath centerItemIndex = null;
Expand Down
76 changes: 76 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33614.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Collections.ObjectModel;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 33614, "CollectionView Scrolled event reports incorrect FirstVisibleItemIndex after programmatic ScrollTo", PlatformAffected.iOS | PlatformAffected.macOS)]
public class Issue33614 : ContentPage
{
public ObservableCollection<string> Items { get; set; }
private Label _firstIndexLabel;
private CollectionView2 _collectionView;
public Issue33614()
{
Items = new ObservableCollection<string>();
for (int i = 0; i <= 50; i++)
{
Items.Add($"Item_{i}");
}

_firstIndexLabel = new Label
{
AutomationId = "FirstIndexLabel",
Text = "FirstVisibleItemIndex: 0",
IsVisible = false,
};

var scrollToButton = new Button
{
AutomationId = "ScrollToButton",
Text = "ScrollTo Index 15",
WidthRequest = 150
};
scrollToButton.Clicked += OnScrollToButtonClicked;

_collectionView = new CollectionView2
{
AutomationId = "TestCollectionView",
ItemsSource = Items,
HeightRequest = 600,
ItemTemplate = new DataTemplate(() =>
{
var label = new Label();
label.SetBinding(Label.TextProperty, ".");
return new Border
{
Margin = new Thickness(5),
Padding = new Thickness(10),
Stroke = Colors.Gray,
Content = label
};
})
};

_collectionView.Scrolled += OnCollectionViewScrolled;

Content = new StackLayout
{
Children =
{
_firstIndexLabel,
scrollToButton,
_collectionView
}
};
}

private void OnCollectionViewScrolled(object sender, ItemsViewScrolledEventArgs e)
{
_firstIndexLabel.Text = $"FirstVisibleItemIndex: {e.FirstVisibleItemIndex}";
_firstIndexLabel.IsVisible = true;
}

private void OnScrollToButtonClicked(object sender, EventArgs e)
{
_collectionView.ScrollTo(15, position: ScrollToPosition.Start, animate: true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue33614 : _IssuesUITest
{
public override string Issue => "CollectionView Scrolled event reports incorrect FirstVisibleItemIndex after programmatic ScrollTo";

public Issue33614(TestDevice device) : base(device) { }

[Test]
[Category(UITestCategories.CollectionView)]
public void FirstVisibleItemIndexShouldBeCorrectAfterScrollTo()
{
App.WaitForElement("ScrollToButton");
App.Tap("ScrollToButton");
var firstIndexText = App.FindElement("FirstIndexLabel").GetText();
Assert.That(firstIndexText, Is.EqualTo("FirstVisibleItemIndex: 15"));
Comment on lines +17 to +20
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

The test may have a timing issue. After tapping the button, the test immediately reads the label text without waiting for the scroll animation to complete or for the label to update. This could lead to a race condition where the test reads the old value before the Scrolled event fires and updates the label.

Consider adding a wait or retry mechanism to ensure the label has been updated with the expected value. For example, you could use App.WaitForElement with a lambda that checks for the expected text, or add Task.Delay similar to other tests that wait for scroll animations (see Issue18961.cs line 29 which uses await Task.Delay(1000) after a scroll).

Copilot uses AI. Check for mistakes.
}
}
Loading