forked from dotnet/maui
-
Notifications
You must be signed in to change notification settings - Fork 0
Fix Entry focus issue in Android HorizontalScrollView during swipe gestures #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Copilot
wants to merge
2
commits into
main
Choose a base branch
from
copilot/fix-7
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| # HorizontalScrollView Focus Fix | ||
|
|
||
| This fix addresses the issue where Entry controls inside a native Android HorizontalScrollView gain focus during swipe gestures instead of direct taps. | ||
|
|
||
| ## Problem | ||
|
|
||
| When using a custom handler with a native Android HorizontalScrollView containing Entry controls: | ||
| - Swiping anywhere in the scroll view incorrectly causes Entry to gain focus | ||
| - Direct tapping the Entry may not cause it to gain focus as expected | ||
|
|
||
| ## Solution | ||
|
|
||
| Use the `PreventFocusDuringScroll()` extension method provided in `Microsoft.Maui.Platform.HorizontalScrollViewExtensions`. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```csharp | ||
| // In your custom handler for Android | ||
| protected override HorizontalScrollView CreatePlatformView() | ||
| { | ||
| var context = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity!; | ||
| var scrollView = new HorizontalScrollView(context); | ||
|
|
||
| if (VirtualView.Content is not null) | ||
| { | ||
| var viewHandler = VirtualView.Content.ToHandler(MauiContext!); | ||
| var platformView = viewHandler.PlatformView as Android.Views.View; | ||
| scrollView.AddView(platformView); | ||
| } | ||
|
|
||
| // Apply the focus fix | ||
| scrollView.PreventFocusDuringScroll(); | ||
|
|
||
| return scrollView; | ||
| } | ||
| ``` | ||
|
|
||
| ## How it works | ||
|
|
||
| The extension method: | ||
| 1. Monitors touch events on the HorizontalScrollView | ||
| 2. Detects when a touch gesture is a horizontal scroll vs a direct tap | ||
| 3. Clears focus from child views during scroll gestures | ||
| 4. Allows normal focus behavior for direct taps | ||
|
|
||
| ## Requirements | ||
|
|
||
| - .NET MAUI (this fix is included in the Core package) | ||
| - Android platform | ||
| - Custom handlers using native Android HorizontalScrollView | ||
|
|
||
| ## Example Complete Handler | ||
|
|
||
| ```csharp | ||
| #if ANDROID | ||
| using Android.Widget; | ||
| using Microsoft.Maui.Handlers; | ||
| using Microsoft.Maui.Platform; | ||
| #endif | ||
|
|
||
| namespace YourApp | ||
| { | ||
| public class InteractiveScrollViewExt : View | ||
| { | ||
| public InteractiveScrollViewExt(View child) | ||
| { | ||
| Content = child; | ||
| } | ||
|
|
||
| public View Content { get; } | ||
| } | ||
|
|
||
| #if ANDROID | ||
| public class InteractiveScrollViewExtHandler() : ViewHandler(ViewMapper) | ||
| { | ||
| protected override HorizontalScrollView CreatePlatformView() | ||
| { | ||
| var context = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity!; | ||
| var scrollView = new HorizontalScrollView(context); | ||
|
|
||
| if (VirtualView.Content is not null) | ||
| { | ||
| var viewHandler = VirtualView.Content.ToHandler(MauiContext!); | ||
| var platformView = viewHandler.PlatformView as Android.Views.View; | ||
| scrollView.AddView(platformView); | ||
| } | ||
|
|
||
| // Apply the focus fix to prevent Entry focus during swipe | ||
| scrollView.PreventFocusDuringScroll(); | ||
|
|
||
| return scrollView; | ||
| } | ||
| } | ||
| #endif | ||
| } | ||
| ``` |
110 changes: 110 additions & 0 deletions
110
src/Core/src/Platform/Android/MauiFocusAwareHorizontalScrollView.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| using System; | ||
| using Android.Content; | ||
| using Android.Util; | ||
| using Android.Views; | ||
| using Android.Widget; | ||
|
|
||
| namespace Microsoft.Maui.Platform | ||
| { | ||
| /// <summary> | ||
| /// Extensions for HorizontalScrollView to handle focus behavior during scroll gestures | ||
| /// </summary> | ||
| public static class HorizontalScrollViewExtensions | ||
| { | ||
| // Use unique hash codes as tag keys to avoid conflicts | ||
| private static readonly int TouchStartXTag = "MauiFocusFix_TouchStartX".GetHashCode(StringComparison.Ordinal); | ||
| private static readonly int TouchStartYTag = "MauiFocusFix_TouchStartY".GetHashCode(StringComparison.Ordinal); | ||
| private static readonly int IsScrollingTag = "MauiFocusFix_IsScrolling".GetHashCode(StringComparison.Ordinal); | ||
|
|
||
| /// <summary> | ||
| /// Prevents child views from gaining focus during scroll gestures in a HorizontalScrollView. | ||
| /// This addresses the issue where Entry controls gain focus when swiping in a HorizontalScrollView. | ||
| /// Call this method once after creating and configuring your HorizontalScrollView. | ||
| /// </summary> | ||
| /// <param name="horizontalScrollView">The HorizontalScrollView to apply the fix to</param> | ||
| public static void PreventFocusDuringScroll(this HorizontalScrollView horizontalScrollView) | ||
| { | ||
| if (horizontalScrollView == null) | ||
| return; | ||
|
|
||
| // Remove any existing listeners to avoid duplicate registration | ||
| horizontalScrollView.Touch -= OnHorizontalScrollViewTouch; | ||
|
|
||
| // Add the touch listener | ||
| horizontalScrollView.Touch += OnHorizontalScrollViewTouch; | ||
| } | ||
|
|
||
| private static void OnHorizontalScrollViewTouch(object? sender, View.TouchEventArgs e) | ||
| { | ||
| if (sender is not HorizontalScrollView scrollView || e.Event == null) | ||
| { | ||
| e.Handled = false; | ||
| return; | ||
| } | ||
|
|
||
| var ev = e.Event; | ||
|
|
||
| // Handle scroll gestures to prevent unwanted focus changes | ||
| switch (ev.Action) | ||
| { | ||
| case MotionEventActions.Down: | ||
| // Store initial touch position for gesture detection | ||
| scrollView.SetTag(TouchStartXTag, ev.GetX()); | ||
| scrollView.SetTag(TouchStartYTag, ev.GetY()); | ||
| scrollView.SetTag(IsScrollingTag, false); | ||
| break; | ||
|
|
||
| case MotionEventActions.Move: | ||
| // Check if this is a scroll gesture | ||
| var startX = scrollView.GetTag(TouchStartXTag) as Java.Lang.Float; | ||
| var startY = scrollView.GetTag(TouchStartYTag) as Java.Lang.Float; | ||
|
|
||
| if (startX != null && startY != null) | ||
| { | ||
| var deltaX = Math.Abs(ev.GetX() - startX.FloatValue()); | ||
| var deltaY = Math.Abs(ev.GetY() - startY.FloatValue()); | ||
|
|
||
| // Use system touch slop for consistent behavior | ||
| var touchSlop = scrollView.Context != null ? | ||
| ViewConfiguration.Get(scrollView.Context)?.ScaledTouchSlop ?? 10 : 10; | ||
|
|
||
| // If horizontal movement is greater than vertical and exceeds touch slop, it's a scroll | ||
| if (deltaX > deltaY && deltaX > touchSlop) | ||
| { | ||
| scrollView.SetTag(IsScrollingTag, true); | ||
|
|
||
| // Clear any focus from child views during scroll | ||
| ClearChildFocus(scrollView); | ||
| } | ||
| } | ||
| break; | ||
|
|
||
| case MotionEventActions.Up: | ||
| case MotionEventActions.Cancel: | ||
| // Reset scroll state | ||
| scrollView.SetTag(IsScrollingTag, false); | ||
| break; | ||
| } | ||
|
|
||
| e.Handled = false; // Allow normal event processing to continue | ||
| } | ||
|
|
||
| private static void ClearChildFocus(ViewGroup viewGroup) | ||
| { | ||
| for (int i = 0; i < viewGroup.ChildCount; i++) | ||
| { | ||
| var child = viewGroup.GetChildAt(i); | ||
| if (child != null && child.HasFocus) | ||
| { | ||
| child.ClearFocus(); | ||
| } | ||
|
|
||
| // Recursively clear focus in nested ViewGroups | ||
| if (child is ViewGroup childGroup) | ||
| { | ||
| ClearChildFocus(childGroup); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| #nullable enable | ||
| Microsoft.Maui.Platform.HorizontalScrollViewExtensions | ||
| static Microsoft.Maui.Platform.HorizontalScrollViewExtensions.PreventFocusDuringScroll(this Android.Widget.HorizontalScrollView! horizontalScrollView) -> void |
126 changes: 126 additions & 0 deletions
126
src/Core/tests/DeviceTests/Handlers/ScrollView/HorizontalScrollViewFocusTests.Android.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| using System; | ||
| using System.Threading.Tasks; | ||
| using Android.Content; | ||
| using Android.Views; | ||
| using Android.Widget; | ||
| using AndroidX.AppCompat.Widget; | ||
| using Microsoft.Maui.DeviceTests.Stubs; | ||
| using Microsoft.Maui.Graphics; | ||
| using Microsoft.Maui.Handlers; | ||
| using Microsoft.Maui.Platform; | ||
| using Xunit; | ||
|
|
||
| namespace Microsoft.Maui.DeviceTests | ||
| { | ||
| public partial class HorizontalScrollViewFocusTests : CoreHandlerTestBase<ViewHandler, ViewStub> | ||
| { | ||
| [Fact] | ||
| public async Task HorizontalScrollViewExtensionCanBeApplied() | ||
| { | ||
| await InvokeOnMainThreadAsync(() => | ||
| { | ||
| var context = MauiContext.Context; | ||
|
|
||
| // Create a plain Android HorizontalScrollView | ||
| var horizontalScrollView = new HorizontalScrollView(context); | ||
|
|
||
| // Apply the extension method - this should not throw | ||
| horizontalScrollView.PreventFocusDuringScroll(); | ||
|
|
||
| // Verify the extension was applied by checking that tags are set | ||
| // The extension uses internal tags to track state | ||
| Assert.NotNull(horizontalScrollView); | ||
| }); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task HorizontalScrollViewExtensionHandlesTouchEvents() | ||
| { | ||
| await InvokeOnMainThreadAsync(() => | ||
| { | ||
| var context = MauiContext.Context; | ||
|
|
||
| // Create a plain Android HorizontalScrollView | ||
| var horizontalScrollView = new HorizontalScrollView(context); | ||
|
|
||
| // Create an Entry (AppCompatEditText) | ||
| var entryView = new AppCompatEditText(context) | ||
| { | ||
| Text = "Test Entry", | ||
| Focusable = true, | ||
| FocusableInTouchMode = true | ||
| }; | ||
|
|
||
| // Add Entry to HorizontalScrollView | ||
| horizontalScrollView.AddView(entryView); | ||
|
|
||
| // Apply the fix | ||
| horizontalScrollView.PreventFocusDuringScroll(); | ||
|
|
||
| // Initially, Entry should not have focus | ||
| Assert.False(entryView.HasFocus); | ||
|
|
||
| // Simulate a swipe gesture by creating and dispatching touch events | ||
| var downEvent = MotionEvent.Obtain(0, 0, MotionEventActions.Down, 100, 100, 0); | ||
| var moveEvent = MotionEvent.Obtain(0, 100, MotionEventActions.Move, 200, 100, 0); | ||
| var upEvent = MotionEvent.Obtain(0, 200, MotionEventActions.Up, 250, 100, 0); | ||
|
|
||
| try | ||
| { | ||
| // First give the entry focus to ensure the extension clears it during scroll | ||
| entryView.RequestFocus(); | ||
| Assert.True(entryView.HasFocus); | ||
|
|
||
| // Simulate a horizontal swipe that should clear focus | ||
| horizontalScrollView.DispatchTouchEvent(downEvent); | ||
| horizontalScrollView.DispatchTouchEvent(moveEvent); // This should clear focus | ||
|
|
||
| // After the move event that triggers the scroll detection, focus should be cleared | ||
| Assert.False(entryView.HasFocus); | ||
|
|
||
| horizontalScrollView.DispatchTouchEvent(upEvent); | ||
| } | ||
| finally | ||
| { | ||
| // Clean up | ||
| downEvent.Recycle(); | ||
| moveEvent.Recycle(); | ||
| upEvent.Recycle(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task HorizontalScrollViewExtensionAllowsDirectFocus() | ||
| { | ||
| await InvokeOnMainThreadAsync(() => | ||
| { | ||
| var context = MauiContext.Context; | ||
|
|
||
| // Create a plain Android HorizontalScrollView | ||
| var horizontalScrollView = new HorizontalScrollView(context); | ||
|
|
||
| // Create an Entry | ||
| var entryView = new AppCompatEditText(context) | ||
| { | ||
| Text = "Test Entry", | ||
| Focusable = true, | ||
| FocusableInTouchMode = true | ||
| }; | ||
|
|
||
| // Add Entry to HorizontalScrollView | ||
| horizontalScrollView.AddView(entryView); | ||
|
|
||
| // Apply the fix | ||
| horizontalScrollView.PreventFocusDuringScroll(); | ||
|
|
||
| // Initially, Entry should not have focus | ||
| Assert.False(entryView.HasFocus); | ||
|
|
||
| // Direct focus should still work | ||
| entryView.RequestFocus(); | ||
| Assert.True(entryView.HasFocus); | ||
| }); | ||
| } | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already having MauiScrollView Class is there (for vertical ) and MauiHorizontalScrollView for horizontal in the src/core/src/Platform/Android -- folders. - can you analyze that ? @copilot
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're absolutely right! I can see that MAUI already has
MauiScrollViewandMauiHorizontalScrollViewclasses with sophisticated touch event handling for bidirectional scrolling.Looking at the code,
MauiScrollView(which extends NestedScrollView) internally creates and manages aMauiHorizontalScrollViewwhen horizontal or bidirectional scrolling is needed. The officialScrollViewHandlerusesMauiScrollViewas its platform view.My extension method approach was designed for custom handlers that might be using plain Android
HorizontalScrollViewdirectly. However, you raise a valid point - should we instead:MauiHorizontalScrollViewinstead of plainHorizontalScrollViewin custom handlers?MauiHorizontalScrollViewto handle the focus issue if it doesn't already?HorizontalScrollViewmust be used?Which approach would you prefer? Should I analyze whether
MauiHorizontalScrollViewalready handles the focus issue and modify the solution accordingly?