diff --git a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs index b5d7d3896df2..33d6e6130e32 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs @@ -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(); } } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue29588.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue29588.cs new file mode 100644 index 000000000000..694d10ef128c --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue29588.cs @@ -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)] +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 Items { get; } = new ObservableCollection(); + + 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)); + } +} + diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29588.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29588.cs new file mode 100644 index 000000000000..b93f3a63cbfa --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29588.cs @@ -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"; + + 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")); + } +}