Skip to content

Fix SfButton text wrapping without explicit WidthRequest #202

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

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
103 changes: 86 additions & 17 deletions maui/src/Button/SfButton.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,20 +538,65 @@ void DrawButtonOutline(ICanvas canvas, RectF dirtyRect)
/// <returns>Calculated width.</returns>
double CalculateWidth(double widthConstraint)
{
if (widthConstraint == double.PositiveInfinity || widthConstraint < 0 || WidthRequest < 0)
// If WidthRequest is explicitly set, use it
if (WidthRequest > 0)
{
if (ShowIcon && ImageSource != null)
{
return ImageAlignment == Alignment.Top || ImageAlignment == Alignment.Bottom
? Math.Max(ImageSize, TextSize.Width) + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2)
: ImageSize + TextSize.Width + StrokeThickness + Padding.Left + Padding.Right + (_leftPadding * 2) + (_rightPadding * 2);
}
else
{
return TextSize.Width + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2);
}
return WidthRequest;
}

// If HorizontalOptions is Fill, use the constraint width when available
if (HorizontalOptions.Alignment == LayoutAlignment.Fill &&
widthConstraint != double.PositiveInfinity && widthConstraint > 0)
{
return widthConstraint;
}

// For HorizontalOptions Start, Center, End, calculate natural width based on content
// but ensure it doesn't exceed available width constraint to prevent overflow
double naturalWidth;
if (ShowIcon && ImageSource != null)
{
naturalWidth = ImageAlignment == Alignment.Top || ImageAlignment == Alignment.Bottom
? Math.Max(ImageSize, TextSize.Width) + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2)
: ImageSize + TextSize.Width + StrokeThickness + Padding.Left + Padding.Right + (_leftPadding * 2) + (_rightPadding * 2);
}
else
{
naturalWidth = TextSize.Width + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2);
}
return widthConstraint;

// If we have a finite width constraint and the natural width would exceed it,
// constrain the width to prevent overflow (especially important on Android)
if (widthConstraint != double.PositiveInfinity && widthConstraint > 0 && naturalWidth > widthConstraint)
{
return widthConstraint;
}

return naturalWidth;
}

/// <summary>
/// Calculates the available text width considering padding, stroke thickness, and icon positioning.
/// </summary>
/// <param name="totalWidth">Total width of the button.</param>
/// <returns>Available width for text.</returns>
double CalculateAvailableTextWidth(double totalWidth)
{
// Start with total width and subtract padding and stroke thickness
double availableWidth = totalWidth - Padding.Left - Padding.Right - StrokeThickness - (_textAreaPadding * 2);

// If icon is positioned left or right (not top/bottom), subtract icon size
if (ShowIcon && ImageSource != null && ImageAlignment != Alignment.Top && ImageAlignment != Alignment.Bottom)
{
availableWidth -= ImageSize + (_leftIconPadding * 2);
}

#if ANDROID
// Account for Android-specific text margin
availableWidth -= AndroidTextMargin;
#endif

return Math.Max(0, availableWidth);
}

