Skip to content

Commit ae56939

Browse files
Implement PointerGestureRecognizer Buttons (#31214)
* Add initial support for Buttons PointerGestureRecognizer desktop * Add `PointerGestureRecognizer` Buttons macOS and Android implementation * Adjust PublicAPI surface area for PointerGestureRecognizer * Adjustments and add UI test * Properly filter pointer gesture button recognizer mask * Update Callback from Copilot review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Enhance `PointerGestureRecognizer` button handling for default button mask and refactor pointer event methods * Fire PointerReleased immediately instead of delaying * Add remarks for secondary button in PointerReleased event for mac and ios --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent de63a9d commit ae56939

File tree

19 files changed

+949
-41
lines changed

19 files changed

+949
-41
lines changed

src/Controls/src/Core/Platform/Android/PointerGestureHandler.cs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
using System;
33
using Android.Views;
44
using Microsoft.Maui.Graphics;
5+
using System.Runtime.Versioning;
56
using AView = Android.Views.View;
67

78
namespace Microsoft.Maui.Controls.Platform
89
{
910
internal class PointerGestureHandler : Java.Lang.Object, AView.IOnHoverListener
1011
{
12+
// Tracks the last button pressed so we can use it for subsequent Move/Up/Cancel
13+
ButtonsMask? _activeButton;
14+
1115
internal PointerGestureHandler(Func<View> getView, Func<AView> getControl)
1216
{
1317
GetView = getView;
@@ -50,6 +54,133 @@ public bool OnHover(AView control, MotionEvent e)
5054
return false;
5155
}
5256

57+
// This method is called by InnerGestureListener to handle touch events for pointer gestures
58+
public bool OnTouch(MotionEvent e)
59+
{
60+
var view = GetView();
61+
62+
if (view == null)
63+
return false;
64+
65+
var control = GetControl();
66+
if (control == null)
67+
return false;
68+
69+
var platformPointerArgs = new PlatformPointerEventArgs(control, e);
70+
71+
foreach (var gesture in view.GetCompositeGestureRecognizers())
72+
{
73+
if (gesture is PointerGestureRecognizer pgr)
74+
{
75+
// Determine the button for this action. For Move/Up/Cancel prefer the active button, if any.
76+
ButtonsMask current = GetPressedButton(e);
77+
ButtonsMask effectiveButton = current;
78+
79+
switch (e.Action)
80+
{
81+
case MotionEventActions.Down:
82+
// Primary button goes through Down/Up
83+
_activeButton = current;
84+
effectiveButton = current;
85+
if (!CheckButtonMask(pgr, effectiveButton))
86+
continue;
87+
pgr.SendPointerPressed(view, (relativeTo) => e.CalculatePosition(GetView(), relativeTo), platformPointerArgs, effectiveButton);
88+
break;
89+
case MotionEventActions.Move:
90+
// Keep reporting the button that initiated the press if one is active
91+
effectiveButton = _activeButton ?? current;
92+
if (!CheckButtonMask(pgr, effectiveButton))
93+
continue;
94+
pgr.SendPointerMoved(view, (relativeTo) => e.CalculatePosition(GetView(), relativeTo), platformPointerArgs, effectiveButton);
95+
break;
96+
case MotionEventActions.Up:
97+
// ACTION_UP does not carry ActionButton. Use the active one if available.
98+
effectiveButton = _activeButton ?? current;
99+
if (!CheckButtonMask(pgr, effectiveButton))
100+
continue;
101+
pgr.SendPointerReleased(view, (relativeTo) => e.CalculatePosition(GetView(), relativeTo), platformPointerArgs, effectiveButton);
102+
// Clear active button after release
103+
_activeButton = null;
104+
break;
105+
case MotionEventActions.Cancel:
106+
// Treat cancel similar to release for active button, then exit
107+
effectiveButton = _activeButton ?? current;
108+
if (!CheckButtonMask(pgr, effectiveButton))
109+
continue;
110+
pgr.SendPointerExited(view, (relativeTo) => e.CalculatePosition(GetView(), relativeTo), platformPointerArgs, effectiveButton);
111+
_activeButton = null;
112+
break;
113+
}
114+
}
115+
}
116+
117+
return false;
118+
}
119+
120+
ButtonsMask GetPressedButton(MotionEvent motionEvent)
121+
{
122+
if (motionEvent == null)
123+
return ButtonsMask.Primary;
124+
125+
var action = motionEvent.Action;
126+
127+
// For explicit button change events (mouse/pen), use ActionButton to determine which button changed
128+
if (OperatingSystem.IsAndroidVersionAtLeast(23) &&
129+
(action == MotionEventActions.ButtonPress || action == MotionEventActions.ButtonRelease))
130+
{
131+
#pragma warning disable CA1416 // Validate platform compatibility
132+
var actionButton = motionEvent.ActionButton; // Which button changed for this event
133+
if ((actionButton & MotionEventButtonState.Secondary) == MotionEventButtonState.Secondary)
134+
return ButtonsMask.Secondary;
135+
if ((actionButton & MotionEventButtonState.Primary) == MotionEventButtonState.Primary)
136+
return ButtonsMask.Primary;
137+
#pragma warning restore CA1416 // Validate platform compatibility
138+
}
139+
140+
// Otherwise, infer from current ButtonState (covers Move/Down/Up and API < 23)
141+
var buttonState = motionEvent.ButtonState;
142+
143+
// Check for secondary button (right mouse button)
144+
if ((buttonState & MotionEventButtonState.Secondary) == MotionEventButtonState.Secondary)
145+
{
146+
return ButtonsMask.Secondary;
147+
}
148+
149+
// Check for stylus secondary button on API 23+
150+
if (OperatingSystem.IsAndroidVersionAtLeast(23))
151+
{
152+
#pragma warning disable CA1416 // Validate platform compatibility
153+
if (CheckStylusSecondaryButton(buttonState))
154+
#pragma warning restore CA1416 // Validate platform compatibility
155+
{
156+
return ButtonsMask.Secondary;
157+
}
158+
}
159+
160+
// Default to primary button
161+
return ButtonsMask.Primary;
162+
}
163+
164+
[SupportedOSPlatform("android23.0")]
165+
bool CheckStylusSecondaryButton(MotionEventButtonState buttonState)
166+
{
167+
return (buttonState & MotionEventButtonState.StylusSecondary) == MotionEventButtonState.StylusSecondary;
168+
}
169+
170+
bool CheckButtonMask(PointerGestureRecognizer recognizer, ButtonsMask currentButton)
171+
{
172+
// If no buttons specified (enum backing value is 0), default to Primary only
173+
if ((int)recognizer.Buttons == 0)
174+
return currentButton == ButtonsMask.Primary;
175+
176+
if (currentButton == ButtonsMask.Secondary)
177+
{
178+
return (recognizer.Buttons & ButtonsMask.Secondary) == ButtonsMask.Secondary;
179+
}
180+
181+
return (recognizer.Buttons & ButtonsMask.Primary) == ButtonsMask.Primary;
182+
}
183+
53184
public void SetupHandlerForPointer()
54185
{
55186
var view = GetView();

src/Controls/src/Core/Platform/Android/TapAndPanGestureDetector.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ namespace Microsoft.Maui.Controls.Platform
1010
class TapAndPanGestureDetector : GestureDetector
1111
{
1212
InnerGestureListener? _listener;
13+
PointerGestureHandler? _pointerGestureHandler;
14+
1315
public TapAndPanGestureDetector(Context context, InnerGestureListener listener) : base(context, listener)
1416
{
1517
_listener = listener;
1618
UpdateLongPressSettings();
1719
}
1820

21+
public void SetPointerGestureHandler(PointerGestureHandler pointerGestureHandler)
22+
{
23+
_pointerGestureHandler = pointerGestureHandler;
24+
}
25+
1926
public void UpdateLongPressSettings()
2027
{
2128
if (_listener == null)
@@ -36,6 +43,12 @@ public override bool OnTouchEvent(MotionEvent ev)
3643
if (base.OnTouchEvent(ev))
3744
return true;
3845

46+
if (_pointerGestureHandler != null && ev?.Action is
47+
MotionEventActions.Up or MotionEventActions.Down or MotionEventActions.Cancel)
48+
{
49+
_pointerGestureHandler.OnTouch(ev);
50+
}
51+
3952
if (_listener != null && ev?.Action == MotionEventActions.Up)
4053
_listener.EndScrolling();
4154

@@ -48,8 +61,12 @@ protected override void Dispose(bool disposing)
4861

4962
if (disposing)
5063
{
51-
_listener?.Dispose();
52-
_listener = null;
64+
if (_listener != null)
65+
{
66+
_listener.Dispose();
67+
_listener = null;
68+
}
69+
_pointerGestureHandler = null;
5370
}
5471
}
5572
}

src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Android.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ TapAndPanGestureDetector InitializeTapAndPanAndSwipeDetector()
129129
throw new InvalidOperationException("Context cannot be null here");
130130

131131
var context = Control.Context;
132+
var pointerHandler = InitializePointerHandler();
132133
var listener = new InnerGestureListener(
133134
new TapGestureHandler(() => View, () =>
134135
{
@@ -140,10 +141,12 @@ TapAndPanGestureDetector InitializeTapAndPanAndSwipeDetector()
140141
new PanGestureHandler(() => View),
141142
new SwipeGestureHandler(() => View),
142143
InitializeDragAndDropHandler(),
143-
InitializePointerHandler()
144+
pointerHandler
144145
);
145146

146-
return new TapAndPanGestureDetector(context, listener);
147+
var detector = new TapAndPanGestureDetector(context, listener);
148+
detector.SetPointerGestureHandler(pointerHandler);
149+
return detector;
147150
}
148151

149152
ScaleGestureDetector InitializeScaleDetector()

src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.Windows.cs

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.UI.Xaml.Controls;
1111
using Microsoft.UI.Xaml.Input;
1212
using Microsoft.UI.Xaml.Media.Imaging;
13+
using Microsoft.UI.Input;
1314
using Windows.Storage.Streams;
1415

1516
namespace Microsoft.Maui.Controls.Platform
@@ -666,26 +667,34 @@ void OnPgrPointerMoved(object sender, PointerRoutedEventArgs e)
666667
=> GetPosition(relativeTo, e), _control is null ? null : new PlatformPointerEventArgs(_control, e)));
667668

668669
void OnPgrPointerPressed(object sender, PointerRoutedEventArgs e)
669-
{
670-
HandlePgrPointerEvent(e, (view, recognizer)
671-
=> recognizer.SendPointerPressed(view, (relativeTo)
672-
=> GetPosition(relativeTo, e), _control is null ? null : new PlatformPointerEventArgs(_control, e)));
673-
674-
if ((_subscriptionFlags & SubscriptionFlags.ContainerManipulationAndPointerEventsSubscribed) != 0)
675-
{
676-
OnPointerPressed(sender, e);
677-
}
678-
}
670+
=> HandlePgrPointerButtonAction(sender, e, true);
679671

680672
void OnPgrPointerReleased(object sender, PointerRoutedEventArgs e)
673+
=> HandlePgrPointerButtonAction(sender, e, false);
674+
675+
void HandlePgrPointerButtonAction(object sender, PointerRoutedEventArgs e, bool isPressed)
681676
{
682-
HandlePgrPointerEvent(e, (view, recognizer)
683-
=> recognizer.SendPointerReleased(view, (relativeTo)
684-
=> GetPosition(relativeTo, e), _control is null ? null : new PlatformPointerEventArgs(_control, e)));
677+
if (Element is View view)
678+
{
679+
var pointerGestures = ElementGestureRecognizers.GetGesturesFor<PointerGestureRecognizer>();
680+
var button = GetPressedButton(sender, e);
681+
foreach (var recognizer in pointerGestures)
682+
{
683+
if (!CheckButtonMask(recognizer, button))
684+
continue;
685+
if (isPressed)
686+
recognizer.SendPointerPressed(view, (relativeTo) => GetPosition(relativeTo, e), _control is null ? null : new PlatformPointerEventArgs(_control, e), button);
687+
else
688+
recognizer.SendPointerReleased(view, (relativeTo) => GetPosition(relativeTo, e), _control is null ? null : new PlatformPointerEventArgs(_control, e), button);
689+
}
690+
}
685691

686692
if ((_subscriptionFlags & SubscriptionFlags.ContainerManipulationAndPointerEventsSubscribed) != 0)
687693
{
688-
OnPointerReleased(sender, e);
694+
if (isPressed)
695+
OnPointerPressed(sender, e);
696+
else
697+
OnPointerReleased(sender, e);
689698
}
690699
}
691700

@@ -745,6 +754,53 @@ bool IsPointerEventRelevantToCurrentElement(PointerRoutedEventArgs e)
745754
}
746755
}
747756

757+
ButtonsMask GetPressedButton(object? sender, PointerRoutedEventArgs e)
758+
{
759+
// Touch/Pen don't have right button semantics; treat as Primary
760+
if (e.Pointer?.PointerDeviceType != PointerDeviceType.Mouse)
761+
return ButtonsMask.Primary;
762+
763+
var reference = sender as UIElement ?? _container ?? _control ?? _container?.XamlRoot?.Content as UIElement;
764+
if (reference is null)
765+
return ButtonsMask.Primary;
766+
767+
var point = e.GetCurrentPoint(reference);
768+
var props = point?.Properties;
769+
if (props is null)
770+
return ButtonsMask.Primary;
771+
772+
switch (props.PointerUpdateKind)
773+
{
774+
case PointerUpdateKind.RightButtonPressed:
775+
case PointerUpdateKind.RightButtonReleased:
776+
return ButtonsMask.Secondary;
777+
case PointerUpdateKind.LeftButtonPressed:
778+
case PointerUpdateKind.LeftButtonReleased:
779+
return ButtonsMask.Primary;
780+
// Middle/other map to Primary by convention
781+
case PointerUpdateKind.MiddleButtonPressed:
782+
case PointerUpdateKind.MiddleButtonReleased:
783+
case PointerUpdateKind.Other:
784+
default:
785+
break;
786+
}
787+
788+
if (props.IsRightButtonPressed)
789+
return ButtonsMask.Secondary;
790+
791+
return ButtonsMask.Primary;
792+
}
793+
794+
bool CheckButtonMask(PointerGestureRecognizer recognizer, ButtonsMask currentButton)
795+
{
796+
if (currentButton == ButtonsMask.Secondary)
797+
{
798+
return (recognizer.Buttons & ButtonsMask.Secondary) == ButtonsMask.Secondary;
799+
}
800+
801+
return (recognizer.Buttons & ButtonsMask.Primary) == ButtonsMask.Primary;
802+
}
803+
748804
Point? GetPosition(IElement? relativeTo, RoutedEventArgs e)
749805
{
750806
var result = e.GetPositionRelativeToElement(relativeTo);

0 commit comments

Comments
 (0)