diff --git a/src/Controls/src/Core/Handlers/Items/Android/CarouselViewOnScrollListener.cs b/src/Controls/src/Core/Handlers/Items/Android/CarouselViewOnScrollListener.cs index d6f3fb434bf5..9bdbcda242e6 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/CarouselViewOnScrollListener.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/CarouselViewOnScrollListener.cs @@ -7,6 +7,11 @@ internal class CarouselViewOnScrollListener : RecyclerViewScrollListener itemsViewAdapter, CarouselViewLoopManager carouselViewLoopManager) : base((CarouselView)itemsView, itemsViewAdapter, true) { @@ -26,18 +31,53 @@ public override void OnScrollStateChanged(RecyclerView recyclerView, int state) _carouselView.SetIsDragging(false); } + // Detect programmatic scrolling. + // Note: this also covers the case where a user drag transitions to settling + // (IsDragging becomes false before ScrollStateSettling fires). This intentionally + // aligns Android behavior with iOS: CurrentItem is only updated once scrolling + // fully completes, not during the settling phase. + if (state == RecyclerView.ScrollStateSettling && !_carouselView.IsDragging) + { + _isProgrammaticScrolling = true; + } + else if (state == RecyclerView.ScrollStateIdle) + { + // When scroll completes, process any cached programmatic scroll data + if (_isProgrammaticScrolling) + { + _isProgrammaticScrolling = false; + OnScrolled(recyclerView, _lastDx, _lastDy); + + _lastDx = 0; + _lastDy = 0; + } + } + _carouselView.IsScrolling = state != RecyclerView.ScrollStateIdle; } public override void OnScrolled(RecyclerView recyclerView, int dx, int dy) { - base.OnScrolled(recyclerView, dx, dy); - - if (_carouselView.Loop) + if (_isProgrammaticScrolling) { - //We could have a race condition where we are scrolling our collection to center the first item - //We save that ScrollToEventARgs and call it again - _carouselViewLoopManager.CheckPendingScrollToEvents(recyclerView); + // Cache scroll data for programmatic scrolls - will be processed when ScrollStateIdle is reached. + // Note: Only the most recent delta is retained. If CurrentItem is changed + // programmatically multiple times during a single animation, earlier intermediate + // positions are not separately finalized. This is an accepted limitation. + _lastDx = dx; + _lastDy = dy; + } + else + { + // Process immediately for manual user scrolling + base.OnScrolled(recyclerView, dx, dy); + + if (_carouselView.Loop) + { + //We could have a race condition where we are scrolling our collection to center the first item + //We save that ScrollToEventARgs and call it again + _carouselViewLoopManager.CheckPendingScrollToEvents(recyclerView); + } } } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue31874.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue31874.cs new file mode 100644 index 000000000000..5ae896095b3d --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue31874.cs @@ -0,0 +1,93 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 31874, "Incorrect Intermediate CurrentItem updates with CarouselView Scroll Animation Enabled", PlatformAffected.Android)] +public class Issue31874 : ContentPage +{ + const string InitialItem = "0"; + const string TargetItem = "4"; + + Label resultStatus; + Label selectedItemLabel; + CarouselView carouselView; + public Issue31874() + { + carouselView = new CarouselView + { + HeightRequest = 150, + Loop = false, + IsScrollAnimated = true, + ItemsSource = new ObservableCollection{ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11" }, + ItemTemplate = new DataTemplate(() => + { + Border border = new Border + { + BackgroundColor = Colors.LightGray, + Content = new Label + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + FontSize = 80 + } + }; + border.Content.SetBinding(Label.TextProperty, "."); + return border; + }) + }; + carouselView.PropertyChanged += CarouselView_PropertyChanged; + + selectedItemLabel = new Label + { + Text = $"Selected Item : {InitialItem}", + FontSize = 16 + }; + + Button selectButton = new Button + { + AutomationId = "Issue31874ScrollBtn", + Text = $"Select item {TargetItem}", + FontSize = 12 + }; + + selectButton.Clicked += (s, e) => + { + carouselView.CurrentItem = TargetItem; + selectedItemLabel.Text = $"Selected Item : {carouselView.CurrentItem}"; + }; + + resultStatus = new Label + { + AutomationId = "Issue31874ResultLabel", + Text = "Success" + }; + + Content = new VerticalStackLayout + { + Padding = 25, + Spacing = 15, + Children = + { + carouselView, + selectedItemLabel, + selectButton, + resultStatus + } + }; + } + + void CarouselView_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CarouselView.CurrentItem)) + { + var currentItemValue = carouselView.CurrentItem?.ToString(); + + if (!string.IsNullOrEmpty(currentItemValue) && currentItemValue != InitialItem && + currentItemValue != TargetItem) + { + resultStatus.Text = "Failure"; + } + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31874.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31874.cs new file mode 100644 index 000000000000..9147df3a3ef6 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31874.cs @@ -0,0 +1,27 @@ +#if TEST_FAILS_ON_WINDOWS // Issue Link - https://github.com/dotnet/maui/issues/31670 +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue31874 : _IssuesUITest +{ + public Issue31874(TestDevice device) : base(device) + { + } + + public override string Issue => "Incorrect Intermediate CurrentItem updates with CarouselView Scroll Animation Enabled"; + + [Test] + [Category(UITestCategories.CarouselView)] + public void VerifyCurrentItemUpdatesWithScrollAnimation() + { + App.WaitForElement("Issue31874ScrollBtn"); + App.Tap("Issue31874ScrollBtn"); + + var resultLabel = App.WaitForElement("Issue31874ResultLabel").GetText(); + Assert.That(resultLabel, Is.EqualTo("Success")); + } +} +#endif