Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/Controls/src/Core/Platform/Android/InnerGestureListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal class InnerGestureListener : Object, GestureDetector.IOnGestureListener
float _lastX;
float _lastY;
bool _disposed;
bool _singleTapFiredInSequence;

Func<float, float, bool> _swipeDelegate;
Func<bool> _swipeCompletedDelegate;
Expand Down Expand Up @@ -79,6 +80,11 @@ bool GestureDetector.IOnDoubleTapListener.OnDoubleTap(MotionEvent e)

if (HasDoubleTapHandler())
{
// Reset the flag since this is completing a double tap sequence
_singleTapFiredInSequence = false;

// Fire only the double tap handler here Single tap was already
// fired earlier in OnSingleTapUp for better timing
return _tapDelegate(2, e);
}

Expand Down Expand Up @@ -151,8 +157,15 @@ bool GestureDetector.IOnGestureListener.OnSingleTapUp(MotionEvent e)

if (HasDoubleTapHandler())
{
// Because we have a handler for double-tap, we need to wait for
// OnSingleTapConfirmed (to verify it's really just a single tap) before running the delegate
// Fire single tap immediately for the first tap, even when we
// have double tap handlers (mimics Windows timing)
if (HasSingleTapHandler())
{
_tapDelegate(1, e);
_singleTapFiredInSequence = true; // Track that we fired single tap
}

// Still return false to continue waiting for potential double tap
return false;
}

Expand All @@ -178,8 +191,15 @@ bool GestureDetector.IOnDoubleTapListener.OnSingleTapConfirmed(MotionEvent e)
return false;
}

// Check if we already fired the single tap in OnSingleTapUp during a potential double tap sequence
if (_singleTapFiredInSequence)
{
_singleTapFiredInSequence = false; // Reset the flag
return false; // Don't fire again
}

// Since there was a double-tap handler, we had to wait for OnSingleTapConfirmed;
// Now that we're sure it's a single tap, we can run the delegate
// This is called only for confirmed single taps (not part of double tap sequences)
return _tapDelegate(1, e);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -797,12 +797,30 @@ bool ProcessGestureRecognizers(IEnumerable<TapGestureRecognizer>? tapGestures)
return handled;
}

bool onlyDoubleTaps = false;

if (e is DoubleTappedRoutedEventArgs)
{
// Is there a recognizer that has exactly two taps?
foreach (var recognizer in tapGestures)
{
if (recognizer.NumberOfTapsRequired == 2)
{
onlyDoubleTaps = true;
break;
}
}
}

foreach (var recognizer in tapGestures)
{
recognizer.SendTapped(view, (relativeTo) => GetPosition(relativeTo, e));
if (!onlyDoubleTaps || recognizer.NumberOfTapsRequired == 2)
{
recognizer.SendTapped(view, (relativeTo) => GetPosition(relativeTo, e));

e.SetHandled(true);
handled = true;
e.SetHandled(true);
handled = true;
}
}

return handled;
Expand All @@ -827,7 +845,9 @@ bool ValidateGesture(TapGestureRecognizer g)
return false;

if (e is DoubleTappedRoutedEventArgs)
{
return g.NumberOfTapsRequired == 1 || g.NumberOfTapsRequired == 2;
}

return g.NumberOfTapsRequired == 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ _handler.VirtualView is View v &&
{
_proxy ??= new ShouldReceiveTouchProxy(this);
nativeRecognizer.ShouldReceiveTouch = _proxy.ShouldReceiveTouch;

PlatformView.AddGestureRecognizer(nativeRecognizer);

}
Expand Down
48 changes: 48 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue20870.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.Issues.Issue20870"
xmlns:local="clr-namespace:Maui.Controls.Sample.Issues"
Title="Issue20870">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>

