Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions src/Controls/src/Core/Handlers/Items/iOS/ItemsViewCell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ protected void InitializeContentConstraints(UIView platformView)
ContentView.TrailingAnchor.ConstraintEqualTo(TrailingAnchor).Active = true;

// And we want the ContentView to be the same size as the root renderer for the Forms element
// TODO: we should probably remove this to support `Margin` applied to the cell's root `VirtualView`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should create a new issue for this, maybe for net10.0

ContentView.TopAnchor.ConstraintEqualTo(platformView.TopAnchor).Active = true;
ContentView.BottomAnchor.ConstraintEqualTo(platformView.BottomAnchor).Active = true;
ContentView.LeadingAnchor.ConstraintEqualTo(platformView.LeadingAnchor).Active = true;
Expand Down
61 changes: 33 additions & 28 deletions src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public abstract class ItemsViewController<TItemsView> : UICollectionViewControll
protected ItemsViewLayout ItemsViewLayout { get; set; }

bool _initialized;
bool _laidOut;
bool _isEmpty = true;
bool _emptyViewDisplayed;
bool _disposed;
Expand Down Expand Up @@ -193,18 +194,48 @@ public override void LoadView()
public override void ViewWillAppear(bool animated)
{
base.ViewWillAppear(animated);
ConstrainItemsToBounds();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes no sense here because bounds are not set yet: they'll be in ViewWillLayoutSubviews.
So any computation made here cannot be trustworthy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we navigate from a CV page to a new page and then back to the CV sometimes ViewWillLayoutSubviews will not always trigger.

Copy link
Contributor Author

@albyrock87 albyrock87 Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, but why is it necessary to trigger this method again considering the view was already laid out before pushing the new page? @rmarinho

}

public override void ViewWillLayoutSubviews()
{
ConstrainItemsToBounds();

if (CollectionView is Items.MauiCollectionView { NeedsCellLayout: true } collectionView)
{
InvalidateLayoutIfItemsMeasureChanged();
collectionView.NeedsCellLayout = false;
}

base.ViewWillLayoutSubviews();
InvalidateMeasureIfContentSizeChanged();
LayoutEmptyView();

_laidOut = true;
}

