diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewCell.cs index 265dbf918cf5..61b5a38d7d5e 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewCell.cs @@ -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` ContentView.TopAnchor.ConstraintEqualTo(platformView.TopAnchor).Active = true; ContentView.BottomAnchor.ConstraintEqualTo(platformView.BottomAnchor).Active = true; ContentView.LeadingAnchor.ConstraintEqualTo(platformView.LeadingAnchor).Active = true; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs index e2146956cbb5..cbcdf2cc8993 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs @@ -29,6 +29,7 @@ public abstract class ItemsViewController : UICollectionViewControll protected ItemsViewLayout ItemsViewLayout { get; set; } bool _initialized; + bool _laidOut; bool _isEmpty = true; bool _emptyViewDisplayed; bool _disposed; @@ -193,18 +194,48 @@ public override void LoadView() public override void ViewWillAppear(bool animated) { base.ViewWillAppear(animated); - ConstrainItemsToBounds(); } 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 invalidatedPaths = null; + var visibleCellsLength = visibleCells.Length; + for (int n = 0; n < visibleCellsLength; n++) + { + if (visibleCells[n] is TemplatedCell { MeasureInvalidated: true } cell) + { + invalidatedPaths ??= new List(visibleCellsLength); + var path = CollectionView.IndexPathForCell(cell); + invalidatedPaths.Add(path); + } + } + + if (invalidatedPaths != null) + { + var layoutInvalidationContext = new UICollectionViewFlowLayoutInvalidationContext(); + layoutInvalidationContext.InvalidateItems(invalidatedPaths.ToArray()); + CollectionView.CollectionViewLayout.InvalidateLayout(layoutInvalidationContext); + } + } void MauiCollectionView.ICustomMauiCollectionViewDelegate.MovedToWindow(UIView view) { @@ -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() @@ -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]; @@ -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); } @@ -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); @@ -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) { diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs index c6f8473b629d..567343fa7e0b 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs @@ -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) { return false; } diff --git a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs index 5958643e5d56..8aa58993ea85 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs @@ -10,6 +10,9 @@ internal class MauiCollectionView : UICollectionView, IUIViewLifeCycleEvents, IP bool _invalidateParentWhenMovedToWindow; WeakReference? _customDelegate; + + internal bool NeedsCellLayout { get; set; } + public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(frame, layout) { } @@ -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)] diff --git a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs index 7d3835bf3f2a..b92557a09a22 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs @@ -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; @@ -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() @@ -123,7 +109,6 @@ internal void UpdateHeaderView() UpdateHeaderFooterPosition(); } - internal void UpdateSubview(object view, DataTemplate viewTemplate, nint viewTag, ref UIView uiView, ref VisualElement formsElement) { uiView?.RemoveFromSuperview(); @@ -131,7 +116,6 @@ internal void UpdateSubview(object view, DataTemplate viewTemplate, nint viewTag if (formsElement != null) { ItemsView.RemoveLogicalChild(formsElement); - formsElement.MeasureInvalidated -= OnFormsElementMeasureInvalidated; } UpdateView(view, viewTemplate, ref uiView, ref formsElement); @@ -150,7 +134,6 @@ internal void UpdateSubview(object view, DataTemplate viewTemplate, nint viewTag if (formsElement != null) { RemeasureLayout(formsElement); - formsElement.MeasureInvalidated += OnFormsElementMeasureInvalidated; } else { @@ -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) { @@ -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); - } } } \ No newline at end of file diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index c440568dfa1f..da66c38015a3 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -10,7 +10,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items { - public abstract class TemplatedCell : ItemsViewCell + public abstract class TemplatedCell : ItemsViewCell, IPlatformMeasureInvalidationController { readonly WeakEventManager _weakEventManager = new(); @@ -41,6 +41,7 @@ public DataTemplate CurrentTemplate // Keep track of the cell size so we can verify whether a measure invalidation // actually changed the size of the cell Size _size; + bool _bound; internal CGSize CurrentSize => _size.ToCGSize(); @@ -51,6 +52,9 @@ protected TemplatedCell(CGRect frame) : base(frame) } WeakReference _handler; + bool _measureInvalidated; + + internal bool MeasureInvalidated => _measureInvalidated; internal IPlatformViewHandler PlatformHandler { @@ -78,9 +82,10 @@ protected void ClearConstraints() internal void Unbind() { + _bound = false; + if (PlatformHandler?.VirtualView is View view) { - view.MeasureInvalidated -= MeasureInvalidated; view.BindingContext = null; } } @@ -90,39 +95,38 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin { var preferredAttributes = base.PreferredLayoutAttributesFittingAttributes(layoutAttributes); - var preferredSize = preferredAttributes.Frame.Size; - - if (preferredSize.IsCloseTo(_size) - && AttributesConsistentWithConstrainedDimension(preferredAttributes)) + if (_measureInvalidated || !AttributesConsistentWithConstrainedDimension(preferredAttributes)) { - return preferredAttributes; - } + // Measure this cell (including the Forms element) if there is no constrained size + var size = ConstrainedSize == default ? Measure() : ConstrainedSize; - var size = UpdateCellSize(); + _size = size.ToSize(); + _measureInvalidated = false; + } // Adjust the preferred attributes to include space for the Forms element - preferredAttributes.Frame = new CGRect(preferredAttributes.Frame.Location, size); + preferredAttributes.Frame = new CGRect(preferredAttributes.Frame.Location, _size); OnLayoutAttributesChanged(preferredAttributes); return preferredAttributes; } - CGSize UpdateCellSize() + public override void LayoutSubviews() { - // Measure this cell (including the Forms element) if there is no constrained size - var size = ConstrainedSize == default ? Measure() : ConstrainedSize; - - // Update the size of the root view to accommodate the Forms element - var platformView = PlatformHandler.ToPlatform(); - platformView.Frame = new CGRect(CGPoint.Empty, size); - - // Layout the Maui element - var nativeBounds = platformView.Frame.ToRectangle(); - PlatformHandler.VirtualView.Arrange(nativeBounds); - _size = nativeBounds.Size; + if (PlatformHandler?.VirtualView is { } virtualView) + { + // While the platform view Frame is set via auto-layout constraints, + // we have to set the Frame on the virtual view manually. + // Subviews will eventually be arranged via LayoutSubviews once the cell comes into play. + var frame = new Rect(Point.Zero, Bounds.Size.ToSize()); + if (virtualView.Frame != frame) + { + virtualView.Arrange(frame); + } + } - return size; + base.LayoutSubviews(); } [Obsolete] @@ -141,11 +145,12 @@ protected void Layout(CGSize constraints) var rectangle = platformView.Frame.ToRectangle(); PlatformHandler.VirtualView.Arrange(rectangle); _size = rectangle.Size; + _measureInvalidated = false; } public override void PrepareForReuse() { - Unbind(); + _bound = false; base.PrepareForReuse(); } @@ -161,11 +166,9 @@ public void Bind(DataTemplate template, object bindingContext, ItemsView itemsVi // Remove the old view, if it exists if (oldElement != null) { - oldElement.MeasureInvalidated -= MeasureInvalidated; oldElement.BindingContext = null; itemsView.RemoveLogicalChild(oldElement); ClearSubviews(); - _size = Size.Zero; } // Create the content and renderer for the view @@ -193,17 +196,21 @@ public void Bind(DataTemplate template, object bindingContext, ItemsView itemsVi else { // Same template - if (oldElement != null) + if (oldElement != null && !ReferenceEquals(bindingContext, oldElement.BindingContext)) { oldElement.BindingContext = bindingContext; - oldElement.MeasureInvalidated += MeasureInvalidated; - - UpdateCellSize(); } } CurrentTemplate = itemTemplate; - this.UpdateAccessibilityTraits(itemsView); + this.UpdateAccessibilityTraits(itemsView); + MarkAsBound(); + } + + void MarkAsBound() + { + _bound = true; + ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); } void SetRenderer(IPlatformViewHandler renderer) @@ -216,10 +223,9 @@ void SetRenderer(IPlatformViewHandler renderer) ClearSubviews(); InitializeContentConstraints(platformView); + ContentView.MarkAsCrossPlatformLayoutBacking(); UpdateVisualStates(); - - (renderer.VirtualView as View).MeasureInvalidated += MeasureInvalidated; } void ClearSubviews() @@ -238,6 +244,8 @@ internal void UseContent(TemplatedCell measurementCell) CurrentTemplate = measurementCell.CurrentTemplate; _size = measurementCell._size; SetRenderer(measurementCell.PlatformHandler); + _bound = true; + ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); } bool IsUsingVSMForSelectionColor(View view) @@ -287,20 +295,14 @@ public override bool Selected protected abstract (bool, Size) NeedsContentSizeUpdate(Size currentSize); - void MeasureInvalidated(object sender, EventArgs args) + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) { - var (needsUpdate, toSize) = NeedsContentSizeUpdate(_size); - - if (!needsUpdate) + // If the cell is not bound (or getting unbounded), we don't want to measure it + // and cause a useless and harming InvalidateLayout on the collection view layout + if (!_measureInvalidated && _bound) { - return; + _measureInvalidated = true; } - - // Cache the size for next time - _size = toSize; - - // Let the controller know that things need to be arranged again - OnContentSizeChanged(); } protected void OnContentSizeChanged() @@ -351,5 +353,10 @@ void UpdateSelectionColor(View view) SelectedBackgroundView.BackgroundColor = UIColor.Clear; } } + + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + // This is a no-op for cells + } } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewCell2.cs index 428fa4812fec..64efc147ee38 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewCell2.cs @@ -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` ContentView.TopAnchor.ConstraintEqualTo(platformView.TopAnchor).Active = true; ContentView.BottomAnchor.ConstraintEqualTo(platformView.BottomAnchor).Active = true; ContentView.LeadingAnchor.ConstraintEqualTo(platformView.LeadingAnchor).Active = true; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs index 8d478c7265a1..7e93d347b36e 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs @@ -189,11 +189,42 @@ public override void LoadView() public override void ViewWillLayoutSubviews() { + if (CollectionView is Items.MauiCollectionView { NeedsCellLayout: true } collectionView) + { + InvalidateLayoutIfItemsMeasureChanged(); + collectionView.NeedsCellLayout = false; + } + base.ViewWillLayoutSubviews(); LayoutEmptyView(); InvalidateMeasureIfContentSizeChanged(); } + void InvalidateLayoutIfItemsMeasureChanged() + { + var collectionView = CollectionView; + var visibleCells = collectionView.VisibleCells; + List invalidatedPaths = null; + + var visibleCellsLength = visibleCells.Length; + for (int n = 0; n < visibleCellsLength; n++) + { + if (visibleCells[n] is TemplatedCell2 { MeasureInvalidated: true } cell) + { + invalidatedPaths ??= new List(visibleCellsLength); + var path = collectionView.IndexPathForCell(cell); + invalidatedPaths.Add(path); + } + } + + if (invalidatedPaths != null) + { + var layoutInvalidationContext = new UICollectionViewLayoutInvalidationContext(); + layoutInvalidationContext.InvalidateItems(invalidatedPaths.ToArray()); + collectionView.CollectionViewLayout.InvalidateLayout(layoutInvalidationContext); + } + } + void Items.MauiCollectionView.ICustomMauiCollectionViewDelegate.MovedToWindow(UIView view) { if (CollectionView?.Window != null) @@ -577,8 +608,8 @@ private protected virtual NSIndexPath GetAdjustedIndexPathForItemSource(NSIndexP internal virtual void CellDisplayingEndedFromDelegate(UICollectionViewCell cell, NSIndexPath indexPath) { - if (cell is TemplatedCell2 TemplatedCell2 && - (TemplatedCell2.PlatformHandler?.VirtualView as View)?.BindingContext is object bindingContext) + if (cell is TemplatedCell2 templatedCell2 && + (templatedCell2.PlatformHandler?.VirtualView as View)?.BindingContext is { } bindingContext) { // We want to unbind a cell that is no longer present in the items source. Unfortunately // it's too expensive to check directly, so let's check that the current binding context @@ -591,7 +622,7 @@ internal virtual void CellDisplayingEndedFromDelegate(UICollectionViewCell cell, !Items.IndexPathHelpers.IsIndexPathValid(itemsSource, indexPath) || !Equals(itemsSource[indexPath], bindingContext)) { - TemplatedCell2.Unbind(); + templatedCell2.Unbind(); } } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/StructuredItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/StructuredItemsViewController2.cs index 32df41d56e1c..4823dff0bbe6 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/StructuredItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/StructuredItemsViewController2.cs @@ -1,8 +1,8 @@ #nullable disable using System; +using System.Collections.Generic; using CoreGraphics; using Foundation; -using Microsoft.Maui.Controls.Handlers.Items; using ObjCRuntime; using UIKit; @@ -181,7 +181,47 @@ protected override CGRect DetermineEmptyViewFrame() public override void ViewWillLayoutSubviews() { + if (CollectionView is Items.MauiCollectionView { NeedsCellLayout: true }) + { + InvalidateLayoutIfItemsMeasureChanged(); + } + base.ViewWillLayoutSubviews(); } + + void InvalidateLayoutIfItemsMeasureChanged() + { + // If the header or footer is a view, we need to check if the measure has changed. + // We could then invalidate the layout for supplementary cell only `collectionView.IndexPathForCell(headerCell)` like we do on standard cells, + // but that causes other cells to oddly collapse (see Issue25362 UITest), so in this case we have to stick with `InvalidateLayout`. + var collectionView = CollectionView; + + if (ItemsView.Header is not null || ItemsView.HeaderTemplate is not null) + { + var visibleHeaders = collectionView.GetVisibleSupplementaryViews(UICollectionElementKindSectionKey.Header); + foreach (var header in visibleHeaders) + { + if (header is TemplatedCell2 { MeasureInvalidated: true }) + { + collectionView.CollectionViewLayout.InvalidateLayout(); + return; + } + } + } + + + if (ItemsView.Footer is not null || ItemsView.FooterTemplate is not null) + { + var visibleFooters = collectionView.GetVisibleSupplementaryViews(UICollectionElementKindSectionKey.Footer); + foreach (var footer in visibleFooters) + { + if (footer is TemplatedCell2 { MeasureInvalidated: true }) + { + collectionView.CollectionViewLayout.InvalidateLayout(); + return; + } + } + } + } } } \ No newline at end of file diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index 32f263333e76..75b8eaac9102 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -10,7 +10,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items2 { - public class TemplatedCell2 : ItemsViewCell2 + public class TemplatedCell2 : ItemsViewCell2, IPlatformMeasureInvalidationController { internal const string ReuseId = "Microsoft.Maui.Controls.TemplatedCell2"; @@ -34,6 +34,13 @@ public event EventHandler LayoutAttributesCha WeakReference _currentTemplate; + bool _bound; + bool _measureInvalidated; + Size _measuredSize; + Size _cachedConstraints; + + internal bool MeasureInvalidated => _measureInvalidated; + public DataTemplate CurrentTemplate { get => _currentTemplate is not null && _currentTemplate.TryGetTarget(out var target) ? target : null; @@ -61,6 +68,8 @@ public TemplatedCell2(CGRect frame) : base(frame) internal void Unbind() { + _bound = false; + if (PlatformHandler?.VirtualView is View view) { //view.MeasureInvalidated -= MeasureInvalidated; @@ -74,33 +83,49 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin { var preferredAttributes = base.PreferredLayoutAttributesFittingAttributes(layoutAttributes); - if (PlatformHandler?.VirtualView is not null) + if (PlatformHandler?.VirtualView is { } virtualView) { - if (ScrollDirection == UICollectionViewScrollDirection.Vertical) - { - var measure = - PlatformHandler.VirtualView.Measure(preferredAttributes.Size.Width, double.PositiveInfinity); + var constraints = ScrollDirection == UICollectionViewScrollDirection.Vertical + ? new Size(preferredAttributes.Size.Width, double.PositiveInfinity) + : new Size(double.PositiveInfinity, preferredAttributes.Size.Height); - preferredAttributes.Frame = - new CGRect(preferredAttributes.Frame.X, preferredAttributes.Frame.Y, - preferredAttributes.Frame.Width, measure.Height); - } - else + if (_measureInvalidated || _cachedConstraints != constraints) { - var measure = - PlatformHandler.VirtualView.Measure(double.PositiveInfinity, preferredAttributes.Size.Height); - - preferredAttributes.Frame = - new CGRect(preferredAttributes.Frame.X, preferredAttributes.Frame.Y, - measure.Width, preferredAttributes.Frame.Height); + var measure = virtualView.Measure(constraints.Width, constraints.Height); + _cachedConstraints = constraints; + _measuredSize = measure; } + var size = ScrollDirection == UICollectionViewScrollDirection.Vertical + ? new Size(preferredAttributes.Size.Width, _measuredSize.Height) + : new Size(_measuredSize.Width, preferredAttributes.Size.Height); + + preferredAttributes.Frame = new CGRect(preferredAttributes.Frame.Location, size); preferredAttributes.ZIndex = 2; + + _measureInvalidated = false; } return preferredAttributes; } + public override void LayoutSubviews() + { + if (PlatformHandler?.VirtualView is { } virtualView) + { + // While the platform view Frame is set via auto-layout constraints, + // we have to set the Frame on the virtual view manually. + // Subviews will eventually be arranged via LayoutSubviews once the cell comes into play. + var frame = new Rect(Point.Zero, Bounds.Size.ToSize()); + if (virtualView.Frame != frame) + { + virtualView.Arrange(frame); + } + } + + base.LayoutSubviews(); + } + public override void PrepareForReuse() { //Unbind(); @@ -109,7 +134,9 @@ public override void PrepareForReuse() public void Bind(DataTemplate template, object bindingContext, ItemsView itemsView) { - var virtualView = template.CreateContent(bindingContext, itemsView) as View; + var virtualView = PlatformHandler?.VirtualView as View ?? + template.CreateContent(bindingContext, itemsView) as View; + BindVirtualView(virtualView, bindingContext, itemsView, false); } @@ -136,6 +163,7 @@ void BindVirtualView(View virtualView, object bindingContext, ItemsView itemsVie PlatformHandler = virtualView.Handler as IPlatformViewHandler; InitializeContentConstraints(PlatformView); + ContentView.MarkAsCrossPlatformLayoutBacking(); virtualView.BindingContext = bindingContext; itemsView.AddLogicalChild(virtualView); @@ -146,6 +174,8 @@ void BindVirtualView(View virtualView, object bindingContext, ItemsView itemsVie view.SetValueFromRenderer(BindableObject.BindingContextProperty, bindingContext); } + _bound = true; + ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); this.UpdateAccessibilityTraits(itemsView); } @@ -260,5 +290,20 @@ void UpdateSelectionColor(View view) SelectedBackgroundView.BackgroundColor = UIColor.Clear; } } + + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + // This is a no-op + } + + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) + { + // If the cell is not bound (or getting unbounded), we don't want to measure it + // and cause a useless and harming InvalidateLayout on the collection view layout + if (!_measureInvalidated && _bound) + { + _measureInvalidated = true; + } + } } } diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 8fd546ccbe70..63a6c7ddd87a 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,5 +1,7 @@ #nullable enable Microsoft.Maui.Controls.StyleableElement.Style.get -> Microsoft.Maui.Controls.Style? +override Microsoft.Maui.Controls.Handlers.Items.TemplatedCell.LayoutSubviews() -> void +override Microsoft.Maui.Controls.Handlers.Items2.TemplatedCell2.LayoutSubviews() -> void override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size ~abstract Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.CreateController(TItemsView newElement, UIKit.UICollectionViewLayout layout) -> Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2 ~abstract Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.SelectLayout() -> UIKit.UICollectionViewLayout 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 6f7e75373a53..4d313d1b0c85 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,5 +1,7 @@ #nullable enable Microsoft.Maui.Controls.StyleableElement.Style.get -> Microsoft.Maui.Controls.Style? +override Microsoft.Maui.Controls.Handlers.Items.TemplatedCell.LayoutSubviews() -> void +override Microsoft.Maui.Controls.Handlers.Items2.TemplatedCell2.LayoutSubviews() -> void override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size ~abstract Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.CreateController(TItemsView newElement, UIKit.UICollectionViewLayout layout) -> Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2 ~abstract Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.SelectLayout() -> UIKit.UICollectionViewLayout diff --git a/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/DataTemplateSelectorGallery.xaml b/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/DataTemplateSelectorGallery.xaml index 1d99e9bdf714..1966658e622f 100644 --- a/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/DataTemplateSelectorGallery.xaml +++ b/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/DataTemplateSelectorGallery.xaml @@ -7,7 +7,7 @@ - + @@ -15,7 +15,7 @@ - + diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml index 0a3da09cfa72..385fe4770463 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml @@ -13,11 +13,11 @@ AbsoluteLayout.LayoutBounds="0,0,1,1"> - + - - + + - - + + diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml.cs index 60e4ef7d973d..6bd442779a04 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue25671.xaml.cs @@ -17,6 +17,13 @@ public Issue25671() { InitializeComponent(); GenerateItems(); + CV.HandlerChanged += (s, e) => + { + if (CV.Handler is { } handler) + { + HeadingLabel.Text = handler.GetType().Name; + } + }; } void RegenerateItems(object sender, EventArgs args) @@ -41,15 +48,6 @@ void GenerateItems() } } -#if IOS -// When CV2 is completed and can handle resize of items we can remove this pointer to CV1 -internal class Issue25671CollectionView : CollectionView1 -#else -public class Issue25671CollectionView : CollectionView -#endif -{ -} - public class Issue25671AbsoluteLayout : AbsoluteLayout { public static long MeasurePasses = 0; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue13203.cs b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue13203.cs index 1f17923cc671..bb3b582e59d3 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue13203.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue13203.cs @@ -11,9 +11,10 @@ protected override void Init() { IsVisible = false, + BackgroundColor = Colors.PaleGreen, ItemTemplate = new DataTemplate(() => { - var label = new Label(); + var label = new Label { FontSize = 40, BackgroundColor = Colors.SeaGreen }; label.SetBinding(Label.TextProperty, new Binding(nameof(Item.Text))); label.SetBinding(Label.AutomationIdProperty, new Binding(nameof(Item.Text))); return label; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue9686.cs b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue9686.cs index 9783393450cf..0df650f1c5cc 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue9686.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/XFIssue/Issue9686.cs @@ -1,4 +1,6 @@ using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Windows.Input; namespace Maui.Controls.Sample.Issues; @@ -59,14 +61,35 @@ public class _9686Item public string Name { get; set; } } - public class _9686Group : List<_9686Item> + public class _9686Group : List<_9686Item>, INotifyPropertyChanged { - public string GroupName { get; set; } + string _groupName; + + public string GroupName + { + get => _groupName; + set => SetField(ref _groupName, value); + } public _9686Group(string groupName, ObservableCollection<_9686Item> items) : base(items) { GroupName = groupName; } + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } } public class _9686ViewModel diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/CarouselViewItemsShouldRenderVertically.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/CarouselViewItemsShouldRenderVertically.png index bfcf800fdf96..2843b230f1b4 100644 Binary files a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/CarouselViewItemsShouldRenderVertically.png and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/CarouselViewItemsShouldRenderVertically.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21967.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21967.cs index 4de64d132003..4ff1331211a1 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21967.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue21967.cs @@ -47,13 +47,13 @@ public async Task CollectionViewWorksWhenRotatingDevice() App.WaitForElement("FullSize"); App.Tap("FullSize"); App.SetOrientationPortrait(); - await Task.Delay(100); + await Task.Delay(300); var itemSizePortrait = App.WaitForElement("Item1").GetRect(); App.SetOrientationLandscape(); - await Task.Delay(100); + await Task.Delay(300); var itemSizeLandscape = App.WaitForElement("Item1").GetRect(); App.SetOrientationPortrait(); - await Task.Delay(100); + await Task.Delay(300); var itemSizePortrait2 = App.WaitForElement("Item1").GetRect(); ClassicAssert.Greater(itemSizeLandscape.Width, itemSizePortrait.Width); diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25362.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25362.cs index 64caa900f72a..7e7b892f2edc 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25362.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25362.cs @@ -13,8 +13,6 @@ public Issue25362(TestDevice device) : base(device) [Test] [Category(UITestCategories.CollectionView)] - [FailsOnIOSWhenRunningOnXamarinUITest("This is not working for CV2 yet")] - [FailsOnMacWhenRunningOnXamarinUITest("This is not working for CV2 yet")] public void HeaderShouldNotCollapseWithItems() { App.WaitForElement("button"); diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25671.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25671.cs index 9a1fbb41487f..baa8499ee319 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25671.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue25671.cs @@ -42,8 +42,14 @@ public async Task LayoutPassesShouldNotIncrease() var arrangePasses = int.Parse(match.Groups[2].Value); #if IOS - const int maxMeasurePasses = 525; - const int maxArrangePasses = 308; + var maxMeasurePasses = 221; + var maxArrangePasses = 237; + + if (App.FindElement("HeadingLabel").GetText() == "CollectionViewHandler2") + { + maxMeasurePasses = 362; + maxArrangePasses = 398; + } #elif ANDROID const int maxMeasurePasses = 353; const int maxArrangePasses = 337; diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/Issue9686.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/Issue9686.cs index cad2ac2804d4..757cba47f10e 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/Issue9686.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/XFIssue/Issue9686.cs @@ -24,11 +24,7 @@ public void AddRemoveEmptyGroupsShouldNotCrashOnInsert() App.Tap(Run); App.WaitForElement("Item 1"); App.Tap(Run); -#if IOS // This test fails with timeout exception in CI due to dynamic updates of the Success Element, although it passes locally. Since the test ensures app crash validation, we simply wait for the second group header element to validate the test. - App.WaitForElement("Group 2"); -#else App.WaitForElement(Success, timeout: TimeSpan.FromSeconds(1)); -#endif } } #endif \ No newline at end of file diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CarouselViewItemsShouldRenderVertically.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CarouselViewItemsShouldRenderVertically.png index e0a2d93cd0fe..9861e6f7f63f 100644 Binary files a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CarouselViewItemsShouldRenderVertically.png and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CarouselViewItemsShouldRenderVertically.png differ diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index 21c621652be3..c4a03a230963 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -1006,6 +1006,13 @@ internal static float GetDisplayDensity(this UIView? view) => internal static bool IsSoftInputShowing(this UIView inputView) => inputView.IsFirstResponder; - internal static bool IsFinalMeasureHandledBySuperView(this UIView? view) => view?.Superview is ICrossPlatformLayoutBacking { CrossPlatformLayout: not null }; + private const nint NativeViewControlledByCrossPlatformLayout = 0x63D2A1; + + internal static bool IsFinalMeasureHandledBySuperView(this UIView? view) => view?.Superview is ICrossPlatformLayoutBacking { CrossPlatformLayout: not null } or { Tag: NativeViewControlledByCrossPlatformLayout }; + + internal static void MarkAsCrossPlatformLayoutBacking(this UIView view) + { + view.Tag = NativeViewControlledByCrossPlatformLayout; + } } }