Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,30 @@ public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)

// Don't send RemainingItemsThresholdReached event for non-linear layout managers
// This can also happen if a layout pass has not happened yet
if (Last == -1)
if (Last == -1 || ItemsViewAdapter is null || _itemsView.RemainingItemsThreshold == -1)
{
return;
}

var itemsSource = ItemsViewAdapter.ItemsSource;
int headerValue = itemsSource.HasHeader ? 1 : 0;
int footerValue = itemsSource.HasFooter ? 1 : 0;

// Calculate actual data item count (excluding header and footer positions)
int actualItemCount = ItemsViewAdapter.ItemCount - footerValue - headerValue;

// Ensure we're within the data items region (not in header/footer)
if (Last < headerValue || Last > actualItemCount)
{
return;
}

// Check if we're at or within threshold distance from the last data item
bool isThresholdReached = (Last == actualItemCount - 1) || (actualItemCount - 1 - Last <= _itemsView.RemainingItemsThreshold);

switch (_itemsView.RemainingItemsThreshold)
if (isThresholdReached)
{
case -1:
return;
case 0:
if (Last == ItemsViewAdapter.ItemsSource.Count - 1)
_itemsView.SendRemainingItemsThresholdReached();
break;
default:
if (ItemsViewAdapter.ItemsSource.Count - 1 - Last <= _itemsView.RemainingItemsThreshold)
_itemsView.SendRemainingItemsThresholdReached();
break;
_itemsView.SendRemainingItemsThresholdReached();
}
}

Expand Down
158 changes: 158 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue29588.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Microsoft.Maui.Controls;

namespace Microsoft.Maui.Controls.Issues;

[Issue(IssueTracker.Github, 29588, "CollectionView RemainingItemsThresholdReachedcommand should trigger on scroll near end", PlatformAffected.Android)]
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Typo in the Issue description: "RemainingItemsThresholdReachedcommand" should have a space before "command".

Suggested change
[Issue(IssueTracker.Github, 29588, "CollectionView RemainingItemsThresholdReachedcommand should trigger on scroll near end", PlatformAffected.Android)]
[Issue(IssueTracker.Github, 29588, "CollectionView RemainingItemsThresholdReached command should trigger on scroll near end", PlatformAffected.Android)]

Copilot uses AI. Check for mistakes.
public class Issue29588 : ContentPage
{
public Issue29588()
{
BindingContext = new Issue29588ViewModel();

var thresholdLabel = new Label
{
AutomationId = "29588ThresholdLabel",
HorizontalOptions = LayoutOptions.Center,
HeightRequest = 50,
FontSize = 18
};
thresholdLabel.SetBinding(Label.TextProperty, nameof(Issue29588ViewModel.ThresholdStatus));

var collectionView = new CollectionView
{
AutomationId = "29588CollectionView",
ItemsSource = ((Issue29588ViewModel)BindingContext).Items,
RemainingItemsThreshold = 1,
RemainingItemsThresholdReachedCommand = ((Issue29588ViewModel)BindingContext).RemainingItemReachedCommand,
Header = new Grid
{
BackgroundColor = Colors.Bisque,
Children =
{
new Label
{
Margin = new Thickness(20,30),
FontSize = 22,
Text = "CollectionView does not fire RemainingItemsThresholdReachedCommand when Header and Footer both are set."
}
}
},
ItemTemplate = new DataTemplate(() =>
{
var label = new Label
{
Margin = new Thickness(20, 30),
FontSize = 25
};
label.SetBinding(Label.TextProperty, ".");
return label;
}),

};

var activityIndicator = new ActivityIndicator
{
Margin = new Thickness(0, 20)
};
activityIndicator.SetBinding(ActivityIndicator.IsRunningProperty, "IsLoadingMore");
activityIndicator.SetBinding(ActivityIndicator.IsVisibleProperty, "IsLoadingMore");
collectionView.Footer = activityIndicator;


var grid = new Grid
{
Padding = 20,
RowDefinitions =
{
new RowDefinition { Height = 50 }, // Threshold label
new RowDefinition { Height = GridLength.Star } // CollectionView
}
};

grid.Add(thresholdLabel, 0, 0);
grid.Add(collectionView, 0, 1);

Content = grid;
}
}

