Skip to content

Comments

Add LongPressGestureRecognizer for .NET MAUI 11#33432

Open
jfversluis wants to merge 27 commits intonet11.0from
feature/longpress-gesture-recognizer
Open

Add LongPressGestureRecognizer for .NET MAUI 11#33432
jfversluis wants to merge 27 commits intonet11.0from
feature/longpress-gesture-recognizer

Conversation

@jfversluis
Copy link
Member

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

Implements LongPressGestureRecognizer for .NET MAUI 11, a comprehensive cross-platform gesture recognizer that fires when the user presses and holds on an element for a configurable duration.

Addresses: #8675

Implementation Overview

Core API

  • LongPressGestureRecognizer: Main recognizer class with 5 bindable properties, 2 events, and Command support
  • LongPressedEventArgs: Event args for gesture completion
  • LongPressingEventArgs: Event args for real-time state updates

Platform Implementations

Platform Implementation Configurable Duration State Tracking Multi-Touch
iOS/MacCatalyst Native UILongPressGestureRecognizer ✅ Yes ✅ Full (Started/Running/Completed/Canceled) ✅ Yes
Android Native GestureDetector.OnLongPress() ⚠️ No (system default ~400ms) ⚠️ Partial (Completed/Canceled only) ❌ No (always 1 touch)
Windows Custom DispatcherTimer ✅ Yes ⚠️ Partial (Completed/Canceled only) ❌ No (always 1 touch)
Tizen Native LongPressGestureDetector ✅ Yes ✅ Full (Started/Finished/Cancelled) ✅ Yes

Key Features

  • Configurable duration: MinimumPressDuration property (default 500ms)
  • Movement threshold: AllowableMovement property cancels gesture if exceeded (default 10px)
  • Multi-touch support: NumberOfTouchesRequired (iOS/MacCatalyst/Tizen only)
  • Real-time state: State property with OneWayToSource binding
  • Gesture coexistence: Works alongside Tap, Swipe, Pan, and ScrollView
  • Command pattern: Full support for Command and CommandParameter

Gesture Coexistence Strategy

  • iOS/MacCatalyst: Uses ShouldRecognizeSimultaneously = true
  • Android: Uses e.Handled = false pattern
  • Windows: Routed event propagation (no e.Handled set)
  • Tizen: Uses e.Handled = false

Testing

Unit Tests

  • 17 unit tests covering all API surface
  • 9 performance tests validating no memory leaks and excellent performance
  • 108 total gesture tests passing (100% pass rate - includes 83 existing tests for regression)

Performance Benchmarks

All benchmarks exceed targets by 50-250x:

  • ✅ 10,000 recognizers created in ~14ms (target: <1000ms) - 71x faster
  • ✅ 10,000 property changes in ~10ms (target: <500ms) - 50x faster
  • ✅ 20,000 events fired in ~2ms (target: <500ms) - 250x faster
  • ✅ 10,000 commands executed in ~3ms (target: <500ms) - 166x faster
  • ✅ No memory leaks detected in any scenario

UI Tests

  • 4 comprehensive interaction tests created:
    • LongPress + Tap coexistence
    • LongPress + Swipe interaction
    • LongPress in ScrollView
    • Multiple LongPress recognizers

Note: UI tests compile successfully but cannot be executed locally due to pre-existing TestCases.HostApp build errors in net11.0 branch (unrelated to this PR). They will be validated in CI.

Sample Gallery

  • LongPressGestureGalleryPage added to Controls.Sample
  • Demonstrates 5 interactive scenarios with real-time state visualization

Platform-Specific Notes

Android Limitation

The MinimumPressDuration property is not configurable on Android due to platform limitations. Android uses the system-default long press timeout (typically ~400ms from ViewConfiguration.getLongPressTimeout()). This is documented in XML remarks on the property.

Windows Edge Case

If multiple LongPressGestureRecognizer instances with different durations are added to the same view, they will all use the last recognizer's duration. This is an extremely rare edge case and is documented in code comments.

API Changes

Added to all PublicAPI.Unshipped.txt files (7 platforms):

  • LongPressGestureRecognizer class (5 properties, 2 events, 1 command)
  • LongPressedEventArgs class
  • LongPressingEventArgs class

Migration Path

For users currently using CommunityToolkit.Maui.TouchBehavior:

Before (CommunityToolkit):

