Skip to content

Commit

Permalink
Remove gesture from page that interferes with accessibility (#10948)
Browse files Browse the repository at this point in the history
* Apply gesture for closing keyboard as needed

* - add entry for memory tests

* - add tests

* Update PublicAPI.Unshipped.txt

* Update PublicAPI.Unshipped.txt

* Update ResignFirstResponderTouchGestureRecognizer.cs

* Update AssertionExtensions.Android.cs

* Update AssertionExtensions.Android.cs

* Update ResignFirstResponderTouchGestureRecognizer.cs

* Update AssertionExtensions.Android.cs
  • Loading branch information
PureWeen authored Jan 12, 2023
1 parent df17b87 commit 3bae700
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 43 deletions.
3 changes: 3 additions & 0 deletions src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ void SetupBuilder()
handlers.AddHandler<ShellSection, ShellSectionHandler>();
handlers.AddHandler<ShellContent, ShellContentHandler>();
#endif
handlers.AddHandler<Entry, EntryHandler>();
handlers.AddHandler<Editor, EditorHandler>();
handlers.AddHandler<SearchBar, SearchBarHandler>();
});
});
}
Expand Down
70 changes: 70 additions & 0 deletions src/Controls/tests/DeviceTests/Elements/Window/WindowTests.iOS.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using Xunit;
using Microsoft.Maui.Handlers;
using System;

namespace Microsoft.Maui.DeviceTests
{
public partial class WindowTests
{
[Theory]
[InlineData(typeof(Editor))]
[InlineData(typeof(Entry))]
[InlineData(typeof(SearchBar))]
public async Task FocusedTextInputAddsResignFirstResponderGesture(Type controlType)
{
SetupBuilder();
var layout = new VerticalStackLayout();
var view = (View)Activator.CreateInstance(controlType);
layout.Children.Add(view);

await CreateHandlerAndAddToWindow<LayoutHandler>(layout, async (handler) =>
{
await OnLoadedAsync(view);
view.Focus();
await AssertionExtensions.WaitForFocused(view);
Assert.Contains(typeof(ResignFirstResponderTouchGestureRecognizer),
handler.PlatformView.Window.GestureRecognizers.Select(x => x.GetType()));

// Work around bug where iOS elements aren't toggling the "IsFocused" property
(view as IView).IsFocused = true;
view.Unfocus();
await AssertionExtensions.WaitForUnFocused(view);

Assert.DoesNotContain(typeof(ResignFirstResponderTouchGestureRecognizer),
handler.PlatformView.Window.GestureRecognizers.Select(x => x.GetType()));
});
}

[Theory]
[InlineData(typeof(Editor))]
[InlineData(typeof(Entry))]
[InlineData(typeof(SearchBar))]
public async Task RemovingControlFromWindowRemovesGesture(Type controlType)
{
SetupBuilder();
var layout = new VerticalStackLayout();
var view = (View)Activator.CreateInstance(controlType);

layout.Children.Add(view);

await CreateHandlerAndAddToWindow<LayoutHandler>(layout, async (handler) =>
{
await OnLoadedAsync(view);
view.Focus();
await AssertionExtensions.WaitForFocused(view);
Assert.Contains(typeof(ResignFirstResponderTouchGestureRecognizer),
handler.PlatformView.Window.GestureRecognizers.Select(x => x.GetType()));

layout.Remove(view);
await OnUnloadedAsync(view);

Assert.DoesNotContain(typeof(ResignFirstResponderTouchGestureRecognizer),
handler.PlatformView.Window.GestureRecognizers.Select(x => x.GetType()));
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ void UpdateContent(UIWindow platformView)
}
else
{
PlatformView.RootViewController.View.AddSubview(view);
AssertionExtensions.FindContentView().AddSubview(view);
}
});
}
Expand Down
37 changes: 0 additions & 37 deletions src/Core/src/Handlers/Page/PageHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,6 @@ protected override ContentView CreatePlatformView()
throw new InvalidOperationException($"PageViewController.View must be a {nameof(ContentView)}");
}

protected override void ConnectHandler(ContentView nativeView)
{
var uiTapGestureRecognizer = new UITapGestureRecognizer(a => nativeView?.EndEditing(true));

uiTapGestureRecognizer.ShouldRecognizeSimultaneously = (recognizer, gestureRecognizer) => true;
uiTapGestureRecognizer.ShouldReceiveTouch = OnShouldReceiveTouch;
uiTapGestureRecognizer.DelaysTouchesBegan =
uiTapGestureRecognizer.DelaysTouchesEnded = uiTapGestureRecognizer.CancelsTouchesInView = false;
nativeView.AddGestureRecognizer(uiTapGestureRecognizer);

base.ConnectHandler(nativeView);
}

protected override void DisconnectHandler(ContentView nativeView)
{
base.DisconnectHandler(nativeView);
}

public static void MapTitle(IPageHandler handler, IContentView page)
{
if (handler is IPlatformViewHandler invh && invh.ViewController != null)
Expand All @@ -52,24 +34,5 @@ public static void MapTitle(IPageHandler handler, IContentView page)
}
}
}

bool OnShouldReceiveTouch(UIGestureRecognizer recognizer, UITouch touch)
{
foreach (UIView v in ViewAndSuperviewsOfView(touch.View))
{
if (v != null && (v is UITableView || v is UITableViewCell || v.CanBecomeFirstResponder))
return false;
}
return true;
}

IEnumerable<UIView> ViewAndSuperviewsOfView(UIView view)
{
while (view != null)
{
yield return view;
view = view.Superview;
}
}
}
}
4 changes: 4 additions & 0 deletions src/Core/src/Platform/iOS/MauiSearchBar.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.ComponentModel.Design;
using System.Drawing;
using CoreGraphics;
using Foundation;
Expand Down Expand Up @@ -59,6 +60,9 @@ public override void WillMoveToWindow(UIWindow? window)

base.WillMoveToWindow(window);

if (editor != null)
ResignFirstResponderTouchGestureRecognizer.Update(editor, window);

if (editor != null)
{
editor.EditingChanged -= OnEditingChanged;
Expand Down
6 changes: 6 additions & 0 deletions src/Core/src/Platform/iOS/MauiTextField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public MauiTextField()
{
}

public override void WillMoveToWindow(UIWindow? window)
{
base.WillMoveToWindow(window);
ResignFirstResponderTouchGestureRecognizer.Update(this, window);
}

public override string? Text
{
get => base.Text;
Expand Down
6 changes: 6 additions & 0 deletions src/Core/src/Platform/iOS/MauiTextView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public MauiTextView(CGRect frame)
Changed += OnChanged;
}

public override void WillMoveToWindow(UIWindow? window)
{
base.WillMoveToWindow(window);
ResignFirstResponderTouchGestureRecognizer.Update(this, window);
}

// Native Changed doesn't fire when the Text Property is set in code
// We use this event as a way to fire changes whenever the Text changes
// via code or user interaction.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using UIKit;

namespace Microsoft.Maui.Platform
{
internal class ResignFirstResponderTouchGestureRecognizer : UITapGestureRecognizer
{
UIView? _targetView;
Token? _token;

public ResignFirstResponderTouchGestureRecognizer(UIView targetView) :
base()
{
ShouldRecognizeSimultaneously = (recognizer, gestureRecognizer) => true;
ShouldReceiveTouch = OnShouldReceiveTouch;
CancelsTouchesInView = false;
DelaysTouchesEnded = false;
DelaysTouchesBegan = false;

_token = AddTarget((a) =>
{
if (a is ResignFirstResponderTouchGestureRecognizer gr && gr.State == UIGestureRecognizerState.Ended)
{
gr.OnTapped();
}
});

_targetView = targetView;
}

void OnTapped()
{
if (_targetView?.IsFirstResponder == true)
_targetView?.ResignFirstResponder();

Disconnect();
}

internal void Disconnect()
{
if (_token != null)
RemoveTarget(_token);

_token = null;
_targetView = null;
}

bool OnShouldReceiveTouch(UIGestureRecognizer recognizer, UITouch touch)
{
foreach (UIView v in ViewAndSuperviewsOfView(touch.View))
{
if (v != null && (v is UITableView || v is UITableViewCell || v.CanBecomeFirstResponder))
return false;
}

return true;
}

IEnumerable<UIView> ViewAndSuperviewsOfView(UIView view)
{
while (view != null)
{
yield return view;
view = view.Superview;
}
}

internal static void Update(UITextView textView, UIWindow? window)
{
if (window != null)
{
textView.Started += OnEditingDidBegin;
textView.Ended += OnEditingDidEnd;
}
else
{
textView.Started -= OnEditingDidBegin;
textView.Ended -= OnEditingDidEnd;
}
}

internal static void Update(UIControl platformControl, UIWindow? window)
{
if (window != null)
{
platformControl.EditingDidBegin += OnEditingDidBegin;
platformControl.EditingDidEnd += OnEditingDidEnd;
}
else
{
platformControl.EditingDidBegin -= OnEditingDidBegin;
platformControl.EditingDidEnd -= OnEditingDidEnd;
}
}

static void OnEditingDidBegin(object? sender, EventArgs e)
{
if (sender is UIView view && view.Window != null)
{
var resignFirstResponder = new ResignFirstResponderTouchGestureRecognizer(view);
view.Window.AddGestureRecognizer(resignFirstResponder);
return;
}
}

static void OnEditingDidEnd(object? sender, EventArgs e)
{
if (sender is UIView view && view.Window?.GestureRecognizers != null)
{
for (var i = 0; i < view.Window.GestureRecognizers.Length; i++)
{
UIGestureRecognizer? gr = view.Window.GestureRecognizers[i];
if (gr is ResignFirstResponderTouchGestureRecognizer)
{
view.Window.RemoveGestureRecognizer(gr);
return;
}
}
}
}
}
}
4 changes: 4 additions & 0 deletions src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#nullable enable
override Microsoft.Maui.Handlers.SwitchHandler.NeedsContainer.get -> bool
static Microsoft.Maui.Layouts.LayoutExtensions.ArrangeContentUnbounded(this Microsoft.Maui.IContentView! contentView, Microsoft.Maui.Graphics.Rect bounds) -> Microsoft.Maui.Graphics.Size
override Microsoft.Maui.Platform.MauiTextField.WillMoveToWindow(UIKit.UIWindow? window) -> void
override Microsoft.Maui.Platform.MauiTextView.WillMoveToWindow(UIKit.UIWindow? window) -> void
*REMOVED*override Microsoft.Maui.Handlers.PageHandler.ConnectHandler(Microsoft.Maui.Platform.ContentView! nativeView) -> void
*REMOVED*override Microsoft.Maui.Handlers.PageHandler.DisconnectHandler(Microsoft.Maui.Platform.ContentView! nativeView) -> void
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#nullable enable
override Microsoft.Maui.Handlers.SwitchHandler.NeedsContainer.get -> bool
static Microsoft.Maui.Layouts.LayoutExtensions.ArrangeContentUnbounded(this Microsoft.Maui.IContentView! contentView, Microsoft.Maui.Graphics.Rect bounds) -> Microsoft.Maui.Graphics.Size
override Microsoft.Maui.Platform.MauiTextField.WillMoveToWindow(UIKit.UIWindow? window) -> void
override Microsoft.Maui.Platform.MauiTextView.WillMoveToWindow(UIKit.UIWindow? window) -> void
*REMOVED*override Microsoft.Maui.Handlers.PageHandler.ConnectHandler(Microsoft.Maui.Platform.ContentView! nativeView) -> void
*REMOVED*override Microsoft.Maui.Handlers.PageHandler.DisconnectHandler(Microsoft.Maui.Platform.ContentView! nativeView) -> void
1 change: 1 addition & 0 deletions src/Core/tests/DeviceTests/Memory/MemoryTestTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { (typeof(DatePickerStub), typeof(DatePickerHandler)) };
yield return new object[] { (typeof(EditorStub), typeof(EditorHandler)) };
yield return new object[] { (typeof(EntryStub), typeof(EntryHandler)) };
yield return new object[] { (typeof(SearchBarStub), typeof(SearchBarHandler)) };
}