/// <summary>
Expand All @@ -566,10 +611,13 @@ double CalculateHeight(double heightConstraint, double width)
{
if (LineBreakMode == LineBreakMode.WordWrap || LineBreakMode == LineBreakMode.CharacterWrap)
{
_numberOfLines = StringExtensions.GetLinesCount(Text, (float)width, this, LineBreakMode, out _);
// Calculate available text width considering padding, stroke thickness, and icon
double availableTextWidth = CalculateAvailableTextWidth(width);
_numberOfLines = StringExtensions.GetLinesCount(Text, (float)availableTextWidth, this, LineBreakMode, out _);
}
else
{
// For truncation modes (Head, Middle, Tail) and NoWrap, text should always be on a single line
_numberOfLines = 1;
}
if (ShowIcon && ImageSource != null)
Expand Down Expand Up @@ -690,7 +738,7 @@ protected override Size MeasureContent(double widthConstraint, double heightCons
base.MeasureContent(widthConstraint, heightConstraint);

double width = CalculateWidth(widthConstraint);
double height = CalculateHeight(heightConstraint, WidthRequest > 0 ? WidthRequest : width);
double height = CalculateHeight(heightConstraint, width);

if (Children.Count > 0 && IsItemTemplate)
{
Expand Down Expand Up @@ -733,14 +781,35 @@ internal override void DrawText(ICanvas canvas, RectF dirtyRect)
: TextAlignment.Center;
UpdateTextRect(dirtyRect);
canvas.SaveState();

// Calculate available width consistently with height calculation
float availableWidth = _textRectF.Width;
#if ANDROID
availableWidth-=AndroidTextMargin;
availableWidth -= AndroidTextMargin;
#endif
var trimmedText = _isFontIconText ? Text : StringExtensions.GetTextBasedOnLineBreakMode(ApplyTextTransform(Text), this, availableWidth, _textRectF.Height, LineBreakMode);

// For truncation modes, ensure we have adequate width and avoid wrapping
string textToRender;
if (_isFontIconText)
{
textToRender = Text;
}
else
{
// Apply text transformation first
string transformedText = ApplyTextTransform(Text);

// For truncation modes, make sure we don't allow wrapping by using single line height
double effectiveHeight = LineBreakMode == LineBreakMode.WordWrap || LineBreakMode == LineBreakMode.CharacterWrap
? _textRectF.Height
: TextSize.Height; // Use single line height for truncation modes

textToRender = StringExtensions.GetTextBasedOnLineBreakMode(transformedText, this, availableWidth, effectiveHeight, LineBreakMode);
}

if (_textRectF.Width > 0 && _textRectF.Height > 0)
{
canvas.DrawText(trimmedText, _textRectF, _isRightToLeft ? (HorizontalAlignment)horizontalTextAlignment : (HorizontalAlignment)HorizontalTextAlignment, (VerticalAlignment)VerticalTextAlignment, this);
canvas.DrawText(textToRender, _textRectF, _isRightToLeft ? (HorizontalAlignment)horizontalTextAlignment : (HorizontalAlignment)HorizontalTextAlignment, (VerticalAlignment)VerticalTextAlignment, this);
}
canvas.RestoreState();
}
Expand Down
56 changes: 44 additions & 12 deletions maui/src/Core/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,52 @@ public static string GetTextBasedOnLineBreakMode(this string text, ITextElement
return text.TrimTextToFit(textElement, availableWidth);

case LineBreakMode.MiddleTruncation:
int charsToKeep = (int)((availableWidth - ("...").Measure(textElement).Width) / 2);
string leftTrimmedText = text;
var leftTrimmedTextSize = leftTrimmedText.Measure((ITextElement)textElement);
int leftLength = 0;

while (leftTrimmedTextSize.Width > charsToKeep && leftTrimmedText.Length > 0)
// Calculate available width for each half after subtracting ellipsis width
double ellipsisWidth = ("...").Measure(textElement).Width;
double halfAvailableWidth = (availableWidth - ellipsisWidth) / 2;

// Trim from the left side
string leftPart = text;
while (leftPart.Length > 0 && leftPart.Measure(textElement).Width > halfAvailableWidth)
{
leftPart = leftPart.Substring(0, leftPart.Length - 1);
}

// Trim from the right side
string rightPart = text;
while (rightPart.Length > 0 && rightPart.Measure(textElement).Width > halfAvailableWidth)
{
rightPart = rightPart.Substring(1);
}

// Ensure we don't duplicate characters from the middle
int leftLength = leftPart.Length;
int rightStartIndex = text.Length - rightPart.Length;

// If there's overlap, adjust the split point
if (leftLength >= rightStartIndex)
{
leftTrimmedText = leftTrimmedText.Substring(0, leftTrimmedText.Length - 1);
leftTrimmedTextSize = leftTrimmedText.Measure((ITextElement)textElement);
leftLength++;
int midPoint = text.Length / 2;
leftPart = text.Substring(0, Math.Min(leftLength, midPoint));
rightPart = text.Substring(Math.Max(rightStartIndex, midPoint));

// Final trim to ensure we fit in available width
string candidateText = leftPart + "..." + rightPart;
while (candidateText.Measure(textElement).Width > availableWidth && (leftPart.Length > 0 || rightPart.Length > 0))
{
if (leftPart.Length > rightPart.Length && leftPart.Length > 0)
{
leftPart = leftPart.Substring(0, leftPart.Length - 1);
}
else if (rightPart.Length > 0)
{
rightPart = rightPart.Substring(1);
}
candidateText = leftPart + "..." + rightPart;
}
}
string rightText = text.Substring(leftLength);
string trimmedText = leftTrimmedText + "..." + rightText;
return trimmedText;

return leftPart + "..." + rightPart;

case LineBreakMode.HeadTruncation:

Expand Down
Loading