diff --git a/eng/pipelines/ci-device-tests.yml b/eng/pipelines/ci-device-tests.yml index 1547831fb642..d33cf5b4e7e4 100644 --- a/eng/pipelines/ci-device-tests.yml +++ b/eng/pipelines/ci-device-tests.yml @@ -5,6 +5,7 @@ trigger: - release/* - net*.0 - inflight/* + - darc-* tags: include: - '*' diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml index c3b6ace38f43..7e16e1733d75 100644 --- a/eng/pipelines/ci-uitests.yml +++ b/eng/pipelines/ci-uitests.yml @@ -5,6 +5,7 @@ trigger: - release/* - net*.0 - inflight/* + - darc-* tags: include: - '*' diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryFocusedShouldNotCauseGapAfterRotation.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryFocusedShouldNotCauseGapAfterRotation.png new file mode 100644 index 000000000000..344b31a7e883 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryFocusedShouldNotCauseGapAfterRotation.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33407.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33407.cs new file mode 100644 index 000000000000..8566fc3d2a3d --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33407.cs @@ -0,0 +1,151 @@ +namespace Maui.Controls.Sample.Issues; + +class Issue33407CategoryItem +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string AutomationId { get; set; } = string.Empty; +} + +class Issue33407TestItem +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string AutomationId { get; set; } = string.Empty; +} + +[Issue(IssueTracker.Github, 33407, "Focusing and entering texts on entry control causes a gap at the top after rotating simulator.", PlatformAffected.iOS)] +public class Issue33407 : Shell +{ + public Issue33407() + { + FlyoutBehavior = FlyoutBehavior.Disabled; + + // Match ManualTests/Sandbox Shell styling so iOS safe area is handled correctly + Shell.SetBackgroundColor(this, Color.FromArgb("#512BD4")); + Shell.SetForegroundColor(this, Colors.White); + Shell.SetTitleColor(this, Colors.White); + Shell.SetNavBarHasShadow(this, false); + + Items.Add(new ShellContent + { + Title = "Home", + Content = new Issue33407CategoriesPage() + }); + } +} + +class Issue33407CategoriesPage : ContentPage +{ + public Issue33407CategoriesPage() + { + Title = "Categories"; + + var collection = new CollectionView { SelectionMode = SelectionMode.Single }; + collection.ItemsSource = new[] + { + new Issue33407CategoryItem { Id = "E", Title = "Entry", AutomationId = "CategoryE" } + }; + collection.ItemTemplate = new DataTemplate(() => + { + var grid = new Grid { Padding = new Thickness(10) }; + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + + var idLabel = new Label { FontAttributes = FontAttributes.Bold, FontSize = 32, VerticalOptions = LayoutOptions.Center }; + idLabel.SetBinding(Label.TextProperty, nameof(Issue33407CategoryItem.Id)); + idLabel.SetBinding(Label.AutomationIdProperty, nameof(Issue33407CategoryItem.AutomationId)); + + var titleLabel = new Label { Margin = new Thickness(20, 0, 0, 0), FontSize = 28, VerticalOptions = LayoutOptions.Center }; + titleLabel.SetBinding(Label.TextProperty, nameof(Issue33407CategoryItem.Title)); + + grid.Add(idLabel, 0, 0); + grid.Add(titleLabel, 1, 0); + return grid; + }); + collection.SelectionChanged += (s, e) => + { + if (e.CurrentSelection.FirstOrDefault() is Issue33407CategoryItem item && item.Id == "E") + Navigation.PushAsync(new Issue33407EntryListPage()); + ((CollectionView)s).SelectedItem = null; + }; + + Content = collection; + } +} + +class Issue33407EntryListPage : ContentPage +{ + public Issue33407EntryListPage() + { + Title = "Entry"; + + var collection = new CollectionView { SelectionMode = SelectionMode.Single }; + collection.ItemsSource = new[] + { + new Issue33407TestItem { Id = "E1", Title = "No gap at top after rotation", AutomationId = "TestE1" } + }; + collection.ItemTemplate = new DataTemplate(() => + { + var grid = new Grid { Padding = new Thickness(10) }; + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + + var idLabel = new Label { FontAttributes = FontAttributes.Bold, FontSize = 32, VerticalOptions = LayoutOptions.Center }; + idLabel.SetBinding(Label.TextProperty, nameof(Issue33407TestItem.Id)); + idLabel.SetBinding(Label.AutomationIdProperty, nameof(Issue33407TestItem.AutomationId)); + + var titleLabel = new Label { Margin = new Thickness(20, 0, 0, 0), FontSize = 12, VerticalOptions = LayoutOptions.Center }; + titleLabel.SetBinding(Label.TextProperty, nameof(Issue33407TestItem.Title)); + + grid.Add(idLabel, 0, 0); + grid.Add(titleLabel, 1, 0); + return grid; + }); + collection.SelectionChanged += (s, e) => + { + if (e.CurrentSelection.FirstOrDefault() is Issue33407TestItem item && item.Id == "E1") + Navigation.PushAsync(new Issue33407E1Page()); + ((CollectionView)s).SelectedItem = null; + }; + + Content = collection; + } +} + +class Issue33407E1Page : ContentPage +{ + public Issue33407E1Page() + { + Title = "E1"; + + Content = new VerticalStackLayout + { + Children = + { + new Label + { + Text = "1. Rotate the device between portrait and landscape with the keyboard hidden. The test passes if no extra gap appears at the top of the page above the entries." + }, + new UITestEntry + { + IsPassword = false, + IsCursorVisible = false, + Placeholder = "Top gap check (normal entry)", + AutomationId = "Entry1" + }, + new Label + { + Text = "2. Tap into each Entry, then rotate the device again. The test passes if no additional top gap appears and both entries remain aligned directly under this text." + }, + new UITestEntry + { + IsPassword = true, + IsCursorVisible = false, + Placeholder = "Top gap check (password entry)", + AutomationId = "Entry2" + } + } + }; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33407.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33407.cs new file mode 100644 index 000000000000..a43ba8b7f7f3 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33407.cs @@ -0,0 +1,45 @@ +#if IOS || ANDROID // Orientation change is not supported on Windows and MacCatalyst +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue33407 : _IssuesUITest +{ + public Issue33407(TestDevice device) : base(device) { } + + public override string Issue => "Focusing and entering texts on entry control causes a gap at the top after rotating simulator."; + + [TearDown] + public void TearDown() + { + App.SetOrientationPortrait(); + } + + [Test] + [Category(UITestCategories.Entry)] + public void EntryFocusedShouldNotCauseGapAfterRotation() + { + App.WaitForElement("CategoryE"); + App.Tap("CategoryE"); + + App.WaitForElement("TestE1"); + App.Tap("TestE1"); + + App.WaitForElement("Entry1"); + App.Tap("Entry1"); + + // Rotate while keyboard is visible — RestorePosition() incorrectly used the portrait Y + // in landscape space before the fix, causing a gap at the top. + App.SetOrientationLandscape(); + + // Dismiss keyboard before screenshot to avoid cursor flakiness. + App.DismissKeyboard(); + + // Use retryTimeout to wait for the keyboard-dismiss/restore animation to fully settle + // before asserting, avoiding flaky mid-transition screenshots. + VerifyScreenshot(retryTimeout: TimeSpan.FromSeconds(2)); + } +} +#endif diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/EntryFocusedShouldNotCauseGapAfterRotation.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/EntryFocusedShouldNotCauseGapAfterRotation.png new file mode 100644 index 000000000000..e945f6f554a3 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/EntryFocusedShouldNotCauseGapAfterRotation.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryFocusedShouldNotCauseGapAfterRotation.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryFocusedShouldNotCauseGapAfterRotation.png new file mode 100644 index 000000000000..52d86e9e87c4 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryFocusedShouldNotCauseGapAfterRotation.png differ diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs b/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs index 4fe675055aa6..0850fcbba2dc 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs @@ -27,6 +27,7 @@ public static class KeyboardAutoManagerScroll internal static CGRect KeyboardFrame = CGRect.Empty; static CGPoint TopViewBeginOrigin = new(nfloat.MaxValue, nfloat.MaxValue); static readonly CGPoint InvalidPoint = new(nfloat.MaxValue, nfloat.MaxValue); + static CGSize TopViewBeginContainerSize = CGSize.Empty; static double AnimationDuration = 0.25; static UIView? View; static UIView? ContainerView; @@ -348,6 +349,7 @@ internal static void AdjustPosition() if (TopViewBeginOrigin == InvalidPoint) { TopViewBeginOrigin = new CGPoint(ContainerView.Frame.X, ContainerView.Frame.Y); + TopViewBeginContainerSize = ContainerView.Frame.Size; } var rootViewOrigin = new CGPoint(ContainerView.Frame.GetMinX(), ContainerView.Frame.GetMinY()); @@ -933,11 +935,25 @@ static void RestorePosition() && (ContainerView.Frame.X != TopViewBeginOrigin.X || ContainerView.Frame.Y != TopViewBeginOrigin.Y) && TopViewBeginOrigin != InvalidPoint) { - var rect = ContainerView.Frame; - rect.X = TopViewBeginOrigin.X; - rect.Y = TopViewBeginOrigin.Y; + // if the container size changed since the keyboard appeared, the device was rotated while + // the keyboard was visible. the stored origin belongs to the previous orientation, so skip + // the restore and let the view settle naturally in the new orientation. + var currentSize = ContainerView.Frame.Size; + // use a 1pt tolerance to guard against sub-pixel floating-point drift in CGSize; + // a real orientation change produces a delta of hundreds of points + const float SizeChangeTolerance = 1.0f; + var sizeChanged = TopViewBeginContainerSize != CGSize.Empty + && (Math.Abs(currentSize.Width - TopViewBeginContainerSize.Width) > SizeChangeTolerance + || Math.Abs(currentSize.Height - TopViewBeginContainerSize.Height) > SizeChangeTolerance); + + if (!sizeChanged) + { + var rect = ContainerView.Frame; + rect.X = TopViewBeginOrigin.X; + rect.Y = TopViewBeginOrigin.Y; - UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => AnimateRootView(rect), () => { }); + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => AnimateRootView(rect), () => { }); + } } if (ScrolledView is not null && ScrolledView.ContentInset != UIEdgeInsets.Zero) @@ -954,6 +970,7 @@ static void RestorePosition() View = null; ContainerView = null; TopViewBeginOrigin = InvalidPoint; + TopViewBeginContainerSize = CGSize.Empty; CursorRect = null; ShouldIgnoreSafeAreaAdjustment = false; ShouldScrollAgain = false;