diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs index 727188c36715..0dd7504072d9 100644 --- a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.cs @@ -60,6 +60,9 @@ void SetupBuilder() handlers.AddHandler(); handlers.AddHandler(); #endif + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); }); }); } diff --git a/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.iOS.cs new file mode 100644 index 000000000000..a7f211e980f2 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Window/WindowTests.iOS.cs @@ -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(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(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())); + }); + } + } +} diff --git a/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs b/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs index 4a4a002f0fa8..e6665fd3b01b 100644 --- a/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs +++ b/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs @@ -38,7 +38,7 @@ void UpdateContent(UIWindow platformView) } else { - PlatformView.RootViewController.View.AddSubview(view); + AssertionExtensions.FindContentView().AddSubview(view); } }); } diff --git a/src/Core/src/Handlers/Page/PageHandler.iOS.cs b/src/Core/src/Handlers/Page/PageHandler.iOS.cs index 2a9b86f00ced..639f5f59d4f8 100644 --- a/src/Core/src/Handlers/Page/PageHandler.iOS.cs +++ b/src/Core/src/Handlers/Page/PageHandler.iOS.cs @@ -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) @@ -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 ViewAndSuperviewsOfView(UIView view) - { - while (view != null) - { - yield return view; - view = view.Superview; - } - } } } diff --git a/src/Core/src/Platform/iOS/MauiSearchBar.cs b/src/Core/src/Platform/iOS/MauiSearchBar.cs index d7db2dc3f6c4..8909d4207d50 100644 --- a/src/Core/src/Platform/iOS/MauiSearchBar.cs +++ b/src/Core/src/Platform/iOS/MauiSearchBar.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.Design; using System.Drawing; using CoreGraphics; using Foundation; @@ -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; diff --git a/src/Core/src/Platform/iOS/MauiTextField.cs b/src/Core/src/Platform/iOS/MauiTextField.cs index 9faf681fec41..77812e5d8889 100644 --- a/src/Core/src/Platform/iOS/MauiTextField.cs +++ b/src/Core/src/Platform/iOS/MauiTextField.cs @@ -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; diff --git a/src/Core/src/Platform/iOS/MauiTextView.cs b/src/Core/src/Platform/iOS/MauiTextView.cs index 48b2a5d39e31..f3a7d4380e26 100644 --- a/src/Core/src/Platform/iOS/MauiTextView.cs +++ b/src/Core/src/Platform/iOS/MauiTextView.cs @@ -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. diff --git a/src/Core/src/Platform/iOS/ResignFirstResponderTouchGestureRecognizer.cs b/src/Core/src/Platform/iOS/ResignFirstResponderTouchGestureRecognizer.cs new file mode 100644 index 000000000000..59a510f7c4ca --- /dev/null +++ b/src/Core/src/Platform/iOS/ResignFirstResponderTouchGestureRecognizer.cs @@ -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 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; + } + } + } + } + } +} diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index befbe6b8eaf7..cfe0d72d2cf8 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -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 diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index befbe6b8eaf7..cfe0d72d2cf8 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -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 diff --git a/src/Core/tests/DeviceTests/Memory/MemoryTestTypes.cs b/src/Core/tests/DeviceTests/Memory/MemoryTestTypes.cs index 25b67c3bcb71..a01c466b4b57 100644 --- a/src/Core/tests/DeviceTests/Memory/MemoryTestTypes.cs +++ b/src/Core/tests/DeviceTests/Memory/MemoryTestTypes.cs @@ -11,6 +11,7 @@ public IEnumerator 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)) }; } diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs index e5962ac08a09..d3824ae5ecd9 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs @@ -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) diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs index f3207c8f2abb..738dc71687bf 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs @@ -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(); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs index 05414bb8e232..16a285ecef80 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs @@ -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); @@ -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 diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index e0661e399618..3ef0fdc9f164 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -32,14 +32,32 @@ public static Task SendKeyboardReturnType(this UIView view, ReturnType returnTyp throw new NotImplementedException(); } - public static Task WaitForFocused(this UIView view, int timeout = 1000) + public static async Task WaitForFocused(this UIView view, int timeout = 1000) { - throw new NotImplementedException(); + if (!view.IsFocused()) + { + await Wait(() => view.IsFocused(), timeout); + } + + Assert.True(view.IsFocused()); + } + + public static async Task WaitForUnFocused(this UIView view, int timeout = 1000) + { + if (view.IsFocused()) + { + await Wait(() => view.IsFocused(), timeout); + } + + Assert.False(view.IsFocused()); } + static bool IsFocused(this UIView view) => view.Focused || view.IsFirstResponder; + public static Task FocusView(this UIView view, int timeout = 1000) { - throw new NotImplementedException(); + view.Focus(new FocusRequest(false)); + return WaitForFocused(view, timeout); } public static Task ShowKeyboardForView(this UIView view, int timeout = 1000) @@ -108,7 +126,7 @@ public static async Task AttachAndRun(this UIView view, Func> acti return result; } - static UIView FindContentView() + public static UIView FindContentView() { if (GetKeyWindow(UIApplication.SharedApplication) is not UIWindow window) {