diff --git a/src/Controls/samples/Controls.Sample.UITests/Elements/DoubleTapGallery.cs b/src/Controls/samples/Controls.Sample.UITests/Elements/DoubleTapGallery.cs new file mode 100644 index 000000000000..fbac1007585c --- /dev/null +++ b/src/Controls/samples/Controls.Sample.UITests/Elements/DoubleTapGallery.cs @@ -0,0 +1,35 @@ +using Microsoft.Maui.Controls; + +namespace Maui.Controls.Sample +{ + public class DoubleTapGallery : Microsoft.Maui.Controls.ContentView + { + public DoubleTapGallery() + { + AutomationId = "DoubleTapGallery"; + + var layout = new VerticalStackLayout() { Margin = 10, Spacing = 10 }; + + var result = new Label() { AutomationId = "DoubleTapResults" }; + + var tapSurface = new Grid() + { + HeightRequest = 200, + WidthRequest = 200, + BackgroundColor = Microsoft.Maui.Graphics.Colors.AliceBlue, + AutomationId = "DoubleTapSurface" + }; + + var doubleTapRecognizer = new TapGestureRecognizer() { NumberOfTapsRequired = 2 }; + doubleTapRecognizer.Tapped += (sender, args) => { result.Text = "Success"; }; + + tapSurface.GestureRecognizers.Add(doubleTapRecognizer); + + layout.Add(tapSurface); + layout.Add(result); + + Content = layout; + } + } +} + diff --git a/src/Controls/samples/Controls.Sample.UITests/Elements/GestureRecognizerGallery.cs b/src/Controls/samples/Controls.Sample.UITests/Elements/GestureRecognizerGallery.cs index 0c3ae81d71fe..cca7d286762e 100644 --- a/src/Controls/samples/Controls.Sample.UITests/Elements/GestureRecognizerGallery.cs +++ b/src/Controls/samples/Controls.Sample.UITests/Elements/GestureRecognizerGallery.cs @@ -1,4 +1,6 @@ -using Microsoft.Maui.Controls.Internals; +using System.Drawing; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Internals; namespace Maui.Controls.Sample { @@ -8,6 +10,7 @@ public class GestureRecognizerGallery : ContentViewGalleryPage public GestureRecognizerGallery() { Add(new PointerGestureRecognizerEvents()); + Add(new DoubleTapGallery()); } } } diff --git a/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml b/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml new file mode 100644 index 000000000000..55693dc12e87 --- /dev/null +++ b/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml.cs b/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml.cs new file mode 100644 index 000000000000..5d0bfc114480 --- /dev/null +++ b/src/Controls/samples/Controls.Sample.UITests/Issues/Issues16561.xaml.cs @@ -0,0 +1,53 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; +using Microsoft.Maui.Platform; + +namespace Maui.Controls.Sample.Issues +{ + [XamlCompilation(XamlCompilationOptions.Compile)] + [Issue(IssueTracker.Github, 16561, "Quick single taps on Android have wrong second tap location", PlatformAffected.Android)] + public partial class Issue16561 : ContentPage + { + int _taps; + + public Issue16561() + { + InitializeComponent(); + + var tapGesture = new TapGestureRecognizer(); + tapGesture.Tapped += TapHandler; + + TapArea.GestureRecognizers.Add(tapGesture); + } + + void TapHandler(object sender, TappedEventArgs e) + { + var pos = e.GetPosition(TapArea); + + if (pos == null) + { + Tap1Label.Text = $"Error, could not get tap position"; + return; + } + +#if ANDROID + // Adjust the results for display density, so they make sense to the + // Appium test consuming this. + var x = this.Handler.MauiContext.Context.ToPixels(pos.Value.X); + var y = this.Handler.MauiContext.Context.ToPixels(pos.Value.Y); + pos = new(x, y); +#endif + + if (_taps % 2 == 0) + { + Tap1Label.Text = $"{pos.Value.X}, {pos.Value.Y}"; + } + else + { + Tap2Label.Text = $"{pos.Value.X}, {pos.Value.Y}"; + } + + _taps += 1; + } + } +} \ No newline at end of file diff --git a/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs b/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs index 7b18e1e31410..5d9d1ced94aa 100644 --- a/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs +++ b/src/Controls/src/Core/Platform/Android/InnerGestureListener.cs @@ -82,10 +82,19 @@ bool GestureDetector.IOnDoubleTapListener.OnDoubleTap(MotionEvent e) return _tapDelegate(2, e); } - if (HasSingleTapHandler()) + // If we're getting here and don't have a double-tap handler, we might be looking at multiple + // single taps; that'll be handled in OnDoubleTapEvent + + return false; + } + + bool GestureDetector.IOnDoubleTapListener.OnDoubleTapEvent(MotionEvent e) + { + if (!HasDoubleTapHandler() && HasSingleTapHandler() && e.Action == MotionEventActions.Up) { // If we're registering double taps and we don't actually have a double-tap handler, // but we _do_ have a single-tap handler, then we're really just seeing two singles in a row + // Fire off the delegate for the second single-tap (OnSingleTapUp already did the first one) return _tapDelegate(1, e); } @@ -93,11 +102,6 @@ bool GestureDetector.IOnDoubleTapListener.OnDoubleTap(MotionEvent e) return false; } - bool GestureDetector.IOnDoubleTapListener.OnDoubleTapEvent(MotionEvent e) - { - return false; - } - bool GestureDetector.IOnGestureListener.OnDown(MotionEvent e) { SetStartingPosition(e); diff --git a/src/Controls/tests/UITests/Tests/GestureRecognizerUITests.cs b/src/Controls/tests/UITests/Tests/GestureRecognizerUITests.cs index 424f0454ee40..969cc87c525a 100644 --- a/src/Controls/tests/UITests/Tests/GestureRecognizerUITests.cs +++ b/src/Controls/tests/UITests/Tests/GestureRecognizerUITests.cs @@ -44,6 +44,20 @@ public void PointerGestureTest() var secondaryLabelText = App.Query("secondaryLabel").First().Text; Assert.IsNotEmpty(secondaryLabelText); } + + [Test] + public void DoubleTap() + { + App.WaitForElement("TargetView"); + App.EnterText("TargetView", "DoubleTapGallery"); + App.Tap("GoButton"); + + App.WaitForElement("DoubleTapSurface"); + App.DoubleTap("DoubleTapSurface"); + + var result = App.Query("DoubleTapResults").First().Text; + Assert.AreEqual("Success", result); + } } } diff --git a/src/Controls/tests/UITests/Tests/Issues/Issue15357.cs b/src/Controls/tests/UITests/Tests/Issues/Issue15357.cs index 5814235617a3..58df3b3ba7a1 100644 --- a/src/Controls/tests/UITests/Tests/Issues/Issue15357.cs +++ b/src/Controls/tests/UITests/Tests/Issues/Issue15357.cs @@ -1,4 +1,5 @@ -using Microsoft.Maui.Appium; +using System.Reflection; +using Microsoft.Maui.Appium; using NUnit.Framework; namespace Microsoft.Maui.AppiumTests.Issues diff --git a/src/Controls/tests/UITests/Tests/Issues/Issue16561.cs b/src/Controls/tests/UITests/Tests/Issues/Issue16561.cs new file mode 100644 index 000000000000..84c74b01b87b --- /dev/null +++ b/src/Controls/tests/UITests/Tests/Issues/Issue16561.cs @@ -0,0 +1,78 @@ +using System.Drawing; +using Microsoft.Maui.Appium; +using NUnit.Framework; +using OpenQA.Selenium.Appium.MultiTouch; +using TestUtils.Appium.UITests; + +namespace Microsoft.Maui.AppiumTests.Issues +{ + public class Issue16561 : _IssuesUITest + { + private string _tapAreaId = "TapArea"; + + public Issue16561(TestDevice device) : base(device) + { + } + + public override string Issue => "Quick single taps on Android have wrong second tap location"; + + [Test] + public void TapTwoPlacesQuickly() + { + if (App is not IApp2 app2 || app2 is null || app2.Driver is null) + { + throw new InvalidOperationException("Cannot run test. Missing driver to run quick tap actions."); + } + + var tapAreaResult = App.WaitForElement(_tapAreaId); + var tapArea = tapAreaResult[0].Rect; + + // The test harness coordinates are absolute + var xOffset = 50; + var harnessCenterX = tapArea.CenterX; + var harnessCenterY = tapArea.CenterY; + + var point1 = new PointF(harnessCenterX - xOffset, harnessCenterY); + var point2 = new PointF(harnessCenterX + xOffset, harnessCenterY); + + // The TapGesture coordinates are relative to the container, so we need to adjust + // for the container position + var expectedY = harnessCenterY - tapArea.Y; + var expectedX1 = point1.X - tapArea.X; + var expectedX2 = point2.X - tapArea.X; + + // Just calling Tap twice will be too slow; we need to queue up the actions so they happen quickly + var actionsList = new TouchAction(app2.Driver); + + // Tap the first point, then the second point + actionsList.Tap(point1.X, point1.Y).Tap(point2.X, point2.Y); + app2.Driver.PerformTouchAction(actionsList); + + // The results for each tap should show up in the labels on the screen; find the text + // of each tap result and check to see that it meets the expected values + var result = App.WaitForElement("Tap1Label"); + AssertCorrectTapLocation(result[0].Text, expectedX1, expectedY, "First"); + + result = App.WaitForElement("Tap2Label"); + AssertCorrectTapLocation(result[0].Text, expectedX2, expectedY, "Second"); + } + + static void AssertCorrectTapLocation(string tapData, float expectedX, float expectedY, string which) + { + // Turn the text values into numbers so we can compare with a tolerance + (var tapX, var tapY) = ParseCoordinates(tapData); + + Assert.AreEqual((double)expectedX, tapX, 1, $"{which} tap has unexpected X value"); + Assert.AreEqual((double)expectedY, tapY, 1, $"{which} tap has unexpected Y value"); + } + + static (double, double) ParseCoordinates(string text) + { + var values = text.Split(',', StringSplitOptions.TrimEntries) + .Select(double.Parse) + .ToArray(); + + return (values[0], values[1]); + } + } +} diff --git a/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs b/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs index 80e67f53125b..be76485b92e9 100644 --- a/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs +++ b/src/TestUtils/src/TestUtils.Appium.UITests/AppiumUITestApp.cs @@ -34,6 +34,13 @@ public class AppiumUITestApp : IApp2 public static TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15); + // Using the default value from https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewConfiguration.java#129 + // and shaving off 50ms so we come in under the threshold + // iOS and Mac use a different way of simulating double taps, so no need for a variation of this constant for those platforms + // The Windows default is 500 ms (https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setdoubleclicktime?redirectedfrom=MSDN#parameters) + // so this delay should be short enough to simulate a double-click on that platform. + const int DOUBLE_TAP_DELAY_MS = 250; + readonly Dictionary _controlNameToTag = new Dictionary { { "button", "ControlType.Button" } @@ -115,6 +122,8 @@ public ApplicationState AppState } } + public AppiumDriver? Driver => _driver; + public void ResetApp() { _driver?.ResetApp(); @@ -243,11 +252,7 @@ private void DoubleTap(AppiumElement element) PointerInputDevice touchDevice = new PointerInputDevice(PointerType); ActionSequence sequence = new ActionSequence(touchDevice, 0); sequence.AddAction(touchDevice.CreatePointerMove(element, 0, 0, TimeSpan.FromMilliseconds(5))); - sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePause(TimeSpan.FromMilliseconds(600))); - sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + AddDoubleTap(touchDevice, sequence); _driver?.PerformActions(new List { sequence }); } } @@ -275,16 +280,22 @@ public void DoubleTapCoordinates(float x, float y) { PointerInputDevice touchDevice = new PointerInputDevice(PointerType); ActionSequence sequence = new ActionSequence(touchDevice, 0); - sequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, (int)x, (int)y, TimeSpan.FromMilliseconds(5))); - sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePause(TimeSpan.FromMilliseconds(600))); - sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); - sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + AddDoubleTap(touchDevice, sequence); _driver?.PerformActions(new List { sequence }); } } + static ActionSequence AddDoubleTap(PointerInputDevice touchDevice, ActionSequence sequence) + { + sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); + sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + sequence.AddAction(touchDevice.CreatePause(TimeSpan.FromMilliseconds(DOUBLE_TAP_DELAY_MS))); + sequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact)); + sequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact)); + + return sequence; + } + public void DragAndDrop(Func from, Func to) { DragAndDrop( diff --git a/src/TestUtils/src/TestUtils.Appium.UITests/IApp2.cs b/src/TestUtils/src/TestUtils.Appium.UITests/IApp2.cs index b140c06be278..02666e916b5d 100644 --- a/src/TestUtils/src/TestUtils.Appium.UITests/IApp2.cs +++ b/src/TestUtils/src/TestUtils.Appium.UITests/IApp2.cs @@ -1,4 +1,5 @@ -using Xamarin.UITest; +using OpenQA.Selenium.Appium; +using Xamarin.UITest; namespace TestUtils.Appium.UITests { @@ -11,5 +12,6 @@ public interface IApp2 : IApp, IDisposable ApplicationState AppState { get; } bool WaitForTextToBePresentInElement(string automationId, string text); public byte[] Screenshot(); + public AppiumDriver? Driver { get; } } }