[net11.0] Make LongPressGestureRecognizer dispatch API public for platform backends - #37861
[net11.0] Make LongPressGestureRecognizer dispatch API public for platform backends#37861Redth wants to merge 1 commit into
Conversation
…ends External platform backends (for example Maui.Tizen) can raise tap and pointer gestures because TapGestureRecognizer.SendTapped and PointerGestureRecognizer.Send* are public infrastructure APIs. LongPressGestureRecognizer.SendLongPressing and SendLongPressed were still internal, so an out-of-tree backend could detect a long press but had no supported way to dispatch it. Promote both methods to public with [EditorBrowsable(EditorBrowsableState.Never)] and an ArgumentNullException guard on sender, matching the existing tap/pointer dispatch APIs exactly. Command execution, event raising, GestureStatus transitions, State updates, position callback behavior, and all in-box platform call sites are unchanged. Adds src/Controls/tests/ExternalGestureBackend, a test-support assembly that is deliberately excluded from Microsoft.Maui.Controls' InternalsVisibleTo list, so its fake backend can only compile against the public surface. Demoting either method back to internal breaks that project's build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37861Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37861" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR exposes LongPressGestureRecognizer’s dispatch hooks as public infrastructure APIs so out-of-tree platform backends can raise long press gestures without InternalsVisibleTo, matching the existing tap/pointer dispatch pattern in Microsoft.Maui.Controls.
Changes:
- Made
LongPressGestureRecognizer.SendLongPressed/SendLongPressingpublicwith[EditorBrowsable(EditorBrowsableState.Never)]and a nullsenderguard. - Added an “external backend” test-support project plus new unit tests to enforce the public dispatch surface and validate lifecycle/command semantics.
- Updated Controls.Core PublicAPI baselines and added a design doc describing the dispatch contract for backend authors.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/Controls/src/Core/LongPressGestureRecognizer.cs | Promotes long-press dispatch methods to public infrastructure APIs with EditorBrowsable(Never) and ArgumentNullException guard. |
| src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (netstandard). |
| src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (net). |
| src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (Android). |
| src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (iOS). |
| src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (MacCatalyst). |
| src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (Tizen). |
| src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt | Adds PublicAPI entries for the two newly-public dispatch methods (Windows). |
| src/Controls/tests/ExternalGestureBackend/Controls.Tests.ExternalGestureBackend.csproj | Introduces a separate assembly (not in Controls’ IVT set) to compile against the public dispatch surface. |
| src/Controls/tests/ExternalGestureBackend/FakeLongPressGestureBackend.cs | Provides a fake “third-party backend” implementation that drives the recognizer via public dispatch APIs. |
| src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj | References the new external-backend test-support project so unit tests can exercise it. |
| src/Controls/tests/Core.UnitTests/Gestures/LongPressGestureRecognizerTests.cs | Adds null-sender contract tests and minor comment cleanup. |
| src/Controls/tests/Core.UnitTests/Gestures/LongPressGestureRecognizerExternalBackendTests.cs | Adds end-to-end tests validating external backend dispatch, ordering, cancellation, touch-count filtering, and EditorBrowsable(Never). |
| docs/design/GestureDispatchForPlatformBackends.md | Documents the cross-recognizer dispatch table and long-press call ordering/contract for backend authors. |
Reviewer notes: design rationale + safety auditTwo questions a reviewer is likely to ask, answered up front. 1. Why public
|
| Pattern | Recognizers | Shape |
|---|---|---|
| Legacy (Xamarin.Forms-era) | PanGestureRecognizer, SwipeGestureRecognizer, PinchGestureRecognizer |
Explicit interface impl — void IPanGestureController.SendPan(...) |
| Current | TapGestureRecognizer, PointerGestureRecognizer, DragGestureRecognizer, DropGestureRecognizer |
Public Send* + [EditorBrowsable(Never)] |
The legacy interfaces are explicitly implemented, so every caller must cast:
((IPanGestureController)panGesture).SendPan(view, x, y, id);An ILongPressGestureController would inherit that ergonomic wart and give backend authors a second dispatch shape to learn, immediately adjacent to the SendTapped / SendPointer* APIs made public in #37420/#37671 that this PR is explicitly matching. LongPressGestureRecognizer is also sealed and entirely unshipped in net11.0, so there's no compatibility pressure toward an interface.
2. Does the new ArgumentNullException guard change in-box behavior?
No — it is unreachable from every in-box call site, all of which null-check the view before dispatching:
| Call site | Guard |
|---|---|
Platform/Android/LongPressGestureHandler.cs |
if (view == null) return; |
GestureManager/GesturePlatformManager.Android.cs (a11y key path) |
if (View is null || sender is not AView ...) return; |
GestureManager/GesturePlatformManager.iOS.cs |
if (lpRecognizer == null || view == null) return; |
GestureManager/LongPressGestureHandler.Windows.cs |
if (view == null) return;, non-null view captured in the timer closure |
Platform/Tizen/LongPressGestureHandler.cs |
if (View == null) return; |
Precedent: SendTapped already carries this identical guard and is called from the same Android accessibility path (GesturePlatformManager.Android.cs), so the pattern is already proven in-box. Parameter lists are unchanged, so this is accessibility-only — no call site needed touching.
3. No reflection / no internals
FakeLongPressGestureBackend in the new Controls.Tests.ExternalGestureBackend assembly contains zero reflection and zero internal access — it compiles purely against the public surface, and that assembly is deliberately absent from Controls.Core's InternalsVisibleTo list. (The one System.Reflection use is a test assertion in LongPressGestureRecognizerExternalBackendTests verifying the methods are public + EditorBrowsable(Never) — it is not part of the dispatch path.)
Caveat worth flagging
IncludeTizenTargetFrameworks is hard-disabled in CI (Directory.Build.props: "Disabled until net10.0-tizen is available"), so the in-box Platform/Tizen/LongPressGestureHandler.cs — which uses exactly this dispatch surface — and the net-tizen/PublicAPI.Unshipped.txt entries are not compile-verified by CI. That's pre-existing and not introduced here; the net-tizen PublicAPI entries were added for consistency with the other six TFM folders.
Separately: the in-box Tizen handler maps Started / Finished / Cancelled but not Tizen's Continuing → GestureStatus.Running. Filing that as an observation rather than changing uncompilable code in an API-exposure PR.
Merge conflicts were the solution's test folder and the workload-free lane's project list. Core reworded its RefPackCompile commentary and split the sample into its own lane, so core's text was taken verbatim and this branch's two Controls entries re-added around it. Long-press mapping ------------------ dotnet/maui#37861 makes SendLongPressed and SendLongPressing public. It is code/CI complete but NOT merged, so it is absent from the pinned package and is not adopted here. What is landed is the translation that adoption will need, specified and tested now so it is a small change later rather than a fresh translation written at the time. The in-box Tizen handler is deliberately NOT the model. Checked against dotnet/maui net11.0: LongPressGestureHandler.cs has no Continuing branch at all, so a Tizen long press never reports GestureStatus.Running and an app tracking the gesture sees Started jump straight to Completed. iOS maps its equivalent (UIGestureRecognizerState.Changed) to Running - verified in GesturePlatformManager.iOS.cs - and that is what this backend follows: Started -> Started LongPressing Continuing -> Running LongPressing Finished -> Completed LongPressed FIRST, then LongPressing Cancelled -> Canceled LongPressing only; never LongPressed, never Command A canceled press reports a status change but is not a press, so raising LongPressed or running the command there would fire the app's handler for a gesture the user aborted. CompletesLongPress encodes that and is tested per state. The handler previously dropped Continuing, inheriting the in-box gap. It now passes it through, and ToLongPressStatus/CompletesLongPress encode the table above. Reintroducing the gap fails four tests across both the mapping and the handler's ordering, so it cannot come back silently during adoption. 208 tests, up from 196. The API15 compile lane passes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
External platform backends can already raise tap and pointer gestures, because #37420 / #37671 made
TapGestureRecognizer.SendTappedand thePointerGestureRecognizer.Send*methods public infrastructure APIs.LongPressGestureRecognizer.SendLongPressing/SendLongPressedwere leftinternal, so an out-of-tree backend can detect a long press but has no supported way to dispatch it.Concrete blocker: Redth/Maui.Tizen#9 has to no-op its long press dispatch.
This PR promotes both methods to
publicwith[EditorBrowsable(EditorBrowsableState.Never)]and anArgumentNullExceptionguard onsender— the smallest additive surface, patterned exactly on the existing tap/pointer dispatch APIs:A
public ILongPressGestureControllerwas considered and rejected: no other recognizer added in this wave uses a controller interface, so an interface would be inconsistent withSendTapped/SendPointer*and would add a second dispatch shape for backend authors to learn.Nothing else changes. Command execution (including
CanExecute), event raising,GestureStatusStarted/Running/Completed/Canceled transitions,Stateupdates, the lazygetPositioncallback contract, touch-count filtering, cancellation semantics, threading expectations, and every in-box platform call site (iOS/MacCatalyst, Android, Windows, Tizen) are untouched. Widening accessibility has no trimming impact, andLongPressGestureRecognizeris entirely unshipped API innet11.0, so this is purely additive.Reproducing the failure
Added
src/Controls/tests/ExternalGestureBackend, a test-support assembly that is deliberately excluded fromMicrosoft.Maui.Controls'InternalsVisibleTolist. Before the fix it fails to build:It now compiles, and its
FakeLongPressGestureBackenddrives the recognizer purely through the public API. Demoting either method back tointernalbreaks that project's build — the contract is enforced by the build, not just by convention.Tests
New
LongPressGestureRecognizerExternalBackendTests(Controls.Core.UnitTests) drives the fake external backend:Started→Running→Completedlifecycle, andLongPressedordering beforeCompletedCanceledraises noLongPressedand executes no commandAllowableMovementCommandParameter, andCanExecute == falsesuppressiongetPositionrelative-to-element behaviorNumberOfTouchesRequiredfiltering, and fan-out to every matching recognizer on the viewpublicand[EditorBrowsable(Never)]Also added
SendLongPressed_ThrowsForNullSender/SendLongPressing_ThrowsForNullSenderto the existingLongPressGestureRecognizerTests, matchingTapGestureRecognizerTests.SendTappedThrowsForNullSender.Results:
Controls.Core.UnitTests— 6219 passed, 0 failed, 30 skipped (includes the pre-existingLongPressGestureRecognizerTestsandLongPressGestureRecognizerPerformanceTests).dotnet build src/Controls/src/Core/Controls.Core.csproj -p:PublicApiType=Validate -f net11.0succeeds with 0 warnings; removing an entry fromPublicAPI.Unshipped.txtcorrectly fails withRS0016, confirming the analyzer is enforcing the new signatures.No device tests exist for
LongPressGestureRecognizer; platform behavior stays covered by the existingLongPressGestureInteractionUI test, which is unaffected.Docs
Added
docs/design/GestureDispatchForPlatformBackends.mdfor backend authors: the full dispatch table across all recognizers, the shared rules (non-null sender, UI thread, lazygetPositionsemantics, honoring recognizer configuration), the exact long press call ordering used by each in-box platform, and a complete example backend.PublicAPI
Added the two entries to all seven
src/Controls/src/Core/PublicAPI/*TFM folders (net,net-android,net-ios,net-maccatalyst,net-tizen,net-windows,netstandard).Issues Fixed
Unblocks external platform backends (e.g. Redth/Maui.Tizen#9) from dispatching long press gestures.