diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index 8a099bd2d06f..49dc42e93d81 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -227,7 +227,7 @@ public override void LayoutSubviews() base.LayoutSubviews(); const float padding = 5f; - var imageSize = _imageView.SizeThatFits(Bounds.Size); + var imageSize = _imageView.SizeThatFitsImage(Bounds.Size); var fullStringSize = _label.SizeThatFits(Bounds.Size); if (imageSize.Width > 0 && (string.IsNullOrEmpty(Text) || fullStringSize.Width > Bounds.Width / 3)) diff --git a/src/Controls/tests/DeviceTests/Elements/Image/ImageTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Image/ImageTests.iOS.cs index cef874cd584e..7d83a24786db 100644 --- a/src/Controls/tests/DeviceTests/Elements/Image/ImageTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Image/ImageTests.iOS.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading.Tasks; using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; using Xunit; @@ -24,5 +25,102 @@ await InvokeOnMainThreadAsync(async () => Assert.True(platformImage.Value.Size.Width > 0); }); } + + [Fact("Large images resize correctly")] + public async Task LargeImagesResizeCorrectly() + { + SetupBuilder(); + var layout = new StackLayout { MaximumWidthRequest = 200 }; + var image = new Image + { + Background = Colors.Red, + Source = "big_white_horizontal.png", + Aspect = Aspect.AspectFit + }; + + layout.Add(image); + + // big_white_horizontal is 2000x1000px, so it should perfectly fit into a 200px wide layout + await InvokeOnMainThreadAsync(async () => + { + var handler = CreateHandler(layout); + + await image.Wait(); + + await handler.ToPlatform().AssertContainsColor(Colors.White, MauiContext); + await handler.ToPlatform().AssertDoesNotContainColor(Colors.Red, MauiContext); + }); + + // We check for both image and layout to ensure that image is the right size + Assert.Equal(100, image.Height); + Assert.Equal(200, image.Width); + + Assert.Equal(100, layout.Height); + Assert.Equal(200, layout.Width); + } + + + // big_white_horizontal is 2000x1000px + // So when it has to fit a 50x200 space, it should resize to 50x25 (width is the smallest scaled dimension) + // When it has to fit in a 200x50 space, it should resize to 100x50 (height is the smallest scaled dimension) + [Theory("Aspect Fit resizes on smallest dimension")] + [InlineData(50, 200, 50, 25)] + [InlineData(200, 50, 100, 50)] + public async Task AspectFitResizesOnSmallestDimensions(int widthRequest, int heightRequest, int expectedWidth, int expectedHeight) + { + SetupBuilder(); + var layout = new Grid { MaximumHeightRequest = heightRequest, MaximumWidthRequest = widthRequest }; + var image = new Image + { + Source = "big_white_horizontal.png", + Aspect = Aspect.AspectFit + }; + + layout.Add(image); + + await InvokeOnMainThreadAsync(async () => + { + var handler = CreateHandler(layout); + + await image.Wait(); + + await handler.ToPlatform().AssertContainsColor(Colors.White, MauiContext); + }); + + Assert.Equal(expectedHeight, image.Height); + Assert.Equal(expectedWidth, image.Width); + + Assert.Equal(expectedHeight, layout.Height); + Assert.Equal(expectedWidth, layout.Width); + } + + [Fact] + public async Task ImagesRespectExplicitConstraints() + { + SetupBuilder(); + + var layout = new Grid(); + var image = new Image + { + Source = "big_white_horizontal.png", + Aspect = Aspect.AspectFit, + HeightRequest = 100 + }; + + layout.Add(image); + + await InvokeOnMainThreadAsync(async () => + { + var handler = CreateHandler(layout); + + await image.Wait(); + + await handler.ToPlatform().AssertContainsColor(Colors.White, MauiContext); + }); + + // We asked the image to have a fixed height, so it should resize accordingly even if it could grow in width + Assert.Equal(100, image.Height); + Assert.Equal(200, image.Width); + } } } \ No newline at end of file diff --git a/src/Controls/tests/DeviceTests/Resources/Images/big_white_horizontal.png b/src/Controls/tests/DeviceTests/Resources/Images/big_white_horizontal.png new file mode 100644 index 000000000000..889f31e4489e Binary files /dev/null and b/src/Controls/tests/DeviceTests/Resources/Images/big_white_horizontal.png differ diff --git a/src/Core/src/Handlers/ViewHandlerExtensions.iOS.cs b/src/Core/src/Handlers/ViewHandlerExtensions.iOS.cs index 321b68461953..11b112cc72b7 100644 --- a/src/Core/src/Handlers/ViewHandlerExtensions.iOS.cs +++ b/src/Core/src/Handlers/ViewHandlerExtensions.iOS.cs @@ -78,7 +78,32 @@ internal static Size GetDesiredSizeFromHandler(this IViewHandler viewHandler, do widthConstraint = Math.Min(widthConstraint, virtualView.MaximumWidth); heightConstraint = Math.Min(heightConstraint, virtualView.MaximumHeight); - var sizeThatFits = platformView.SizeThatFits(new CoreGraphics.CGSize((float)widthConstraint, (float)heightConstraint)); + CGSize sizeThatFits; + + // Calling SizeThatFits on an ImageView always returns the image's dimensions, so we need to call the extension method + // This also affects ImageButtons + if (platformView is UIImageView imageView) + { + widthConstraint = IsExplicitSet(virtualView.Width) ? virtualView.Width : widthConstraint; + heightConstraint = IsExplicitSet(virtualView.Height) ? virtualView.Height : heightConstraint; + + sizeThatFits = imageView.SizeThatFitsImage(new CGSize((float)widthConstraint, (float)heightConstraint)); + } + else if (platformView is UIButton imageButton && imageButton.ImageView?.Image is not null) + { + widthConstraint = IsExplicitSet(virtualView.Width) ? virtualView.Width : widthConstraint; + heightConstraint = IsExplicitSet(virtualView.Height) ? virtualView.Height : heightConstraint; + + sizeThatFits = imageButton.ImageView.SizeThatFitsImage(new CGSize((float)widthConstraint, (float)heightConstraint)); + } + else if (platformView is WrapperView wrapper) + { + sizeThatFits = wrapper.SizeThatFitsWrapper(new CGSize((float)widthConstraint, (float)heightConstraint), virtualView.Width, virtualView.Height); + } + else + { + sizeThatFits = platformView.SizeThatFits(new CGSize((float)widthConstraint, (float)heightConstraint)); + } var size = new Size( sizeThatFits.Width == float.PositiveInfinity ? double.PositiveInfinity : sizeThatFits.Width, diff --git a/src/Core/src/Platform/iOS/ImageViewExtensions.cs b/src/Core/src/Platform/iOS/ImageViewExtensions.cs index a226bf0f3322..f463fbc006b6 100644 --- a/src/Core/src/Platform/iOS/ImageViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ImageViewExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using CoreGraphics; using ObjCRuntime; using UIKit; @@ -17,7 +18,7 @@ public static void Clear(this UIImageView imageView) public static void UpdateAspect(this UIImageView imageView, IImage image) { imageView.ContentMode = image.Aspect.ToUIViewContentMode(); - imageView.ClipsToBounds = imageView.ContentMode == UIViewContentMode.ScaleAspectFill; + imageView.ClipsToBounds = imageView.ContentMode == UIViewContentMode.ScaleAspectFill || imageView.ContentMode == UIViewContentMode.Center; } public static void UpdateIsAnimationPlaying(this UIImageView imageView, IImageSourcePart image) @@ -54,5 +55,37 @@ public static void UpdateSource(this UIImageView imageView, UIImage? uIImage, II imageView.Image = uiImage; }, scale, cancellationToken); } + + /// + /// Gets the size that fits on the screen for a to be consistent cross-platform. + /// + /// The default iOS implementation of SizeThatFits only returns the image's dimensions and ignores the constraints. + /// The to be measured. + /// The specified size constraints. + /// The size where the image would fit depending on the aspect ratio. + internal static CGSize SizeThatFitsImage(this UIImageView imageView, CGSize constraints) + { + // If there's no image, we don't need to take up any space + if (imageView.Image is null) + return new CGSize(0, 0); + + var heightConstraint = constraints.Height; + var widthConstraint = constraints.Width; + var imageSize = imageView.Image.Size; + + var widthRatio = Math.Min(imageSize.Width, widthConstraint) / imageSize.Width; + var heightRatio = Math.Min(imageSize.Height, heightConstraint) / imageSize.Height; + + // 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 scaleFactor = Math.Min(widthRatio, heightRatio); + return new CGSize(imageSize.Width * scaleFactor, imageSize.Height * scaleFactor); + } + + // Cases where AspectMode is ScaleToFill or Center + return constraints; + } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/WrapperView.cs b/src/Core/src/Platform/iOS/WrapperView.cs index 1a196f91d427..9be2dec7c87b 100644 --- a/src/Core/src/Platform/iOS/WrapperView.cs +++ b/src/Core/src/Platform/iOS/WrapperView.cs @@ -4,6 +4,7 @@ using CoreGraphics; using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics.Platform; +using static Microsoft.Maui.Primitives.Dimension; using UIKit; namespace Microsoft.Maui.Platform @@ -119,9 +120,37 @@ public override CGSize SizeThatFits(CGSize size) var child = Subviews[0]; + // Calling SizeThatFits on an ImageView always returns the image's dimensions, so we need to call the extension method + // This also affects ImageButtons + if (child is UIImageView imageView) + { + return imageView.SizeThatFitsImage(size); + } + else if (child is UIButton imageButton && imageButton.ImageView?.Image is not null) + { + return imageButton.ImageView.SizeThatFitsImage(size); + } + return child.SizeThatFits(size); } + internal CGSize SizeThatFitsWrapper(CGSize originalSpec, double virtualViewWidth, double virtualViewHeight) + { + if (Subviews.Length == 0) + return base.SizeThatFits(originalSpec); + + var child = Subviews[0]; + + if (child is UIImageView || (child is UIButton imageButton && imageButton.ImageView?.Image is not null)) + { + var widthConstraint = IsExplicitSet(virtualViewWidth) ? virtualViewWidth : originalSpec.Width; + var heightConstraint = IsExplicitSet(virtualViewHeight) ? virtualViewHeight : originalSpec.Height; + return SizeThatFits(new CGSize(widthConstraint, heightConstraint)); + } + + return SizeThatFits(originalSpec); + } + public override void SetNeedsLayout() { base.SetNeedsLayout();