diff --git a/src/Controls/samples/Controls.Sample.UITests/Issues/Issue14257.cs b/src/Controls/samples/Controls.Sample.UITests/Issues/Issue14257.cs new file mode 100644 index 000000000000..3dde0da98647 --- /dev/null +++ b/src/Controls/samples/Controls.Sample.UITests/Issues/Issue14257.cs @@ -0,0 +1,39 @@ +using Microsoft.Maui.Controls; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 14257, "VerticalStackLayout inside Scrollview: Button at the bottom not clickable on IOS", PlatformAffected.iOS)] + public class Issue14257 : TestContentPage + { + protected override void Init() + { + var scrollView = new ScrollView(); + var layout = new VerticalStackLayout() { Margin = new Microsoft.Maui.Thickness(10, 40) }; + + var description = new Label { Text = "Tap the Resize button; this will force the Test button off the screen. Then tap the Test button; if a Label with the text \"Success\" appears, the test has passed." }; + + var resizeButton = new Button() { Text = "Resize", AutomationId = "Resize" }; + var layoutContent = new Label() { Text = "Content", HeightRequest = 50 }; + var testButton = new Button() { Text = "Test", AutomationId = "Test" }; + var resultLabel = new Label() { AutomationId = "Result" }; + + layout.Add(description); + layout.Add(resizeButton); + layout.Add(layoutContent); + layout.Add(resultLabel); + layout.Add(testButton); + + scrollView.Content = layout; + Content = scrollView; + + resizeButton.Clicked += (sender, args) => { + // Resize the ScrollView content so the test button will be off the screen + // If the bug is present, this will make the button untappable + layoutContent.HeightRequest = 1000; + }; + + // Show the Success label if the button is tapped, so we can verify the bug is not present + testButton.Clicked += (sender, args) => { resultLabel.Text = "Success"; }; + } + } +} diff --git a/src/Controls/tests/UITests/Tests/Issues/Issue14257.cs b/src/Controls/tests/UITests/Tests/Issues/Issue14257.cs new file mode 100644 index 000000000000..247cf7b73e0a --- /dev/null +++ b/src/Controls/tests/UITests/Tests/Issues/Issue14257.cs @@ -0,0 +1,26 @@ +using Microsoft.Maui.Appium; +using NUnit.Framework; + +namespace Microsoft.Maui.AppiumTests.Issues +{ + public class Issue14257 : _IssuesUITest + { + public Issue14257(TestDevice device) : base(device) { } + + public override string Issue => "VerticalStackLayout inside Scrollview: Button at the bottom not clickable on IOS"; + + [Test] + public void ResizeScrollViewAndTapButtonTest() + { + // Tapping the Resize button will change the height of the ScrollView content + App.Tap("Resize"); + + // Scroll down to the Test button. When the bug is present, the button cannot be tapped. + App.ScrollDownTo("Test"); + App.Tap("Test"); + + // If we can successfully tap the button, the Success label will be displayed + Assert.IsTrue(App.WaitForTextToBePresentInElement("Result", "Success")); + } + } +} diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 411e4894c63d..a089b87d1f2f 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -188,36 +188,6 @@ static void InsertContentView(UIScrollView platformScrollView, IScrollView scrol platformScrollView.AddSubview(contentContainer); } - static Size MeasureScrollViewContent(double widthConstraint, double heightConstraint, Func internalMeasure, UIScrollView platformScrollView, IScrollView scrollView) - { - var presentedContent = scrollView.PresentedContent; - if (presentedContent == null) - { - return Size.Zero; - } - - var scrollViewBounds = platformScrollView.Bounds; - var padding = scrollView.Padding; - - if (widthConstraint == 0) - { - widthConstraint = scrollViewBounds.Width; - } - - if (heightConstraint == 0) - { - heightConstraint = scrollViewBounds.Height; - } - - // Account for the ScrollView Padding before measuring the content - widthConstraint = AccountForPadding(widthConstraint, padding.HorizontalThickness); - heightConstraint = AccountForPadding(heightConstraint, padding.VerticalThickness); - - var result = internalMeasure.Invoke(widthConstraint, heightConstraint); - - return result.AdjustForFill(new Rect(0, 0, widthConstraint, heightConstraint), presentedContent); - } - public override Size GetDesiredSize(double widthConstraint, double heightConstraint) { var virtualView = VirtualView; @@ -338,6 +308,16 @@ Size ICrossPlatformLayout.CrossPlatformArrange(Rect bounds) { var scrollView = VirtualView; var platformScrollView = PlatformView; + + var contentSize = scrollView.CrossPlatformArrange(bounds); + + // The UIScrollView's bounds are available, so we can use them to make sure the ContentSize makes sense + // for the ScrollView orientation + var viewportBounds = platformScrollView.Bounds; + var viewportHeight = viewportBounds.Height; + var viewportWidth = viewportBounds.Width; + SetContentSizeForOrientation(platformScrollView, viewportWidth, viewportHeight, scrollView.Orientation, contentSize); + var container = GetContentView(platformScrollView); if (container?.Superview is UIScrollView uiScrollView) @@ -347,23 +327,15 @@ Size ICrossPlatformLayout.CrossPlatformArrange(Rect bounds) // container. (Everything will look correct if they do, but hit testing won't work properly.) var scrollViewBounds = uiScrollView.Bounds; - var containerBounds = container.Bounds; + var containerBounds = contentSize; container.Bounds = new CGRect(0, 0, Math.Max(containerBounds.Width, scrollViewBounds.Width), Math.Max(containerBounds.Height, scrollViewBounds.Height)); + container.Center = new CGPoint(container.Bounds.GetMidX(), container.Bounds.GetMidY()); } - var contentSize = scrollView.CrossPlatformArrange(bounds); - - // The UIScrollView's bounds are available, so we can use them to make sure the ContentSize makes sense - // for the ScrollView orientation - var viewportBounds = platformScrollView.Bounds; - var viewportHeight = viewportBounds.Height; - var viewportWidth = viewportBounds.Width; - SetContentSizeForOrientation(platformScrollView, viewportWidth, viewportHeight, scrollView.Orientation, contentSize); - return contentSize; } diff --git a/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs b/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs index 7d3e3d49c630..49c2f503dfc4 100644 --- a/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs +++ b/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs @@ -6,12 +6,16 @@ using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Enums; using OpenQA.Selenium.Appium.Interactions; +using OpenQA.Selenium.Appium.Interfaces; using OpenQA.Selenium.Appium.iOS; +using OpenQA.Selenium.Appium.MultiTouch; +using OpenQA.Selenium.DevTools.V104.Page; using OpenQA.Selenium.Interactions; using OpenQA.Selenium.Support.UI; using Xamarin.UITest; using Xamarin.UITest.Queries; using Xamarin.UITest.Queries.Tokens; +using Xamarin.UITest.Shared.Execution; namespace TestUtils.Appium.UITests { @@ -141,13 +145,13 @@ public void Back() } else { - QueryWindows("NavigationViewBackButton", true).First().Click(); + QueryAppium("NavigationViewBackButton", true).First().Click(); } } public void ClearText(Func query) { - var result = QueryWindows(query, true).First(); + var result = QueryAppium(query, true).First(); result.Clear(); } @@ -158,7 +162,7 @@ public void ClearText(Func query) public void ClearText(string marked) { - var result = QueryWindows(marked, true).First(); + var result = QueryAppium(marked, true).First(); result.Clear(); } @@ -194,13 +198,13 @@ public void DismissKeyboard() public void DoubleTap(Func query) { - var result = QueryWindows(query, true).First(); + var result = QueryAppium(query, true).First(); DoubleTap(result); } public void DoubleTap(string marked) { - var result = QueryWindows(marked, true).First(); + var result = QueryAppium(marked, true).First(); DoubleTap(result); } @@ -235,15 +239,15 @@ public void DoubleTapCoordinates(float x, float y) public void DragAndDrop(Func from, Func to) { DragAndDrop( - QueryWindows(from, true).First(), - QueryWindows(to, true).First()); + QueryAppium(from, true).First(), + QueryAppium(to, true).First()); } public void DragAndDrop(string from, string to) { DragAndDrop( - QueryWindows(from, true).First(), - QueryWindows(to, true).First()); + QueryAppium(from, true).First(), + QueryAppium(to, true).First()); } public void DragAndDrop(AppiumElement source, AppiumElement destination) @@ -335,13 +339,13 @@ public void EnterText(string text) public void EnterText(Func query, string text) { - var result = QueryWindows(query, true).First(); + var result = QueryAppium(query, true).First(); EnterText(result, text); } public void EnterText(string marked, string text) { - var result = QueryWindows(marked, true).First(); + var result = QueryAppium(marked, true).First(); EnterText(result, text); } @@ -439,13 +443,13 @@ public void PressVolumeUp() public AppResult[] Query(Func? query = null) { - ReadOnlyCollection elements = QueryWindows(query); + ReadOnlyCollection elements = QueryAppium(query); return elements.Select(ToAppResult).ToArray(); } public AppResult[] Query(string marked) { - ReadOnlyCollection elements = QueryWindows(marked); + ReadOnlyCollection elements = QueryAppium(marked); return elements.Select(ToAppResult).ToArray(); } @@ -528,7 +532,7 @@ public void ScrollDown(string withinMarked, ScrollStrategy strategy = ScrollStra public void ScrollDownTo(string toMarked, string? withinMarked = null, ScrollStrategy strategy = ScrollStrategy.Auto, double swipePercentage = 0.67, int swipeSpeed = 500, bool withInertia = true, TimeSpan? timeout = null) { - throw new NotImplementedException(); + ScrollTo(FromMarked(toMarked), withinMarked == null ? null : FromMarked(withinMarked), timeout); } public void ScrollDownTo(Func toQuery, string withinMarked, ScrollStrategy strategy = ScrollStrategy.Auto, double swipePercentage = 0.67, int swipeSpeed = 500, bool withInertia = true, TimeSpan? timeout = null) @@ -691,13 +695,13 @@ public void WaitFor(Func predicate, string timeoutMessage = "Timed out wai public AppResult[] WaitForElement(Func query, string timeoutMessage = "Timed out waiting for element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { - Func> result = () => QueryWindows(query); + Func> result = () => QueryAppium(query); return WaitForAtLeastOne(result, timeoutMessage, timeout, retryFrequency).Select(AppiumExtensions.ToAppResult).ToArray(); } public AppResult[] WaitForElement(string marked, string timeoutMessage = "Timed out waiting for element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { - Func> result = () => QueryWindows(marked); + Func> result = () => QueryAppium(marked); var results = WaitForAtLeastOne(result, timeoutMessage, timeout, retryFrequency).Select(AppiumExtensions.ToAppResult).ToArray(); return results; @@ -710,13 +714,13 @@ public void WaitFor(Func predicate, string timeoutMessage = "Timed out wai public void WaitForNoElement(Func query, string timeoutMessage = "Timed out waiting for no element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { - Func> result = () => QueryWindows(query); + Func> result = () => QueryAppium(query); WaitForNone(result, timeoutMessage, timeout, retryFrequency); } public void WaitForNoElement(string marked, string timeoutMessage = "Timed out waiting for no element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { - Func> result = () => QueryWindows(marked); + Func> result = () => QueryAppium(marked); WaitForNone(result, timeoutMessage, timeout, retryFrequency); } @@ -743,13 +747,13 @@ ReadOnlyCollection QueryAppium(AppiumQuery query, bool findFirst } } - ReadOnlyCollection QueryWindows(string marked, bool findFirst = false) + ReadOnlyCollection QueryAppium(string marked, bool findFirst = false) { AppiumQuery appiumQuery = AppiumQuery.FromMarked(marked, _appId, Platform); return QueryAppium(appiumQuery, findFirst); } - ReadOnlyCollection QueryWindows(Func? query, bool findFirst = false) + ReadOnlyCollection QueryAppium(Func? query, bool findFirst = false) { AppiumQuery winQuery = AppiumQuery.FromQuery(query, _appId, Platform); return QueryAppium(winQuery, findFirst); @@ -896,11 +900,10 @@ AppiumElement GetWindow() return _window; } - _window = QueryWindows(_appId)[0]; + _window = QueryAppium(_appId)[0]; return _window; } - static PointF GetClickablePoint(AppiumElement element) { string cpString = element.GetAttribute("ClickablePoint"); @@ -911,7 +914,6 @@ static PointF GetClickablePoint(AppiumElement element) return new PointF(x, y); } - internal enum ClickType { SingleClick, @@ -919,5 +921,73 @@ internal enum ClickType ContextClick } + AppiumQuery FromMarked(string marked) + { + return AppiumQuery.FromMarked(marked, _appId, Platform); + } + + void ScrollTo(AppiumQuery toQuery, AppiumQuery? withinQuery = null, TimeSpan? timeout = null, bool down = true) + { + // This method will keep scrolling in the specified direction until it finds an element + // which matches the query, or until it times out. + + // First we need to determine the area within which we'll make our scroll gestures + Size? scrollAreaSize = null; + + if (withinQuery != null) + { + var within = FindFirstElement(withinQuery); + scrollAreaSize = within?.Size; + } + + if(scrollAreaSize is null) + { + var window = _driver?.Manage().Window ?? throw new InvalidOperationException("Element to scroll within not specified, and no Window available. Cannot scroll."); + scrollAreaSize = window.Size; + } + + var x = scrollAreaSize.Value.Width / 2; + var windowHeight = scrollAreaSize.Value.Height; + var topEdgeOfScrollAction = windowHeight * 0.1; + var bottomEdgeOfScrollAction = windowHeight * 0.5; + var startY = down ? bottomEdgeOfScrollAction : topEdgeOfScrollAction; + var endY = down ? topEdgeOfScrollAction : bottomEdgeOfScrollAction; + + timeout ??= DefaultTimeout; + DateTime start = DateTime.Now; + + TimeSpan iterationTimeout = TimeSpan.FromMilliseconds(0); + TimeSpan retryFrequency = TimeSpan.FromMilliseconds(0); + Func> result = () => QueryAppium(toQuery); + + while (true) + { + try + { + ReadOnlyCollection found = WaitForAtLeastOne(result, timeoutMessage: null, + timeout: iterationTimeout, retryFrequency: retryFrequency); + + if (found.Count > 0) + { + // Success! + return; + } + } + catch (TimeoutException) + { + // Haven't found it yet, keep scrolling + } + + long elapsed = DateTime.Now.Subtract(start).Ticks; + if (elapsed >= timeout.Value.Ticks) + { + Debug.WriteLine($">>>>> {elapsed} ticks elapsed, timeout value is {timeout.Value.Ticks}"); + throw new TimeoutException($"Timed out scrolling to {toQuery}"); + } + + var scrollAction = new TouchAction(_driver).Press(x, startY).MoveTo(x, endY).Release(); + scrollAction.Perform(); + } + } } }