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}");
+ }
+}