Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
67 changes: 67 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue36505.cs
Original file line number Diff line number Diff line change
@@ -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 }
};
}
}
1 change: 1 addition & 0 deletions src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
Loading