-
Notifications
You must be signed in to change notification settings - Fork 2k
[iOS, MacCatalyst] Fix CollectionView2 grouped ScrollTo MakeVisible producing inconsistent positions #35668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
[iOS, MacCatalyst] Fix CollectionView2 grouped ScrollTo MakeVisible producing inconsistent positions #35668
Changes from all commits
49a337b
e9d3a26
6d77cb3
dd40287
f3026ae
6b905cd
3c207ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| #nullable disable | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Collections.Specialized; | ||
| using System.ComponentModel; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Linq; | ||
|
|
@@ -88,6 +89,7 @@ protected override void Dispose(bool disposing) | |
|
|
||
| if (disposing) | ||
| { | ||
| UnsubscribeFromItemsSourceUpdating(); | ||
| ItemsSource?.Dispose(); | ||
|
|
||
| ((IUIViewLifeCycleEvents)CollectionView).MovedToWindow -= MovedToWindow; | ||
|
|
@@ -163,6 +165,7 @@ public override void ViewDidLoad() | |
| base.ViewDidLoad(); | ||
|
|
||
| ItemsSource = CreateItemsViewSource(); | ||
| SubscribeToItemsSourceUpdating(); | ||
|
|
||
| if (!(OperatingSystem.IsIOSVersionAtLeast(11) || OperatingSystem.IsMacCatalystVersionAtLeast(11) | ||
| #if TVOS | ||
|
|
@@ -251,16 +254,65 @@ internal void ReloadData() | |
| handler.SetCachedFirstItemSize(CoreGraphics.CGSize.Empty); | ||
| } | ||
|
|
||
| ResetEstimatedItemSize(); | ||
| CollectionView.ReloadData(); | ||
| } | ||
|
|
||
| internal void DisposeItemsSource() | ||
| { | ||
| UnsubscribeFromItemsSourceUpdating(); | ||
| ItemsSource?.Dispose(); | ||
| ItemsSource = new Items.EmptySource(); | ||
| ReloadData(); | ||
| } | ||
|
|
||
| void SubscribeToItemsSourceUpdating() | ||
| { | ||
| if (ItemsSource is Items.ObservableItemsSource observableSource) | ||
| { | ||
| observableSource.CollectionViewUpdating += OnItemsSourceCollectionViewUpdating; | ||
| } | ||
| else if (ItemsSource is Items.ObservableGroupedSource observableGroupedSource) | ||
| { | ||
| observableGroupedSource.CollectionViewUpdating += OnItemsSourceCollectionViewUpdating; | ||
| } | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Memory Leak Prevention — |
||
| void UnsubscribeFromItemsSourceUpdating() | ||
| { | ||
| if (ItemsSource is Items.ObservableItemsSource observableSource) | ||
| { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] CollectionView iOS/MacCatalyst — Subscribing only to the top-level |
||
| observableSource.CollectionViewUpdating -= OnItemsSourceCollectionViewUpdating; | ||
| } | ||
| else if (ItemsSource is Items.ObservableGroupedSource observableGroupedSource) | ||
| { | ||
| observableGroupedSource.CollectionViewUpdating -= OnItemsSourceCollectionViewUpdating; | ||
| } | ||
| } | ||
|
|
||
| void OnItemsSourceCollectionViewUpdating(object sender, NotifyCollectionChangedEventArgs e) | ||
| { | ||
| // ObservableItemsSource/ObservableGroupedSource Reload() calls | ||
| // collectionView.ReloadData() directly, bypassing ItemsViewController2.ReloadData(). | ||
| // Reset the measured estimate here so the section provider uses fresh measurements. | ||
| if (e.Action == NotifyCollectionChangedAction.Reset) | ||
| { | ||
| ResetEstimatedItemSize(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Resets the measured estimated item size so the section provider | ||
| /// falls back to the default estimate until a new measurement arrives. | ||
| /// </summary> | ||
| void ResetEstimatedItemSize() | ||
| { | ||
| if (CollectionView?.CollectionViewLayout is LayoutFactory2.CustomUICollectionViewCompositionalLayout compLayout) | ||
| { | ||
| compLayout.MeasuredEstimatedItemSize = null; | ||
| } | ||
| } | ||
|
|
||
| void EnsureLayoutInitialized() | ||
| { | ||
| if (_initialized) | ||
|
|
@@ -290,8 +342,10 @@ protected virtual Items.IItemsViewSource CreateItemsViewSource() | |
|
|
||
| public virtual void UpdateItemsSource() | ||
| { | ||
| UnsubscribeFromItemsSourceUpdating(); | ||
| ItemsSource?.Dispose(); | ||
| ItemsSource = CreateItemsViewSource(); | ||
| SubscribeToItemsSourceUpdating(); | ||
|
|
||
| ReloadData(); | ||
| CollectionView.CollectionViewLayout.InvalidateLayout(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,10 +93,31 @@ static UICollectionViewLayout CreateListLayout(UICollectionViewScrollDirection s | |
| //create global header and footer | ||
| layoutConfiguration.BoundarySupplementaryItems = CreateSupplementaryItems(null, layoutHeaderFooterInfo, scrollDirection, groupWidth, groupHeight); | ||
|
|
||
| var estimatedSizeHolder = new EstimatedItemSizeHolder(); | ||
| var layout = new CustomUICollectionViewCompositionalLayout(snapInfo, groupingInfo, layoutHeaderFooterInfo, (sectionIndex, environment) => | ||
| { | ||
| var effectiveItemHeight = itemHeight; | ||
| var effectiveGroupHeight = groupHeight; | ||
| var effectiveItemWidth = itemWidth; | ||
| var effectiveGroupWidth = groupWidth; | ||
|
|
||
| if (estimatedSizeHolder.Value is nfloat measuredSize) | ||
| { | ||
| var estimatedDimension = NSCollectionLayoutDimension.CreateEstimated(measuredSize); | ||
| if (scrollDirection == UICollectionViewScrollDirection.Vertical) | ||
| { | ||
| effectiveItemHeight = estimatedDimension; | ||
| effectiveGroupHeight = estimatedDimension; | ||
| } | ||
| else | ||
| { | ||
| effectiveItemWidth = estimatedDimension; | ||
| effectiveGroupWidth = estimatedDimension; | ||
| } | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] CollectionView iOS/MacCatalyst — The measured estimate is applied to items and groups, but the group header/footer supplementary items created a few lines below still use the original |
||
| // Each item has a size | ||
| var itemSize = NSCollectionLayoutSize.Create(itemWidth, itemHeight); | ||
| var itemSize = NSCollectionLayoutSize.Create(effectiveItemWidth, effectiveItemHeight); | ||
| // Create the item itself from the size | ||
| var item = NSCollectionLayoutItem.Create(layoutSize: itemSize); | ||
|
|
||
|
|
@@ -105,16 +126,16 @@ static UICollectionViewLayout CreateListLayout(UICollectionViewScrollDirection s | |
| if (scrollDirection == UICollectionViewScrollDirection.Vertical) | ||
| { | ||
| var newGroupHeight = environment.Container.ContentSize.Height - peekAreaInsets.Top - peekAreaInsets.Bottom; | ||
| groupHeight = NSCollectionLayoutDimension.CreateAbsolute((nfloat)newGroupHeight); | ||
| effectiveGroupHeight = NSCollectionLayoutDimension.CreateAbsolute((nfloat)newGroupHeight); | ||
| } | ||
| else | ||
| { | ||
| var newGroupWidth = environment.Container.ContentSize.Width - peekAreaInsets.Left - peekAreaInsets.Right; | ||
| groupWidth = NSCollectionLayoutDimension.CreateAbsolute((nfloat)newGroupWidth); | ||
| effectiveGroupWidth = NSCollectionLayoutDimension.CreateAbsolute((nfloat)newGroupWidth); | ||
| } | ||
| } | ||
| // Each group of items (for grouped collections) has a size | ||
| var groupSize = NSCollectionLayoutSize.Create(groupWidth, groupHeight); | ||
| var groupSize = NSCollectionLayoutSize.Create(effectiveGroupWidth, effectiveGroupHeight); | ||
|
|
||
| // Create the group | ||
| // If vertical list, we want the group to layout horizontally (eg: grid columns go left to right) | ||
|
|
@@ -143,7 +164,7 @@ static UICollectionViewLayout CreateListLayout(UICollectionViewScrollDirection s | |
| groupHeight); | ||
|
|
||
| return section; | ||
| }, layoutConfiguration, itemsLayout); | ||
| }, layoutConfiguration, itemsLayout, estimatedSizeHolder); | ||
|
|
||
| return layout; | ||
| } | ||
|
|
@@ -155,15 +176,36 @@ static UICollectionViewLayout CreateGridLayout(UICollectionViewScrollDirection s | |
| var layoutConfiguration = new UICollectionViewCompositionalLayoutConfiguration(); | ||
| layoutConfiguration.ScrollDirection = scrollDirection; | ||
|
|
||
| var estimatedSizeHolder = new EstimatedItemSizeHolder(); | ||
| var layout = new CustomUICollectionViewCompositionalLayout(snapInfo, groupingInfo, headerFooterInfo, (sectionIndex, environment) => | ||
| { | ||
| var effectiveItemHeight = itemHeight; | ||
| var effectiveGroupHeight = groupHeight; | ||
| var effectiveItemWidth = itemWidth; | ||
| var effectiveGroupWidth = groupWidth; | ||
|
|
||
| if (estimatedSizeHolder.Value is nfloat measuredSize) | ||
| { | ||
| var estimatedDimension = NSCollectionLayoutDimension.CreateEstimated(measuredSize); | ||
| if (scrollDirection == UICollectionViewScrollDirection.Vertical) | ||
| { | ||
| effectiveItemHeight = estimatedDimension; | ||
| effectiveGroupHeight = estimatedDimension; | ||
| } | ||
| else | ||
| { | ||
| effectiveItemWidth = estimatedDimension; | ||
| effectiveGroupWidth = estimatedDimension; | ||
| } | ||
| } | ||
|
|
||
| // Each item has a size | ||
| var itemSize = NSCollectionLayoutSize.Create(itemWidth, itemHeight); | ||
| var itemSize = NSCollectionLayoutSize.Create(effectiveItemWidth, effectiveItemHeight); | ||
| // Create the item itself from the size | ||
| var item = NSCollectionLayoutItem.Create(layoutSize: itemSize); | ||
|
|
||
| // Each group of items (for grouped collections) has a size | ||
| var groupSize = NSCollectionLayoutSize.Create(groupWidth, groupHeight); | ||
| var groupSize = NSCollectionLayoutSize.Create(effectiveGroupWidth, effectiveGroupHeight); | ||
|
|
||
| // Create the group | ||
| // If vertical list, we want the group to layout horizontally (eg: grid columns go left to right) | ||
|
|
@@ -197,7 +239,7 @@ static UICollectionViewLayout CreateGridLayout(UICollectionViewScrollDirection s | |
| groupHeight); | ||
|
|
||
| return section; | ||
| }, layoutConfiguration, itemsLayout); | ||
| }, layoutConfiguration, itemsLayout, estimatedSizeHolder); | ||
|
|
||
| return layout; | ||
| } | ||
|
|
@@ -497,19 +539,40 @@ public static bool IsIndexPathValid(NSIndexPath indexPath, UICollectionView coll | |
| return false; | ||
| } | ||
| } | ||
| class CustomUICollectionViewCompositionalLayout : UICollectionViewCompositionalLayout | ||
| /// <summary> | ||
| /// Holds the measured estimated item size shared between the layout and its | ||
| /// section provider closure, avoiding a layout → closure → layout retain cycle. | ||
| /// </summary> | ||
| internal sealed class EstimatedItemSizeHolder | ||
| { | ||
| internal nfloat? Value { get; set; } | ||
| } | ||
|
|
||
| internal class CustomUICollectionViewCompositionalLayout : UICollectionViewCompositionalLayout | ||
| { | ||
| LayoutSnapInfo _snapInfo; | ||
| ItemsLayout? _itemsLayout; | ||
| LayoutGroupingInfo? _groupingInfo; | ||
| LayoutHeaderFooterInfo? _headerFooterInfo; | ||
|
|
||
| public CustomUICollectionViewCompositionalLayout(LayoutSnapInfo snapInfo, LayoutGroupingInfo? groupingInfo, LayoutHeaderFooterInfo? headerFooterInfo, UICollectionViewCompositionalLayoutSectionProvider sectionProvider, UICollectionViewCompositionalLayoutConfiguration configuration, ItemsLayout? itemsLayout) : base(sectionProvider, configuration) | ||
| // Shared holder so the section provider closure can read the measured | ||
| // size without capturing a strong reference to this layout instance. | ||
| internal EstimatedItemSizeHolder EstimatedSizeHolder { get; } | ||
|
|
||
| // Convenience accessor — delegates to the shared holder. | ||
| internal nfloat? MeasuredEstimatedItemSize | ||
| { | ||
| get => EstimatedSizeHolder.Value; | ||
| set => EstimatedSizeHolder.Value = value; | ||
| } | ||
|
|
||
| public CustomUICollectionViewCompositionalLayout(LayoutSnapInfo snapInfo, LayoutGroupingInfo? groupingInfo, LayoutHeaderFooterInfo? headerFooterInfo, UICollectionViewCompositionalLayoutSectionProvider sectionProvider, UICollectionViewCompositionalLayoutConfiguration configuration, ItemsLayout? itemsLayout, EstimatedItemSizeHolder? estimatedSizeHolder = null) : base(sectionProvider, configuration) | ||
| { | ||
| _snapInfo = snapInfo; | ||
| _itemsLayout = itemsLayout; | ||
| _groupingInfo = groupingInfo; | ||
| _headerFooterInfo = headerFooterInfo; | ||
| EstimatedSizeHolder = estimatedSizeHolder ?? new EstimatedItemSizeHolder(); | ||
| } | ||
|
|
||
| public override void FinalizeCollectionViewUpdates() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -126,6 +126,8 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin | |
| // If this is the first item being measured, cache it for MeasureFirstItem strategy | ||
| SetCachedFirstItemSizeToHandler(_measuredSize.ToCGSize()); | ||
| } | ||
|
|
||
| UpdateLayoutEstimatedItemSize(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[moderate] Performance-Critical Path — |
||
| } | ||
| else | ||
| { | ||
|
|
@@ -176,6 +178,29 @@ private void SetCachedFirstItemSizeToHandler(CGSize size) | |
| CollectionViewHandler?.SetCachedFirstItemSize(size); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Updates the layout's estimated item size with the actual measured size. | ||
| /// Called once after the first non-supplementary cell is measured, so that | ||
| /// subsequent section provider calls use a realistic estimate instead of | ||
| /// the hardcoded 30px default. | ||
| /// </summary> | ||
| private void UpdateLayoutEstimatedItemSize() | ||
| { | ||
| if (CollectionViewHandler?.Controller?.CollectionView?.CollectionViewLayout | ||
| is LayoutFactory2.CustomUICollectionViewCompositionalLayout compLayout | ||
| && compLayout.MeasuredEstimatedItemSize is null) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] CollectionView iOS/MacCatalyst — |
||
| { | ||
| var measuredDimension = ScrollDirection == UICollectionViewScrollDirection.Vertical | ||
| ? (nfloat)_measuredSize.Height | ||
| : (nfloat)_measuredSize.Width; | ||
|
|
||
| if (measuredDimension > 0 && !nfloat.IsNaN(measuredDimension) && !nfloat.IsInfinity(measuredDimension)) | ||
| { | ||
| compLayout.MeasuredEstimatedItemSize = measuredDimension; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[major] Logic and Correctness — The measured estimate is written into the layout, but nothing invalidates the layout afterwards. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| public override void LayoutSubviews() | ||
| { | ||
| base.LayoutSubviews(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[minor] Performance-Critical Path —
NotifyCollectionChangedEventArgsis allocated unconditionally on everyReload(), even though the only consumer ofCollectionViewUpdating(CV2'sItemsViewController2) ignores everything exceptAction == Reset, and there is normally no subscriber at all for CV1. Guard the allocation on the delegate being non-null (if (CollectionViewUpdating is { } handler) handler(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));) —Reload()is called for everyReseton the bound collection.