<Image>
    <Image.Behaviors>
        <toolkit:TouchBehavior 
            LongPressCommand="{Binding LongPressCommand}" 
            LongPressDuration="1000" />
    </Image.Behaviors>
</Image>

After (MAUI built-in):

<Image>
    <Image.GestureRecognizers>
        <LongPressGestureRecognizer 
            Command="{Binding LongPressCommand}"
            MinimumPressDuration="1000" />
    </Image.GestureRecognizers>
</Image>

Example Usage

Basic Long Press

<BoxView BackgroundColor="Blue">
    <BoxView.GestureRecognizers>
        <LongPressGestureRecognizer LongPressed="OnLongPressed" />
    </BoxView.GestureRecognizers>
</BoxView>

With Custom Duration and State Tracking

<Frame>
    <Frame.GestureRecognizers>
        <LongPressGestureRecognizer 
            MinimumPressDuration="1000"
            AllowableMovement="5"
            LongPressed="OnLongPressed"
            LongPressing="OnLongPressing"
            State="{Binding GestureState, Mode=OneWayToSource}" />
    </Frame.GestureRecognizers>
</Frame>

Code-Behind

void OnLongPressed(object sender, LongPressedEventArgs e)
{
    Console.WriteLine($"Long press at position: {e.Position}");
}

void OnLongPressing(object sender, LongPressingEventArgs e)
{
    Console.WriteLine($"Gesture state: {e.Status}");
}

Breaking Changes

None - this is a new API in .NET 11.

Checklist

  • All 5 platforms implemented
  • Native integrations where available
  • Comprehensive unit tests (108/108 passing)
  • Performance tests (no leaks, exceeds benchmarks)
  • UI interaction tests created
  • Sample gallery page added
  • XML documentation on all public APIs
  • Platform limitations documented
  • PublicAPI files updated
  • Gesture coexistence verified

Copilot AI review requested due to automatic review settings January 8, 2026 21:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a comprehensive LongPressGestureRecognizer for .NET MAUI 11, adding long-press gesture support across all five platforms (iOS, MacCatalyst, Android, Windows, Tizen). The implementation includes:

  • A new LongPressGestureRecognizer class with 5 bindable properties, 2 events, and Command support
  • Platform-specific implementations leveraging native APIs where available
  • 17 unit tests and 9 performance tests ensuring reliability and no memory leaks
  • 4 UI interaction tests validating gesture coexistence
  • Comprehensive XML documentation and sample gallery page

