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; }
}
}