diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs index 624a1db7025a..0e7164801615 100644 --- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Windows.cs @@ -15,6 +15,8 @@ using WScrollMode = Microsoft.UI.Xaml.Controls.ScrollMode; using WSnapPointsAlignment = Microsoft.UI.Xaml.Controls.Primitives.SnapPointsAlignment; using WSnapPointsType = Microsoft.UI.Xaml.Controls.SnapPointsType; +using WSetter = Microsoft.UI.Xaml.Setter; +using WStyle = Microsoft.UI.Xaml.Style; namespace Microsoft.Maui.Controls.Handlers.Items { @@ -26,6 +28,7 @@ public partial class CarouselViewHandler : ItemsViewHandler WScrollBarVisibility? _verticalScrollBarVisibilityWithoutLoop; Size _currentSize; bool _isCarouselViewReady; + bool _isInternalPositionUpdate; int _gotoPosition = -1; NotifyCollectionChangedEventHandler _collectionChanged; readonly WeakNotifyCollectionChangedProxy _proxy = new(); @@ -146,7 +149,7 @@ protected override ItemsViewScrolledEventArgs ComputeVisibleIndexes(ItemsViewScr { args = base.ComputeVisibleIndexes(args, orientation, advancing); - if (ItemsView.Loop && ItemsView.ItemsSource is not null) + if (ItemsView.Loop && ItemsView.ItemsSource is not null && ItemCount > 0) { args.FirstVisibleItemIndex %= ItemCount; args.CenterItemIndex %= ItemCount; @@ -156,6 +159,21 @@ protected override ItemsViewScrolledEventArgs ComputeVisibleIndexes(ItemsViewScr return args; } + protected override void UpdateEmptyViewVisibility() + { + if (ItemsView?.Loop == true) + { + bool isEmpty = (CollectionViewSource?.View?.Count ?? 0) == 0; + var targetTemplate = isEmpty ? null : CarouselItemsViewTemplate; + if (ListViewBase.ItemTemplate != targetTemplate) + { + ListViewBase.ItemTemplate = targetTemplate; + } + } + + base.UpdateEmptyViewVisibility(); + } + ListViewBase CreateCarouselListLayout(ItemsLayoutOrientation layoutOrientation) { UI.Xaml.Controls.ListView listView; @@ -165,7 +183,8 @@ ListViewBase CreateCarouselListLayout(ItemsLayoutOrientation layoutOrientation) listView = new FormsListView() { Style = (UI.Xaml.Style)WApp.Current.Resources["HorizontalCarouselListStyle"], - ItemsPanel = (ItemsPanelTemplate)WApp.Current.Resources["HorizontalListItemsPanel"] + ItemsPanel = (ItemsPanelTemplate)WApp.Current.Resources["HorizontalListItemsPanel"], + ItemContainerStyle = GetItemContainerStyle(true) }; ScrollViewer.SetHorizontalScrollBarVisibility(listView, WScrollBarVisibility.Auto); @@ -175,7 +194,8 @@ ListViewBase CreateCarouselListLayout(ItemsLayoutOrientation layoutOrientation) { listView = new FormsListView() { - Style = (UI.Xaml.Style)WApp.Current.Resources["VerticalCarouselListStyle"] + Style = (UI.Xaml.Style)WApp.Current.Resources["VerticalCarouselListStyle"], + ItemContainerStyle = GetItemContainerStyle(false) }; ScrollViewer.SetHorizontalScrollBarVisibility(listView, WScrollBarVisibility.Disabled); @@ -289,7 +309,7 @@ double GetItemWidth() if (CarouselItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal) { - itemWidth = ListViewBase.ActualWidth - ItemsView.PeekAreaInsets.Left - ItemsView.PeekAreaInsets.Right; + itemWidth = ListViewBase.ActualWidth - ItemsView.PeekAreaInsets.Left - ItemsView.PeekAreaInsets.Right - ItemsView.ItemsLayout.ItemSpacing; } return Math.Max(itemWidth, 0); @@ -301,7 +321,7 @@ double GetItemHeight() if (CarouselItemsLayout.Orientation == ItemsLayoutOrientation.Vertical) { - itemHeight = ListViewBase.ActualHeight - ItemsView.PeekAreaInsets.Top - ItemsView.PeekAreaInsets.Bottom; + itemHeight = ListViewBase.ActualHeight - ItemsView.PeekAreaInsets.Top - ItemsView.PeekAreaInsets.Bottom - ItemsView.ItemsLayout.ItemSpacing; } return Math.Max(itemHeight, 0); @@ -334,9 +354,7 @@ bool IsValidPosition(int position) void SetCarouselViewPosition(int position) { if (ItemCount == 0) - { return; - } if (!IsValidPosition(position)) return; @@ -408,7 +426,10 @@ void UpdateCurrentItem() var currentItemPosition = GetItemPositionInCarousel(ItemsView.CurrentItem); - if (currentItemPosition < 0 || currentItemPosition >= ItemCount) + bool isOutOfBounds = currentItemPosition < 0 || currentItemPosition >= ItemCount; + bool isSamePosition = ItemsView.Position == currentItemPosition; + + if (isOutOfBounds || isSamePosition) { return; } @@ -418,7 +439,9 @@ void UpdateCurrentItem() return; } - ItemsView.ScrollTo(currentItemPosition, position: ScrollToPosition.Center, animate: ItemsView.AnimateCurrentItemChanges); + // Disable animation during collection changes to prevent cascading scroll events + var animate = ItemsView.AnimateCurrentItemChanges && !_isInternalPositionUpdate; + ItemsView.ScrollTo(currentItemPosition, position: ScrollToPosition.Center, animate: animate); } void UpdatePosition() @@ -566,34 +589,53 @@ void OnScrollViewChanged(object sender, ScrollViewerViewChangedEventArgs e) void OnCollectionItemsSourceChanged(object sender, NotifyCollectionChangedEventArgs e) { - var carouselPosition = ItemsView.Position; - var currentItemPosition = GetItemPositionInCarousel(ItemsView.CurrentItem); - var count = (sender as IList).Count; + // Set flag to disable animation during collection changes + _isInternalPositionUpdate = true; - bool removingCurrentElement = currentItemPosition == -1; - bool removingLastElement = e.OldStartingIndex == count; - bool removingFirstElement = e.OldStartingIndex == 0; - bool removingCurrentElementButNotFirst = removingCurrentElement && removingLastElement && ItemsView.Position > 0; - - if (removingCurrentElementButNotFirst) + try { - carouselPosition = ItemsView.Position - 1; + var carouselPosition = ItemsView.Position; + var currentItemPosition = GetItemPositionInCarousel(ItemsView.CurrentItem); + var count = (sender as IList).Count; - } - else if (removingFirstElement && !removingCurrentElement) - { - carouselPosition = currentItemPosition; - } + bool removingCurrentElement = currentItemPosition == -1; + bool removingLastElement = e.OldStartingIndex == count; + bool removingFirstElement = e.OldStartingIndex == 0; + bool removingCurrentElementButNotFirst = removingCurrentElement && removingLastElement && ItemsView.Position > 0; - // If we are adding a new item make sure to maintain the CurrentItemPosition - else if (e.Action == NotifyCollectionChangedAction.Add - && currentItemPosition != -1) + if (removingCurrentElementButNotFirst) + { + carouselPosition = ItemsView.Position - 1; + } + else if (removingFirstElement && !removingCurrentElement) + { + carouselPosition = currentItemPosition; + } + + // If we are adding a new item make sure to maintain the CurrentItemPosition + else if (e.Action == NotifyCollectionChangedAction.Add + && currentItemPosition != -1) + { + carouselPosition = currentItemPosition; + } + + if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepLastItemInView) + { + carouselPosition = count == 0 ? 0 : count - 1; + } + else if (ItemsView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepItemsInView) + { + carouselPosition = 0; + } + + SetCarouselViewCurrentItem(carouselPosition); + SetCarouselViewPosition(carouselPosition); + } + finally { - carouselPosition = currentItemPosition; + // Reset flag after collection operations complete + _isInternalPositionUpdate = false; } - - SetCarouselViewCurrentItem(carouselPosition); - SetCarouselViewPosition(carouselPosition); } void OnListViewSizeChanged(object sender, SizeChangedEventArgs e) => Resize(e.NewSize); @@ -639,5 +681,15 @@ void InvalidateItemSize() item.ItemWidth = itemWidth; } } + + WStyle GetItemContainerStyle(bool isHorizontalLayout) + { + var h = CarouselItemsLayout?.ItemSpacing > 0 ? (CarouselItemsLayout.ItemSpacing) / 2 : 0; + var padding = isHorizontalLayout ? WinUIHelpers.CreateThickness(h, 0, h, 0) : WinUIHelpers.CreateThickness(0, h, 0, h); + + var style = new WStyle(typeof(ListViewItem)); + style.Setters.Add(new WSetter(Control.PaddingProperty, padding)); + return style; + } } } diff --git a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs index afe9aa668d40..aaa05b725338 100644 --- a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs @@ -148,7 +148,11 @@ void OnItemsVectorChanged(global::Windows.Foundation.Collections.IObservableVect return; } - var itemsCount = items.Count; + // When looping is enabled in CarouselView, Items. Count returns + // the FakeCount instead of the actual item count. + // Use items.Count for CollectionView to account for this + // behavior: otherwise, use ItemCount for other types. + var itemsCount = VirtualView is CollectionView ? items.Count : ItemCount; if (itemsCount == 0) { @@ -703,7 +707,7 @@ object FindBoundItem(ScrollToRequestEventArgs args) return null; } - protected virtual int ItemCount => CollectionViewSource.View.Count; + protected virtual int ItemCount => CollectionViewSource is not null ? CollectionViewSource.View.Count : 0; protected virtual object GetItem(int index) { diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue29420.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue29420.cs new file mode 100644 index 000000000000..4e2ddb3cd732 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue29420.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; + +namespace Maui.Controls.Sample.Issues; +[Issue(IssueTracker.Github, 29420, "KeepLastInView Not Working as Expected in CarouselView", PlatformAffected.UWP)] +public class Issue29420 : ContentPage +{ + public Issue29420() + { + var count = 0; + var verticalStackLayout = new VerticalStackLayout(); + var carouselItems = new ObservableCollection + { + "Item 0", "Item 1","Item 2", "Item 3", "Item 4", "Item 5", + + }; + + CarouselView carouselView = new CarouselView + { + ItemsSource = carouselItems, + AutomationId = "CarouselView", + Loop = false, + ItemsUpdatingScrollMode = ItemsUpdatingScrollMode.KeepLastItemInView, + + ItemTemplate = new DataTemplate(() => + { + var grid = new Grid + { + Padding = 10 + }; + + var label = new Label + { + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + FontSize = 18, + Background = Colors.Pink, + }; + label.SetBinding(Label.TextProperty, "."); + label.SetBinding(Label.AutomationIdProperty, "."); + + grid.Children.Add(label); + return grid; + }), + }; + + var insertButton = new Button + { + Text = "Insert item at 0th index", + AutomationId = "InsertButton", + Margin = new Thickness(20), + }; + + var addButton = new Button + { + Text = "Add item at end", + AutomationId = "AddButton", + Margin = new Thickness(20), + }; + + insertButton.Clicked += (sender, e) => + { + carouselItems.Insert(0, "NewItem" + count.ToString()); + count++; + }; + + addButton.Clicked += (sender, e) => + { + carouselItems.Add("NewItem"); + }; + + verticalStackLayout.Children.Add(insertButton); + verticalStackLayout.Children.Add(addButton); + verticalStackLayout.Children.Add(carouselView); + Content = verticalStackLayout; + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29420.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29420.cs new file mode 100644 index 000000000000..2d50b25d1913 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue29420.cs @@ -0,0 +1,34 @@ +#if TEST_FAILS_ON_ANDROID && TEST_FAILS_ON_IOS && TEST_FAILS_ON_CATALYST // CarouselView Fails to Keep Last Item in View on iOS, android and macOS https://github.com/dotnet/maui/issues/18029, https://github.com/dotnet/maui/issues/29415 +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; +public class Issue29420 : _IssuesUITest +{ + public override string Issue => "KeepLastInView Not Working as Expected in CarouselView"; + + public Issue29420(TestDevice device) : base(device) + { } + + [Test, Order(1)] + [Category(UITestCategories.CarouselView)] + public async Task VerifyCarouselViewKeepLastInViewOnItemInsert() + { + App.WaitForElement("CarouselView"); + App.Tap("InsertButton"); + await Task.Delay(200); // Wait for the scrollbar to disappear. + VerifyScreenshot("CarouselViewKeepLastInViewOnItemInsert"); + } + + [Test, Order(2)] + [Category(UITestCategories.CarouselView)] + public async Task VerifyCarouselViewKeepLastInViewOnItemAdd() + { + App.WaitForElement("CarouselView"); + App.Tap("AddButton"); + await Task.Delay(200); // Wait for the scrollbar to disappear. + VerifyScreenshot("CarouselViewKeepLastInViewOnItemAdd"); + } +} +#endif \ No newline at end of file diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemAdd.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemAdd.png new file mode 100644 index 000000000000..48aec5b4b13a Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemAdd.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemInsert.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemInsert.png new file mode 100644 index 000000000000..eb39b718d447 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CarouselViewKeepLastInViewOnItemInsert.png differ