Skip to content
1 change: 1 addition & 0 deletions eng/pipelines/ci-device-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
- darc-*
tags:
include:
- '*'
Expand Down
1 change: 1 addition & 0 deletions eng/pipelines/ci-uitests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ trigger:
- release/*
- net*.0
- inflight/*
- darc-*
tags:
include:
- '*'
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
151 changes: 151 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33407.cs
Original file line number Diff line number Diff line change
@@ -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"
}
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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();

Comment on lines +35 to +36
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread.Sleep(2000) introduces an arbitrary delay and will also fail to compile unless System.Threading is imported (implicit global usings don’t include Thread). Prefer using the screenshot retry mechanisms (e.g., VerifyScreenshot with a retry timeout) and/or an awaited delay with an async test if you truly need to wait for rotation/keyboard animations to settle.

Copilot uses AI. Check for mistakes.
// 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));
}
Comment on lines +37 to +43
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test dismisses the keyboard right before taking the screenshot, but the regression is about incorrect restoration during keyboardWillHide after an orientation change. To ensure the test is actually exercising the buggy path, consider explicitly waiting for the keyboard-dismiss/restore animation to complete (instead of relying on timing) before asserting the screenshot, otherwise the screenshot could be taken mid-transition and become flaky.

Copilot uses AI. Check for mistakes.
}
#endif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 21 additions & 4 deletions src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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)
Expand All @@ -954,6 +970,7 @@ static void RestorePosition()
View = null;
ContainerView = null;
TopViewBeginOrigin = InvalidPoint;
TopViewBeginContainerSize = CGSize.Empty;
CursorRect = null;
ShouldIgnoreSafeAreaAdjustment = false;
ShouldScrollAgain = false;
Expand Down
Loading