<FlexLayout Grid.Row="0" Direction="Row" Wrap="Wrap" JustifyContent="SpaceAround" AlignItems="Center" Margin="10" MinimumHeightRequest="80">
<!-- Using Labels instead of Buttons for gesture tests to avoid platform-specific container wrapping (iOS/macOS) affecting double-tap routing. -->
<Label x:Name="ButtonSingleTap" AutomationId="ButtonSingleTap" Text="SingleTap" FontSize="20" TextColor="Red" VerticalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="OnTap"/>
</Label.GestureRecognizers>
</Label>
<Label x:Name="ButtonDoubleTap" AutomationId="ButtonDoubleTap" Text="DoubleTap" FontSize="20" TextColor="Green" VerticalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="OnDoubleTap" NumberOfTapsRequired="2"/>
</Label.GestureRecognizers>
</Label>
<Label x:Name="ButtonSingleAndDoubleTap" AutomationId="ButtonSingleAndDoubleTap" Text="SingleAndDoubleTap" FontSize="20" TextColor="Blue" VerticalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="OnTap"/>
<TapGestureRecognizer Tapped="OnDoubleTap" NumberOfTapsRequired="2"/>
</Label.GestureRecognizers>
</Label>
<Label x:Name="ButtonDoubleAndSingleTap" AutomationId="ButtonDoubleAndSingleTap" Text="DoubleAndSingleTap" FontSize="20" TextColor="Purple" VerticalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="OnDoubleTap" NumberOfTapsRequired="2"/>
<TapGestureRecognizer Tapped="OnTap"/>
</Label.GestureRecognizers>
</Label>
<Label x:Name="Clear" AutomationId="Clear" Text="Clear" FontSize="20" TextColor="Black" VerticalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="Clear_Tapped" />
</Label.GestureRecognizers>
</Label>
</FlexLayout>

<ScrollView Grid.Row="1">
<Editor x:Name="Results" AutomationId="Results" Margin="20"/>
</ScrollView>
</Grid>
</ContentPage>
40 changes: 40 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue20870.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#nullable enable
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml;

namespace Maui.Controls.Sample.Issues;

[XamlCompilation(XamlCompilationOptions.Compile)]
[Issue(IssueTracker.Github, 20870, "Single-tap and double-tap tests", PlatformAffected.All)]

public partial class Issue20870 : ContentPage
{
public Issue20870()
{
InitializeComponent();
BindingContext = this;
}

// NOTE: Using Labels for gesture recognizer tests (instead of Buttons) to avoid
// platform-specific wrapper/container views on iOS/macOS that can interfere with
// hit testing and double-tap routing. This keeps gesture routing consistent across
// platforms for the purpose of these cross-platform UITests.
private void Clear_Tapped(object? sender, TappedEventArgs e)
{
Results.Text = string.Empty;
}

private void OnTap(object? sender, TappedEventArgs e)
{
string text = Results.Text;
string delimiter = string.IsNullOrEmpty(text) ? "" : "|";
Results.Text = $"{text}{delimiter}OnTap called";
}

private void OnDoubleTap(object? sender, TappedEventArgs e)
{
string text = Results.Text;
string delimiter = string.IsNullOrEmpty(text) ? "" : "|";
Results.Text = $"{text}{delimiter}OnDoubleTap called";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using NUnit.Framework;
using NUnit.Framework.Legacy;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue20870 : _IssuesUITest
{
public Issue20870(TestDevice device) : base(device) { }

public override string Issue => "Single-tap and double-tap tests";

[Test]
[Category(UITestCategories.Gestures)]
public async Task TapsAndDoubleTaps()
{
// Initialize.
App.WaitForElement("Results");

// Verify a button with a single-tap gesture recognizer.
App.Tap("ButtonSingleTap");
await Task.Delay(200); // Do not artificially simulate a double-tap.
App.Tap("ButtonSingleTap");

await Task.Delay(1000);

string? text = App.FindElement("Results").GetText()?.Trim();
ClassicAssert.AreEqual("OnTap called|OnTap called", text);

App.Tap("Clear");

// Verify a button with a double-tap gesture recognizer.
App.Tap("ButtonDoubleTap");
await Task.Delay(200); // Do not artificially simulate a triple-tap.
App.DoubleTap("ButtonDoubleTap");

text = App.FindElement("Results").GetText()?.Trim();
ClassicAssert.AreEqual("OnDoubleTap called", text);

App.Tap("Clear");

// Verify a button with single-tap and double-tap gesture recognizers. Note the order of recognizer registrations.
App.Tap("ButtonSingleAndDoubleTap");
await Task.Delay(200);
App.DoubleTap("ButtonSingleAndDoubleTap");

text = App.FindElement("Results").GetText()?.Trim();
ClassicAssert.AreEqual("OnTap called|OnTap called|OnDoubleTap called", text);

App.Tap("Clear");

// Verify a button with double-tap and single-tap gesture recognizers. Note the order of recognizer registrations.
App.Tap("ButtonDoubleAndSingleTap");
await Task.Delay(200);
App.DoubleTap("ButtonDoubleAndSingleTap");

text = App.FindElement("Results").GetText()?.Trim();
ClassicAssert.AreEqual("OnTap called|OnTap called|OnDoubleTap called", text);
}
}
Loading