diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs index 5efafef7e98b..5044fe44ddc2 100644 --- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs @@ -27,6 +27,7 @@ public partial class CarouselViewHandler : ItemsViewHandler bool _isCarouselViewReady; NotifyCollectionChangedEventHandler _collectionChanged; readonly WeakNotifyCollectionChangedProxy _proxy = new(); + int _lastScrolledToPosition = -1; // tracks last position we issued ScrollTo for, to avoid re-entry ~CarouselViewHandler() => _proxy.Unsubscribe(); @@ -360,14 +361,9 @@ void UpdateInitialPosition() if (ListViewBase.Items.Count > 0) { - if (Element.Loop) - { - var item = ItemsView.CurrentItem ?? ListViewBase.Items.FirstOrDefault(); - _loopableCollectionView.CenterMode = true; - ListViewBase.ScrollIntoView(item); - _loopableCollectionView.CenterMode = false; - } - + // Loop centering is no longer needed here: UpdateCurrentItem and UpdatePosition + // now use _lastScrolledToPosition to guard against re-entry and scroll to the + // correct centered position programmatically, making the explicit CenterMode block redundant. if (ItemsView.CurrentItem != null) UpdateCurrentItem(); else @@ -387,7 +383,11 @@ void UpdateCurrentItem() if (currentItemPosition < 0 || currentItemPosition >= ItemCount) return; - ItemsView.ScrollTo(currentItemPosition, position: ScrollToPosition.Center, animate: ItemsView.AnimateCurrentItemChanges); + if (ItemsView.Position != currentItemPosition && _lastScrolledToPosition != currentItemPosition) + { + _lastScrolledToPosition = currentItemPosition; + ItemsView.ScrollTo(currentItemPosition, position: ScrollToPosition.Center, animate: ItemsView.AnimateCurrentItemChanges); + } } void UpdatePosition() @@ -400,6 +400,12 @@ void UpdatePosition() if (carouselPosition < 0 || carouselPosition >= ItemCount) return; + if (!ItemsView.IsDragging && !ItemsView.IsScrolling && carouselPosition != _lastScrolledToPosition) + { + _lastScrolledToPosition = carouselPosition; + ItemsView.ScrollTo(carouselPosition, position: ScrollToPosition.Center, animate: ItemsView.AnimateCurrentItemChanges); + } + SetCarouselViewCurrentItem(carouselPosition); } @@ -498,6 +504,12 @@ void CarouselScrolled(object sender, ItemsViewScrolledEventArgs e) return; } + // User scrolled to this position — reset tracker so a future programmatic scroll to same index still fires + if (position != _lastScrolledToPosition) + { + _lastScrolledToPosition = -1; + } + if (position == Element.Position) { return; @@ -523,6 +535,7 @@ void OnScrollViewChanged(object sender, ScrollViewerViewChangedEventArgs e) void OnCollectionItemsSourceChanged(object sender, NotifyCollectionChangedEventArgs e) { + _lastScrolledToPosition = -1; var carouselPosition = ItemsView.Position; var currentItemPosition = GetItemPositionInCarousel(ItemsView.CurrentItem); var count = (sender as IList).Count; diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewScrolling.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewScrolling.png new file mode 100644 index 000000000000..0df9d6e696be Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyCarouselViewScrolling.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue27563.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue27563.cs new file mode 100644 index 000000000000..6c812294dd63 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue27563.cs @@ -0,0 +1,149 @@ +using System.Collections.ObjectModel; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 27563, "[Windows] CarouselView Scrolling Issue", PlatformAffected.UWP)] +public partial class Issue27563 : ContentPage +{ + public Issue27563() + { + var verticalStackLayout = new VerticalStackLayout(); + + var carouselItems = new ObservableCollection + { + "Remain View", + "Actual View", + "Percentage View", + }; + + CarouselView carousel = new CarouselView + { + ItemsSource = carouselItems, + AutomationId = "carouselview", + HeightRequest = 200, + ItemTemplate = new DataTemplate(() => + { + var grid = new Grid + { + Padding = 10 + }; + + var label = new Label + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + FontSize = 18, + }; + label.SetBinding(Label.TextProperty, "."); + label.SetBinding(Label.AutomationIdProperty, "."); + + grid.Children.Add(label); + return grid; + }), + HorizontalOptions = LayoutOptions.Fill, + }; + + var indicatorView = new IndicatorView + { + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + }; + + carousel.IndicatorView = indicatorView; + + var carouselPositionLabel = new Label + { + Text = "CarouselPos:0", + AutomationId = "carouselPositionLabel", + HorizontalOptions = LayoutOptions.Center, + }; + + var indicatorPositionLabel = new Label + { + Text = "IndicatorPos:0", + AutomationId = "indicatorPositionLabel", + HorizontalOptions = LayoutOptions.Center, + }; + + var pingLabel = new Label + { + Text = "Ping:0", + AutomationId = "pingLabel", + HorizontalOptions = LayoutOptions.Center, + }; + + var currentItemLabel = new Label + { + Text = "CurrentItem:Remain View", + AutomationId = "currentItemLabel", + HorizontalOptions = LayoutOptions.Center, + }; + + var scrollToSecondButton = new Button + { + Text = "Scroll To Second Item", + AutomationId = "ScrollToSecondButton", + Margin = new Thickness(20, 10), + }; + + scrollToSecondButton.Clicked += (sender, e) => + { + carousel.ScrollTo(1, position: ScrollToPosition.Center, animate: false); + }; + + var positionButton = new Button + { + Text = "Change IndicatorView Position", + AutomationId = "PositionButton", + Margin = new Thickness(20, 10), + }; + + positionButton.Clicked += (sender, e) => + { + indicatorView.Position = 2; + }; + + var pingCount = 0; + var pingButton = new Button + { + Text = "Ping", + AutomationId = "PingButton", + Margin = new Thickness(20, 10), + }; + + pingButton.Clicked += (sender, e) => + { + pingCount++; + pingLabel.Text = $"Ping:{pingCount}"; + }; + + carousel.PropertyChanged += (sender, e) => + { + if (e.PropertyName == CarouselView.PositionProperty.PropertyName) + carouselPositionLabel.Text = $"CarouselPos:{carousel.Position}"; + }; + + indicatorView.PropertyChanged += (sender, e) => + { + if (e.PropertyName == IndicatorView.PositionProperty.PropertyName) + indicatorPositionLabel.Text = $"IndicatorPos:{indicatorView.Position}"; + }; + + carousel.CurrentItemChanged += (sender, e) => + { + currentItemLabel.Text = $"CurrentItem:{carousel.CurrentItem}"; + }; + + verticalStackLayout.Children.Add(carousel); + verticalStackLayout.Children.Add(indicatorView); + verticalStackLayout.Children.Add(scrollToSecondButton); + verticalStackLayout.Children.Add(positionButton); + verticalStackLayout.Children.Add(pingButton); + verticalStackLayout.Children.Add(carouselPositionLabel); + verticalStackLayout.Children.Add(indicatorPositionLabel); + verticalStackLayout.Children.Add(currentItemLabel); + verticalStackLayout.Children.Add(pingLabel); + + Content = verticalStackLayout; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27563.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27563.cs new file mode 100644 index 000000000000..1d1140bed78f --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27563.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues +{ + public class Issue27563 : _IssuesUITest + { + public override string Issue => "[Windows] CarouselView Scrolling Issue"; + + public Issue27563(TestDevice device) + : base(device) + { } + +#if !WINDOWS // On Windows, TimeoutException is thrown when enabling the Loop. Refer issue: https://github.com/dotnet/maui/issues/29245 + [Test, Order(1)] + [Category(UITestCategories.CarouselView)] + public void VerifyCarouselViewIndicatorPositionWithoutLooping() + { + App.WaitForElement("carouselview"); + App.WaitForElement("Remain View"); + + App.Tap("ScrollToSecondButton"); + App.WaitForElement("Actual View"); + + App.Tap("PositionButton"); + App.WaitForElement("Percentage View"); + + App.Tap("PingButton"); + App.WaitForElement("Ping:1"); + App.Tap("ScrollToSecondButton"); + } +#endif + +#if !WINDOWS && !MACCATALYST + // On Catalyst, Swipe actions not supported in Appium. + // On Windows, TimeoutException is thrown when enabling the Loop. Refer issue: https://github.com/dotnet/maui/issues/29245 + [Test, Order(2)] + [Category(UITestCategories.CarouselView)] + public void VerifyCarouselViewScrolling() + { + App.WaitForElement("carouselview"); + App.SwipeRightToLeft("carouselview"); + App.WaitForElement("Percentage View"); + App.Tap("PositionButton"); + VerifyScreenshot(); + } +#endif + } +} diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/VerifyCarouselViewScrolling.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/VerifyCarouselViewScrolling.png new file mode 100644 index 000000000000..b86a8ab8386e Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/VerifyCarouselViewScrolling.png differ