diff --git a/maui/src/Button/SfButton.Methods.cs b/maui/src/Button/SfButton.Methods.cs
index 32d48a4c..4c4e2c2a 100644
--- a/maui/src/Button/SfButton.Methods.cs
+++ b/maui/src/Button/SfButton.Methods.cs
@@ -538,20 +538,65 @@ void DrawButtonOutline(ICanvas canvas, RectF dirtyRect)
/// Calculated width.
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;
+ }
+
+ ///
+ /// Calculates the available text width considering padding, stroke thickness, and icon positioning.
+ ///
+ /// Total width of the button.
+ /// Available width for text.
+ 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);
}
///
@@ -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)
@@ -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)
{
@@ -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();
}
diff --git a/maui/src/Core/Extensions/StringExtensions.cs b/maui/src/Core/Extensions/StringExtensions.cs
index 3f13a729..53a7e174 100644
--- a/maui/src/Core/Extensions/StringExtensions.cs
+++ b/maui/src/Core/Extensions/StringExtensions.cs
@@ -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:
diff --git a/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs b/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs
index ef4a8e03..6897d243 100644
--- a/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs
+++ b/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs
@@ -776,6 +776,281 @@ private void AttachVisualStates(SfButton button)
#endregion
+ #region HorizontalOptions Width Tests
+
+ [Theory]
+ [InlineData(LayoutAlignment.Start)]
+ [InlineData(LayoutAlignment.Center)]
+ [InlineData(LayoutAlignment.End)]
+ public void CalculateWidth_WithNonFillHorizontalOptions_ShouldUseContentWidth(LayoutAlignment alignment)
+ {
+ var button = new SfButton();
+ button.Text = "Sample Text";
+ button.HorizontalOptions = new LayoutOptions(alignment, false);
+
+ // Test with available width constraint larger than content
+ double widthConstraint = 300;
+ var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint);
+
+ // Should not use the full constraint width, but rather content-based width
+ Assert.True(actualWidth < widthConstraint,
+ $"Button with HorizontalOptions.{alignment} should not fill constraint width {widthConstraint}, but got {actualWidth}");
+ }
+
+ [Fact]
+ public void CalculateWidth_WithFillHorizontalOptions_ShouldUseConstraintWidth()
+ {
+ var button = new SfButton();
+ button.Text = "Sample Text";
+ button.HorizontalOptions = LayoutOptions.Fill;
+
+ // Test with available width constraint
+ double widthConstraint = 300;
+ var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint);
+
+ // Should use the constraint width when HorizontalOptions is Fill
+ Assert.Equal(widthConstraint, actualWidth);
+ }
+
+ [Fact]
+ public void CalculateWidth_WithWidthRequest_ShouldAlwaysUseWidthRequest()
+ {
+ var button = new SfButton();
+ button.Text = "Sample Text";
+ button.WidthRequest = 150;
+ button.HorizontalOptions = LayoutOptions.Fill;
+
+ // Test with larger width constraint
+ double widthConstraint = 300;
+ var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint);
+
+ // Should use WidthRequest regardless of HorizontalOptions
+ Assert.Equal(150, actualWidth);
+ }
+
+ [Fact]
+ public void CalculateWidth_WithInfiniteConstraint_ShouldUseContentWidth()
+ {
+ var button = new SfButton();
+ button.Text = "Sample Text";
+ button.HorizontalOptions = LayoutOptions.Fill;
+
+ // Test with infinite width constraint
+ double widthConstraint = double.PositiveInfinity;
+ var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint);
+
+ // Should fall back to content width even with Fill when constraint is infinite
+ Assert.True(actualWidth > 0 && actualWidth != double.PositiveInfinity,
+ $"Button should calculate content width when constraint is infinite, but got {actualWidth}");
+ }
+
+ #endregion
+
+ #region Text Wrapping Tests
+
+ [Fact]
+ public void TextWrapping_ShouldWrapWithoutWidthRequest()
+ {
+ var button = new SfButton();
+ button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly";
+ button.LineBreakMode = LineBreakMode.WordWrap;
+ button.HorizontalOptions = LayoutOptions.Start;
+ button.VerticalOptions = LayoutOptions.Start;
+
+ // Measure with width constraint but no WidthRequest
+ var size = button.MeasureContent(200, double.PositiveInfinity);
+
+ // Calculate expected single line height for comparison
+ var singleLineButton = new SfButton();
+ singleLineButton.Text = "Short text";
+ singleLineButton.LineBreakMode = LineBreakMode.NoWrap;
+ var singleLineSize = singleLineButton.MeasureContent(200, double.PositiveInfinity);
+
+ // Height should be greater than single line due to text wrapping
+ Assert.True(size.Height > singleLineSize.Height,
+ $"Button height {size.Height} should be greater than single line height {singleLineSize.Height} when text wraps");
+
+ // Width should not exceed the constraint
+ Assert.True(size.Width <= 200,
+ $"Button width {size.Width} should not exceed width constraint of 200");
+ }
+
+ [Fact]
+ public void TextWrapping_ShouldRespectWidthRequest()
+ {
+ var button = new SfButton();
+ button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly";
+ button.LineBreakMode = LineBreakMode.WordWrap;
+ button.WidthRequest = 150;
+
+ // Measure with larger width constraint, but WidthRequest should take precedence
+ var size = button.MeasureContent(300, double.PositiveInfinity);
+
+ // Width should be close to WidthRequest (accounting for padding)
+ Assert.True(size.Width >= 150,
+ $"Button width {size.Width} should respect WidthRequest of 150");
+ }
+
+ [Fact]
+ public void TextWrapping_WithIcon_ShouldAccountForIconSpace()
+ {
+ var button = new SfButton();
+ button.Text = "This is a very long text that should automatically wrap into multiple lines";
+ button.LineBreakMode = LineBreakMode.WordWrap;
+ button.ShowIcon = true;
+ button.ImageAlignment = Alignment.Start; // Icon on left side
+ button.ImageSize = 20;
+
+ // Measure with width constraint
+ var sizeWithIcon = button.MeasureContent(200, double.PositiveInfinity);
+
+ // Compare with button without icon
+ var buttonNoIcon = new SfButton();
+ buttonNoIcon.Text = button.Text;
+ buttonNoIcon.LineBreakMode = LineBreakMode.WordWrap;
+ var sizeNoIcon = buttonNoIcon.MeasureContent(200, double.PositiveInfinity);
+
+ // Button with icon should potentially wrap more (higher height) due to less available text width
+ Assert.True(sizeWithIcon.Height >= sizeNoIcon.Height,
+ $"Button with icon height {sizeWithIcon.Height} should be >= button without icon height {sizeNoIcon.Height}");
+ }
+
+ [Fact]
+ public void TextWrapping_AndroidOverflowPrevention_ShouldConstrainWidth()
+ {
+ var button = new SfButton();
+ button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly without overflowing the screen bounds on Android";
+ button.LineBreakMode = LineBreakMode.WordWrap;
+ button.HorizontalOptions = LayoutOptions.Start; // Non-Fill alignment
+ button.VerticalOptions = LayoutOptions.Start;
+
+ // Simulate Android screen constraint (smaller width)
+ var size = button.MeasureContent(250, double.PositiveInfinity);
+
+ // Button width should be constrained to prevent overflow
+ Assert.True(size.Width <= 250,
+ $"Button width {size.Width} should be constrained to prevent overflow on Android (max 250)");
+
+ // Height should be greater than single line height due to wrapping
+ var singleLineHeight = button.MeasureContent(double.PositiveInfinity, double.PositiveInfinity).Height;
+ Assert.True(size.Height >= singleLineHeight,
+ $"Button height {size.Height} should accommodate wrapped text (>= {singleLineHeight})");
+ }
+
+ #endregion
+
+ #region Text Truncation Tests
+
+ [Theory]
+ [InlineData(LineBreakMode.TailTruncation)]
+ [InlineData(LineBreakMode.HeadTruncation)]
+ [InlineData(LineBreakMode.MiddleTruncation)]
+ [InlineData(LineBreakMode.NoWrap)]
+ public void TextTruncation_ShouldAlwaysUseSingleLine(LineBreakMode lineBreakMode)
+ {
+ var button = new SfButton();
+ button.Text = "This is a very long text that should be truncated instead of wrapping to multiple lines";
+ button.LineBreakMode = lineBreakMode;
+
+ // Test with constrained width that would normally cause wrapping
+ var size = button.MeasureContent(150, double.PositiveInfinity);
+
+ // Calculate single line height for comparison
+ var singleLineButton = new SfButton();
+ singleLineButton.Text = "Short";
+ singleLineButton.LineBreakMode = LineBreakMode.NoWrap;
+ var singleLineHeight = singleLineButton.MeasureContent(double.PositiveInfinity, double.PositiveInfinity).Height;
+
+ // For truncation modes, height should be approximately single line height
+ var heightDifference = Math.Abs(size.Height - singleLineHeight);
+ Assert.True(heightDifference < 5, // Allow small padding differences
+ $"Truncation mode {lineBreakMode} should use single line height. Expected ~{singleLineHeight}, got {size.Height}");
+ }
+
+ [Fact]
+ public void TextTruncation_MiddleMode_ShouldTrimBothEnds()
+ {
+ var longText = "This is a very long text that should be truncated in the middle with ellipsis";
+
+ // Use StringExtensions directly to test the truncation logic
+ var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.MiddleTruncation);
+
+ // Should contain ellipsis
+ Assert.Contains("...", result);
+
+ // Should be shorter than original
+ Assert.True(result.Length < longText.Length,
+ $"Truncated text '{result}' should be shorter than original '{longText}'");
+
+ // Should start with beginning of original text and end with end of original text
+ Assert.True(result.StartsWith(longText.Substring(0, Math.Min(10, longText.Length))),
+ $"Truncated text '{result}' should start with beginning of original text");
+ }
+
+ [Fact]
+ public void TextTruncation_TailMode_ShouldAddEllipsisAtEnd()
+ {
+ var longText = "This is a very long text that should be truncated at the end";
+
+ var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.TailTruncation);
+
+ // Should end with ellipsis
+ Assert.True(result.EndsWith("..."),
+ $"Tail truncated text '{result}' should end with ellipsis");
+
+ // Should be shorter than original
+ Assert.True(result.Length < longText.Length,
+ $"Truncated text '{result}' should be shorter than original '{longText}'");
+ }
+
+ [Fact]
+ public void TextTruncation_HeadMode_ShouldAddEllipsisAtStart()
+ {
+ var longText = "This is a very long text that should be truncated at the beginning";
+
+ var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.HeadTruncation);
+
+ // Should start with ellipsis
+ Assert.True(result.StartsWith("..."),
+ $"Head truncated text '{result}' should start with ellipsis");
+
+ // Should be shorter than original
+ Assert.True(result.Length < longText.Length,
+ $"Truncated text '{result}' should be shorter than original '{longText}'");
+ }
+
+ [Fact]
+ public void TextTruncation_ShouldHandleSpacesCorrectly()
+ {
+ // This tests the scenario mentioned in the comment where spaces cause wrapping instead of truncation
+ var textWithSpaces = "Word1 Word2 Word3 Word4 Word5 Word6 Word7 Word8 Word9 Word10";
+
+ var result = StringExtensions.GetTextBasedOnLineBreakMode(textWithSpaces, new TestTextElement(), 80, 20, LineBreakMode.TailTruncation);
+
+ // Should truncate, not wrap
+ Assert.Contains("...", result);
+ Assert.True(result.Length < textWithSpaces.Length);
+
+ // Should not contain line breaks or be multi-line
+ Assert.False(result.Contains('\n'));
+ Assert.False(result.Contains('\r'));
+ }
+
+ // Helper class for testing text measurements
+ private class TestTextElement : ITextElement
+ {
+ public FontAttributes FontAttributes => FontAttributes.None;
+ public string FontFamily => null;
+ public double FontSize => 14;
+ public bool FontAutoScalingEnabled => false;
+ public Color TextColor => Colors.Black;
+
+ // Simple width calculation for testing (assumes each char is ~8 pixels wide)
+ public Size Measure(string text) => new Size(text.Length * 8, 20);
+ }
+
+ #endregion
+
#region AutomationScenario
[Theory]