diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue30957.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue30957.cs new file mode 100644 index 000000000000..a3d4f4d58fb3 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue30957.cs @@ -0,0 +1,126 @@ +using Microsoft.Maui.Layouts; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 30957, "FlexLayout Wrap Misalignment with Dynamically-Sized Buttons in .NET MAUI", PlatformAffected.All)] + public class Issue30957 : ContentPage + { + FlexLayout _testFlexLayout; + Label _statusLabel; + Button _toggleButton1, _toggleButton2, _toggleButton3; + bool _isToggled = false; + + public Issue30957() + { + Title = "Issue30957"; + + var stackLayout = new StackLayout { Padding = new Thickness(20) }; + + var instructionsLabel = new Label + { + AutomationId = "Issue30957FirstLabel", + Text = "This reproduces FlexLayout wrapping issue with font family switching. Click 'Toggle Font' to trigger precision issues.", + Margin = new Thickness(0, 0, 0, 20) + }; + stackLayout.Children.Add(instructionsLabel); + + var toggleAllButton = new Button + { + Text = "Toggle Font Family", + AutomationId = "Issue30957ToggleButton", + BackgroundColor = Colors.LightBlue, + Margin = new Thickness(0, 0, 0, 10) + }; + toggleAllButton.Clicked += OnToggleFontClicked; + stackLayout.Children.Add(toggleAllButton); + + var border = new Border + { + Stroke = Colors.Black, + StrokeThickness = 1, + BackgroundColor = Colors.White, + HorizontalOptions = LayoutOptions.Start, + Padding = new Thickness(4) + }; + + _testFlexLayout = new FlexLayout + { + Wrap = FlexWrap.Wrap + }; + + _toggleButton1 = new Button + { + Text = "Button1", + AutomationId = "Issue30957Button1", + BackgroundColor = Colors.White, + FontFamily = "OpenSansRegular", + CornerRadius = 0, + FontSize = 16, + TextColor = Colors.Black + }; + + _toggleButton2 = new Button + { + Text = "Button2", + AutomationId = "Issue30957Button2", + BackgroundColor = Colors.White, + FontFamily = "OpenSansRegular", + CornerRadius = 0, + FontSize = 16, + TextColor = Colors.Black + }; + + _toggleButton3 = new Button + { + Text = "Button3", + AutomationId = "Issue30957Button3", + BackgroundColor = Colors.White, + FontFamily = "OpenSansRegular", + CornerRadius = 0, + FontSize = 16, + TextColor = Colors.Black + }; + + _testFlexLayout.Children.Add(_toggleButton1); + _testFlexLayout.Children.Add(_toggleButton2); + _testFlexLayout.Children.Add(_toggleButton3); + + border.Content = _testFlexLayout; + stackLayout.Children.Add(border); + + _statusLabel = new Label + { + AutomationId = "Issue30957StatusLabel", + Text = "Status: Ready to test - Click 'Toggle Font Family' to trigger precision issue", + Margin = new Thickness(0, 10, 0, 0), + TextColor = Colors.DarkGreen + }; + stackLayout.Children.Add(_statusLabel); + + Content = stackLayout; + } + + void OnToggleFontClicked(object sender, EventArgs e) + { + _isToggled = !_isToggled; + + string fontFamily = _isToggled ? "OpenSansSemibold" : "OpenSansRegular"; + Color backgroundColor = _isToggled ? Colors.Black : Colors.White; + Color textColor = _isToggled ? Colors.White : Colors.Black; + + _toggleButton1.FontFamily = fontFamily; + _toggleButton1.BackgroundColor = backgroundColor; + _toggleButton1.TextColor = textColor; + + _toggleButton2.FontFamily = fontFamily; + _toggleButton2.BackgroundColor = backgroundColor; + _toggleButton2.TextColor = textColor; + + _toggleButton3.FontFamily = fontFamily; + _toggleButton3.BackgroundColor = backgroundColor; + _toggleButton3.TextColor = textColor; + + _statusLabel.Text = $"Status: Font toggled to {fontFamily} - This triggers precision issue that tolerance fix addresses"; + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30957.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30957.cs new file mode 100644 index 000000000000..c3582cc5ce3c --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30957.cs @@ -0,0 +1,35 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues +{ + public class Issue30957 : _IssuesUITest + { + public Issue30957(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "FlexLayout Wrap Misalignment with Dynamically-Sized Buttons in .NET MAUI"; + + [Test] + [Category(UITestCategories.Layout)] + public void FlexLayoutWrappingWithToleranceWorksCorrectly() + { + App.WaitForElement("Issue30957ToggleButton"); + App.Tap("Issue30957ToggleButton"); + + var button1Rect = App.WaitForElement("Issue30957Button1").GetRect(); + var button2Rect = App.WaitForElement("Issue30957Button2").GetRect(); + var button3Rect = App.WaitForElement("Issue30957Button3").GetRect(); + + // All three buttons should be on the same row (same Y position) + Assert.That(button2Rect.Y, Is.EqualTo(button1Rect.Y), "Button1 and Button2 should have the same Y position (same row)"); + Assert.That(button3Rect.Y, Is.EqualTo(button1Rect.Y), "Button1 and Button3 should have the same Y position (same row)"); + + // Buttons should be laid out horizontally: each subsequent button starts after the previous one + Assert.That(button2Rect.X, Is.GreaterThan(button1Rect.X), "Button2 should be to the right of Button1"); + Assert.That(button3Rect.X, Is.GreaterThan(button2Rect.X), "Button3 should be to the right of Button2"); + } + } +} diff --git a/src/Core/src/Layouts/Flex.cs b/src/Core/src/Layouts/Flex.cs index 2ecadeccf1af..31f4d1557777 100644 --- a/src/Core/src/Layouts/Flex.cs +++ b/src/Core/src/Layouts/Flex.cs @@ -533,10 +533,29 @@ static void layout_item(Item item, float width, float height, bool inMeasureMode child.Frame[layout.frame_size_i] = basis - child.MarginThickness(layout.vertical); } +#if ANDROID || WINDOWS + // A small tolerance value used to account for floating-point precision errors + // when determining whether a child item fits on the current line during flex wrapping. + // Without this tolerance, items that should fit may be incorrectly wrapped to a new line + // due to minor rounding discrepancies in size calculations. + // 0.1f accounts for sub-pixel rounding errors introduced by DPI scaling factors + // (e.g., 1.25x on Windows, 2.625x density on Android). Values below 1.0f have + // no meaningful effect on intentional layout gaps which are always >= 1dp. + const float FlexWrapTolerance = 0.1f; + + // The issue was originally reported on Windows, related to device density or + // scaling factor. It has also been reproduced on Android due to density variations. + // The tolerance is applied unconditionally across all platforms as a conservative fix; + // it has no adverse effects on layout behavior. + float flex_tolerance = layout.flex_dim + FlexWrapTolerance; +#else + // On other platforms, we haven't observed the same precision issues, so we can use the actual available space as the tolerance. + float flex_tolerance = layout.flex_dim; +#endif float child_size = child.Frame[layout.frame_size_i]; if (layout.wrap) { - if (layout.flex_dim < child_size) + if (flex_tolerance < child_size) { // Not enough space for this child on this line, layout the // remaining items and move it to a new line.