Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
96 changes: 96 additions & 0 deletions docs/HorizontalScrollViewFocusFix.md
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 src/Core/src/Platform/Android/MauiFocusAwareHorizontalScrollView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System;
Copy link
Owner

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

Copy link
Author

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 MauiScrollView and MauiHorizontalScrollView classes with sophisticated touch event handling for bidirectional scrolling.

Looking at the code, MauiScrollView (which extends NestedScrollView) internally creates and manages a MauiHorizontalScrollView when horizontal or bidirectional scrolling is needed. The official ScrollViewHandler uses MauiScrollView as its platform view.

My extension method approach was designed for custom handlers that might be using plain Android HorizontalScrollView directly. However, you raise a valid point - should we instead:

  1. Guide developers to use MauiHorizontalScrollView instead of plain HorizontalScrollView in custom handlers?
  2. Enhance the existing MauiHorizontalScrollView to handle the focus issue if it doesn't already?
  3. Keep the extension method for cases where plain HorizontalScrollView must be used?

Which approach would you prefer? Should I analyze whether MauiHorizontalScrollView already handles the focus issue and modify the solution accordingly?

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);
}
}
}
}
}
2 changes: 2 additions & 0 deletions src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
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
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);
});
}
}
}