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 6 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
48 changes: 35 additions & 13 deletions maui/src/Button/SfButton.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,20 +538,41 @@ 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);
}

// 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 widthConstraint;

return naturalWidth;
}

/// <summary>
Expand All @@ -570,6 +591,7 @@ double CalculateHeight(double heightConstraint, double width)
}
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 +712,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
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,169 @@ 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 AutomationScenario

[Theory]
Expand Down