Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[iOS] Fix gestures in Label Spans #15544

Merged
merged 6 commits into from
Feb 15, 2024
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 @@ -167,13 +167,41 @@
<Span
x:Name="Link3"
TextDecorations="Underline"
Text="Link3_1&#10;Link3_2"
Text="Link3_1&#10;Link3_2&#10;"
TextColor="Blue">
<Span.GestureRecognizers>
<TapGestureRecognizer
Tapped="OnLink3Tapped" />
</Span.GestureRecognizers>
</Span>
<Span
Text="Mixed with other spans:&#10;" />
<Span
Text="Regular text " />
<Span
x:Name="Link4"
TextDecorations="Underline"
Text="Link4_1&#10;Link4_2"
TextColor="Blue">
<Span.GestureRecognizers>
<TapGestureRecognizer
Tapped="OnLink4Tapped" />
</Span.GestureRecognizers>
</Span>
<Span
Text=" more text " />
<Span
x:Name="Link5"
TextDecorations="Underline"
Text="Link5_1&#10;Link5_2&#10;"
TextColor="Blue">
<Span.GestureRecognizers>
<TapGestureRecognizer
Tapped="OnLink5Tapped" />
</Span.GestureRecognizers>
</Span>
<Span
Text=" more text." />
</FormattedString>
</Label.FormattedText>
</Label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ void OnLink3Tapped(object sender, EventArgs e)
{
SetRandomBackgroundColor(Link3);
}
void OnLink4Tapped(object sender, EventArgs e)
{
SetRandomBackgroundColor(Link4);
}

void OnLink5Tapped(object sender, EventArgs e)
{
SetRandomBackgroundColor(Link5);
}

