Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 193 additions & 1 deletion src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
using CoreFoundation;
using CoreGraphics;
using Foundation;
using UIKit;

namespace Microsoft.Maui.Controls.Handlers.Items;
Expand All @@ -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;
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.

// 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(() =>
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
{
if (Handle == IntPtr.Zero || Window is null)
{
return;
}

if (Tracking || Dragging || Decelerating)
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
{
return;
}

using var indexPath = NSIndexPath.Create(section, item);

if (!IsIndexPathValidForRestore(indexPath))
{
ClearPendingScrollRestore();
return;
}

ScrollToItem(indexPath, scrollPosition, false);
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.

// 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);
}

/// <summary>
/// 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.
/// </summary>
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)
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
{
return false;
}

return indexPath.Item >= 0 && indexPath.Item < itemCount;
}
#endif

internal bool NeedsCellLayout { get; set; }

public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(frame, layout)
Expand All @@ -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
Expand Down Expand Up @@ -169,5 +349,17 @@ public override void MovedToWindow()
_invalidateParentWhenMovedToWindow = false;
this.InvalidateAncestorsMeasures();
}

#if MACCATALYST
if (Window is not null)
{
StartContentOffsetObserver();
}
else
{
StopContentOffsetObserver();
ClearPendingScrollRestore();
}
#endif
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
{
mauiCVAfter.SetPendingScrollRestore((int)goToIndexPath.Section, (int)goToIndexPath.Item, loopScrollPosition);
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
}
#endif
}
else
{
Expand Down
54 changes: 52 additions & 2 deletions src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +79,11 @@ protected override UIView CreatePlatformView()
public static void MapItemsSource(ItemsViewHandler2<TItemsView> 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();
}

Expand Down Expand Up @@ -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<TItemsView> 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))
Expand All @@ -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)
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
{
mauiCV.SetPendingScrollRestore(section, item, scrollPosition);
}
}
Comment thread
Vignesh-SF3580 marked this conversation as resolved.
#endif

NSIndexPath DetermineIndex(ScrollToRequestEventArgs args)
{
Expand Down
Loading
Loading