Key achievements:

  • Proper gesture coexistence with Tap, Swipe, Pan, and ScrollView gestures
  • Platform-specific optimizations (native UILongPressGestureRecognizer on iOS/MacCatalyst, native LongPressGestureDetector on Tizen)
  • Documented platform limitations (Android's fixed duration, Windows/Android single-touch only)

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
LongPressGestureRecognizer.cs Core gesture recognizer with 5 properties, 2 events, state management
LongPressedEventArgs.cs Event args for gesture completion with position and parameter
LongPressingEventArgs.cs Event args for real-time state updates
GesturePlatformManager.iOS.cs iOS/MacCatalyst native implementation with UILongPressGestureRecognizer
LongPressGestureHandler.cs (Android) Android implementation using GestureDetector.OnLongPress
InnerGestureListener.cs Android gesture listener integration
GesturePlatformManager.Android.cs Android gesture manager integration
LongPressGestureHandler.Windows.cs Windows custom timer-based implementation
GesturePlatformManager.Windows.cs Windows gesture manager integration
LongPressGestureHandler.cs (Tizen) Tizen native NUI LongPressGestureDetector implementation
GestureDetector.cs (Tizen) Tizen gesture detector registration
LongPressGestureRecognizerTests.cs 17 unit tests covering all API surface
LongPressGestureRecognizerPerformanceTests.cs 9 performance tests validating no memory leaks
LongPressGestureInteraction.* UI test page and tests for gesture coexistence (3 files)
PublicAPI.Unshipped.txt API surface additions for all 7 platforms
LongPressGestureGalleryPage.cs Sample gallery demonstrating 5 interactive scenarios (has bugs)
GesturesViewModel.cs Gallery navigation update
LongPressGestureRecognizer_Implementation_Plan.md Comprehensive implementation plan document

@jfversluis jfversluis added the area-gestures Gesture types label Jan 8, 2026
Copy link
Contributor

@pictos pictos left a comment

Choose a reason for hiding this comment

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

Really great to see this coming to .net 11, I've a couple of suggestions.

The "invert if" suggestions are to reduce the cyclomatic complexity, right now it should be fine, but we don't how much this code can increase in the future

@jfversluis
Copy link
Member Author

@copilot After applying the last suggestion we face this build error: src\Controls\src\Core\Platform\GestureManager\GesturePlatformManager.iOS.cs(384,8): error CS0841: (NETCORE_ENGINEERING_TELEMETRY=Build) Cannot use local variable 'longPressRecognizer' before it is declared

I prefer to make it work with the syntax proposed by @pictos can you see if you can fix it? If not, revert the changes in commit 40b5aa5

Copy link
Contributor

Copilot AI commented Jan 12, 2026

@jfversluis I've opened a new pull request, #33482, to work on those changes. Once the pull request is ready, I'll request review from you.

@pictos
Copy link
Contributor

pictos commented Jan 12, 2026

@jfversluis the issue is that copilot is using the same variable name after the type check,

if (longPressRecognizer is not LongPressGestureRecognizer longPressRecognizer)

@jfversluis
Copy link
Member Author

@pictos this copilot? :P

image

@pictos
Copy link
Contributor

pictos commented Jan 12, 2026

@jfversluis yeap, it should be smarter than me 😶‍🌫️ xD

@jfversluis jfversluis added this to the .NET 11.0-preview1 milestone Jan 13, 2026
@jfversluis
Copy link
Member Author

/azp run maui-pr-uitests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@jfversluis
Copy link
Member Author

@copilot Build error in the UI test sample app src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs(245,24): Error RS0016: Symbol '~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Please don't touch the Public API declarations other than the ones you need for this change

Copy link
Contributor

Copilot AI commented Jan 13, 2026

@jfversluis I've opened a new pull request, #33504, to work on those changes. Once the pull request is ready, I'll request review from you.

@jfversluis
Copy link
Member Author

jfversluis commented Jan 15, 2026

/azp run maui-pr-uitests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@PureWeen
Copy link
Member

/rebase

jfversluis and others added 20 commits February 13, 2026 08:43
- Created LongPressGestureHandler.cs using Tizen NUI's native LongPressGestureDetector
- Uses native gesture state tracking (Started/Finished/Cancelled)
- Configured with MinimumPressDuration and NumberOfTouchesRequired
- Integrated into GestureDetector.CreateHandler()
- Native support for full state lifecycle (Started → Completed/Canceled)
- Sets e.Handled = false for gesture coexistence
- Fires LongPressed and LongPressing events with proper state transitions
- All 99 gesture tests passing (17 LongPress + 82 existing)
BREAKING CHANGE: Replaced custom timer-based Android implementation with native
Android GestureDetector.OnLongPress() integration.

Changes:
- Removed custom timer-based LongPressGestureHandler.Android.cs from GestureManager/
- Created new native LongPressGestureHandler.cs in Platform/Android/
- Integrated into InnerGestureListener.OnLongPress() alongside DragAndDrop
- Uses Android's native GestureDetector with system-default long press timeout
- Updated InnerGestureListener.EnableLongPressGestures to include LongPress gestures
- Removed manual OnTouchEvent handling from GesturePlatformManager

Benefits:
- Native Android gesture detection (better system integration)
- No manual timer lifecycle management
- Consistent with other gesture implementations (Tap, Pan, Swipe)
- Proper integration with Android's gesture system

Platform Limitation (Documented):
- Android uses system-default long press timeout (~400ms from ViewConfiguration)
- MinimumPressDuration property is IGNORED on Android (cannot be configured per gesture)
- Added XML documentation remarks noting this limitation

Technical Details:
- TapAndPanGestureDetector.IsLongpressEnabled now returns true when LongPress gestures present
- LongPressGestureHandler follows same pattern as TapGestureHandler/PanGestureHandler
- OnLongPress fires LongPressed and LongPressing(Completed) events
- All 99 gesture tests passing (17 LongPress + 82 existing)
Created comprehensive UI tests to verify LongPress works correctly with other gestures.

Test Page (TestCases.HostApp/Issues/LongPressGestureInteraction.xaml):
- Test 1: LongPress + Tap on same element
- Test 2: LongPress + Swipe (swipe cancels long press)
- Test 3: LongPress in ScrollView (fires when still, cancels on scroll)
- Test 4: Multiple independent LongPress recognizers

NUnit Tests (TestCases.Shared.Tests/Tests/Issues/LongPressGestureInteraction.cs):
- LongPressWithTap_BothFireIndependently()
  * Quick tap fires Tap only
  * Long hold fires LongPress only
- LongPressWithSwipe_SwipeCancelsLongPress()
  * Swipe movement cancels LongPress (movement threshold)
- LongPressInScrollView_FiresWhenStill()
  * LongPress fires when holding still in ScrollView
- MultipleLongPress_AllWorkIndependently()
  * Each LongPress recognizer works on its own element independently

Test Design:
- Uses AutomationIds for Appium element location
- Counter labels for verification
- TouchAndHold() and SwipeLeftToRight() Appium methods
- Validates gesture isolation (Tap doesn't fire LongPress, etc.)
- Validates movement cancellation (swipe cancels long press)

Category: [Category(UITestCategories.Gestures)]

Notes:
- TestCases.HostApp has pre-existing build errors (unrelated to this PR)
- TestCases.Shared.Tests builds successfully
- Tests follow existing MAUI UI test patterns (inherit from _IssuesUITest)
- Ready for CI execution when HostApp build issues are resolved
…tureRecognizer

- Added LongPressGestureGalleryPage to Controls.Sample with 5 interactive demos:
  * Basic long press (500ms default)
  * Custom duration (1000ms)
  * Movement sensitive (5px threshold)
  * Combined with tap gesture
  * Real-time state and position display
- Added to GesturesViewModel gallery list
- Created comprehensive performance test suite (9 tests):
  * Memory leak detection (creation, add/remove, event subscription)
  * Performance benchmarks (creation, property changes, events, commands)
  * State change stress testing
- All 9 performance tests passing (total: 109/109 tests)
- Tests verify:
  * No memory leaks from recognizer creation or event subscriptions
  * Fast creation (10k recognizers < 1s)
  * Fast property changes (10k changes < 500ms)
  * Fast event firing (20k events < 500ms)
  * Fast command execution (10k commands < 500ms)
  * Functional after heavy state changes (4000 state transitions)
- Windows: Fix multiple LongPress recognizers on same view - now fires all recognizers instead of just the last one
- Windows: Add comment documenting edge case limitation (shared timer duration)
- Tizen: Remove incorrect comment - Tizen DOES support configurable MinimumPressDuration via SetMinimumHoldingTime
- All 108 gesture tests still passing
- Use type aliases to disambiguate Windows.Foundation.Point vs Microsoft.Maui.Graphics.Point
- WinPoint = Windows.Foundation.Point (for native pointer position)
- MauiPoint = Microsoft.Maui.Graphics.Point (for MAUI gesture APIs)
- Fixes build error CS0104 on Windows platform
Add missing 'using Microsoft.Maui;' to resolve CS0103 error.
TextAlignment enum is in Microsoft.Maui namespace.
- LongPressedEventArgs: Use Position property (not GetPosition method), no State property
- LongPressingEventArgs: Use Status property (not State), add Position display
- LongPressed event always means completed, so hardcode state display
TouchAndHold() takes only element ID parameter, not TimeSpan.
The method holds for 2 seconds by default (hardcoded in implementation),
which is more than sufficient for our 500ms default threshold.

Fixes CS1501 compilation errors on lines 31, 62, 77, 87.
Changes requested by @pictos:
- iOS: Invert if statements to reduce nesting (lines 383, 391)
- iOS: Cache ShouldRecognizeSimultaneously lambda to avoid allocation
- Windows: Use null-coalescing assignment (??=) for handler initialization
- Windows: Invert if statement in SubscribeEvents to reduce nesting
- Windows: Move GestureRecognizers null check earlier in OnPointerMoved
  to avoid unnecessary calculations when recognizers collection is null

These changes reduce cyclomatic complexity and improve performance.
Renamed 'uiRecognizer' to 'longPressUiRecognizer' to avoid CS0136 error.
When the if block was inverted, the variable was moved into the same
scope as other gesture recognizers that also use 'uiRecognizer', causing
a name collision.

Fixes: error CS0136: A local or parameter named 'uiRecognizer' cannot be
declared in this scope because that name is used in an enclosing local
scope to define a local or parameter
…anager.iOS.cs

Co-authored-by: Pedro Jesus <pedrojesus.cefet@gmail.com>
BREAKING CHANGE: This aligns LongPressedEventArgs and LongPressingEventArgs
with the established MAUI gesture pattern used by TapGestureRecognizer and
PointerGestureRecognizer.

The GetPosition(Element? relativeTo) pattern allows:
- Getting position relative to any element in the visual tree
- Getting position relative to the window (pass null)
- Proper support for positioning context menus/tooltips

Changes:
- LongPressedEventArgs: Position property → GetPosition(Element?) method
- LongPressingEventArgs: Position property → GetPosition(Element?) method
- Updated public constructors to not require position
- Internal constructors accept Func<IElement?, Point?> for lazy evaluation
- All platform handlers (iOS, Android, Windows, Tizen) updated with
  position calculation logic
- PublicAPI files updated with new signatures
- Sample gallery and unit tests updated

This is a preview-only change that must be made before GA to avoid
breaking user code later.
- Use App.LongPress() instead of App.TouchAndHold() on MacCatalyst
  (TouchAndHold uses touch pointer type which isn't supported on Mac)
- Fix swipe direction to match SwipeGestureRecognizer.Direction="Left"
  (SwipeRightToLeft swipes in the Left direction)
- Add TapElement() helper to UtilExtensions for coordinate-based tap
- Use TapElement instead of App.Tap for TapGestureRecognizer elements
- App.Tap uses element.Click() which triggers Android's performClick()
- MAUI uses GestureDetector for tap recognition, not OnClickListener
- TapElement uses TapCoordinates which sends actual touch events
Swipe and LongPress gestures on the same element conflict on these platforms:
- Windows: Manipulation system captures pointer events, preventing
  LongPressGestureHandler's PointerMoved-based cancellation from working
- macOS Catalyst: Discrete UISwipeGestureRecognizer conflicts with
  continuous UILongPressGestureRecognizer during Appium simulation
The XAML page used FindByName to update labels, but labels only had
AutomationId set (not x:Name). FindByName searches by x:Name, so it
always returned null and label text never updated from initial values.

Converting to pure C# with direct label references fixes the root cause.
This also follows UI test guidelines (prefer C# over XAML for non-XAML
tests) and may resolve the Windows page rendering issue.
Elements at the bottom of the page were below the viewport, causing
Appium to time out finding them on Android and failing to interact
with them on iOS. Removed the outer ScrollView, reduced element
heights and spacing so all 4 test areas fit on a single screen.
@jfversluis jfversluis force-pushed the feature/longpress-gesture-recognizer branch from fb9f089 to 8ca54da Compare February 13, 2026 07:44
@jfversluis
Copy link
Member Author

/azp run maui-pr-uitests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@jfversluis
Copy link
Member Author

/azp run maui-pr-uitests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

- Add HasAccessibleLongPressGesture() extension matching TapGesture pattern
- iOS: Set UIAccessibilityTrait.Button for LongPress elements (VoiceOver)
- Android: Enable keyboard focus and confirm key activation (TalkBack)
@jfversluis
Copy link
Member Author

/azp run maui-pr-uitests

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

Windows:
- Fix timer leak: cancel existing timers on new press
- Fix multi-recognizer: per-recognizer timer tracking via Dictionary
- Fix double-fire: per-recognizer HashSet instead of shared bool
- Fix collection safety: snapshot recognizers in OnPointerMoved

iOS/Mac Catalyst:
- Fix tap+longpress exclusivity: add RequireGestureRecognizerToFail
  so taps don't fire during long presses (matching Android behavior)
- Add PropertyChanged handler for runtime property updates
  (MinimumPressDuration, AllowableMovement, NumberOfTouchesRequired)

Android:
- Fix dispose: null _longPressGestureHandler in Dispose
- Fix accessibility: if/else if for Tap vs LongPress on Enter/Space
- Update outdated comment in TapAndPanGestureDetector

Core:
- Add input validation: MinimumPressDuration >= 0,
  NumberOfTouchesRequired > 0, AllowableMovement >= 0

Tests:
- Add 3 validation unit tests
- Replace deprecated Frame with Border in UI test host page
- Fix nullable annotations in test file

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-gestures Gesture types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants