Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 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
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,20 @@ public override bool OnTouchEvent(MotionEvent ev)
{
bool baseHandled = base.OnTouchEvent(ev);

bool pointerHandled = false;
bool shouldConsumeForPointer = false;
if (_pointerGestureHandler != null && ev?.Action is
MotionEventActions.Up or MotionEventActions.Down or MotionEventActions.Move or MotionEventActions.Cancel)
{
_pointerGestureHandler.OnTouch(ev);
pointerHandled = _pointerGestureHandler.HasAnyPointerGestures();

shouldConsumeForPointer =
_pointerGestureHandler.HasAnyPointerGestures() && baseHandled;
}

if (_listener != null && ev?.Action == MotionEventActions.Up)
_listener.EndScrolling();

return baseHandled || pointerHandled;
return baseHandled || shouldConsumeForPointer;
}

protected override void Dispose(bool disposing)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
Expand Down Expand Up @@ -79,13 +79,18 @@ public bool OnTouchEvent(MotionEvent e)
}

var eventConsumed = false;
if (ViewHasPinchGestures())

bool hasPinchGestures = ViewHasPinchGestures();

if (hasPinchGestures)
{
eventConsumed = _scaleDetector.Value.OnTouchEvent(e);
}

if (!ViewHasPinchGestures() || !_scaleDetector.Value.IsInProgress)
if (!hasPinchGestures || !_scaleDetector.Value.IsInProgress)
{
eventConsumed = _tapAndPanAndSwipeDetector.Value.OnTouchEvent(e) || eventConsumed;
}

return eventConsumed;
}
Expand Down Expand Up @@ -192,30 +197,21 @@ void SetupGestures()

bool shouldAddTouchEvent = false;

// This change is probably not 100 percent correct.
// The main purpose right now is to maintain the behavior of this code
// prior to this change
// https://github.com/dotnet/maui/commit/2c301d7988a06c3b41c2992bbee557aca04c9388#diff-2d78f02242798d0f2863f679e4dfdee230944be37db5e1a1446bfa4c6c43a5c6R183
// If the only CompositeGestureRecognizers is a PointerGestureRecognizer
//
// Most likely we should just not subscribe to Touch at all if the only gesture is a PGR
// But that will be re-evaluated for preview6
if (View.GestureRecognizers.Count == 0)
// Only subscribe to touch events if there are gestures that require touch handling
var recognizers = View?.GestureController?.CompositeGestureRecognizers;

if (recognizers != null)
{
var recognizers = View.GestureController.CompositeGestureRecognizers;
foreach (var recognizer in recognizers)
int count = recognizers.Count;
for (int i = 0; i < count; i++)
{
if (recognizer is not PointerGestureRecognizer)
if (recognizers[i] is not PointerGestureRecognizer)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Android gesture handler correctness — Skipping the touch/RecyclerView listener when the effective recognizers are pointer-only removes the only path that delivers Down/Move/Up/Cancel to PointerGestureHandler.OnTouch. SetupHandlerForPointer() only installs the hover listener, while PointerPressed/PointerReleased are raised from TapAndPanGestureDetector.OnTouchEvent; without platformView.Touch/GestureItemTouchListener, pointer-only views will not receive pressed/released touch events. This breaks the pointer semantics the PR is trying to preserve.

{
shouldAddTouchEvent = true;
break;
}
}
}
else
{
shouldAddTouchEvent = true;
}

// Always unsubscribe first to avoid duplicates
ClearRecyclerViewTouchListener(platformView);
Expand Down Expand Up @@ -424,6 +420,8 @@ void UpdateIsEnabled()
_isEnabled = Element.IsEnabled;
}



void ClearRecyclerViewTouchListener(AView platformView)
{
if (_recyclerViewTouchListener is not null && platformView is RecyclerView recyclerView)
Expand Down
153 changes: 153 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue34491.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 34491, "CollectionView item selection not triggered when PointerGestureRecognizer is added inside ItemTemplate", PlatformAffected.Android)]
public class Issue34491 : ContentPage
{
public Issue34491()
{
var statusLabel = new Label
{
Text = "No Selection",
AutomationId = "StatusLabel",
Padding = new Thickness(10),
FontSize = 18
};

var pointerStatusLabel = new Label
{
Text = "No Pointer Events",
AutomationId = "PointerStatusLabel",
Padding = new Thickness(10),
FontSize = 16
};

var collectionView = new CollectionView
{
AutomationId = "TestCollectionView",
SelectionMode = SelectionMode.Single,
ItemsSource = new List<string> { "Item 1", "Item 2", "Item 3" },
ItemTemplate = new DataTemplate(() =>
{
var label = new Label
{
Padding = new Thickness(10),
FontSize = 16
};
label.SetBinding(Label.TextProperty, ".");
label.SetBinding(Label.AutomationIdProperty, ".");

var grid = new Grid
{
BackgroundColor = Colors.LightGray,
Padding = new Thickness(10),
HeightRequest = 50,
Children = { label }
};

var pointerGesture = new PointerGestureRecognizer();

pointerGesture.PointerPressed += (s, e) =>
pointerStatusLabel.Text = $"Pointer Pressed: {grid.BindingContext}";

pointerGesture.PointerReleased += (s, e) =>
pointerStatusLabel.Text = $"Pointer Released: {grid.BindingContext}";

grid.GestureRecognizers.Add(pointerGesture);

return grid;
})
};

collectionView.SelectionChanged += (s, e) =>
{
if (e.CurrentSelection.Count > 0)
{
statusLabel.Text = $"Selected: {e.CurrentSelection[0]}";
}
};


var mixedSelectionStatusLabel = new Label
{
Text = "No Mixed Selection",
AutomationId = "MixedSelectionStatusLabel",
Padding = new Thickness(10),
FontSize = 18
};

var mixedTapStatusLabel = new Label
{
Text = "No Mixed Tap",
AutomationId = "MixedTapStatusLabel",
Padding = new Thickness(10),
FontSize = 16
};

var mixedCollectionView = new CollectionView
{
AutomationId = "MixedTestCollectionView",
SelectionMode = SelectionMode.Single,
ItemsSource = new List<string> { "Mixed Item 1", "Mixed Item 2", "Mixed Item 3" },
ItemTemplate = new DataTemplate(() =>
{
var label = new Label
{
Padding = new Thickness(10),
FontSize = 16
};
label.SetBinding(Label.TextProperty, ".");
label.SetBinding(Label.AutomationIdProperty, ".");

var grid = new Grid
{
BackgroundColor = Colors.LightBlue,
Padding = new Thickness(10),
HeightRequest = 50,
Children = { label }
};

var pointerGesture = new PointerGestureRecognizer();

pointerGesture.PointerPressed += (s, e) =>
pointerStatusLabel.Text = $"Pointer Pressed: {grid.BindingContext}";

pointerGesture.PointerReleased += (s, e) =>
pointerStatusLabel.Text = $"Pointer Released: {grid.BindingContext}";

grid.GestureRecognizers.Add(pointerGesture);

var tapGesture = new TapGestureRecognizer();
tapGesture.Tapped += (s, e) =>
mixedTapStatusLabel.Text = $"Tapped: {grid.BindingContext}";

grid.GestureRecognizers.Add(tapGesture);

return grid;
})
};

mixedCollectionView.SelectionChanged += (s, e) =>
{
if (e.CurrentSelection.Count > 0)
{
mixedSelectionStatusLabel.Text = $"Mixed Selected: {e.CurrentSelection[0]}";
}
};

Content = new VerticalStackLayout
{
Padding = new Thickness(10),
Spacing = 10,
Children =
{
statusLabel,
pointerStatusLabel,
collectionView,

mixedSelectionStatusLabel,
mixedTapStatusLabel,
mixedCollectionView
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#if ANDROID
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue => "CollectionView item selection not triggered when PointerGestureRecognizer is added inside ItemTemplate";

[Test]
[Category(UITestCategories.CollectionView)]
public void CollectionViewSelectionWorksWithPointerGestureRecognizer()
{
App.WaitForElement("TestCollectionView");
App.WaitForElement("StatusLabel");

var initialText = App.FindElement("StatusLabel").GetText() ?? string.Empty;
Assert.That(initialText, Is.EqualTo("No Selection"));

App.WaitForElement("Item 1");
App.Tap("Item 1");

App.WaitForTextToBePresentInElement("StatusLabel", "Selected: Item 1");

var finalText = App.FindElement("StatusLabel").GetText() ?? string.Empty;

Assert.That(finalText, Is.EqualTo("Selected: Item 1"),
"SelectionChanged should fire when tapping a CollectionView item that has a PointerGestureRecognizer");
}

[Test]
[Category(UITestCategories.CollectionView)]
public void PointerPressedAndReleasedStillFire()
{
App.WaitForElement("TestCollectionView");
App.WaitForElement("PointerStatusLabel");

var initialText = App.FindElement("PointerStatusLabel").GetText() ?? string.Empty;
Assert.That(initialText, Is.EqualTo("No Pointer Events"));

App.WaitForElement("Item 1");
App.Tap("Item 1");

App.WaitForTextToBePresentInElement("PointerStatusLabel", "Pointer Released: Item 1");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[moderate] Regression test coverage — This test name says PointerPressedAndReleasedStillFire, but it only verifies the final Pointer Released text. Since App.Tap sends press and release as one gesture and the sample overwrites the same label on release, a regression where PointerPressed is skipped but PointerReleased still fires would pass. Please record separate pressed/released state or an event history/counter so the test actually proves both callbacks ran.


var finalText = App.FindElement("PointerStatusLabel").GetText() ?? string.Empty;

Assert.That(finalText, Is.EqualTo("Pointer Released: Item 1"),
"PointerPressed/PointerReleased should still fire after fix");
}

[Test]
[Category(UITestCategories.CollectionView)]
public void MixedTapAndPointerGesturesStillAllowSelectionAndTap()
{
App.WaitForElement("MixedTestCollectionView");
App.WaitForElement("MixedSelectionStatusLabel");
App.WaitForElement("MixedTapStatusLabel");

App.WaitForElement("Mixed Item 1");
App.Tap("Mixed Item 1");

App.WaitForTextToBePresentInElement("MixedSelectionStatusLabel", "Mixed Selected: Mixed Item 1");
App.WaitForTextToBePresentInElement("MixedTapStatusLabel", "Tapped: Mixed Item 1");

var selectionText = App.FindElement("MixedSelectionStatusLabel").GetText() ?? string.Empty;
var tapText = App.FindElement("MixedTapStatusLabel").GetText() ?? string.Empty;

Assert.That(selectionText, Is.EqualTo("Mixed Selected: Mixed Item 1"),
"SelectionChanged should still fire when tap and pointer gestures coexist");

Assert.That(tapText, Is.EqualTo("Tapped: Mixed Item 1"),
"TapGestureRecognizer should still fire when pointer gestures are present");
}
}
#endif
Loading