diff --git a/THIRD-PARTY-NOTICES.TXT b/THIRD-PARTY-NOTICES.TXT index ccc2d383f7f2..58bb1b5a09e9 100644 --- a/THIRD-PARTY-NOTICES.TXT +++ b/THIRD-PARTY-NOTICES.TXT @@ -465,3 +465,31 @@ License notice for Gradle (https://github.com/gradle/gradle) ============================================================================== + +License notice for IQKeyboardManager +========================================= + +(https://github.com/hackiftekhar/IQKeyboardManager/blob/master/LICENSE.md) + +MIT License + +Copyright (c) 2013-2017 Iftekhar Qurashi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +========================================= diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs index 78d57258ecac..ae27b27949c7 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs @@ -182,7 +182,7 @@ static bool OnShouldReturn(UITextField view) if (handler != null) handler(realCell, EventArgs.Empty); - view.ResignFirstResponder(); + KeyboardAutoManager.GoToNextResponderOrResign(view); return true; } diff --git a/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs b/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs index 5380d17d193f..278516ca3636 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs @@ -3,6 +3,7 @@ using Microsoft.Maui.Graphics; using ObjCRuntime; using UIKit; +using Microsoft.Maui.Platform; namespace Microsoft.Maui.Handlers { @@ -122,9 +123,7 @@ public static void MapFormatting(IEntryHandler handler, IEntry entry) protected virtual bool OnShouldReturn(UITextField view) { - view.ResignFirstResponder(); - - // TODO: Focus next View + KeyboardAutoManager.GoToNextResponderOrResign(view); VirtualView?.Completed(); diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs new file mode 100644 index 000000000000..c0380f87956c --- /dev/null +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -0,0 +1,50 @@ +/* + * This class is adapted from IQKeyboardManager which is an open-source + * library implemented for iOS to handle Keyboard interactions with + * UITextFields/UITextViews. Link to their MIT License can be found here: + * https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md + */ + +using System; +using UIKit; + +namespace Microsoft.Maui.Platform; + +internal static class KeyboardAutoManager +{ + internal static void GoToNextResponderOrResign(UIView view, UIView? customSuperView = null) + { + if (!view.CheckIfEligible()) + { + view.ResignFirstResponder(); + return; + } + + var superview = customSuperView ?? view.FindResponder()?.View; + if (superview is null) + { + view.ResignFirstResponder(); + return; + } + + var nextField = view.FindNextView(superview, view => + { + var isValidTextView = view is UITextView textView && textView.Editable; + var isValidTextField = view is UITextField textField && textField.Enabled; + + return (isValidTextView || isValidTextField) && !view.Hidden && view.Alpha != 0f; + }); + + view.ChangeFocusedView(nextField); + } + + static bool CheckIfEligible(this UIView view) + { + if (view is UITextField field && field.ReturnKeyType == UIReturnKeyType.Next) + return true; + else if (view is UITextView) + return true; + + return false; + } +} diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index 0874ea676d9b..1bdc07fc2914 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -698,5 +698,66 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton if (stroke.CornerRadius >= 0) layer.CornerRadius = stroke.CornerRadius; } + + internal static T? FindResponder(this UIView view) where T : UIResponder + { + var nextResponder = view as UIResponder; + while (nextResponder is not null) + { + nextResponder = nextResponder.NextResponder; + + if (nextResponder is T responder) + return responder; + } + return null; + } + + internal static UIView? FindNextView(this UIView view, UIView superView, Func isValidType) + { + var passedOriginal = false; + + var nextView = superView.FindNextView(view, ref passedOriginal, isValidType); + + // if we did not find the next view, try to find the first one + nextView ??= superView.FindNextView(null, ref passedOriginal, isValidType); + + return nextView; + } + + static UIView? FindNextView(this UIView view, UIView? origView, ref bool passedOriginal, Func isValidType) + { + foreach (var child in view.Subviews) + { + if (isValidType(child)) + { + if (origView is null) + return child; + + if (passedOriginal) + return child; + + if (child == origView) + passedOriginal = true; + } + + else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) + { + var nextLevel = child.FindNextView(origView, ref passedOriginal, isValidType); + if (nextLevel is not null) + return nextLevel; + } + } + + return null; + } + + internal static void ChangeFocusedView(this UIView view, UIView? newView) + { + if (newView is null) + view.ResignFirstResponder(); + + else + newView.BecomeFirstResponder(); + } } } diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index 53e04584ca71..eecf49a86259 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -1,9 +1,11 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Foundation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using ObjCRuntime; using UIKit; using Xunit; @@ -88,6 +90,396 @@ public async Task CharacterSpacingInitializesCorrectly() Assert.Equal(xplatCharacterSpacing, values.PlatformViewValue); } + [Fact] + public async Task NextMovesToNextEntry() + { + var entry1 = new EntryStub + { + Text = "Entry 1", + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + Text = "Entry 2", + ReturnType = ReturnType.Next + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); + Assert.True(entry2.IsFocused); + }, entry1, entry2); + } + + [Fact] + public async Task NextMovesPastNotEnabledEntry() + { + var entry1 = new EntryStub + { + Text = "Entry 1", + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + Text = "Entry 2", + ReturnType = ReturnType.Next, + IsEnabled = false + }; + + var entry3 = new EntryStub + { + Text = "Entry 2", + ReturnType = ReturnType.Next + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); + Assert.True(entry3.IsFocused); + }, entry1, entry2, entry3); + } + + [Fact] + public async Task NextMovesToEditor() + { + var entry = new EntryStub + { + Text = "Entry", + ReturnType = ReturnType.Next + }; + + var editor = new EditorStub + { + Text = "Editor" + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); + Assert.True(editor.IsFocused); + }, entry, editor); + } + + [Fact] + public async Task NextMovesPastNotEnabledEditor() + { + var entry = new EntryStub + { + Text = "Entry", + ReturnType = ReturnType.Next + }; + + var editor1 = new EditorStub + { + Text = "Editor1", + IsEnabled = false + }; + + var editor2 = new EditorStub + { + Text = "Editor2" + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); + Assert.True(editor2.IsFocused); + }, entry, editor1, editor2); + } + + [Fact] + public async Task NextMovesToSearchBar() + { + var entry = new EntryStub + { + Text = "Entry", + ReturnType = ReturnType.Next + }; + + var searchBar = new SearchBarStub + { + Text = "Search Bar" + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); + var uISearchBar = searchBar.Handler.PlatformView as UISearchBar; + Assert.True(uISearchBar.GetSearchTextField().IsFirstResponder); + }, entry, searchBar); + } + + [Fact] + public async Task NextMovesRightToLeftEntry() + { + var hsl = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.RightToLeft + }; + + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + hsl.Add(entry1); + hsl.Add(entry2); + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), customSuperView: hsl.ToPlatform().Superview); + var entry1Rect = entry1.ToPlatform().ConvertRectToView(entry1.ToPlatform().Bounds, hsl.ToPlatform()); + var entry2Rect = entry2.ToPlatform().ConvertRectToView(entry2.ToPlatform().Bounds, hsl.ToPlatform()); + Assert.True(entry1Rect.Right > entry2Rect.Right); + Assert.True(entry2.IsFocused); + }, hsl); + } + + [Fact] + public async Task NextMovesRightToLeftMultilineEntry() + { + var hsl1 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.RightToLeft + }; + + var hsl2 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.RightToLeft + }; + + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry3 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry4 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + hsl1.Add(entry1); + hsl1.Add(entry2); + hsl2.Add(entry3); + hsl2.Add(entry4); + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry2.ToPlatform(), customSuperView: hsl1.ToPlatform().Superview); + var entry2Rect = entry2.ToPlatform().ConvertRectToView(entry2.ToPlatform().Bounds, hsl1.ToPlatform()); + var entry3Rect = entry3.ToPlatform().ConvertRectToView(entry3.ToPlatform().Bounds, hsl2.ToPlatform()); + Assert.True(entry2Rect.Right < entry3Rect.Right); + Assert.True(entry3.IsFocused); + }, hsl1, hsl2); + } + + [Fact] + public async Task NextMovesLtrToRtlMultilineEntry() + { + var hsl1 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.LeftToRight + }; + + var hsl2 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.RightToLeft + }; + + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry3 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + var entry4 = new EntryStub + { + ReturnType = ReturnType.Next, + Width = 25 + }; + + hsl1.Add(entry1); + hsl1.Add(entry2); + hsl2.Add(entry3); + hsl2.Add(entry4); + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry2.ToPlatform(), customSuperView: hsl1.ToPlatform().Superview); + var entry2Rect = entry2.ToPlatform().ConvertRectToView(entry2.ToPlatform().Bounds, hsl1.ToPlatform()); + var entry3Rect = entry3.ToPlatform().ConvertRectToView(entry3.ToPlatform().Bounds, hsl2.ToPlatform()); + Assert.True(entry2Rect.Right < entry3Rect.Right); + Assert.True(entry3.IsFocused); + }, hsl1, hsl2); + } + + [Fact] + public async Task NextMovesRtlToLtrMultilineEntry() + { + var hsl1 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.RightToLeft + }; + + var hsl2 = new HorizontalStackLayoutStub + { + FlowDirection = FlowDirection.LeftToRight + }; + + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry3 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry4 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + hsl1.Add(entry1); + hsl1.Add(entry2); + hsl2.Add(entry3); + hsl2.Add(entry4); + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry2.ToPlatform(), customSuperView: hsl1.ToPlatform().Superview); + var entry2Rect = entry2.ToPlatform().ConvertRectToView(entry2.ToPlatform().Bounds, hsl1.ToPlatform()); + var entry3Rect = entry3.ToPlatform().ConvertRectToView(entry3.ToPlatform().Bounds, hsl2.ToPlatform()); + Assert.True(entry2Rect.Right > entry3Rect.Right); + Assert.True(entry3.IsFocused); + }, hsl1, hsl2); + } + + [Fact] + public async Task NextMovesBackToTop() + { + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry2.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); + Assert.True(entry1.IsFocused); + }, entry1, entry2); + } + + [Fact] + public async Task NextMovesBackToTopIgnoringNotEnabled() + { + var entry1 = new EntryStub + { + ReturnType = ReturnType.Next, + IsEnabled = false + }; + + var editor = new EntryStub + { + ReturnType = ReturnType.Next, + IsEnabled = false + }; + + var entry2 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + var entry3 = new EntryStub + { + ReturnType = ReturnType.Next + }; + + await NextMovesHelper(() => + { + KeyboardAutoManager.GoToNextResponderOrResign(entry3.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); + Assert.True(entry2.IsFocused); + }, entry1, editor, entry2, entry3); + } + + async Task NextMovesHelper(Action action = null, params StubBase[] views) + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handler => + { + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + }); + }); + + var layout = new VerticalStackLayoutStub(); + + foreach (var view in views) + { + layout.Add(view); + } + + layout.Width = 300; + layout.Height = 150; + + await InvokeOnMainThreadAsync(async () => + { + var contentViewHandler = CreateHandler(layout); + await contentViewHandler.PlatformView.AttachAndRun(() => + { + action?.Invoke(); + }); + }); + } + double GetNativeCharacterSpacing(EntryHandler entryHandler) { var entry = GetNativeEntry(entryHandler); diff --git a/src/Core/tests/DeviceTests/Stubs/HorizontalStackLayoutStub.cs b/src/Core/tests/DeviceTests/Stubs/HorizontalStackLayoutStub.cs new file mode 100644 index 000000000000..327370aef087 --- /dev/null +++ b/src/Core/tests/DeviceTests/Stubs/HorizontalStackLayoutStub.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 HorizontalStackLayoutStub : LayoutStub, IStackLayout + { + public double Spacing => 0; + + protected override ILayoutManager CreateLayoutManager() + { + return new HorizontalStackLayoutManager(this); + } + } +}