diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs index 8b36aaf43785..952fe4f00e5e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs @@ -217,34 +217,6 @@ protected override void Dispose(bool disposing) } } - bool _pendingSuperViewSetNeedsLayout; - - public override void SetNeedsLayout() - { - base.SetNeedsLayout(); - - if (Window is not null) - { - _pendingSuperViewSetNeedsLayout = false; - this.Superview?.SetNeedsLayout(); - } - else - { - _pendingSuperViewSetNeedsLayout = true; - } - } - - public override void MovedToWindow() - { - base.MovedToWindow(); - if (_pendingSuperViewSetNeedsLayout) - { - this.Superview?.SetNeedsLayout(); - } - - _pendingSuperViewSetNeedsLayout = false; - } - [Microsoft.Maui.Controls.Internals.Preserve(Conditional = true)] class FrameView : Microsoft.Maui.Platform.ContentView { diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs index eed44ee37327..df68dfc77367 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs @@ -1,14 +1,14 @@ using System; using System.ComponentModel; using Microsoft.Maui.Controls.Platform; -using Microsoft.Maui.Graphics; using UIKit; namespace Microsoft.Maui.Controls.Handlers.Compatibility { - public abstract partial class VisualElementRenderer : UIView, IPlatformViewHandler, IElementHandler + public abstract partial class VisualElementRenderer : UIView, IPlatformViewHandler, IElementHandler, IPlatformMeasureInvalidationController where TElement : Element, IView { + bool _invalidateParentWhenMovedToWindow; object? IElementHandler.PlatformView => Subviews.Length > 0 ? Subviews[0] : this; public virtual UIViewController? ViewController => null; @@ -93,5 +93,22 @@ void OnSizeChanged(object? sender, EventArgs e) { UpdateNativeWidget(); } + + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + _invalidateParentWhenMovedToWindow = true; + } + + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) => SetNeedsLayout(); + + public override void MovedToWindow() + { + base.MovedToWindow(); + if (_invalidateParentWhenMovedToWindow) + { + _invalidateParentWhenMovedToWindow = false; + this.InvalidateAncestorsMeasures(); + } + } } } diff --git a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs index 5097ddd14a0c..5958643e5d56 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/MauiCollectionView.cs @@ -5,8 +5,10 @@ namespace Microsoft.Maui.Controls.Handlers.Items; -internal class MauiCollectionView : UICollectionView, IUIViewLifeCycleEvents +internal class MauiCollectionView : UICollectionView, IUIViewLifeCycleEvents, IPlatformMeasureInvalidationController { + bool _invalidateParentWhenMovedToWindow; + WeakReference? _customDelegate; public MauiCollectionView(CGRect frame, UICollectionViewLayout layout) : base(frame, layout) { @@ -18,8 +20,22 @@ public override void ScrollRectToVisible(CGRect rect, bool animated) base.ScrollRectToVisible(rect, animated); } + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + _invalidateParentWhenMovedToWindow = true; + } + + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) + { + if (!isPropagating) + { + SetNeedsLayout(); + } + } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = IUIViewLifeCycleEvents.UnconditionalSuppressMessage)] EventHandler? _movedToWindow; + event EventHandler? IUIViewLifeCycleEvents.MovedToWindow { add => _movedToWindow += value; @@ -35,6 +51,12 @@ public override void MovedToWindow() { target.MovedToWindow(this); } + + if (_invalidateParentWhenMovedToWindow) + { + _invalidateParentWhenMovedToWindow = false; + this.InvalidateAncestorsMeasures(); + } } internal void SetCustomDelegate(ICustomMauiCollectionViewDelegate customDelegate) diff --git a/src/Controls/src/Core/ImageButton/ImageButton.iOS.cs b/src/Controls/src/Core/ImageButton/ImageButton.iOS.cs index 3b3e930c9dbe..0f4dd0929b76 100644 --- a/src/Controls/src/Core/ImageButton/ImageButton.iOS.cs +++ b/src/Controls/src/Core/ImageButton/ImageButton.iOS.cs @@ -25,7 +25,7 @@ Size ICrossPlatformLayout.CrossPlatformMeasure(double widthConstraint, double he if (platformButton.ImageView.Image is not null) { return platformButton.ImageView - .SizeThatFitsImage(constraintSize, Padding.IsNaN ? null : Padding).ToSize(); + .SizeThatFitsImage(constraintSize, Padding.IsNaN ? default : Padding).ToSize(); } return platformButton.SizeThatFits(constraintSize).ToSize(); 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 42d5115c8acc..8fd546ccbe70 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -340,5 +340,7 @@ virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2 virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2.UpdateVisibility() -> void virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewDelegator2.GetVisibleItemsIndex() -> (bool VisibleItems, int First, int Center, int Last) virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.UpdateLayout() -> void -override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.MovedToWindow() -> void -~Microsoft.Maui.Controls.Internals.TypedBindingBase.UpdateSourceEventName.set -> void \ No newline at end of file +~Microsoft.Maui.Controls.Internals.TypedBindingBase.UpdateSourceEventName.set -> void +*REMOVED*override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.SetNeedsLayout() -> void +*REMOVED*override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.MovedToWindow() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.VisualElementRenderer.MovedToWindow() -> void 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 d316177c7108..6f7e75373a53 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -341,4 +341,6 @@ virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2 virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewController2.UpdateVisibility() -> void virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewDelegator2.GetVisibleItemsIndex() -> (bool VisibleItems, int First, int Center, int Last) virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.UpdateLayout() -> void -override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.MovedToWindow() -> void \ No newline at end of file +*REMOVED*override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.SetNeedsLayout() -> void +*REMOVED*override Microsoft.Maui.Controls.Handlers.Compatibility.FrameRenderer.MovedToWindow() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.VisualElementRenderer.MovedToWindow() -> void \ No newline at end of file diff --git a/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.cs b/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.cs index 84e4ea9988b0..1b193f8d1706 100644 --- a/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/CarouselView/CarouselViewTests.cs @@ -147,7 +147,10 @@ await CreateHandlerAndAddToWindow(carouselView, async (hand Assert.False(data.IsCollectionChangedEventEmpty); }); - carouselView.Handler?.DisconnectHandler(); + await InvokeOnMainThreadAsync(() => + { + carouselView.Handler?.DisconnectHandler(); + }); Assert.True(data.IsCollectionChangedEventEmpty); } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml new file mode 100644 index 000000000000..175d0c8b1157 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml.cs new file mode 100644 index 000000000000..d207bfdceb15 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue24996.xaml.cs @@ -0,0 +1,73 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 24996, "Changing Translation of an element causes Maui in iOS to constantly run Measure & ArrangeChildren", PlatformAffected.All)] +public partial class Issue24996 : ContentPage +{ + Point[] _translations = [ + new(40, 80), + new(1000, 20), + new(20, 1000), + new(1000, 1000), + ]; + + int _index = -1; + + public Issue24996() + { + InitializeComponent(); + UpdateText(); + + SizeChanged += delegate { + if (Width > 0) { + // For some reason, constraining this layout to a fixed size causes a `SetNeedsLayout` to be called + // when translating the Lvl2 view outside the bottom boundary. + // This causes a layout pass to be called on the Root, Lvl1, Lvl2, and Lvl3. + Lvl1.WidthRequest = Width; + Lvl1.HeightRequest = Height; + } + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + await Task.Delay(250); + Lvl1.MeasurePasses = Lvl1.ArrangePasses = 0; + Lvl2.MeasurePasses = Lvl2.ArrangePasses = 0; + Lvl3.MeasurePasses = Lvl3.ArrangePasses = 0; + UpdateText(); + } + + public async void OnTapped(object sender, EventArgs e) + { + var testPoint = _translations[++_index % _translations.Length]; + Coords.Text = $"X: {testPoint.X}, Y: {testPoint.Y}"; + Lvl2.TranslationX = testPoint.X; + Lvl2.TranslationY = testPoint.Y; + await Task.Delay(100); + UpdateText(); + } + + void UpdateText() + { + Stats.Text = $"Lvl1[{Lvl1.MeasurePasses}/{Lvl1.ArrangePasses}] - Lvl2[{Lvl2.MeasurePasses}/{Lvl2.ArrangePasses}] - Lvl3[{Lvl3.MeasurePasses}/{Lvl3.ArrangePasses}]"; + } +} + +public class ObservedLayout24996 : AbsoluteLayout +{ + public int MeasurePasses { get; set; } + public int ArrangePasses { get; set; } + + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) + { + MeasurePasses++; + return base.MeasureOverride(widthConstraint, heightConstraint); + } + + protected override Size ArrangeOverride(Rect bounds) + { + ArrangePasses++; + return base.ArrangeOverride(bounds); + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue18242Test.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue18242Test.png index 42cc7bf1c2f4..786803210c28 100644 Binary files a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue18242Test.png and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue18242Test.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue24996.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue24996.cs new file mode 100644 index 000000000000..f00636ba97d8 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue24996.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using NUnit.Framework.Legacy; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues +{ + public class Issue24996 : _IssuesUITest + { + public Issue24996(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "Changing Translation of an element causes Maui in iOS to constantly run Measure & ArrangeChildren"; + + [Test] + [Category(UITestCategories.Layout)] + public async Task ChangingTranslationShouldNotCauseLayoutPassOnAncestors() + { + var element = App.WaitForElement("Stats"); + // Tries to translate the element in different positions, on-screen and off-screen. + for (int i = 0; i < 4; i++) + { + element.Tap(); + await Task.Delay(150); + ClassicAssert.True(element.GetText()!.StartsWith("Lvl1[0/0]")); + } + } + } +} \ No newline at end of file diff --git a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs index 72953f006d85..138ca588c892 100644 --- a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs +++ b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs @@ -218,6 +218,11 @@ public override void SetImageSource(UIImage? platformImage) platformImage = platformImage?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); button.SetImage(platformImage, UIControlState.Normal); + + // UIButton.SetImage(image, forState:) does not immediately assign the image to UIButton.ImageView.Image. + // Instead, the image is set internally and only applied to ImageView when the button is rendered. + // To ensure SizeThatFits is correct, and avoid race conditions, we have to force a layout. + button.LayoutIfNeeded(); } } } diff --git a/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs b/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs index 7973976541a9..9be216cbfc09 100644 --- a/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs +++ b/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs @@ -83,6 +83,11 @@ public override void SetImageSource(UIImage? platformImage) button.SetImage(platformImage, UIControlState.Normal); button.HorizontalAlignment = UIControlContentHorizontalAlignment.Fill; button.VerticalAlignment = UIControlContentVerticalAlignment.Fill; + + // UIButton.SetImage(image, forState:) does not immediately assign the image to UIButton.ImageView.Image. + // Instead, the image is set internally and only applied to ImageView when the button is rendered. + // To ensure SizeThatFits is correct, and avoid race conditions, we have to force a layout. + button.LayoutIfNeeded(); } } diff --git a/src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs b/src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs index a482f55212e9..385375633e2b 100644 --- a/src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs +++ b/src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs @@ -38,6 +38,8 @@ public override void SetVirtualView(IView view) { PlatformView.AddSubview(child.ToPlatform(MauiContext)); } + + PlatformView.InvalidateAncestorsMeasures(); } public void Add(IView child) @@ -54,6 +56,8 @@ public void Add(IView child) { childPlatformView.UpdateFlowDirection(child); } + + PlatformView.InvalidateAncestorsMeasures(); } public void Remove(IView child) @@ -65,11 +69,14 @@ public void Remove(IView child) { childView.RemoveFromSuperview(); } + + PlatformView.InvalidateAncestorsMeasures(); } public void Clear() { PlatformView.ClearSubviews(); + PlatformView.InvalidateAncestorsMeasures(); } public void Insert(int index, IView child) @@ -86,6 +93,8 @@ public void Insert(int index, IView child) { childPlatformView.UpdateFlowDirection(child); } + + PlatformView.InvalidateAncestorsMeasures(); } public void Update(int index, IView child) @@ -99,6 +108,7 @@ public void Update(int index, IView child) var targetIndex = VirtualView.GetLayoutHandlerIndex(child); PlatformView.InsertSubview(child.ToPlatform(MauiContext), targetIndex); PlatformView.SetNeedsLayout(); + PlatformView.InvalidateAncestorsMeasures(); } public void UpdateZIndex(IView child) @@ -137,6 +147,7 @@ void EnsureZIndexOrder(IView child) { PlatformView.Subviews.RemoveAt(currentIndex); PlatformView.InsertSubview(nativeChildView, targetIndex); + PlatformView.InvalidateAncestorsMeasures(); } } diff --git a/src/Core/src/Handlers/Page/PageHandler.iOS.cs b/src/Core/src/Handlers/Page/PageHandler.iOS.cs index 2a11d919ed8a..a8ec163f1386 100644 --- a/src/Core/src/Handlers/Page/PageHandler.iOS.cs +++ b/src/Core/src/Handlers/Page/PageHandler.iOS.cs @@ -21,6 +21,14 @@ protected override ContentView CreatePlatformView() throw new InvalidOperationException($"PageViewController.View must be a {nameof(ContentView)}"); } + public override void SetVirtualView(IView view) + { + base.SetVirtualView(view); + + // Ensure we tag the ContentView as a Page so that InvalidateAncestorsMeasures can stop propagation here + PlatformView.IsPage = true; + } + public static void MapBackground(IPageHandler handler, IContentView page) { if (handler is IPlatformViewHandler platformViewHandler && platformViewHandler.ViewController is not null) diff --git a/src/Core/src/Platform/iOS/ContentView.cs b/src/Core/src/Platform/iOS/ContentView.cs index 5f6f14398f95..1979a25ef36b 100644 --- a/src/Core/src/Platform/iOS/ContentView.cs +++ b/src/Core/src/Platform/iOS/ContentView.cs @@ -16,6 +16,8 @@ public class ContentView : MauiView // verify we're using the correct subview for masking (and any other purposes) internal const nint ContentTag = 0x63D2A0; + internal bool IsPage { get; set; } + public ContentView() { if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13, 1)) diff --git a/src/Core/src/Platform/iOS/IPlatformMeasureInvalidationController.cs b/src/Core/src/Platform/iOS/IPlatformMeasureInvalidationController.cs new file mode 100644 index 000000000000..ed7be737f8b0 --- /dev/null +++ b/src/Core/src/Platform/iOS/IPlatformMeasureInvalidationController.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Maui.Platform; + +internal interface IPlatformMeasureInvalidationController +{ + void InvalidateAncestorsMeasuresWhenMovedToWindow(); + void InvalidateMeasure(bool isPropagating = false); +} \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/ImageViewExtensions.cs b/src/Core/src/Platform/iOS/ImageViewExtensions.cs index 0b7b1595e589..e922dd148de6 100644 --- a/src/Core/src/Platform/iOS/ImageViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ImageViewExtensions.cs @@ -72,34 +72,41 @@ public static void UpdateSource(this UIImageView imageView, UIImage? uIImage, II internal static CGSize SizeThatFitsImage( this UIImageView imageView, CGSize constraints, - Thickness? padding = null) + Thickness padding = default) { // If there's no image, we don't need to take up any space if (imageView.Image is null) + { return new CGSize(0, 0); + } - Thickness calculatedPadding = padding ?? default(Thickness); CGSize imageSize = imageView.Image.Size; + double imageWidth = imageSize.Width; + double imageHeight = imageSize.Height; - double contentWidth = imageSize.Width + calculatedPadding.HorizontalThickness; - double contentHeight = imageSize.Height + calculatedPadding.VerticalThickness; + var horizontalThickness = padding.HorizontalThickness; + var verticalThickness = padding.VerticalThickness; - double constrainedWidth = Math.Min(contentWidth, constraints.Width); - double constrainedHeight = Math.Min(contentHeight, constraints.Height); + var widthConstraint = constraints.Width - horizontalThickness; + var heightConstraint = constraints.Height - verticalThickness; - double widthRatio = constrainedWidth / imageSize.Width; - double heightRatio = constrainedHeight / imageSize.Height; + + var constrainedWidth = Math.Min(imageWidth, widthConstraint); + var constrainedHeight = Math.Min(imageHeight, heightConstraint); // In cases where we the image must fit its given constraints, we must shrink based on the smallest dimension (scale factor) // that can fit it if (imageView.ContentMode == UIViewContentMode.ScaleAspectFit) { + var widthRatio = constrainedWidth / imageWidth; + var heightRatio = constrainedHeight / imageHeight; var scaleFactor = Math.Min(widthRatio, heightRatio); - return new CGSize(imageSize.Width * scaleFactor, imageSize.Height * scaleFactor); + + return new CGSize(imageWidth * scaleFactor + horizontalThickness, imageHeight * scaleFactor + verticalThickness); } // Cases where AspectMode is ScaleToFill or Center - return new CGSize(constrainedWidth, constrainedHeight); + return new CGSize(constrainedWidth + horizontalThickness, constrainedHeight + verticalThickness); } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/LayoutView.cs b/src/Core/src/Platform/iOS/LayoutView.cs index 1ea0d6e62260..17fe038c6cab 100644 --- a/src/Core/src/Platform/iOS/LayoutView.cs +++ b/src/Core/src/Platform/iOS/LayoutView.cs @@ -11,14 +11,12 @@ public override void SubviewAdded(UIView uiview) { InvalidateConstraintsCache(); base.SubviewAdded(uiview); - TryToInvalidateSuperView(false); } public override void WillRemoveSubview(UIView uiview) { InvalidateConstraintsCache(); base.WillRemoveSubview(uiview); - TryToInvalidateSuperView(false); } public override UIView? HitTest(CGPoint point, UIEvent? uievent) diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index b14d54071867..1036b4ef7a87 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -6,7 +6,7 @@ namespace Microsoft.Maui.Platform { - public class MauiScrollView : UIScrollView, IUIViewLifeCycleEvents, ICrossPlatformLayoutBacking + public class MauiScrollView : UIScrollView, IUIViewLifeCycleEvents, ICrossPlatformLayoutBacking, IPlatformMeasureInvalidationController { bool _invalidateParentWhenMovedToWindow; double _lastMeasureHeight; @@ -33,18 +33,14 @@ public override void LayoutSubviews() var widthConstraint = (double)bounds.Width; var heightConstraint = (double)bounds.Height; var frameChanged = _lastArrangeWidth != widthConstraint || _lastArrangeHeight != heightConstraint; + + // If the frame changed, we need to arrange (and potentially measure) the content again if (frameChanged && CrossPlatformLayout is { } crossPlatformLayout) { _lastArrangeWidth = widthConstraint; _lastArrangeHeight = heightConstraint; - // If the SuperView is a cross-platform layout backed view (i.e. MauiView, MauiScrollView, LayoutView, ..), - // then measurement has already happened via SizeThatFits and doesn't need to be repeated in LayoutSubviews. - // This is especially important to avoid overriding potentially infinite measurement constraints - // imposed by the parent (i.e. scroll view) with the current bounds. - // But we _do_ need LayoutSubviews to make a measurement pass if the parent is something else (for example, - // the window); there's no guarantee that SizeThatFits has been called in that case. - if (!IsMeasureValid(widthConstraint, heightConstraint) && !this.IsFinalMeasureHandledBySuperView()) + if (!IsMeasureValid(widthConstraint, heightConstraint)) { crossPlatformLayout.CrossPlatformMeasure(widthConstraint, heightConstraint); CacheMeasureConstraints(widthConstraint, heightConstraint); @@ -52,8 +48,19 @@ public override void LayoutSubviews() // Account for safe area adjustments automatically added by iOS var crossPlatformBounds = AdjustedContentInset.InsetRect(bounds).Size.ToSize(); - var size = crossPlatformLayout.CrossPlatformArrange(new Rect(new Point(), crossPlatformBounds)); - ContentSize = size.ToCGSize(); + var crossPlatformContentSize = crossPlatformLayout.CrossPlatformArrange(new Rect(new Point(), crossPlatformBounds)); + var contentSize = crossPlatformContentSize.ToCGSize(); + + // When the content size changes, we need to adjust the scrollable area size so that the content can fit in it. + if (ContentSize != contentSize) + { + ContentSize = contentSize; + + // Invalidation stops at `UIScrollViews` for performance reasons, + // but when the content size changes, we need to invalidate the ancestors + // in case the ScrollView is configured to grow/shrink with its content. + this.InvalidateAncestorsMeasures(); + } } base.LayoutSubviews(); @@ -75,12 +82,15 @@ public override CGSize SizeThatFits(CGSize size) return contentSize; } - public override void SetNeedsLayout() + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() { - base.SetNeedsLayout(); - InvalidateConstraintsCache(); + _invalidateParentWhenMovedToWindow = true; + } - TryToInvalidateSuperView(false); + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) + { + SetNeedsLayout(); + InvalidateConstraintsCache(); } bool IsMeasureValid(double widthConstraint, double heightConstraint) @@ -112,26 +122,6 @@ public override void ScrollRectToVisible(CGRect rect, bool animated) base.ScrollRectToVisible(rect, animated); } - private protected void TryToInvalidateSuperView(bool shouldOnlyInvalidateIfPending) - { - if (shouldOnlyInvalidateIfPending && !_invalidateParentWhenMovedToWindow) - { - return; - } - - // We check for Window to avoid scenarios where an invalidate might propagate up the tree - // To a SuperView that's been disposed which will cause a crash when trying to access it - if (Window is not null) - { - this.Superview?.SetNeedsLayout(); - _invalidateParentWhenMovedToWindow = false; - } - else - { - _invalidateParentWhenMovedToWindow = true; - } - } - [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = IUIViewLifeCycleEvents.UnconditionalSuppressMessage)] EventHandler? _movedToWindow; @@ -145,7 +135,11 @@ public override void MovedToWindow() { base.MovedToWindow(); _movedToWindow?.Invoke(this, EventArgs.Empty); - TryToInvalidateSuperView(true); + if (_invalidateParentWhenMovedToWindow) + { + _invalidateParentWhenMovedToWindow = false; + this.InvalidateAncestorsMeasures(); + } } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/MauiView.cs b/src/Core/src/Platform/iOS/MauiView.cs index c1f605c591d3..00669b776fa7 100644 --- a/src/Core/src/Platform/iOS/MauiView.cs +++ b/src/Core/src/Platform/iOS/MauiView.cs @@ -7,9 +7,9 @@ namespace Microsoft.Maui.Platform { - public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTreeElementProvidable, IUIViewLifeCycleEvents + public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTreeElementProvidable, IUIViewLifeCycleEvents, IPlatformMeasureInvalidationController { - bool _fireSetNeedsLayoutOnParentWhenWindowAttached; + bool _invalidateParentWhenMovedToWindow; static bool? _respondsToSafeArea; double _lastMeasureHeight = double.NaN; @@ -140,33 +140,6 @@ public override void LayoutSubviews() CrossPlatformArrange(bounds); } - public override void SetNeedsLayout() - { - InvalidateConstraintsCache(); - base.SetNeedsLayout(); - TryToInvalidateSuperView(false); - } - - private protected void TryToInvalidateSuperView(bool shouldOnlyInvalidateIfPending) - { - if (shouldOnlyInvalidateIfPending && !_fireSetNeedsLayoutOnParentWhenWindowAttached) - { - return; - } - - // We check for Window to avoid scenarios where an invalidate might propagate up the tree - // To a SuperView that's been disposed which will cause a crash when trying to access it - if (Window is not null) - { - this.Superview?.SetNeedsLayout(); - _fireSetNeedsLayoutOnParentWhenWindowAttached = false; - } - else - { - _fireSetNeedsLayoutOnParentWhenWindowAttached = true; - } - } - IVisualTreeElement? IVisualTreeElementProvidable.GetElement() { @@ -185,6 +158,17 @@ private protected void TryToInvalidateSuperView(bool shouldOnlyInvalidateIfPendi return null; } + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + _invalidateParentWhenMovedToWindow = true; + } + + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) + { + InvalidateConstraintsCache(); + SetNeedsLayout(); + } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = IUIViewLifeCycleEvents.UnconditionalSuppressMessage)] EventHandler? _movedToWindow; event EventHandler? IUIViewLifeCycleEvents.MovedToWindow @@ -197,7 +181,11 @@ public override void MovedToWindow() { base.MovedToWindow(); _movedToWindow?.Invoke(this, EventArgs.Empty); - TryToInvalidateSuperView(true); + if (_invalidateParentWhenMovedToWindow) + { + _invalidateParentWhenMovedToWindow = false; + this.InvalidateAncestorsMeasures(); + } } } } diff --git a/src/Core/src/Platform/iOS/PageViewController.cs b/src/Core/src/Platform/iOS/PageViewController.cs index 3dcfa801cb6f..a34c8211a761 100644 --- a/src/Core/src/Platform/iOS/PageViewController.cs +++ b/src/Core/src/Platform/iOS/PageViewController.cs @@ -1,5 +1,4 @@ -using Microsoft.Maui.ApplicationModel; -using UIKit; +using UIKit; namespace Microsoft.Maui.Platform { @@ -17,7 +16,7 @@ protected override UIView CreatePlatformView(IElement view) { return new ContentView { - CrossPlatformLayout = ((IContentView)view) + CrossPlatformLayout = (IContentView)view }; } diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index b01dae974d54..a76bd5a26b56 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -274,14 +274,70 @@ static void UpdateBackgroundLayers(this CALayer[] layers, string layerName, CGRe } } + /// + /// Invalidates the measure of the view and all its ancestors through propagation. + /// + /// + /// Stops when it reaches the page view or a scrollable area, including . + /// public static void InvalidateMeasure(this UIView platformView, IView view) { - platformView.SetNeedsLayout(); + if (platformView is IPlatformMeasureInvalidationController mauiPlatformView) + { + mauiPlatformView.InvalidateMeasure(); + } + else + { + platformView.SetNeedsLayout(); + } + + platformView.InvalidateAncestorsMeasures(); + } - // MauiView/WrapperView already propagates the SetNeedsLayout to the parent - if (platformView is not MauiView && platformView is not WrapperView) + internal static void InvalidateAncestorsMeasures(this UIView child) + { + var childMauiPlatformLayout = child as IPlatformMeasureInvalidationController; + + while (true) { - platformView.Superview?.SetNeedsLayout(); + // We verify the presence of a Window to prevent scenarios where an invalidate might propagate up the view hierarchy + // to a SuperView that has already been disposed. Accessing such a disposed view would result in a crash (see #24032). + // This validation is only possible using `IMauiPlatformView`, as it provides a way to schedule an invalidation when the view is moved to window. + // For other cases, we accept the risk since avoiding it could lead to the layout not being updated properly. + if (childMauiPlatformLayout is not null && child.Window is null) + { + childMauiPlatformLayout.InvalidateAncestorsMeasuresWhenMovedToWindow(); + return; + } + + var superview = child.Superview; + if (superview is null) + { + return; + } + + // Now invalidate the parent view + var superviewMauiPlatformLayout = superview as IPlatformMeasureInvalidationController; + if (superviewMauiPlatformLayout is not null) + { + superviewMauiPlatformLayout.InvalidateMeasure(isPropagating: true); + } + else + { + superview.SetNeedsLayout(); + } + + // Potential improvement: if the MAUI view (superview here) is constrained to a fixed size, we could stop propagating + // when doing this, we must pay attention to a scenario where a non-fixed-size view becomes fixed-size + if (superview is ContentView { IsPage: true } or UIScrollView) + { + // We reached the root view or a scrollable area (includes collection view), stop propagating + // The view will eventually watch its content size and invoke InvalidateAncestorsMeasures when needed + return; + } + + child = superview; + childMauiPlatformLayout = superviewMauiPlatformLayout; } } diff --git a/src/Core/src/Platform/iOS/WrapperView.cs b/src/Core/src/Platform/iOS/WrapperView.cs index 76ca003e2943..ed58e1db4192 100644 --- a/src/Core/src/Platform/iOS/WrapperView.cs +++ b/src/Core/src/Platform/iOS/WrapperView.cs @@ -9,9 +9,9 @@ namespace Microsoft.Maui.Platform { - public partial class WrapperView : UIView, IDisposable, IUIViewLifeCycleEvents, ICrossPlatformLayoutBacking + public partial class WrapperView : UIView, IDisposable, IUIViewLifeCycleEvents, ICrossPlatformLayoutBacking, IPlatformMeasureInvalidationController { - bool _fireSetNeedsLayoutOnParentWhenWindowAttached; + bool _invalidateParentWhenMovedToWindow; WeakReference? _crossPlatformLayoutReference; ICrossPlatformLayout? ICrossPlatformLayoutBacking.CrossPlatformLayout @@ -224,32 +224,6 @@ internal CGSize SizeThatFitsWrapper(CGSize originalSpec, double virtualViewWidth return returnSize; } - public override void SetNeedsLayout() - { - base.SetNeedsLayout(); - TryToInvalidateSuperView(false); - } - - private protected void TryToInvalidateSuperView(bool onlyIfPending) - { - if (onlyIfPending && !_fireSetNeedsLayoutOnParentWhenWindowAttached) - { - return; - } - - // We check for Window to avoid scenarios where an invalidate might propagate up the tree - // To a SuperView that's been disposed which will cause a crash when trying to access it - if (Window is not null) - { - _fireSetNeedsLayoutOnParentWhenWindowAttached = false; - this.Superview?.SetNeedsLayout(); - } - else - { - _fireSetNeedsLayoutOnParentWhenWindowAttached = true; - } - } - partial void ClipChanged() { SetClip(); @@ -257,6 +231,12 @@ partial void ClipChanged() partial void BorderChanged() => SetBorder(); + void InvalidateConstraintsCache() + { + _lastMeasureWidth = double.NaN; + _lastMeasureHeight = double.NaN; + } + void SetClip() { var clip = Clip; @@ -334,6 +314,17 @@ void SetBorder() return null; } + void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow() + { + _invalidateParentWhenMovedToWindow = true; + } + + void IPlatformMeasureInvalidationController.InvalidateMeasure(bool isPropagating) + { + InvalidateConstraintsCache(); + SetNeedsLayout(); + } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = IUIViewLifeCycleEvents.UnconditionalSuppressMessage)] EventHandler? _movedToWindow; event EventHandler? IUIViewLifeCycleEvents.MovedToWindow @@ -346,7 +337,11 @@ public override void MovedToWindow() { base.MovedToWindow(); _movedToWindow?.Invoke(this, EventArgs.Empty); - TryToInvalidateSuperView(true); + if (_invalidateParentWhenMovedToWindow) + { + _invalidateParentWhenMovedToWindow = false; + this.InvalidateAncestorsMeasures(); + } } } } \ No newline at end of file diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index f3c2b3c7d5d5..19bf7d6f03af 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -54,7 +54,6 @@ override Microsoft.Maui.Handlers.HybridWebViewHandler.ConnectHandler(WebKit.WKWe override Microsoft.Maui.Handlers.HybridWebViewHandler.CreatePlatformView() -> WebKit.WKWebView! override Microsoft.Maui.Handlers.HybridWebViewHandler.DisconnectHandler(WebKit.WKWebView! platformView) -> void override Microsoft.Maui.Platform.MauiScrollView.LayoutSubviews() -> void -override Microsoft.Maui.Platform.MauiScrollView.SetNeedsLayout() -> void override Microsoft.Maui.Platform.MauiScrollView.SizeThatFits(CoreGraphics.CGSize size) -> CoreGraphics.CGSize *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void @@ -88,3 +87,6 @@ override Microsoft.Maui.Handlers.EditorHandler.NeedsContainer.get -> bool override Microsoft.Maui.Handlers.WindowHandler.DisconnectHandler(UIKit.UIWindow! platformView) -> void *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.NeedsContainer.get -> bool override Microsoft.Maui.Handlers.ImageButtonHandler.SetupContainer() -> void +override Microsoft.Maui.Handlers.PageHandler.SetVirtualView(Microsoft.Maui.IView! view) -> void +*REMOVED*override Microsoft.Maui.Platform.WrapperView.SetNeedsLayout() -> void +*REMOVED*override Microsoft.Maui.Platform.MauiView.SetNeedsLayout() -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 0640e7fa9b67..731fd8731bd3 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -55,7 +55,6 @@ override Microsoft.Maui.Handlers.HybridWebViewHandler.ConnectHandler(WebKit.WKWe override Microsoft.Maui.Handlers.HybridWebViewHandler.CreatePlatformView() -> WebKit.WKWebView! override Microsoft.Maui.Handlers.HybridWebViewHandler.DisconnectHandler(WebKit.WKWebView! platformView) -> void override Microsoft.Maui.Platform.MauiScrollView.LayoutSubviews() -> void -override Microsoft.Maui.Platform.MauiScrollView.SetNeedsLayout() -> void override Microsoft.Maui.Platform.MauiScrollView.SizeThatFits(CoreGraphics.CGSize size) -> CoreGraphics.CGSize *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void @@ -89,3 +88,6 @@ override Microsoft.Maui.Handlers.EditorHandler.NeedsContainer.get -> bool override Microsoft.Maui.Handlers.WindowHandler.DisconnectHandler(UIKit.UIWindow! platformView) -> void *REMOVED*override Microsoft.Maui.Handlers.ScrollViewHandler.NeedsContainer.get -> bool override Microsoft.Maui.Handlers.ImageButtonHandler.SetupContainer() -> void +override Microsoft.Maui.Handlers.PageHandler.SetVirtualView(Microsoft.Maui.IView! view) -> void +*REMOVED*override Microsoft.Maui.Platform.WrapperView.SetNeedsLayout() -> void +*REMOVED*override Microsoft.Maui.Platform.MauiView.SetNeedsLayout() -> void