diff --git a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs index 5fde20a05303..25c118ea9f48 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using CoreFoundation; using CoreGraphics; +using Foundation; using UIKit; namespace Microsoft.Maui.Controls.Handlers.Items; @@ -13,6 +15,182 @@ public class MauiCollectionView : UICollectionView, IUIViewLifeCycleEvents, IPla readonly WeakEventManager _movedToWindowEventManager = new(); +#if MACCATALYST + // KVO-based scroll restore for Mac Catalyst silent contentOffset reset (Issue #34271) + // This bug is Mac Catalyst-only: UIKit silently shifts contentOffset during any + // window state change (Picker dismiss, window minimize/maximize, window resize). + // + // NOTE: Current tracking is Y-axis only, so it won't affect existing X-axis + // (horizontal CollectionView) behavior. + + // True while we're watching for a silent UIKit contentOffset reset after a programmatic scroll. + // Stays armed across multiple silent clamps; cleared by DraggingStarted (user intent), + // view detach, Dispose, or a new ScrollTo request. + bool _isTrackingScrollRestore; + + // The section, item, and alignment to re-scroll to if a silent reset is detected. + int _pendingScrollRestoreSection; + int _pendingScrollRestoreItem; + UICollectionViewScrollPosition _pendingScrollRestorePosition; + + // Item count snapshot at arm time. If the count changes before restore, the source + // was mutated (Insert/Remove/Clear that bypass MapItemsSource) and the saved index + // is semantically stale — abort. + nint _pendingScrollRestoreItemCount; + + // The last observed Y offset while tracking — used as the reference to detect a sudden drop. + nfloat _lastKnownOffsetY; + + // Threshold (in points) for detecting a silent UIKit contentOffset reset. + // Any Y-offset drop larger than this is treated as a silent reset rather than normal movement. + static readonly nfloat SilentResetThreshold = 10f; + + // KVO observer token for contentOffset — active while the view is attached to a window. + IDisposable? _contentOffsetObserver; + + internal void SetPendingScrollRestore(int section, int item, UICollectionViewScrollPosition position) + { + _pendingScrollRestoreSection = section; + _pendingScrollRestoreItem = item; + _pendingScrollRestorePosition = position; + _pendingScrollRestoreItemCount = section >= 0 && section < NumberOfSections() + ? NumberOfItemsInSection(section) + : 0; + _lastKnownOffsetY = ContentOffset.Y; + _isTrackingScrollRestore = true; + } + + internal void ClearPendingScrollRestore() + { + _isTrackingScrollRestore = false; + _lastKnownOffsetY = 0; + _pendingScrollRestoreItemCount = 0; + } + + void OnContentOffsetChanged(NSObservedChange change) + { + if (!_isTrackingScrollRestore) + { + return; + } + + var y = ContentOffset.Y; + + // Y is within 10px of the settled reference — normal movement, update reference. + // DraggingStarted clears _isTrackingScrollRestore before any user drag fires KVO, + // so any drop > 10px here is a silent UIKit reset. + // This handles both Y≈0 resets AND non-zero clamp resets (e.g., Y=713→Y=300). + if (y >= _lastKnownOffsetY - SilentResetThreshold) + { + _lastKnownOffsetY = y; + return; + } + + // Y dropped more than 10px — silent UIKit reset detected. + // Guard: if the last known reference Y is at or below 0, the original target was already + // at the top of the list (or never moved away from it). In that case there is nothing + // meaningful to "restore" — UIKit ending up at Y=0 IS the target state, so we skip the + // async ScrollToItem to avoid an unnecessary scroll and a potential restore loop. + // `<= 0` (rather than `< 0` or `== 0`) covers any negative bounce/overscroll values + // UIKit may briefly report. + if (_lastKnownOffsetY <= 0) + { + return; + } + + var section = _pendingScrollRestoreSection; + var item = _pendingScrollRestoreItem; + var scrollPosition = _pendingScrollRestorePosition; + + // NOTE: Do NOT clear the pending restore here. A single window interaction + // (Picker hover/select, resize, minimize/restore) can cause UIKit to recalculate + // bounds multiple times, producing several silent clamps in succession. Keeping the + // target armed allows each subsequent clamp to be restored. + // The pending restore is cleared on: user drag (DraggingStarted), view detach + // (MovedToWindow with Window == null), Dispose, or a new ScrollTo request. + + DispatchQueue.MainQueue.DispatchAsync(() => + { + if (Handle == IntPtr.Zero || Window is null) + { + return; + } + + if (Tracking || Dragging || Decelerating) + { + return; + } + + using var indexPath = NSIndexPath.Create(section, item); + + if (!IsIndexPathValidForRestore(indexPath)) + { + ClearPendingScrollRestore(); + return; + } + + ScrollToItem(indexPath, scrollPosition, false); + + // Refresh the reference Y so subsequent KVO callbacks measure + // the next drop against the restored offset, not the pre-restore one. + if (_isTrackingScrollRestore) + { + _lastKnownOffsetY = ContentOffset.Y; + } + }); + } + + void StartContentOffsetObserver() + { + if (_contentOffsetObserver is not null) + { + return; + } + + _contentOffsetObserver = this.AddObserver("contentOffset", NSKeyValueObservingOptions.New, OnContentOffsetChanged); + } + + void StopContentOffsetObserver() + { + _contentOffsetObserver?.Dispose(); + _contentOffsetObserver = null; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + StopContentOffsetObserver(); + ClearPendingScrollRestore(); + } + base.Dispose(disposing); + } + + /// + /// Validates that the index path is within the current data source bounds + /// before attempting a scroll restore. This prevents NSInternalInconsistencyException + /// if the data source changed after the restore was armed. + /// + bool IsIndexPathValidForRestore(NSIndexPath indexPath) + { + var sectionCount = NumberOfSections(); + if (indexPath.Section < 0 || indexPath.Section >= sectionCount) + { + return false; + } + + var itemCount = NumberOfItemsInSection(indexPath.Section); + + // Source-mutation guard: count changed → saved index is semantically stale. + if (itemCount != _pendingScrollRestoreItemCount) + { + return false; + } + + return indexPath.Item >= 0 && indexPath.Item < itemCount; + } +#endif + internal bool NeedsCellLayout { get; set; } public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(frame, layout) @@ -22,7 +200,9 @@ public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(fr public override void ScrollRectToVisible(CGRect rect, bool animated) { if (!KeyboardAutoManagerScroll.IsKeyboardAutoScrollHandling) + { base.ScrollRectToVisible(rect, animated); + } } // UICollectionViewCompositionalLayout with OrthogonalScrollingBehavior creates a private @@ -169,5 +349,17 @@ public override void MovedToWindow() _invalidateParentWhenMovedToWindow = false; this.InvalidateAncestorsMeasures(); } + +#if MACCATALYST + if (Window is not null) + { + StartContentOffsetObserver(); + } + else + { + StopContentOffsetObserver(); + ClearPendingScrollRestore(); + } +#endif } -} \ No newline at end of file +} diff --git a/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.iOS.cs b/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.iOS.cs index b645b4c74687..a6178e630739 100644 --- a/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.iOS.cs @@ -64,10 +64,28 @@ protected override void ScrollToRequested(object sender, ScrollToRequestEventArg bool IsHorizontal = VirtualView.ItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal; UICollectionViewScrollDirection scrollDirection = IsHorizontal ? UICollectionViewScrollDirection.Horizontal : UICollectionViewScrollDirection.Vertical; + var loopScrollPosition = args.ScrollToPosition.ToCollectionViewScrollPosition(scrollDirection); + + // This looping path bypasses the base ScrollToRequested, so it must clear/arm + // the MacCatalyst pending restore itself; otherwise this scroll is not protected from + // the silent contentOffset clamp. +#if MACCATALYST + if (Controller?.CollectionView is MauiCollectionView mauiCV) + { + mauiCV.ClearPendingScrollRestore(); + } +#endif Controller.CollectionView.ScrollToItem(goToIndexPath, - args.ScrollToPosition.ToCollectionViewScrollPosition(scrollDirection), // TODO: Fix _layout.ScrollDirection), + loopScrollPosition, args.IsAnimated); + +#if MACCATALYST + if (!args.IsAnimated && Controller?.CollectionView is MauiCollectionView mauiCVAfter) + { + mauiCVAfter.SetPendingScrollRestore((int)goToIndexPath.Section, (int)goToIndexPath.Item, loopScrollPosition); + } +#endif } else { diff --git a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs index c02d9e6e07b6..84ca5123a2bf 100644 --- a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs +++ b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs @@ -4,6 +4,7 @@ using System.Text; using CoreGraphics; using Foundation; +using Microsoft.Maui.Controls.Handlers.Items; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; using ObjCRuntime; @@ -78,6 +79,11 @@ protected override UIView CreatePlatformView() public static void MapItemsSource(ItemsViewHandler2 handler, ItemsView itemsView) { MapItemsUpdatingScrollMode(handler, itemsView); +#if MACCATALYST + // ItemsSource replacement: saved index may now resolve to different content even + // if still in range. Clear before applying the new source. + ClearMacCatalystPendingScrollRestore(handler); +#endif handler.Controller?.UpdateItemsSource(); } @@ -140,10 +146,29 @@ protected virtual void UpdateLayout() { _layout = SelectLayout(); Controller?.UpdateLayout(_layout); + +#if MACCATALYST + // Layout swap (ItemTemplate / FlowDirection / etc.) may remap the saved + // section/item to a different visual position. Clear to avoid stale restore. + ClearMacCatalystPendingScrollRestore(this); +#endif + } + +#if MACCATALYST + static void ClearMacCatalystPendingScrollRestore(ItemsViewHandler2 handler) + { + if (handler?.Controller?.CollectionView is MauiCollectionView mauiCV) + { + mauiCV.ClearPendingScrollRestore(); + } } +#endif protected virtual void ScrollToRequested(object sender, ScrollToRequestEventArgs args) { + int section = 0, item = 0; + UICollectionViewScrollPosition scrollPosition = UICollectionViewScrollPosition.None; + using (var indexPath = DetermineIndex(args)) { if (!IsIndexPathValid(indexPath)) @@ -153,11 +178,36 @@ protected virtual void ScrollToRequested(object sender, ScrollToRequestEventArgs } var scrollDirection = Controller.GetScrollDirection(); - var position = Items.ScrollToPositionExtensions.ToCollectionViewScrollPosition(args.ScrollToPosition, scrollDirection); + scrollPosition = Items.ScrollToPositionExtensions.ToCollectionViewScrollPosition(args.ScrollToPosition, scrollDirection); + + // Capture section and item as ints before the using block disposes the indexPath + section = (int)indexPath.Section; + item = (int)indexPath.Item; + + // Clear any previously armed restore before issuing the new scroll. Otherwise the + // synchronous KVO contentOffset notification fired by this ScrollToItem call could be + // evaluated against a stale armed target and queue a restore back to the old position. +#if MACCATALYST + if (Controller?.CollectionView is MauiCollectionView mauiCVBeforeScroll) + { + mauiCVBeforeScroll.ClearPendingScrollRestore(); + } +#endif Controller.CollectionView.ScrollToItem(indexPath, - position, args.IsAnimated); + scrollPosition, args.IsAnimated); + } + + // After non-animated scroll, arm KVO restore to recover from silent Mac Catalyst contentOffset shift +#if MACCATALYST + if (Controller?.CollectionView is MauiCollectionView mauiCV) + { + if (!args.IsAnimated) + { + mauiCV.SetPendingScrollRestore(section, item, scrollPosition); + } } +#endif NSIndexPath DetermineIndex(ScrollToRequestEventArgs args) { diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index 895a827c6084..da26218a6810 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -471,7 +471,7 @@ internal void UpdateLoop() { return; } - + if (ItemsView is not CarouselView carousel) { return; @@ -526,7 +526,25 @@ void ScrollToPosition(int goToPosition, int carouselPosition, bool animate, bool } _gotoPosition = goToPosition; + + // This scroll (Position/CurrentItem changes, loop toggles) bypasses ScrollToRequested, + // so it must clear/arm the MacCatalyst pending restore itself; otherwise it is not + // protected from the silent contentOffset clamp. +#if MACCATALYST + if (CollectionView is Items.MauiCollectionView mauiCV) + { + mauiCV.ClearPendingScrollRestore(); + } +#endif + CollectionView.ScrollToItem(goToIndexPath, uICollectionViewScrollPosition, animate); + +#if MACCATALYST + if (!animate && CollectionView is Items.MauiCollectionView mauiCVAfter) + { + mauiCVAfter.SetPendingScrollRestore((int)goToIndexPath.Section, (int)goToIndexPath.Item, uICollectionViewScrollPosition); + } +#endif } } @@ -566,7 +584,7 @@ internal void SetPosition(int position) } } - _lastSyncedPosition = position; + _lastSyncedPosition = position; ItemsView.SetValueFromRenderer(CarouselView.PositionProperty, position); SetCurrentItem(position); UpdateVisualStates(); @@ -775,8 +793,25 @@ await Task.Delay(100).ContinueWith(_ => return; } + // This scroll bypasses ScrollToRequested, so it must clear/arm the MacCatalyst + // pending restore itself; otherwise it is not protected from the silent + // contentOffset clamp. +#if MACCATALYST + if (CollectionView is Items.MauiCollectionView mauiCV) + { + mauiCV.ClearPendingScrollRestore(); + } +#endif + CollectionView.ScrollToItem(projectedPosition, uICollectionViewScrollPosition, false); +#if MACCATALYST + if (CollectionView is Items.MauiCollectionView mauiCVAfter) + { + mauiCVAfter.SetPendingScrollRestore((int)projectedPosition.Section, (int)projectedPosition.Item, uICollectionViewScrollPosition); + } +#endif + //Set the position on VirtualView to update the CurrentItem also SetPosition(position); diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs index b820d1b23f79..cfd1b8433fd5 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs @@ -83,6 +83,16 @@ public override void Scrolled(UIScrollView scrollView) } } +#if MACCATALYST + public override void DraggingStarted(UIScrollView scrollView) + { + if (scrollView is MauiCollectionView mauiCV) + { + mauiCV.ClearPendingScrollRestore(); + } + } +#endif + public override UIEdgeInsets GetInsetForSection(UICollectionView collectionView, UICollectionViewLayout layout, nint section) { @@ -237,4 +247,4 @@ static NSIndexPath GetCenteredIndexPath(UICollectionView collectionView) // return ViewController?.GetSizeForItem(indexPath) ?? CGSize.Empty; // } } -} \ No newline at end of file +} diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 20a084a1300f..b65b21e0ebf3 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,8 +1,10 @@ #nullable enable override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.AddSubview(UIKit.UIView! view) -> void +override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(UIKit.UIView platformView) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewDelegator2.DraggingStarted(UIKit.UIScrollView scrollView) -> void override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34271.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34271.cs new file mode 100644 index 000000000000..2723835c5e25 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34271.cs @@ -0,0 +1,174 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34271, "CollectionView scroll position resets to top after ScrollTo last item when Picker is dismissed", PlatformAffected.macOS, isInternetRequired: true)] +public class Issue34271 : ContentPage +{ + // Varying image heights widen the gap between UIKit's estimated contentSize + // (NSCollectionLayoutDimension.CreateEstimated(30f)) and actual contentSize, + // making the silent offset reset more pronounced and reliably testable. + public record Monkey(string Name, string Location, string ImageUrl, double ImageHeight); + + readonly List _monkeys = + [ + new("Baboon", "Africa & Asia", "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Papio_anubis_%28Serengeti%2C_2009%29.jpg/200px-Papio_anubis_%28Serengeti%2C_2009%29.jpg", 40), + new("Capuchin Monkey", "Central & South America", "https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Capuchin_Costa_Rica.jpg/200px-Capuchin_Costa_Rica.jpg", 80), + new("Blue Monkey", "Central and East Africa", "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/BlueMonkey.jpg/220px-BlueMonkey.jpg", 50), + new("Squirrel Monkey", "Central & South America", "https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Saimiri_sciureus-1_Luc_Viatour.jpg/220px-Saimiri_sciureus-1_Luc_Viatour.jpg", 100), + new("Golden Lion Tamarin", "Brazil", "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Golden_lion_tamarin_portrait3.jpg/220px-Golden_lion_tamarin_portrait3.jpg", 45), + new("Howler Monkey", "South America", "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Alouatta_guariba.jpg/200px-Alouatta_guariba.jpg", 90), + new("Japanese Macaque", "Japan", "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Macaca_fuscata_fuscata1.jpg/220px-Macaca_fuscata_fuscata1.jpg", 55), + new("Mandrill", "Southern Cameroon", "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Mandrill_at_san_francisco_zoo.jpg/220px-Mandrill_at_san_francisco_zoo.jpg", 110), + new("Red-shanked Douc", "Vietnam, Laos", "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9f/Portrait_of_a_Douc.jpg/159px-Portrait_of_a_Douc.jpg", 40), + new("Gray-shanked Douc", "Vietnam", "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Cuc.Phuong.Primate.Rehab.center.jpg/320px-Cuc.Phuong.Primate.Rehab.center.jpg", 85), + new("Golden Snub-nosed Monkey", "China", "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Golden_Snub-nosed_Monkeys%2C_Qinling_Mountains_-_China.jpg/165px-Golden_Snub-nosed_Monkeys%2C_Qinling_Mountains_-_China.jpg", 60), + new("Black Snub-nosed Monkey", "China", "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/RhinopitecusBieti.jpg/320px-RhinopitecusBieti.jpg", 95), + new("Tonkin Snub-nosed Monkey", "Vietnam", "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Tonkin_snub-nosed_monkeys_%28Rhinopithecus_avunculus%29.jpg/320px-Tonkin_snub-nosed_monkeys_%28Rhinopithecus_avunculus%29.jpg", 50), + new("Thomas's Langur", "Indonesia", "https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Thomas%27s_langur_Presbytis_thomasi.jpg/142px-Thomas%27s_langur_Presbytis_thomasi.jpg", 105), + new("Purple-faced Langur", "Sri Lanka", "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Semnopith%C3%A8que_blanch%C3%A2tre_m%C3%A2le.JPG/192px-Semnopith%C3%A8que_blanch%C3%A2tre_m%C3%A2le.JPG", 45), + new("Gelada", "Ethiopia", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Gelada-Pavian.jpg/320px-Gelada-Pavian.jpg", 80), + new("Proboscis Monkey", "Borneo", "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Proboscis_Monkey_in_Borneo.jpg/250px-Proboscis_Monkey_in_Borneo.jpg", 70), + ]; + + readonly CollectionView _collectionView; + readonly Picker _picker; + readonly Switch _animateSwitch; + + public Issue34271() + { + _picker = new Picker + { + AutomationId = "PositionPicker", + SelectedIndex = 0, + ItemsSource = new List { "MakeVisible", "Start", "Center", "End" }, + }; + + _animateSwitch = new Switch + { + AutomationId = "AnimateSwitch", + IsToggled = false, + }; + + var scrollButton = new Button + { + AutomationId = "ScrollButton", + Text = "Scroll to Proboscis Monkey", + }; + + // Triggers InvalidateMeasure on the CollectionView to force UIKit to + // recompute layout bounds — an alternative path to reproduce the silent offset reset. + var triggerLayoutButton = new Button + { + AutomationId = "TriggerLayoutButton", + Text = "Trigger Layout Recomputation", + }; + + _collectionView = new CollectionView + { + AutomationId = "MonkeyCollectionView", + ItemsSource = _monkeys, + ItemTemplate = new DataTemplate(() => + { + var image = new Image + { + Aspect = Aspect.AspectFill, + WidthRequest = 60, + }; + image.SetBinding(Image.SourceProperty, nameof(Monkey.ImageUrl)); + image.SetBinding(Image.HeightRequestProperty, nameof(Monkey.ImageHeight)); + Grid.SetRowSpan(image, 2); + + var nameLabel = new Label { FontAttributes = FontAttributes.Bold }; + nameLabel.SetBinding(Label.TextProperty, nameof(Monkey.Name)); + nameLabel.SetBinding(Label.AutomationIdProperty, nameof(Monkey.Name)); + Grid.SetColumn(nameLabel, 1); + + var locationLabel = new Label + { + FontAttributes = FontAttributes.Italic, + VerticalOptions = LayoutOptions.End, + }; + locationLabel.SetBinding(Label.TextProperty, nameof(Monkey.Location)); + Grid.SetRow(locationLabel, 1); + Grid.SetColumn(locationLabel, 1); + + var itemGrid = new Grid + { + Padding = new Thickness(10), + RowDefinitions = + [ + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + ], + ColumnDefinitions = + [ + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Star }, + ], + }; + itemGrid.Add(image); + itemGrid.Add(nameLabel); + itemGrid.Add(locationLabel); + return itemGrid; + }), + }; + + scrollButton.Clicked += (s, e) => + { + var monkey = _monkeys.FirstOrDefault(m => m.Name == "Proboscis Monkey"); + var position = (ScrollToPosition)_picker.SelectedIndex; + _collectionView.ScrollTo(monkey, position: position, animate: _animateSwitch.IsToggled); + }; + + triggerLayoutButton.Clicked += (s, e) => + { + _collectionView.InvalidateMeasure(); + }; + + var pickerRow = new StackLayout + { + Orientation = StackOrientation.Horizontal, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label { Text = "ScrollToPosition: ", VerticalTextAlignment = TextAlignment.Center }, + _picker, + }, + }; + + var animateRow = new StackLayout + { + Orientation = StackOrientation.Horizontal, + HorizontalOptions = LayoutOptions.Center, + Children = + { + new Label { Text = "Animate scroll: ", VerticalTextAlignment = TextAlignment.Center }, + _animateSwitch, + }, + }; + + var grid = new Grid + { + Margin = new Thickness(20), + RowDefinitions = + [ + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Star }, + ], + }; + Grid.SetRow(pickerRow, 0); + Grid.SetRow(animateRow, 1); + Grid.SetRow(scrollButton, 2); + Grid.SetRow(triggerLayoutButton, 3); + Grid.SetRow(_collectionView, 4); + grid.Children.Add(pickerRow); + grid.Children.Add(animateRow); + grid.Children.Add(scrollButton); + grid.Children.Add(triggerLayoutButton); + grid.Children.Add(_collectionView); + + Content = grid; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34271.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34271.cs new file mode 100644 index 000000000000..a2dcc6ef605f --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34271.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue34271 : _IssuesUITest +{ + public Issue34271(TestDevice device) : base(device) { } + + public override string Issue => "CollectionView scroll position resets to top after ScrollTo last item when Picker is dismissed"; + + [Test] + [Category(UITestCategories.CollectionView)] + public void CollectionViewScrollPositionPreservedAfterPickerDismiss() + { + // Scroll CollectionView to the last item (Proboscis Monkey) + App.WaitForElement("ScrollButton"); + App.Tap("ScrollButton"); + + // Verify last item is now visible + App.WaitForElement("Proboscis Monkey"); + var rectBeforePicker = App.WaitForElement("Proboscis Monkey").GetRect(); + + // Open the picker (triggers the bug on Mac Catalyst via TraitCollectionDidChange) + App.Tap("PositionPicker"); + + // Dismiss the picker (App.ClosePicker handles all platforms: + // Android=Cancel button, iOS/Mac=Done button, Windows=TapCoordinates) + App.ClosePicker(windowsTapx: 10, windowsTapy: 10); + + // Verify scroll position was preserved — last item still visible at same position + App.WaitForElement("Proboscis Monkey"); + var rectAfterPicker = App.WaitForElement("Proboscis Monkey").GetRect(); + + Assert.That(rectAfterPicker.Y, Is.EqualTo(rectBeforePicker.Y).Within(5), + $"CollectionView scroll position reset after Picker dismiss. " + + $"Before: Y={rectBeforePicker.Y}, After: Y={rectAfterPicker.Y}"); + + // Trigger layout recomputation via InvalidateMeasure — forces UIKit to + // recompute contentSize using estimated item sizes, which can silently + // clamp contentOffset when varying-height items cause a large estimated/actual gap. + App.Tap("TriggerLayoutButton"); + + // Verify scroll position is still preserved after layout recomputation + App.WaitForElement("Proboscis Monkey"); + var rectAfterLayout = App.WaitForElement("Proboscis Monkey").GetRect(); + + Assert.That(rectAfterLayout.Y, Is.EqualTo(rectBeforePicker.Y).Within(5), + $"CollectionView scroll position reset after layout recomputation. " + + $"Before: Y={rectBeforePicker.Y}, After: Y={rectAfterLayout.Y}"); + } +}