Expand Down
25 changes: 25 additions & 0 deletions src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,37 @@ public static async Task WaitForFocused(this AView view, int timeout = 1000)

void OnFocused(object? sender, AView.FocusChangeEventArgs e)
{
if (!e.HasFocus)
return;

view.FocusChange -= OnFocused;
focusSource.SetResult();
}
}
}

public static async Task WaitForUnFocused(this AView view, int timeout = 1000)
{
if (view.IsFocused)
{
TaskCompletionSource focusSource = new TaskCompletionSource();
view.FocusChange += OnUnFocused;
await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout));

// Even though the event fires unfocus hasn't fully been achieved
await Task.Delay(10);

void OnUnFocused(object? sender, AView.FocusChangeEventArgs e)
{
if (e.HasFocus)
return;

view.FocusChange -= OnUnFocused;
focusSource.SetResult();
}
}
}

public static Task FocusView(this AView view, int timeout = 1000)
{
if (!view.IsFocused)
Expand Down
5 changes: 5 additions & 0 deletions src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public static Task WaitForFocused(this FrameworkElement view, int timeout = 1000
throw new NotImplementedException();
}

public static Task WaitForUnFocused(this FrameworkElement view, int timeout = 1000)
{
throw new NotImplementedException();
}

public static Task FocusView(this FrameworkElement view, int timeout = 1000)
{
throw new NotImplementedException();
Expand Down
4 changes: 3 additions & 1 deletion src/TestUtils/src/DeviceTests/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ public static Task SendKeyboardReturnType(this IView view, ReturnType returnType
public static Task ShowKeyboardForView(this IView view, int timeout = 1000) =>
view.ToPlatform().ShowKeyboardForView(timeout);

public static Task WaitForUnFocused(this IView view, int timeout = 1000) =>
view.ToPlatform().WaitForUnFocused(timeout);

public static Task WaitForFocused(this IView view, int timeout = 1000) =>
view.ToPlatform().WaitForFocused(timeout);

Expand All @@ -126,7 +129,6 @@ public static Task FocusView(this IView view, int timeout = 1000) =>
public static bool IsAccessibilityElement(this IView view) =>
view.ToPlatform().IsAccessibilityElement();


public static bool IsExcludedWithChildren(this IView view) =>
view.ToPlatform().IsExcludedWithChildren();
#endif
Expand Down
Loading

0 comments on commit 3bae700

Please sign in to comment.