diff --git a/src/Controls/src/Core/Platform/iOS/Extensions/ButtonExtensions.cs b/src/Controls/src/Core/Platform/iOS/Extensions/ButtonExtensions.cs index f9986be709a9..d63afdb155e6 100644 --- a/src/Controls/src/Core/Platform/iOS/Extensions/ButtonExtensions.cs +++ b/src/Controls/src/Core/Platform/iOS/Extensions/ButtonExtensions.cs @@ -2,6 +2,7 @@ using System; using CoreGraphics; using Foundation; +using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Internals; using ObjCRuntime; using UIKit; @@ -11,7 +12,7 @@ namespace Microsoft.Maui.Controls.Platform { public static class ButtonExtensions { - static CGRect GetTitleBoundingRect(this UIButton platformButton) + static CGRect GetTitleBoundingRect(this UIButton platformButton, Thickness padding) { if (platformButton.CurrentAttributedTitle != null || platformButton.CurrentTitle != null) @@ -20,10 +21,28 @@ static CGRect GetTitleBoundingRect(this UIButton platformButton) platformButton.CurrentAttributedTitle ?? new NSAttributedString(platformButton.CurrentTitle, new UIStringAttributes { Font = platformButton.TitleLabel.Font }); - return title.GetBoundingRect( - platformButton.Bounds.Size, - NSStringDrawingOptions.UsesLineFragmentOrigin | NSStringDrawingOptions.UsesFontLeading, + // Use the available height when calculating the bounding rect + var lineHeight = platformButton.TitleLabel.Font.LineHeight; + var availableHeight = platformButton.Bounds.Size.Height; + + // If the line break mode is one of the truncation modes, limit the height to the line height + if (platformButton.TitleLabel.LineBreakMode == UILineBreakMode.HeadTruncation || + platformButton.TitleLabel.LineBreakMode == UILineBreakMode.MiddleTruncation || + platformButton.TitleLabel.LineBreakMode == UILineBreakMode.TailTruncation || + platformButton.TitleLabel.LineBreakMode == UILineBreakMode.Clip) + { + availableHeight = lineHeight; + } + + var availableSize = new CGSize(platformButton.Bounds.Size.Width, availableHeight); + + var boundingRect = title.GetBoundingRect( + availableSize, + NSStringDrawingOptions.UsesLineFragmentOrigin | NSStringDrawingOptions.UsesFontLeading | NSStringDrawingOptions.UsesDeviceMetrics, null); + + // NSStringDrawingOptions.UsesDeviceMetrics can split at characters instead of words but ignore the height. Pass the height constraint back in. + return new CGRect(boundingRect.Location, new CGSize(boundingRect.Width, availableHeight)); } return CGRect.Empty; @@ -31,34 +50,10 @@ static CGRect GetTitleBoundingRect(this UIButton platformButton) public static void UpdatePadding(this UIButton platformButton, Button button) { - double spacingVertical = 0; - double spacingHorizontal = 0; - - if (button.ImageSource != null) - { - if (button.ContentLayout.IsHorizontal()) - { - spacingHorizontal = button.ContentLayout.Spacing; - } - else - { - var imageHeight = platformButton.ImageView.Image?.Size.Height ?? 0f; - - if (imageHeight < platformButton.Bounds.Height) - { - spacingVertical = button.ContentLayout.Spacing + - platformButton.GetTitleBoundingRect().Height; - } - - } - } - var padding = button.Padding; if (padding.IsNaN) padding = ButtonHandler.DefaultPadding; - padding += new Thickness(spacingHorizontal / 2, spacingVertical / 2); - platformButton.UpdatePadding(padding); } @@ -77,19 +72,31 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt var image = platformButton.CurrentImage; - // if the image is too large then we just position at the edge of the button // depending on the position the user has picked // This makes the behavior consistent with android var contentMode = UIViewContentMode.Center; + var padding = button.Padding; + if (padding.IsNaN) + padding = ButtonHandler.DefaultPadding; + + // If the button's image takes up too much space, we will want to hide the title + var hidesTitle = false; + if (image != null && !string.IsNullOrEmpty(platformButton.CurrentTitle)) { // TODO: Do not use the title label as it is not yet updated and // if we move the image, then we technically have more // space and will require a new layout pass. - var titleRect = platformButton.GetTitleBoundingRect(); + // Resize the image if necessary and then update the image variable + if (ResizeImageIfNecessary(platformButton, button, image, spacing, padding)) + { + image = platformButton.CurrentImage; + } + + var titleRect = platformButton.GetTitleBoundingRect(padding); var titleWidth = titleRect.Width; var titleHeight = titleRect.Height; var imageWidth = image.Size.Width; @@ -98,10 +105,15 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt var buttonHeight = platformButton.Bounds.Height; var sharedSpacing = spacing / 2; + // The titleWidth will include the part of the title that is potentially truncated. Let's figure out the max width of the title in the button for our calculations. + // Note: we do not calculate spacing in maxTitleWidth since the original button laid out by iOS will not contain the spacing in the measurements. + var maxTitleWidth = platformButton.Bounds.Width - (imageWidth + (nfloat)padding.Left + (nfloat)padding.Right); + var titleWidthMove = (nfloat)Math.Min(maxTitleWidth, titleWidth); + // These are just used to shift the image and title to center // Which makes the later math easier to follow - imageInsets.Left += titleWidth / 2; - imageInsets.Right -= titleWidth / 2; + imageInsets.Left += titleWidthMove / 2; + imageInsets.Right -= titleWidthMove / 2; titleInsets.Left -= imageWidth / 2; titleInsets.Right += imageWidth / 2; @@ -111,14 +123,12 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt { contentMode = UIViewContentMode.Top; } - else - { - imageInsets.Top -= (titleHeight / 2) + sharedSpacing; - imageInsets.Bottom += titleHeight / 2; - titleInsets.Top += imageHeight / 2; - titleInsets.Bottom -= (imageHeight / 2) + sharedSpacing; - } + imageInsets.Top -= (titleHeight / 2) + sharedSpacing; + imageInsets.Bottom += (titleHeight / 2) + sharedSpacing; + + titleInsets.Top += (imageHeight / 2) + sharedSpacing; + titleInsets.Bottom -= (imageHeight / 2) + sharedSpacing; } else if (layout.Position == ButtonContentLayout.ImagePosition.Bottom) { @@ -126,14 +136,12 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt { contentMode = UIViewContentMode.Bottom; } - else - { - imageInsets.Top += titleHeight / 2; - imageInsets.Bottom -= (titleHeight / 2) + sharedSpacing; - } + + imageInsets.Top += (titleHeight / 2) + sharedSpacing; + imageInsets.Bottom -= (titleHeight / 2) + sharedSpacing; titleInsets.Top -= (imageHeight / 2) + sharedSpacing; - titleInsets.Bottom += imageHeight / 2; + titleInsets.Bottom += (imageHeight / 2) + sharedSpacing; } else if (layout.Position == ButtonContentLayout.ImagePosition.Left) { @@ -141,14 +149,13 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt { contentMode = UIViewContentMode.Left; } - else - { - imageInsets.Left -= (titleWidth / 2) + sharedSpacing; - imageInsets.Right += titleWidth / 2; - } - titleInsets.Left += imageWidth / 2; + imageInsets.Left -= (titleWidthMove / 2) + sharedSpacing; + imageInsets.Right += (titleWidthMove / 2) + sharedSpacing; + + titleInsets.Left += (imageWidth / 2) + sharedSpacing; titleInsets.Right -= (imageWidth / 2) + sharedSpacing; + } else if (layout.Position == ButtonContentLayout.ImagePosition.Right) { @@ -156,17 +163,21 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt { contentMode = UIViewContentMode.Right; } - else - { - imageInsets.Left += titleWidth / 2; - imageInsets.Right -= (titleWidth / 2) + sharedSpacing; - } + + imageInsets.Left += (titleWidthMove / 2) + sharedSpacing; + imageInsets.Right -= (titleWidthMove / 2) + sharedSpacing; titleInsets.Left -= (imageWidth / 2) + sharedSpacing; - titleInsets.Right += imageWidth / 2; + titleInsets.Right += (imageWidth / 2) + sharedSpacing; } } + // If we just have an image, we can still resize it here + else if (image is not null) + { + ResizeImageIfNecessary(platformButton, button, image, 0, padding); + } + platformButton.ImageView.ContentMode = contentMode; // This is used to match the behavior between platforms. @@ -182,15 +193,141 @@ public static void UpdateContentLayout(this UIButton platformButton, Button butt platformButton.UpdatePadding(button); -#pragma warning disable CA1416, CA1422 // TODO: [UnsupportedOSPlatform("ios15.0")] +#pragma warning disable CA1416, CA1422 if (platformButton.ImageEdgeInsets != imageInsets || platformButton.TitleEdgeInsets != titleInsets) { platformButton.ImageEdgeInsets = imageInsets; platformButton.TitleEdgeInsets = titleInsets; platformButton.Superview?.SetNeedsLayout(); + return; } #pragma warning restore CA1416, CA1422 + + var titleRectHeight = platformButton.GetTitleBoundingRect(padding).Height; + + var buttonContentHeight = + + (nfloat)Math.Max(titleRectHeight, platformButton.CurrentImage?.Size.Height ?? 0) + + (nfloat)padding.Top + + (nfloat)padding.Bottom; + + if (image is not null && !string.IsNullOrEmpty(platformButton.CurrentTitle)) + { + if (layout.Position == ButtonContentLayout.ImagePosition.Top || layout.Position == ButtonContentLayout.ImagePosition.Bottom) + { + if (!hidesTitle) + { + buttonContentHeight += spacing; + buttonContentHeight += (nfloat)Math.Min(titleRectHeight, platformButton.CurrentImage?.Size.Height ?? 0); + } + // If the title is hidden, we don't need to add the spacing or the title to this measurement + else + { + if (titleRectHeight > platformButton.CurrentImage.Size.Height) + { + buttonContentHeight -= titleRectHeight; + buttonContentHeight += platformButton.CurrentImage.Size.Height; + } + } + } + +#pragma warning disable CA1416, CA1422 + // If the button's content is larger than the button, we need to adjust the ContentEdgeInsets. + // Apply a small buffer to the image size comparison since iOS can return a size that is off by a fraction of a pixel + if (buttonContentHeight - button.Height > 1 && button.HeightRequest == -1) + { + var contentInsets = platformButton.ContentEdgeInsets; + + var additionalVerticalSpace = (buttonContentHeight - button.Height) / 2; + + platformButton.ContentEdgeInsets = new UIEdgeInsets( + (nfloat)(additionalVerticalSpace + (nfloat)padding.Top), + contentInsets.Left, + (nfloat)(additionalVerticalSpace + (nfloat)padding.Bottom), + contentInsets.Right); + + platformButton.Superview?.SetNeedsLayout(); + platformButton.Superview?.LayoutIfNeeded(); + } +#pragma warning restore CA1416, CA1422 + } + } + + static bool ResizeImageIfNecessary(UIButton platformButton, Button button, UIImage image, nfloat spacing, Thickness padding) + { + // If the image is on the left or right, we still have an implicit width constraint + if (button.HeightRequest == -1 && button.WidthRequest == -1 && (button.ContentLayout.Position == ButtonContentLayout.ImagePosition.Top || button.ContentLayout.Position == ButtonContentLayout.ImagePosition.Bottom)) + { + return false; + } + + nfloat availableHeight = nfloat.MaxValue; + nfloat availableWidth = nfloat.MaxValue; + + // Apply a small buffer to the image size comparison since iOS can return a size that is off by a fraction of a pixel. + var buffer = 0.1; + + if (platformButton.Bounds != CGRect.Empty + && (button.Height != double.NaN || button.Width != double.NaN)) + { + var contentWidth = platformButton.Bounds.Width - (nfloat)padding.Left - (nfloat)padding.Right; + + if (image.Size.Width - contentWidth > buffer) + { + availableWidth = contentWidth; + } + + var contentHeight = platformButton.Bounds.Height - ((nfloat)padding.Top + (nfloat)padding.Bottom); + if (image.Size.Height - contentHeight > buffer) + { + availableHeight = contentHeight; + } + } + + availableHeight = button.HeightRequest == -1 ? nfloat.PositiveInfinity : (nfloat)Math.Max(availableHeight, 0); + // availableWidth = button.WidthRequest == -1 ? platformButton.Bounds.Width : (nfloat)Math.Max(availableWidth, 0); + + availableWidth = (nfloat)Math.Max(availableWidth, 0); + + try + { + if (image.Size.Height - availableHeight > buffer || image.Size.Width - availableWidth > buffer) + { + image = ResizeImageSource(image, availableWidth, availableHeight); + } + else + { + return false; + } + + image = image?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); + + platformButton.SetImage(image, UIControlState.Normal); + + platformButton.Superview?.SetNeedsLayout(); + + return true; + } + catch (Exception) + { + button.Handler.MauiContext?.CreateLogger()?.LogWarning("Can not load Button ImageSource"); + } + + return false; + } + + static UIImage ResizeImageSource(UIImage sourceImage, nfloat maxWidth, nfloat maxHeight) + { + if (sourceImage is null || sourceImage.CGImage is null) + return null; + + var sourceSize = sourceImage.Size; + float maxResizeFactor = (float)Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); + + if (maxResizeFactor > 1) + return sourceImage; + + return UIImage.FromImage(sourceImage.CGImage, sourceImage.CurrentScale / maxResizeFactor, sourceImage.Orientation); } public static void UpdateText(this UIButton platformButton, Button button) @@ -217,4 +354,4 @@ public static void UpdateLineBreakMode(this UIButton nativeButton, Button button }; } } -} \ No newline at end of file +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue18242.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue18242.cs index e2387b6220b0..07be0d07ced6 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue18242.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue18242.cs @@ -15,8 +15,6 @@ public Issue18242(TestDevice device) : base(device) [Test] public void Issue18242Test() { - this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Mac, TestDevice.iOS }, "iOS will be fixed in https://github.com/dotnet/maui/pull/20953"); - App.WaitForElement("WaitForStubControl"); VerifyScreenshot();