void SetRandomBackgroundColor(Span span)
{
Expand Down
24 changes: 23 additions & 1 deletion src/Controls/src/Core/Label/Label.iOS.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
#nullable disable
using System;
using Microsoft.Maui.Controls.Platform;
using Microsoft.Maui.Graphics;
using UIKit;

namespace Microsoft.Maui.Controls
{
public partial class Label
{
protected override Size ArrangeOverride(Rect bounds)
{
var size = base.ArrangeOverride(bounds);

RecalculateSpanPositions();

return size;
}

public static void MapText(LabelHandler handler, Label label) => MapText((ILabelHandler)handler, label);

public static void MapText(ILabelHandler handler, Label label)
Expand Down Expand Up @@ -39,5 +50,16 @@ static void MapFormatting(ILabelHandler handler, Label label)

LabelHandler.MapFormatting(handler, label);
}

void RecalculateSpanPositions()
{
if (Handler is LabelHandler labelHandler)
{
if (labelHandler.PlatformView is not UILabel platformView || labelHandler.VirtualView is not Label virtualView)
return;

platformView.RecalculateSpanPositions(virtualView);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using CoreGraphics;
using System.Collections.Generic;
using Foundation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Graphics;
using System;

#if !MACOS
using ObjCRuntime;
using UIKit;
Expand Down Expand Up @@ -124,5 +128,175 @@ public static NSAttributedString ToNSAttributedString(

return attrString;
}

internal static void RecalculateSpanPositions(this UILabel control, Label element)
{
if (element is null)
return;

if (element.TextType == TextType.Html)
return;

if (element?.FormattedText?.Spans is null
|| element.FormattedText.Spans.Count == 0)
return;

var finalSize = control.Frame;

if (finalSize.Width <= 0 || finalSize.Height <= 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this possibly backfire if the Frame has infinite constraints? Feel free to resolve this comment if that could never happen :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is possible as this is the iOS frame, so will always have some number value - probably 0 if there is an issue.

return;

var inline = control.AttributedText;

if (inline is null)
return;

NSTextStorage textStorage = new NSTextStorage();
textStorage.SetString(inline);

var layoutManager = new NSLayoutManager();
textStorage.AddLayoutManager(layoutManager);

var textContainer = new NSTextContainer(size: finalSize.Size)
{
LineFragmentPadding = 0
};

layoutManager.AddTextContainer(textContainer);

var currentLocation = 0;

for (int i = 0; i < element.FormattedText.Spans.Count; i++)
{
var span = element.FormattedText.Spans[i];

var location = currentLocation;
var length = span.Text?.Length ?? 0;

if (length == 0 || span?.Text is null)
continue;

var startRect = GetCharacterBounds(new NSRange(location, 1), layoutManager, textContainer);
var endRect = GetCharacterBounds(new NSRange(location + length, 1), layoutManager, textContainer);

var defaultLineHeight = control.FindDefaultLineHeight(location, length);

var yaxis = startRect.Top;
var lineHeights = new List<double>();

while ((endRect.Bottom - yaxis) > 0.001)
{
double lineHeight;
if (yaxis == startRect.Top) // First Line
{
lineHeight = startRect.Bottom - startRect.Top;
}
else if (yaxis != endRect.Top) // Middle Line(s)
{
lineHeight = defaultLineHeight;
}
else // Bottom Line
{
lineHeight = endRect.Bottom - endRect.Top;
}
lineHeights.Add(lineHeight);
yaxis += (float)lineHeight;
}

// if the span is multiline, we need to calculate the bounds for each line individually
if (lineHeights.Count > 1)
{
var spanRectangles = GetMultilinedBounds(new NSRange(location, length), layoutManager, textContainer, startRect, endRect, lineHeights, span.Text.EndsWith('\n') || span.Text.EndsWith("\r\n"));
((ISpatialElement)span).Region = Region.FromRectangles(spanRectangles).Inflate(5);
}
else
{
((ISpatialElement)span).Region = Region.FromLines(lineHeights.ToArray(), finalSize.Width, startRect.X, endRect.X, startRect.Top).Inflate(5);
}

// Update current location
currentLocation += length;
}
}

static CGRect GetCharacterBounds(NSRange characterRange, NSLayoutManager layoutManager, NSTextContainer textContainer)
{
layoutManager.GetCharacterRange(characterRange, out NSRange glyphRange);

return layoutManager.GetBoundingRect(glyphRange, textContainer);
}

static Rect[] GetMultilinedBounds(NSRange characterRange, NSLayoutManager layoutManager, NSTextContainer textContainer, CGRect startRect, CGRect endRect, List<double> lineHeights, bool endsWithNewLine)
{
var glyphRange = layoutManager.GetCharacterRange(characterRange);
var multilineRects = new List<CGRect>();

layoutManager.EnumerateLineFragments(glyphRange, (CGRect rect, CGRect usedRect, NSTextContainer textContainer, NSRange lineGlyphRange, out bool stop) =>
{
multilineRects.Add(usedRect);
stop = false;
});

return CreateSpanRects (startRect, endRect, lineHeights, multilineRects, endsWithNewLine);
}

static Rect[] CreateSpanRects (CGRect startRect, CGRect endRect, List<double> lineHeights, List<CGRect> multilineRects, bool endsWithNewLine)
{
List<Rect> spanRectangles = new List<Rect>();
var curHeight = (double)startRect.Top;

// go through each line and create a Rect for the text contained
for (int i = 0; i < multilineRects.Count; i++){
var rect = multilineRects[i];

// top line
// The rect.Width measures from the start of the line even if the span does not start
// at the beginning of the line so we will take the difference for the width.
if (i == 0)
{
spanRectangles.Add(new Rect(startRect.X, startRect.Top, rect.Width - startRect.Left, lineHeights[i]));
}
// middle lines
else if (i < multilineRects.Count - 1)
{
spanRectangles.Add(new Rect(0, curHeight, rect.Width, lineHeights[i]));
}
// bottom line
// rect.Width is the width of the entire last line that is not a new line character - including if there is more text after the span we are processing.
// endRect.X will consider a new line character at the end of a span as a new line and will not give useful information about the end X position in that case.
// As such, we select which to use as the width based on if the span ends with a new line character.
else
{
spanRectangles.Add(new Rect(0, curHeight, endsWithNewLine ? rect.Width : endRect.X, lineHeights[i]));
}

curHeight += lineHeights[i];
}

return spanRectangles.ToArray();
}

static double FindDefaultLineHeight(this UILabel control, int start, int length)
{
if (length == 0)
return 0.0;

var textStorage = new NSTextStorage();

if (control.AttributedText is not null)
textStorage.SetString(control.AttributedText.Substring(start, length));

var layoutManager = new NSLayoutManager();
textStorage.AddLayoutManager(layoutManager);

var textContainer = new NSTextContainer(size: new SizeF(float.MaxValue, float.MaxValue))
{
LineFragmentPadding = 0
};
layoutManager.AddTextContainer(textContainer);

var rect = GetCharacterBounds(new NSRange(0, 1), layoutManager, textContainer);
return rect.Bottom - rect.Top;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
override Microsoft.Maui.Controls.Label.ArrangeOverride(Microsoft.Maui.Graphics.Rect bounds) -> Microsoft.Maui.Graphics.Size
Microsoft.Maui.Controls.DropCompletedEventArgs.PlatformArgs.get -> Microsoft.Maui.Controls.PlatformDropCompletedEventArgs?
Microsoft.Maui.Controls.PlatformDragEventArgs
Microsoft.Maui.Controls.PlatformDragEventArgs.DropInteraction.get -> UIKit.UIDropInteraction!
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
override Microsoft.Maui.Controls.Label.ArrangeOverride(Microsoft.Maui.Graphics.Rect bounds) -> Microsoft.Maui.Graphics.Size
Microsoft.Maui.Controls.PlatformDragStartingEventArgs
Microsoft.Maui.Controls.PlatformDragStartingEventArgs.DragInteraction.get -> UIKit.UIDragInteraction!
Microsoft.Maui.Controls.PlatformDragStartingEventArgs.DragSession.get -> UIKit.IUIDragSession!
Expand Down
6 changes: 2 additions & 4 deletions src/Controls/src/Core/Region.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,12 @@ public Region Inflate(double left, double top, double right, double bottom)
{
var region = Regions[i];

if (i == 0) // this is the first line
region.Top -= top;
region.Top -= top;

region.Left -= left;
region.Width += right + left;

if (i == Regions.Count - 1) // This is the last line
region.Height += bottom + top;
region.Height += bottom + top;

rectangles[i] = region;
Comment on lines 105 to 115
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made these changes since moving the region up but not making it bigger only really makes sense if we can guarantee that the next line will be right below the top line. This doesn't work in cases where the top line is at the end of the line and goes a little onto the next line for example.

}
Expand Down
11 changes: 7 additions & 4 deletions src/Controls/tests/UITests/Tests/Issues/Issue3525.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ public Issue3525(TestDevice device)
[Test]
public void SpanRegionClicking()
{
if (Device == TestDevice.Mac ||
Device == TestDevice.iOS ||
Device == TestDevice.Windows)
if (Device == TestDevice.Mac)
{
Assert.Ignore("This test is failing on iOS/Mac Catalyst/Windows because the feature is not yet implemented: https://github.com/dotnet/maui/issues/4734");
Assert.Ignore("Click (x, y) pointer type mouse is not implemented.");
}

if (Device == TestDevice.Windows)
{
Assert.Ignore("This test is failing on Windows because the feature is not yet implemented: https://github.com/dotnet/maui/pull/17731");
}

var label = App.WaitForElement(kLabelTestAutomationId);
Expand Down
11 changes: 7 additions & 4 deletions src/Controls/tests/UITests/Tests/LabelUITests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ public override void _IsEnabled()
[Test]
public void SpanTapped()
{
if (Device == TestDevice.Mac ||
Device == TestDevice.iOS ||
Device == TestDevice.Windows)
if (Device == TestDevice.Mac)
{
Assert.Ignore("This test is failing on iOS/Mac Catalyst/Windows because the feature is not yet implemented: https://github.com/dotnet/maui/issues/4734");
Assert.Ignore("Click (x, y) pointer type mouse is not implemented.");
}

if (Device == TestDevice.Windows)
{
Assert.Ignore("This test is failing on Windows because the feature is not yet implemented: https://github.com/dotnet/maui/issues/4734");
}

var remote = new EventViewContainerRemote(UITestContext, Test.FormattedString.SpanTapped);
Expand Down
Loading