void InvalidateLayoutIfItemsMeasureChanged()
{
var visibleCells = CollectionView.VisibleCells;
List<NSIndexPath> invalidatedPaths = null;

var visibleCellsLength = visibleCells.Length;
for (int n = 0; n < visibleCellsLength; n++)
{
if (visibleCells[n] is TemplatedCell { MeasureInvalidated: true } cell)
{
invalidatedPaths ??= new List<NSIndexPath>(visibleCellsLength);
var path = CollectionView.IndexPathForCell(cell);
invalidatedPaths.Add(path);
}
}

if (invalidatedPaths != null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (invalidatedPaths != null)
if (invalidatedPaths is not null)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or is null return here

{
var layoutInvalidationContext = new UICollectionViewFlowLayoutInvalidationContext();
layoutInvalidationContext.InvalidateItems(invalidatedPaths.ToArray());
CollectionView.CollectionViewLayout.InvalidateLayout(layoutInvalidationContext);
}
}

void MauiCollectionView.ICustomMauiCollectionViewDelegate.MovedToWindow(UIView view)
{
Expand Down Expand Up @@ -284,7 +315,7 @@ void ConstrainItemsToBounds()
{
var contentBounds = CollectionView.AdjustedContentInset.InsetRect(CollectionView.Bounds);
var constrainedSize = contentBounds.Size;
ItemsViewLayout.UpdateConstraints(constrainedSize);
ItemsViewLayout.UpdateConstraints(constrainedSize, !_laidOut);
}

void EnsureLayoutInitialized()
Expand Down Expand Up @@ -373,7 +404,6 @@ protected virtual void UpdateDefaultCell(DefaultCell cell, NSIndexPath indexPath

protected virtual void UpdateTemplatedCell(TemplatedCell cell, NSIndexPath indexPath)
{
cell.ContentSizeChanged -= CellContentSizeChanged;
cell.LayoutAttributesChanged -= CellLayoutAttributesChanged;

var bindingContext = ItemsSource[indexPath];
Expand All @@ -382,7 +412,6 @@ protected virtual void UpdateTemplatedCell(TemplatedCell cell, NSIndexPath index
if (_measurementCells != null && _measurementCells.TryGetValue(bindingContext, out TemplatedCell measurementCell))
{
_measurementCells.Remove(bindingContext);
measurementCell.ContentSizeChanged -= CellContentSizeChanged;
measurementCell.LayoutAttributesChanged -= CellLayoutAttributesChanged;
cell.UseContent(measurementCell);
}
Expand All @@ -391,7 +420,6 @@ protected virtual void UpdateTemplatedCell(TemplatedCell cell, NSIndexPath index
cell.Bind(ItemsView.ItemTemplate, ItemsSource[indexPath], ItemsView);
}

cell.ContentSizeChanged += CellContentSizeChanged;
cell.LayoutAttributesChanged += CellLayoutAttributesChanged;

ItemsViewLayout.PrepareCellForLayout(cell);
Expand All @@ -407,29 +435,6 @@ protected object GetItemAtIndex(NSIndexPath index)
return ItemsSource[index];
}

[UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: CollectionViewTests.ItemsSourceDoesNotLeak")]
void CellContentSizeChanged(object sender, EventArgs e)
{
if (_disposed)
return;

if (!(sender is TemplatedCell cell))
{
return;
}

var visibleCells = CollectionView.VisibleCells;

for (int n = 0; n < visibleCells.Length; n++)
{
if (cell == visibleCells[n])
{
ItemsViewLayout?.InvalidateLayout();
return;
}
}
}

[UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: CollectionViewTests.ItemsSourceDoesNotLeak")]
void CellLayoutAttributesChanged(object sender, LayoutAttributesChangedEventArgs args)
{
Expand Down
4 changes: 2 additions & 2 deletions src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ protected virtual void HandlePropertyChanged(PropertyChangedEventArgs propertyCh
}
}

internal virtual bool UpdateConstraints(CGSize size)
internal virtual bool UpdateConstraints(CGSize size, bool forceUpdate = false)
{
if (size.IsCloseTo(_currentSize))
if (size.IsCloseTo(_currentSize) && !forceUpdate)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When used as ContentPage.Content = new CollectionView() the _currentSize never changes so this code path was not hit on the first LayoutSubviews, leading to wrong preferredAttributes computation and bad cell positioning.
You can verify this on main by testing out Issue13203 where the text is being positioned in the middle behind the safe area instead of being left aligned.

{
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ internal class MauiCollectionView : UICollectionView, IUIViewLifeCycleEvents, IP
bool _invalidateParentWhenMovedToWindow;

WeakReference<ICustomMauiCollectionViewDelegate>? _customDelegate;

internal bool NeedsCellLayout { get; set; }

public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(frame, layout)
{
}
Expand All @@ -27,10 +30,12 @@ void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMoved

void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating)
{
if (!isPropagating)
if (isPropagating)
{
SetNeedsLayout();
NeedsCellLayout = true;
}

SetNeedsLayout();
}

[UnconditionalSuppressMessage("Memory", "MEM0002", Justification = IUIViewLifeCycleEvents.UnconditionalSuppressMessage)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,6 @@ internal override void Disconnect()
{
base.Disconnect();

if (_headerViewFormsElement is not null)
{
_headerViewFormsElement.MeasureInvalidated -= OnFormsElementMeasureInvalidated;
}

if (_footerViewFormsElement is not null)
{
_footerViewFormsElement.MeasureInvalidated -= OnFormsElementMeasureInvalidated;
}

_headerUIView = null;
_headerViewFormsElement = null;
_footerUIView = null;
Expand Down Expand Up @@ -86,27 +76,23 @@ protected override CGRect DetermineEmptyViewFrame()

public override void ViewWillLayoutSubviews()
{
base.ViewWillLayoutSubviews();

// This update is only relevant if you have a footer view because it's used to place the footer view
// based on the ContentSize so we just update the positions if the ContentSize has changed
if (_footerUIView != null)
var hasHeaderOrFooter = _footerViewFormsElement is not null || _headerViewFormsElement is not null;
if (hasHeaderOrFooter && CollectionView is MauiCollectionView { NeedsCellLayout: true } collectionView)
{
var emptyView = CollectionView.ViewWithTag(EmptyTag);

if (IsHorizontal)
if (_headerViewFormsElement is not null)
{
if (_footerUIView.Frame.X != ItemsViewLayout.CollectionViewContentSize.Width ||
_footerUIView.Frame.X < emptyView?.Frame.X)
UpdateHeaderFooterPosition();
RemeasureLayout(_headerViewFormsElement);
}
else

if (_footerViewFormsElement is not null)
{
if (_footerUIView.Frame.Y != ItemsViewLayout.CollectionViewContentSize.Height ||
_footerUIView.Frame.Y < (emptyView?.Frame.Y + emptyView?.Frame.Height))
UpdateHeaderFooterPosition();
RemeasureLayout(_footerViewFormsElement);
}

UpdateHeaderFooterPosition();
}

base.ViewWillLayoutSubviews();
}

internal void UpdateFooterView()
Expand All @@ -123,15 +109,13 @@ internal void UpdateHeaderView()
UpdateHeaderFooterPosition();
}


internal void UpdateSubview(object view, DataTemplate viewTemplate, nint viewTag, ref UIView uiView, ref VisualElement formsElement)
{
uiView?.RemoveFromSuperview();

if (formsElement != null)
{
ItemsView.RemoveLogicalChild(formsElement);
formsElement.MeasureInvalidated -= OnFormsElementMeasureInvalidated;
}

UpdateView(view, viewTemplate, ref uiView, ref formsElement);
Expand All @@ -150,7 +134,6 @@ internal void UpdateSubview(object view, DataTemplate viewTemplate, nint viewTag
if (formsElement != null)
{
RemeasureLayout(formsElement);
formsElement.MeasureInvalidated += OnFormsElementMeasureInvalidated;
}
else
{
Expand All @@ -176,7 +159,11 @@ void UpdateHeaderFooterPosition()
}

if (_footerUIView != null && (_footerUIView.Frame.X != ItemsViewLayout.CollectionViewContentSize.Width || emptyWidth > 0))
_footerUIView.Frame = new CoreGraphics.CGRect(ItemsViewLayout.CollectionViewContentSize.Width + emptyWidth, 0, footerWidth, CollectionView.Frame.Height);
{
_footerUIView.Frame = new CoreGraphics.CGRect(
ItemsViewLayout.CollectionViewContentSize.Width + emptyWidth, 0, footerWidth,
CollectionView.Frame.Height);
}

if (CollectionView.ContentInset.Left != headerWidth || CollectionView.ContentInset.Right != footerWidth)
{
Expand Down Expand Up @@ -249,14 +236,5 @@ protected override void HandleFormsElementMeasureInvalidated(VisualElement forms
var size = base.GetSize();
return new Size(size.Value.Width, size.Value.Height + (_headerUIView?.Frame.Height ?? 0) + (_footerUIView?.Frame.Height ?? 0));
}

internal void UpdateLayoutMeasurements()
{
if (_headerViewFormsElement != null)
HandleFormsElementMeasureInvalidated(_headerViewFormsElement);

if (_footerViewFormsElement != null)
HandleFormsElementMeasureInvalidated(_footerViewFormsElement);
}
}
}
Loading
Loading