diff --git a/src/Core/src/Handlers/Entry/EntryHandler.Android.cs b/src/Core/src/Handlers/Entry/EntryHandler.Android.cs index e0a1261cf187..1e24af5728a1 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.Android.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.Android.cs @@ -169,7 +169,7 @@ void OnEditorAction(object? sender, EditorActionEventArgs e) } } - e.Handled = true; + e.Handled = false; } private void OnSelectionChanged(object? sender, EventArgs e) diff --git a/src/Controls/src/Core/Platform/Android/KeyboardManager.cs b/src/Core/src/Platform/Android/KeyboardManager.cs similarity index 81% rename from src/Controls/src/Core/Platform/Android/KeyboardManager.cs rename to src/Core/src/Platform/Android/KeyboardManager.cs index 7c353d255636..d25fda42006e 100644 --- a/src/Controls/src/Core/Platform/Android/KeyboardManager.cs +++ b/src/Core/src/Platform/Android/KeyboardManager.cs @@ -2,25 +2,27 @@ using Android.App; using Android.Content; using Android.OS; +using Android.Views; using Android.Views.InputMethods; using Android.Widget; +using AndroidX.Core.View; using AView = Android.Views.View; -namespace Microsoft.Maui.Controls.Platform +namespace Microsoft.Maui.Platform { internal static class KeyboardManager { internal static void HideKeyboard(this AView inputView, bool overrideValidation = false) { - if (inputView == null) + if (inputView?.Context == null) throw new ArgumentNullException(nameof(inputView) + " must be set before the keyboard can be hidden."); - using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)) + using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)!) { if (!overrideValidation && !(inputView is EditText || inputView is TextView || inputView is SearchView)) throw new ArgumentException("inputView should be of type EditText, SearchView, or TextView"); - IBinder windowToken = inputView.WindowToken; + var windowToken = inputView.WindowToken; if (windowToken != null && inputMethodManager != null) inputMethodManager.HideSoftInputFromWindow(windowToken, HideSoftInputFlags.None); } @@ -28,10 +30,10 @@ internal static void HideKeyboard(this AView inputView, bool overrideValidation internal static void ShowKeyboard(this TextView inputView) { - if (inputView == null) + if (inputView?.Context == null) throw new ArgumentNullException(nameof(inputView) + " must be set before the keyboard can be shown."); - using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)) + using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)!) { // The zero value for the second parameter comes from // https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int) @@ -42,7 +44,7 @@ internal static void ShowKeyboard(this TextView inputView) internal static void ShowKeyboard(this SearchView searchView) { - if (searchView == null) + if (searchView?.Context == null || searchView?.Resources == null) { throw new ArgumentNullException(nameof(searchView)); } @@ -64,7 +66,7 @@ internal static void ShowKeyboard(this SearchView searchView) return; } - using (var inputMethodManager = (InputMethodManager)searchView.Context.GetSystemService(Context.InputMethodService)) + using (var inputMethodManager = (InputMethodManager)searchView.Context.GetSystemService(Context.InputMethodService)!) { // The zero value for the second parameter comes from // https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int) @@ -102,5 +104,15 @@ void ShowKeyboard() view.Post(ShowKeyboard); } + + public static bool IsSoftKeyboardVisible(this AView view) + { + var insets = ViewCompat.GetRootWindowInsets(view); + if (insets == null) + return false; + + var result = insets.IsVisible(WindowInsetsCompat.Type.Ime()); + return result; + } } } \ No newline at end of file diff --git a/src/Core/src/Properties/AssemblyInfo.cs b/src/Core/src/Properties/AssemblyInfo.cs index 86013117cfdb..ee97638bb931 100644 --- a/src/Core/src/Properties/AssemblyInfo.cs +++ b/src/Core/src/Properties/AssemblyInfo.cs @@ -34,4 +34,5 @@ [assembly: InternalsVisibleTo("CommunityToolkit.Maui.Markup.UnitTests")] [assembly: InternalsVisibleTo("Reloadify-emit")] [assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests.Runners")] +[assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests")] [assembly: InternalsVisibleTo("Microsoft.Maui.DeviceTests.Shared")] \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs index c762034be590..97045f148093 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs @@ -4,6 +4,7 @@ using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using Xunit; namespace Microsoft.Maui.DeviceTests @@ -494,6 +495,95 @@ await ValidateUnrelatedPropertyUnaffected( () => entry.CharacterSpacing = newSize); } +#if ANDROID + [Fact] + public async Task NextMovesToNextEntrySuccessfully() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handler => + { + handler.AddHandler(); + handler.AddHandler(); + }); + }); + + var layout = new VerticalStackLayoutStub(); + + var entry1 = new EntryStub + { + Text = "Entry 1", + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + Text = "Entry 2", + ReturnType = ReturnType.Next + }; + + layout.Add(entry1); + layout.Add(entry2); + + layout.Width = 100; + layout.Height = 150; + + await InvokeOnMainThreadAsync(async () => + { + var contentViewHandler = CreateHandler(layout); + await contentViewHandler.PlatformView.AttachAndRun(async () => + { + await entry1.SendKeyboardReturnType(ReturnType.Next); + await entry2.WaitForFocused(); + Assert.True(entry2.IsFocused); + }); + }); + } + + [Fact] + public async Task DoneClosesKeyboard() + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handler => + { + handler.AddHandler(); + handler.AddHandler(); + }); + }); + + var layout = new VerticalStackLayoutStub(); + + var entry1 = new EntryStub + { + Text = "Entry 1", + ReturnType = ReturnType.Done + }; + + var entry2 = new EntryStub + { + Text = "Entry 2", + ReturnType = ReturnType.Done + }; + + layout.Add(entry1); + layout.Add(entry2); + + layout.Width = 100; + layout.Height = 150; + + await InvokeOnMainThreadAsync(async () => + { + var handler = CreateHandler(layout); + await handler.PlatformView.AttachAndRun(async () => + { + await entry1.SendKeyboardReturnType(ReturnType.Done); + await entry1.WaitForKeyboardToHide(); + }); + }); + } +#endif + [Category(TestCategory.Entry)] public class EntryTextStyleTests : TextStyleHandlerTests { diff --git a/src/Core/tests/DeviceTests/Stubs/LayoutStub.cs b/src/Core/tests/DeviceTests/Stubs/LayoutStub.cs index df8be84fe999..b11d956bc509 100644 --- a/src/Core/tests/DeviceTests/Stubs/LayoutStub.cs +++ b/src/Core/tests/DeviceTests/Stubs/LayoutStub.cs @@ -77,7 +77,9 @@ public Size CrossPlatformArrange(Rect bounds) public int Count => _children.Count; public bool IsReadOnly => _children.IsReadOnly; - ILayoutManager LayoutManager => _layoutManager ??= new LayoutManagerStub(); + ILayoutManager LayoutManager => _layoutManager ??= CreateLayoutManager(); + + protected virtual ILayoutManager CreateLayoutManager() => new LayoutManagerStub(); public bool IgnoreSafeArea => false; diff --git a/src/Core/tests/DeviceTests/Stubs/StubBase.cs b/src/Core/tests/DeviceTests/Stubs/StubBase.cs index ec1426bfc7d1..e327f4702107 100644 --- a/src/Core/tests/DeviceTests/Stubs/StubBase.cs +++ b/src/Core/tests/DeviceTests/Stubs/StubBase.cs @@ -95,6 +95,11 @@ public Size Arrange(Rect bounds) { Frame = bounds; DesiredSize = bounds.Size; + + // If this view is attached to the visual tree then let's arrange it + if (IsLoaded) + Handler?.PlatformArrange(Frame); + return DesiredSize; } @@ -139,5 +144,14 @@ public Size Measure(double widthConstraint, double heightConstraint) IReadOnlyList IVisualTreeElement.GetVisualChildren() => this.Children.Cast().ToList().AsReadOnly(); IVisualTreeElement IVisualTreeElement.GetVisualParent() => this.Parent as IVisualTreeElement; + + + public bool IsLoaded + { + get + { + return (Handler as IPlatformViewHandler)?.PlatformView?.IsLoaded() == true; + } + } } } diff --git a/src/Core/tests/DeviceTests/Stubs/VerticalStackLayoutStub.cs b/src/Core/tests/DeviceTests/Stubs/VerticalStackLayoutStub.cs new file mode 100644 index 000000000000..dd150c8b7437 --- /dev/null +++ b/src/Core/tests/DeviceTests/Stubs/VerticalStackLayoutStub.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Maui.Layouts; + +namespace Microsoft.Maui.DeviceTests.Stubs +{ + public class VerticalStackLayoutStub : LayoutStub, IStackLayout + { + public double Spacing => 0; + + protected override ILayoutManager CreateLayoutManager() + { + return new VerticalStackLayoutManager(this); + } + } +} diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs index 16d55019162d..3fe045894392 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs @@ -6,6 +6,7 @@ using Android.Graphics.Drawables; using Android.Text; using Android.Views; +using Android.Views.InputMethods; using Android.Widget; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; @@ -17,12 +18,89 @@ namespace Microsoft.Maui.DeviceTests { public static partial class AssertionExtensions { + public static async Task SendValueToKeyboard(this AView view, char value, int timeout = 1000) + { + await view.ShowKeyboardForView(timeout); + + // I tried various permutations of KeyEventActions to set the keyboard in upper case + // But I wasn't successful + if (Enum.TryParse($"{value}".ToUpper(), out Keycode result)) + { + view.OnCreateInputConnection(new EditorInfo())? + .SendKeyEvent(new KeyEvent(10, 10, KeyEventActions.Down, result, 0)); + + view.OnCreateInputConnection(new EditorInfo())? + .SendKeyEvent(new KeyEvent(10, 10, KeyEventActions.Up, result, 0)); + } + } + + public static async Task SendKeyboardReturnType(this AView view, ReturnType returnType, int timeout = 1000) + { + await view.ShowKeyboardForView(timeout); + + view + .OnCreateInputConnection(new EditorInfo())? + .PerformEditorAction(returnType.ToPlatform()); + + // Let the action propagate + await Task.Delay(10); + } + + public static async Task WaitForFocused(this AView view, int timeout = 1000) + { + if (!view.IsFocused) + { + TaskCompletionSource focusSource = new TaskCompletionSource(); + view.FocusChange += OnFocused; + await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout)); + + // Even thuogh the event fires focus hasn't fully been achieved + await Task.Delay(10); + + void OnFocused(object? sender, AView.FocusChangeEventArgs e) + { + view.FocusChange -= OnFocused; + focusSource.SetResult(); + } + } + } + + public static Task FocusView(this AView view, int timeout = 1000) + { + if (!view.IsFocused) + { + view.Focus(new FocusRequest(view.IsFocused)); + return view.WaitForFocused(timeout); + } + + return Task.CompletedTask; + } + + public static async Task ShowKeyboardForView(this AView view, int timeout = 1000) + { + await view.FocusView(timeout); + KeyboardManager.ShowKeyboard(view); + await view.WaitForKeyboardToShow(timeout); + } + + public static async Task WaitForKeyboardToShow(this AView view, int timeout = 1000) + { + var result = await Wait(() => KeyboardManager.IsSoftKeyboardVisible(view), timeout); + Assert.True(result); + + } + + public static async Task WaitForKeyboardToHide(this AView view, int timeout = 1000) + { + var result = await Wait(() => !KeyboardManager.IsSoftKeyboardVisible(view), timeout); + Assert.True(result); + } + public static Task WaitForLayout(AView view, int timeout = 1000) { var tcs = new TaskCompletionSource(); view.LayoutChange += OnLayout; - var cts = new CancellationTokenSource(); cts.Token.Register(() => OnLayout(view), true); cts.CancelAfter(timeout); @@ -36,6 +114,7 @@ void OnLayout(object? sender = null, AView.LayoutChangeEventArgs? e = null) if (view.Handle != IntPtr.Zero) view.LayoutChange -= OnLayout; + // let the layout resolve after changing tcs.TrySetResult(e != null); } } diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs index 77a62f735e40..8dde41766826 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs @@ -15,6 +15,41 @@ namespace Microsoft.Maui.DeviceTests { public static partial class AssertionExtensions { + public static Task WaitForKeyboardToShow(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task WaitForKeyboardToHide(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task SendValueToKeyboard(this FrameworkElement view, char value, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task SendKeyboardReturnType(this FrameworkElement view, ReturnType returnType, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task WaitForFocused(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task FocusView(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task ShowKeyboardForView(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + public static Task CreateColorAtPointError(this CanvasBitmap bitmap, WColor expectedColor, int x, int y) => CreateColorError(bitmap, $"Expected {expectedColor} at point {x},{y} in renderered view."); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs index 3725ab89178b..bd86ec1005ec 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs @@ -1,6 +1,6 @@ using System; -using System.Runtime.ExceptionServices; using System.Threading.Tasks; +using Microsoft.Maui.Platform; using Xunit; using Xunit.Sdk; @@ -51,5 +51,85 @@ public static void CloseEnough(double expected, double actual, double epsilon = var diff = Math.Abs(expected - actual); Assert.True(diff <= epsilon, $"Expected: {expected}. Actual: {actual}. Diff: {diff} Epsilon: {epsilon}.{message}"); } + +#if !TIZEN + + public static Task WaitForKeyboardToShow(this IView view, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().WaitForKeyboardToShow(timeout); +#endif + } + + public static Task WaitForKeyboardToHide(this IView view, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().WaitForKeyboardToHide(timeout); +#endif + } + + /// + /// Shane: I haven't fully tested this API. I was trying to use this to send "ReturnType" + /// and then found the correct API. But, I figured this would be useful to have so I left it here + /// so a future tester can hopefully use it and be successful! + /// + /// + /// + /// + /// + public static Task SendValueToKeyboard(this IView view, char value, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().SendValueToKeyboard(value, timeout); +#endif + } + + + public static Task SendKeyboardReturnType(this IView view, ReturnType returnType, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().SendKeyboardReturnType(returnType, timeout); +#endif + } + + public static Task ShowKeyboardForView(this IView view, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().ShowKeyboardForView(timeout); +#endif + } + + public static Task WaitForFocused(this IView view, int timeout = 1000) + { +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().WaitForFocused(timeout); +#endif + } + + public static Task FocusView(this IView view, int timeout = 1000) + { + +#if !PLATFORM + return Task.CompletedTask; +#else + return view.ToPlatform().FocusView(timeout); +#endif + } + + +#endif + } } \ No newline at end of file diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index 343bc8b6053a..94fd813803f7 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -12,6 +12,41 @@ namespace Microsoft.Maui.DeviceTests { public static partial class AssertionExtensions { + public static Task WaitForKeyboardToShow(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task WaitForKeyboardToHide(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task SendValueToKeyboard(this UIView view, char value, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task SendKeyboardReturnType(this UIView view, ReturnType returnType, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task WaitForFocused(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task FocusView(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + + public static Task ShowKeyboardForView(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + public static string CreateColorAtPointError(this UIImage bitmap, UIColor expectedColor, int x, int y) => CreateColorError(bitmap, $"Expected {expectedColor} at point {x},{y} in renderered view.");