public class Issue29588ViewModel : INotifyPropertyChanged
{
private bool _isLoadingMore;
private int _loadCount = 0;
private string thresholdStatus;

public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<string> Items { get; } = new ObservableCollection<string>();

public ICommand RemainingItemReachedCommand { get; }

public bool IsLoadingMore
{
get => _isLoadingMore;
set
{
if (_isLoadingMore != value)
{
_isLoadingMore = value;
OnPropertyChanged();
}
}
}
public string ThresholdStatus
{
get => thresholdStatus;
set
{
if (thresholdStatus != value)
{
thresholdStatus = value;
OnPropertyChanged();
}
}
}

public Issue29588ViewModel()
{
ThresholdStatus = "Threshold not reached";
RemainingItemReachedCommand = new Command(async () => await LoadMoreItemsAsync());
LoadInitialItems();
}

private void LoadInitialItems()
{
for (int i = 1; i <= 20; i++)
{
Items.Add($"Item {i}");
}
}

private async Task LoadMoreItemsAsync()
{
if (IsLoadingMore)
return;

IsLoadingMore = true;

await Task.Delay(1500); // Simulate API call or long operation

for (int i = 1; i <= 10; i++)
{
Items.Add($"Loaded Item {_loadCount * 10 + i + 20}");
}

_loadCount++;
IsLoadingMore = false;
ThresholdStatus = "Threshold reached";
}

protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Tests.Issues;

internal class Issue29588 : _IssuesUITest
{
public override string Issue => "CollectionView RemainingItemsThresholdReachedcommand should trigger on scroll near end";
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Typo in the Issue description: "RemainingItemsThresholdReachedcommand" should have a space before "command".

Suggested change
public override string Issue => "CollectionView RemainingItemsThresholdReachedcommand should trigger on scroll near end";
public override string Issue => "CollectionView RemainingItemsThresholdReached command should trigger on scroll near end";

Copilot uses AI. Check for mistakes.

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

[Test]
[Category(UITestCategories.CollectionView)]
public void RemainingItemsThresholdReachedEventShouldTrigger()
{
App.WaitForElement("29588CollectionView");
for (int i = 0; i < 5; i++)
{
App.ScrollDown("29588CollectionView", ScrollStrategy.Gesture, 0.8, 500);
}
App.WaitForElement("29588ThresholdLabel");
Assert.That(App.FindElement("29588ThresholdLabel").GetText(), Is.EqualTo("Threshold reached"));
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

Potential race condition: The RemainingItemReachedCommand is async and includes a 1.5 second delay (Task.Delay(1500) in LoadMoreItemsAsync()), but the test immediately checks the label text after scrolling. The assertion on line 25 might execute before ThresholdStatus is updated to "Threshold reached". Consider adding a wait/polling mechanism after the scrolling to ensure the async command completes, or use a framework method that waits for the text to change to the expected value.

Suggested change
Assert.That(App.FindElement("29588ThresholdLabel").GetText(), Is.EqualTo("Threshold reached"));
// Wait up to 5 seconds for the label text to become "Threshold reached"
const int timeoutMs = 5000;
const int pollIntervalMs = 200;
var start = DateTime.UtcNow;
string labelText = "";
do
{
labelText = App.FindElement("29588ThresholdLabel").GetText();
if (labelText == "Threshold reached")
break;
System.Threading.Thread.Sleep(pollIntervalMs);
}
while ((DateTime.UtcNow - start).TotalMilliseconds < timeoutMs);
Assert.That(labelText, Is.EqualTo("Threshold reached"));

Copilot uses AI. Check for mistakes.
}
}
Loading