diff --git a/src/Controls/src/Core/Platform/iOS/Extensions/FormattedStringExtensions.cs b/src/Controls/src/Core/Platform/iOS/Extensions/FormattedStringExtensions.cs index 22c370c9474a..721911a421d9 100644 --- a/src/Controls/src/Core/Platform/iOS/Extensions/FormattedStringExtensions.cs +++ b/src/Controls/src/Core/Platform/iOS/Extensions/FormattedStringExtensions.cs @@ -220,16 +220,29 @@ internal static void RecalculateSpanPositions(this UILabel control, Label elemen nint NSMaxRange(NSRange range) => range.Location + range.Length; using var textStorage = new NSTextStorage(); - using var layoutManager = new NSLayoutManager(); + // On iOS 16+, NSLayoutManager's default UsesFontLeading=true causes it to include + // font leading (extra line spacing) from the OS/2 typographic metrics that CoreText + // uses when a font has an OpenType STAT table. This makes the layout manager compute + // line heights that don't match what CoreText uses to draw the glyphs, resulting in + // span tap hitboxes being vertically offset from the rendered text. + // Disabling UsesFontLeading on iOS 16+ makes NSLayoutManager match CoreText's metrics + // so the calculated span rects align with the actual rendered text positions. + // See: https://github.com/dotnet/maui/issues/36505 + using var layoutManager = new NSLayoutManager + { + UsesFontLeading = !OperatingSystem.IsIOSVersionAtLeast(16) + }; using var textContainer = new NSTextContainer { LineFragmentPadding = 0 }; textStorage.AddLayoutManager(layoutManager); layoutManager.AddTextContainer(textContainer); - // On iOS 26+ with NavigationPage, UILabel.Bounds may still be {0,0,0,0} - // during ArrangeOverride. Use finalSize (MAUI's computed size) as fallback. - var containerWidth = control.Bounds.Width > 0 ? control.Bounds.Width : (nfloat)finalSize.Width; - var containerHeight = control.Bounds.Height > 0 ? control.Bounds.Height : (nfloat)finalSize.Height; + // Always prefer finalSize from MAUI's layout system — it is the authoritative + // size for this arrange pass. On Mac Catalyst (and iOS 26+ with NavigationPage), + // control.Bounds may be stale or {0,0,0,0} during ArrangeOverride because UIKit + // frame updates can lag behind MAUI's layout. + var containerWidth = (nfloat)finalSize.Width > 0 ? (nfloat)finalSize.Width : control.Bounds.Width; + var containerHeight = (nfloat)finalSize.Height > 0 ? (nfloat)finalSize.Height : control.Bounds.Height; textContainer.Size = new(containerWidth, control.Lines == 0 ? nfloat.MaxValue : containerHeight); textStorage.SetString(attributedText); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36505.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36505.cs new file mode 100644 index 000000000000..65ea6de7de5a --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36505.cs @@ -0,0 +1,67 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 36505, + "[iOS] Span TapGestureRecognizer hitbox is mispositioned inside FormattedString when the font contains a STAT table (line-height/leading mismatch)", + PlatformAffected.iOS | PlatformAffected.macOS)] +public class Issue36505 : ContentPage +{ + public Issue36505() + { + var statusLabel = new Label + { + AutomationId = "StatusLabel", + Text = "Not tapped", + }; + + void OnSpanTapped(object sender, TappedEventArgs e) + { + statusLabel.Text = "Success"; + } + + var fs = new FormattedString(); + + fs.Spans.Add(new Span + { + Text = "Line 1.\nLine 2.\nLine 3.\nLine 4.\nLine 5.\n" + + "Line 6.\nLine 7.\nLine 8.\nLine 9.\nLine 10.\n" + + "Line 11.\nLine 12.\nLine 13.\nLine 14.\nLine 15.\n", + FontFamily = "MyCustomFont", + FontSize = 16, + }); + + var tappableSpan = new Span + { + Text = "Click me", + FontFamily = "MyCustomFont", + FontSize = 16, + TextColor = Colors.Blue, + TextDecorations = TextDecorations.Underline, + }; + var tapRecognizer = new TapGestureRecognizer(); + tapRecognizer.Tapped += OnSpanTapped; + tappableSpan.GestureRecognizers.Add(tapRecognizer); + fs.Spans.Add(tappableSpan); + + var spanLabel = new Label + { + AutomationId = "SpanLabel", + FormattedText = fs, + LineBreakMode = LineBreakMode.WordWrap, + }; + + var lineRefLabel = new Label + { + AutomationId = "LineRef", + Text = "Reference line", + FontFamily = "MyCustomFont", + FontSize = 16, + }; + + Content = new VerticalStackLayout + { + Padding = new Thickness(20), + Spacing = 20, + Children = { spanLabel, lineRefLabel, statusLabel } + }; + } +} diff --git a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs index beae20e666d9..b927598da191 100644 --- a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs +++ b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs @@ -33,6 +33,7 @@ public static MauiApp CreateMauiApp() fonts.AddFont("FontAwesome.ttf", "FA"); fonts.AddFont("ionicons.ttf", "Ion"); fonts.AddFont("Montserrat-Bold.otf", "MontserratBold"); + fonts.AddFont("MyCustomFont.ttf", "MyCustomFont"); }) .RenderingPerformanceAddMappers() .Issue21109AddMappers() diff --git a/src/Controls/tests/TestCases.HostApp/Resources/Fonts/MyCustomFont.ttf b/src/Controls/tests/TestCases.HostApp/Resources/Fonts/MyCustomFont.ttf new file mode 100644 index 000000000000..0c92a6b25328 Binary files /dev/null and b/src/Controls/tests/TestCases.HostApp/Resources/Fonts/MyCustomFont.ttf differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36505.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36505.cs new file mode 100644 index 000000000000..d8f995465bbc --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36505.cs @@ -0,0 +1,47 @@ +#if IOS || MACCATALYST // This issue is only reproducible on iOS and MacCatalyst, so skip the test on other platforms. +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36505 : _IssuesUITest +{ + public Issue36505(TestDevice device) : base(device) { } + + public override string Issue => + "[iOS] Span TapGestureRecognizer hitbox is mispositioned inside FormattedString when the font contains a STAT table (line-height/leading mismatch)"; + + [Test] + [Category(UITestCategories.Label)] + public void SpanTapHitboxShouldAlignWithRenderedTextWhenFontHasStatTable() + { + var label = App.WaitForElement("SpanLabel"); + var location = label.GetRect(); + + // LineRef is a standalone single-line label with the same font and size. + // Its height = pure visual line height (UILabel, no leading on iOS 16+). + var visualLineHeight = App.WaitForElement("LineRef").GetRect().Height; + var lineCenterOffset = visualLineHeight / 2; + var y = location.Y; + + // Tap "Click me" (line 16) at its visual centre. + // On MacCatalyst the rendered position is slightly higher, so shift up by + // half a visual line to land reliably on the tappable span. + TapClickMe(location.X + 10, y, visualLineHeight, lineCenterOffset); + + Assert.That(App.WaitForTextToBePresentInElement("StatusLabel", "Success", timeout: TimeSpan.FromSeconds(3)), + Is.True, + "Tapping the span at its visual position should trigger the TapGestureRecognizer"); + } + + void TapClickMe(float x, float y, float visualLineHeight, float lineCenterOffset) + { +#if MACCATALYST + App.TapCoordinates(x, y + (visualLineHeight * 15) + lineCenterOffset - visualLineHeight * 0.5f); +#else + App.TapCoordinates(x, y + (visualLineHeight * 15) + lineCenterOffset); +#endif + } +} +#endif \ No newline at end of file