From 1e92437164edc8f3eab7d0e7c2baed453cf49b78 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Tue, 6 Dec 2022 17:56:42 -0600 Subject: [PATCH 01/12] Enable the Next button on keyboard to find next text field - ios --- .../src/Handlers/Entry/EntryHandler.iOS.cs | 5 +- .../src/Platform/iOS/KeyboardAutoManager.cs | 126 ++++++++++++++ .../Handlers/Entry/EntryHandlerTests.iOS.cs | 158 +++++++++++++++++- 3 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/Core/src/Platform/iOS/KeyboardAutoManager.cs 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..67f6e499bcd0 --- /dev/null +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using UIKit; + +namespace Microsoft.Maui.Platform; + +internal static class KeyboardAutoManager +{ + internal static void GoToNextResponderOrResign(UIView view) + { + if (!view.CheckIfEligible()) + { + view.ResignFirstResponder(); + return; + } + + var textFields = GetDeepResponderViews(view.Superview); + + // get the index of the current textField and go to the next one + var currentIndex = textFields.FindIndex(v => v == view); + var nextIndex = currentIndex < textFields.Count - 1 ? currentIndex + 1 : -1; + + if (nextIndex != -1) + textFields[nextIndex].BecomeFirstResponder(); + else + view.ResignFirstResponder(); + } + + 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; + } + + internal static List GetDeepResponderViews(UIView view) + { + var textItems = view.GetResponderViews(); + + textItems.Sort(new ResponderSorter()); + + return textItems; + } + + static List GetResponderViews(this UIView view) + { + var textItems = new List(); + + foreach (var child in view.Subviews) + { + if (child is UITextField textField && child.CanBecomeFirstResponder()) + textItems.Add(textField); + + else if (child is UITextView textView && child.CanBecomeFirstResponder()) + textItems.Add(textView); + + else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) + textItems.AddRange(child.GetResponderViews()); + } + + return textItems; + } + + static bool CanBecomeFirstResponder(this UIView view) + { + var isFirstResponder = false; + + if (view is UITextView tview) + isFirstResponder = tview.Editable; + else if (view is UITextField field) + isFirstResponder = field.Enabled; + + return !isFirstResponder ? false : + !view.Hidden + && view.Alpha != 0f + && !view.IsAlertViewTextField(); + } + + static bool IsAlertViewTextField(this UIView view) + { + var alertViewController = view.GetViewController(); + + while (alertViewController is not null) + { + if (alertViewController is UIAlertController) + return true; + + alertViewController = alertViewController.NextResponder as UIViewController; + } + + return false; + } + + static UIViewController? GetViewController(this UIView view) + { + var nextResponder = view as UIResponder; + while (nextResponder is not null) + { + nextResponder = nextResponder.NextResponder; + + if (nextResponder is UIViewController viewController) + return viewController; + } + return null; + } + + class ResponderSorter : Comparer + { + public override int Compare(UIView? view1, UIView? view2) + { + if (view1 is null || view2 is null) + return 1; + + var bound1 = view1.ConvertRectToView(view1.Bounds, null); + var bound2 = view2.ConvertRectToView(view2.Bounds, null); + + if (bound1.Top != bound2.Top) + return bound1.Top < bound2.Top ? -1 : 1; + else + return bound1.Left < bound2.Left ? -1 : 1; + } + } +} + diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index f7097870ae99..22f39d5e79e7 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; @@ -114,6 +116,160 @@ 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()); + 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()); + 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()); + 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()); + 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()); + var uISearchBar = searchBar.Handler.PlatformView as UISearchBar; + Assert.True(uISearchBar.GetSearchTextField().IsFirstResponder); + }, entry, searchBar); + } + + async Task NextMovesHelper(Action action = null, params StubBase[] views) + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handler => + { + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + }); + }); + + var layout = new VerticalStackLayoutStub(); + + foreach (var view in views) + { + layout.Add(view); + } + + layout.Width = 100; + layout.Height = 150; + + await InvokeOnMainThreadAsync(async () => + { + var contentViewHandler = CreateHandler(layout); + await contentViewHandler.PlatformView.AttachAndRun(() => + { + action?.Invoke(); + }); + }); + } + double GetNativeCharacterSpacing(EntryHandler entryHandler) { var entry = GetNativeEntry(entryHandler); From 7ac22f72e3641972e94710eccf200f5d5f4c8692 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Tue, 6 Dec 2022 18:48:10 -0600 Subject: [PATCH 02/12] Use the most top superview or allow user to specify --- .../src/Platform/iOS/KeyboardAutoManager.cs | 18 ++++++++++++++++-- .../Handlers/Entry/EntryHandlerTests.iOS.cs | 10 +++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 67f6e499bcd0..921e2b01da98 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -5,7 +5,8 @@ namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { - internal static void GoToNextResponderOrResign(UIView view) + // you can provide a topView argument or it will use the most top Superview + internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null) { if (!view.CheckIfEligible()) { @@ -13,7 +14,7 @@ internal static void GoToNextResponderOrResign(UIView view) return; } - var textFields = GetDeepResponderViews(view.Superview); + var textFields = GetDeepResponderViews(topView ?? view.FindTopView()); // get the index of the current textField and go to the next one var currentIndex = textFields.FindIndex(v => v == view); @@ -35,6 +36,19 @@ static bool CheckIfEligible(this UIView view) return false; } + static UIView FindTopView (this UIView view) + { + var curView = view; + + while (curView.Superview is not null) + { + curView = curView.Superview; + } + + return curView; + } + + // Find all of the eligible UITextFields and UITextViews inside this view internal static List GetDeepResponderViews(UIView view) { var textItems = view.GetResponderViews(); diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index 22f39d5e79e7..bde6a1bea736 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -133,7 +133,7 @@ public async Task NextMovesToNextEntry() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform()); + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), entry1.ToPlatform().Superview); Assert.True(entry2.IsFocused); }, entry1, entry2); } @@ -162,7 +162,7 @@ public async Task NextMovesPastNotEnabledEntry() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform()); + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), entry1.ToPlatform().Superview); Assert.True(entry3.IsFocused); }, entry1, entry2, entry3); } @@ -183,7 +183,7 @@ public async Task NextMovesToEditor() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform()); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); Assert.True(editor.IsFocused); }, entry, editor); } @@ -210,7 +210,7 @@ public async Task NextMovesPastNotEnabledEditor() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform()); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); Assert.True(editor2.IsFocused); }, entry, editor1, editor2); } @@ -231,7 +231,7 @@ public async Task NextMovesToSearchBar() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform()); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); var uISearchBar = searchBar.Handler.PlatformView as UISearchBar; Assert.True(uISearchBar.GetSearchTextField().IsFirstResponder); }, entry, searchBar); From ff16920ecc6b13c0b765c5aeed8f5adb6f29b063 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Thu, 8 Dec 2022 12:39:32 -0600 Subject: [PATCH 03/12] Add message for IQKeyboard and stop the upward search as the ContainerViewController --- .../src/Platform/iOS/KeyboardAutoManager.cs | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 921e2b01da98..535620b9b045 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -1,11 +1,18 @@ -using System.Collections.Generic; +/* + * 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 license can be found here: + * https://github.com/hackiftekhar/IQKeyboardManager/tree/09d22c087732b02d4fb594c7bb61502bf7bb2378#license + */ + +using System.Collections.Generic; using UIKit; namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { - // you can provide a topView argument or it will use the most top Superview + // you can provide a topView argument or it will try to find the ContainerViewController internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null) { if (!view.CheckIfEligible()) @@ -14,16 +21,16 @@ internal static void GoToNextResponderOrResign(UIView view, UIView? topView = nu return; } - var textFields = GetDeepResponderViews(topView ?? view.FindTopView()); + var targetSuperview = topView ?? view.GetViewController()?.GetContainerViewController(); + if (targetSuperview is null) + { + view.ResignFirstResponder(); + return; + } - // get the index of the current textField and go to the next one - var currentIndex = textFields.FindIndex(v => v == view); - var nextIndex = currentIndex < textFields.Count - 1 ? currentIndex + 1 : -1; + var textFields = GetDeepResponderViews(targetSuperview); - if (nextIndex != -1) - textFields[nextIndex].BecomeFirstResponder(); - else - view.ResignFirstResponder(); + view.MoveToNextField(textFields); } static bool CheckIfEligible(this UIView view) @@ -36,16 +43,32 @@ static bool CheckIfEligible(this UIView view) return false; } - static UIView FindTopView (this UIView view) + static UIView? GetContainerViewController(this UIViewController controller) { - var curView = view; + var curView = controller.View; - while (curView.Superview is not null) + while (curView?.Superview is not null) { + var curController = curView.GetViewController(); + if (curController is ContainerViewController container) + return container.View; + curView = curView.Superview; } - return curView; + return null; + } + + static void MoveToNextField (this UIView view, List textFields) + { + // get the index of the current textField and go to the next one + var currentIndex = textFields.FindIndex(v => v == view); + var nextIndex = currentIndex < textFields.Count - 1 ? currentIndex + 1 : -1; + + if (nextIndex != -1) + textFields[nextIndex].BecomeFirstResponder(); + else + view.ResignFirstResponder(); } // Find all of the eligible UITextFields and UITextViews inside this view @@ -137,4 +160,3 @@ public override int Compare(UIView? view1, UIView? view2) } } } - From c988a6bd0895b80bce263e13fcf2eb41ab7bd3f8 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Thu, 8 Dec 2022 14:22:29 -0600 Subject: [PATCH 04/12] add ThirdPartyNotice.txt from Android repo --- ThirdPartyNotices.txt | 29 +++++++++++++++++++ .../src/Platform/iOS/KeyboardAutoManager.cs | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 ThirdPartyNotices.txt diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt new file mode 100644 index 000000000000..cc2aff375139 --- /dev/null +++ b/ThirdPartyNotices.txt @@ -0,0 +1,29 @@ +.NET MAUI uses third-party libraries or other resources that may be +distributed under licenses different than the .NET MAUI software. + +Attributions and license notices for test cases originally authored by +third parties can be found in the respective test directories. + +In the event that we accidentally failed to list a required notice, please +bring it to our attention. Post an issue or email us: + + dotnet@microsoft.com + +The attached notices are provided for information only. + +1. IQKeyBoard (https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md) + +IQKeyBoard NOTICES AND INFORMATION BEGIN HERE +=================================================================== +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. + +------------------------------------------------------------------- +END OF IQKeyBoard NOTICES AND INFORMATION diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 535620b9b045..8a9a3e09c36b 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -1,8 +1,8 @@ /* * 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 license can be found here: - * https://github.com/hackiftekhar/IQKeyboardManager/tree/09d22c087732b02d4fb594c7bb61502bf7bb2378#license + * UITextFields/UITextViews. Link to their MIT License can be found here: + * https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md */ using System.Collections.Generic; From 4a176a88f6081e7dff974a951fdbf98d87f5125a Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Thu, 8 Dec 2022 14:58:09 -0600 Subject: [PATCH 05/12] remove the ThirdPartyNotices.txt file for now --- ThirdPartyNotices.txt | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 ThirdPartyNotices.txt diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt deleted file mode 100644 index cc2aff375139..000000000000 --- a/ThirdPartyNotices.txt +++ /dev/null @@ -1,29 +0,0 @@ -.NET MAUI uses third-party libraries or other resources that may be -distributed under licenses different than the .NET MAUI software. - -Attributions and license notices for test cases originally authored by -third parties can be found in the respective test directories. - -In the event that we accidentally failed to list a required notice, please -bring it to our attention. Post an issue or email us: - - dotnet@microsoft.com - -The attached notices are provided for information only. - -1. IQKeyBoard (https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md) - -IQKeyBoard NOTICES AND INFORMATION BEGIN HERE -=================================================================== -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. - -------------------------------------------------------------------- -END OF IQKeyBoard NOTICES AND INFORMATION From 93512f9f852732dfc6c19cf4db3800adcb767c8a Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Wed, 4 Jan 2023 10:54:56 -0600 Subject: [PATCH 06/12] address Shane comments and use more efficient search for next field --- .../ListView/iOS/EntryCellRenderer.cs | 2 +- .../src/Platform/iOS/KeyboardAutoManager.cs | 148 +++++++----------- src/Core/src/Platform/iOS/ViewExtensions.cs | 13 ++ 3 files changed, 69 insertions(+), 94 deletions(-) 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..b882582cf130 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, isEligibleOverride: true); return true; } diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 8a9a3e09c36b..49d1c9408b03 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -6,6 +6,7 @@ */ using System.Collections.Generic; +using CoreGraphics; using UIKit; namespace Microsoft.Maui.Platform; @@ -13,29 +14,29 @@ namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { // you can provide a topView argument or it will try to find the ContainerViewController - internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null) + internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null, bool isEligibleOverride = false) { - if (!view.CheckIfEligible()) + if (!view.CheckIfEligible(isEligibleOverride)) { view.ResignFirstResponder(); return; } - var targetSuperview = topView ?? view.GetViewController()?.GetContainerViewController(); + var targetSuperview = topView ?? view.GetViewController()?.View; if (targetSuperview is null) { view.ResignFirstResponder(); return; } - var textFields = GetDeepResponderViews(targetSuperview); - - view.MoveToNextField(textFields); + var nextField = view.FindNextField(targetSuperview); + view.MoveToNextField(nextField); } - static bool CheckIfEligible(this UIView view) + static bool CheckIfEligible(this UIView view, bool isEligibleOverride) { - if (view is UITextField field && field.ReturnKeyType == UIReturnKeyType.Next) + // have is isEligibleOverride flag since EntryCells have a default ReturnKeyType by default + if (view is UITextField field && (field.ReturnKeyType == UIReturnKeyType.Next || isEligibleOverride)) return true; else if (view is UITextView) return true; @@ -43,120 +44,81 @@ static bool CheckIfEligible(this UIView view) return false; } - static UIView? GetContainerViewController(this UIViewController controller) - { - var curView = controller.View; - - while (curView?.Superview is not null) - { - var curController = curView.GetViewController(); - if (curController is ContainerViewController container) - return container.View; - - curView = curView.Superview; - } - - return null; - } - - static void MoveToNextField (this UIView view, List textFields) - { - // get the index of the current textField and go to the next one - var currentIndex = textFields.FindIndex(v => v == view); - var nextIndex = currentIndex < textFields.Count - 1 ? currentIndex + 1 : -1; - - if (nextIndex != -1) - textFields[nextIndex].BecomeFirstResponder(); - else - view.ResignFirstResponder(); - } - - // Find all of the eligible UITextFields and UITextViews inside this view - internal static List GetDeepResponderViews(UIView view) + internal static UIView? FindNextField(this UIView view, UIView superView) { - var textItems = view.GetResponderViews(); + var originalRect = view.ConvertRectToView(view.Bounds, null); + var nextField = superView.SearchBestField(originalRect, null); - textItems.Sort(new ResponderSorter()); - - return textItems; + return nextField; } - static List GetResponderViews(this UIView view) + static UIView? SearchBestField(this UIView view, CGRect originalRect, UIView? currentBest) { - var textItems = new List(); - foreach (var child in view.Subviews) { - if (child is UITextField textField && child.CanBecomeFirstResponder()) - textItems.Add(textField); - - else if (child is UITextView textView && child.CanBecomeFirstResponder()) - textItems.Add(textView); + if ((child is UITextField || child is UITextView) && child.CanBecomeFirstResponder()) + { + if (TryFindNewBestField(originalRect, currentBest, child, out var newBest)) + currentBest = newBest; + } else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) - textItems.AddRange(child.GetResponderViews()); + { + var newBestChild = child.SearchBestField(originalRect, currentBest); + if (newBestChild is not null && TryFindNewBestField(originalRect, currentBest, newBestChild, out var newBest)) + currentBest = newBest; + } } - return textItems; + return currentBest; } - static bool CanBecomeFirstResponder(this UIView view) + static bool TryFindNewBestField(CGRect originalRect, UIView? currentBest, UIView newView, out UIView newBest) { - var isFirstResponder = false; - - if (view is UITextView tview) - isFirstResponder = tview.Editable; - else if (view is UITextField field) - isFirstResponder = field.Enabled; - - return !isFirstResponder ? false : - !view.Hidden - && view.Alpha != 0f - && !view.IsAlertViewTextField(); - } + var currentBestRect = currentBest?.ConvertRectToView(currentBest.Bounds, null); + var newViewRect = newView.ConvertRectToView(newView.Bounds, null); - static bool IsAlertViewTextField(this UIView view) - { - var alertViewController = view.GetViewController(); + var cbrValue = currentBestRect.GetValueOrDefault(); + newBest = newView; - while (alertViewController is not null) + if (originalRect.Top < newViewRect.Top && + (currentBestRect is null || newViewRect.Top < cbrValue.Top)) { - if (alertViewController is UIAlertController) - return true; + return true; + } - alertViewController = alertViewController.NextResponder as UIViewController; + else if (originalRect.Top == newViewRect.Top && + originalRect.Left < newViewRect.Left && + (currentBestRect is null || newViewRect.Left < cbrValue.Left)) + { + return true; } return false; } - static UIViewController? GetViewController(this UIView view) + static void MoveToNextField(this UIView view, UIView? newView) { - var nextResponder = view as UIResponder; - while (nextResponder is not null) - { - nextResponder = nextResponder.NextResponder; + if (newView is null) + view.ResignFirstResponder(); - if (nextResponder is UIViewController viewController) - return viewController; - } - return null; + else + newView.BecomeFirstResponder(); } - class ResponderSorter : Comparer + static bool CanBecomeFirstResponder(this UIView view) { - public override int Compare(UIView? view1, UIView? view2) - { - if (view1 is null || view2 is null) - return 1; + var isFirstResponder = false; - var bound1 = view1.ConvertRectToView(view1.Bounds, null); - var bound2 = view2.ConvertRectToView(view2.Bounds, null); + if (view is UITextView tview) + isFirstResponder = tview.Editable; + else if (view is UITextField field) + isFirstResponder = field.Enabled; - if (bound1.Top != bound2.Top) - return bound1.Top < bound2.Top ? -1 : 1; - else - return bound1.Left < bound2.Left ? -1 : 1; - } + return !isFirstResponder ? false : + !view.Hidden + && view.Alpha != 0f; + // && !view.IsAlertViewTextField(); + // the above is in the original code but is not useful here } } diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index 0874ea676d9b..fd8bc7fcddae 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -698,5 +698,18 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton if (stroke.CornerRadius >= 0) layer.CornerRadius = stroke.CornerRadius; } + + internal static UIViewController? GetViewController(this UIView view) where T : UIViewController + { + var nextResponder = view as UIResponder; + while (nextResponder is not null) + { + nextResponder = nextResponder.NextResponder; + + if (nextResponder is T viewController) + return viewController; + } + return null; + } } } From 488812ac104eea259b4b2dc467d706b332c03dc2 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Wed, 4 Jan 2023 17:37:35 -0600 Subject: [PATCH 07/12] make the search modular and more generic to fit inside ViewExtensions.cs --- .../src/Platform/iOS/KeyboardAutoManager.cs | 100 ++---------------- src/Core/src/Platform/iOS/ViewExtensions.cs | 97 +++++++++++++++++ 2 files changed, 107 insertions(+), 90 deletions(-) diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 49d1c9408b03..6c34ffce4ea5 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -5,120 +5,40 @@ * https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md */ -using System.Collections.Generic; -using CoreGraphics; +using System; using UIKit; namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { - // you can provide a topView argument or it will try to find the ContainerViewController - internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null, bool isEligibleOverride = false) + internal static void GoToNextResponderOrResign(UIView view, bool isUnchangeableReturnKey = false) { - if (!view.CheckIfEligible(isEligibleOverride)) + if (!view.CheckIfEligible(isUnchangeableReturnKey)) { view.ResignFirstResponder(); return; } - var targetSuperview = topView ?? view.GetViewController()?.View; - if (targetSuperview is null) + var superview = view.GetViewController()?.View; + if (superview is null) { view.ResignFirstResponder(); return; } - var nextField = view.FindNextField(targetSuperview); - view.MoveToNextField(nextField); + var nextField = view.FindNextView(superview, new Type[] { typeof(UITextView), typeof(UITextField) }); + view.ChangeFocusedView(nextField); } - static bool CheckIfEligible(this UIView view, bool isEligibleOverride) + static bool CheckIfEligible(this UIView view, bool isUnchangeableReturnKey) { - // have is isEligibleOverride flag since EntryCells have a default ReturnKeyType by default - if (view is UITextField field && (field.ReturnKeyType == UIReturnKeyType.Next || isEligibleOverride)) + // have isUnchangeableReturnKey flag since EntryCells do not have a public property to change ReturnKeyType + if (view is UITextField field && (field.ReturnKeyType == UIReturnKeyType.Next || isUnchangeableReturnKey)) return true; else if (view is UITextView) return true; return false; } - - internal static UIView? FindNextField(this UIView view, UIView superView) - { - var originalRect = view.ConvertRectToView(view.Bounds, null); - var nextField = superView.SearchBestField(originalRect, null); - - return nextField; - } - - static UIView? SearchBestField(this UIView view, CGRect originalRect, UIView? currentBest) - { - foreach (var child in view.Subviews) - { - if ((child is UITextField || child is UITextView) && child.CanBecomeFirstResponder()) - { - if (TryFindNewBestField(originalRect, currentBest, child, out var newBest)) - currentBest = newBest; - } - - else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) - { - var newBestChild = child.SearchBestField(originalRect, currentBest); - if (newBestChild is not null && TryFindNewBestField(originalRect, currentBest, newBestChild, out var newBest)) - currentBest = newBest; - } - } - - return currentBest; - } - - static bool TryFindNewBestField(CGRect originalRect, UIView? currentBest, UIView newView, out UIView newBest) - { - var currentBestRect = currentBest?.ConvertRectToView(currentBest.Bounds, null); - var newViewRect = newView.ConvertRectToView(newView.Bounds, null); - - var cbrValue = currentBestRect.GetValueOrDefault(); - newBest = newView; - - if (originalRect.Top < newViewRect.Top && - (currentBestRect is null || newViewRect.Top < cbrValue.Top)) - { - return true; - } - - else if (originalRect.Top == newViewRect.Top && - originalRect.Left < newViewRect.Left && - (currentBestRect is null || newViewRect.Left < cbrValue.Left)) - { - return true; - } - - return false; - } - - static void MoveToNextField(this UIView view, UIView? newView) - { - if (newView is null) - view.ResignFirstResponder(); - - else - newView.BecomeFirstResponder(); - } - - static bool CanBecomeFirstResponder(this UIView view) - { - var isFirstResponder = false; - - if (view is UITextView tview) - isFirstResponder = tview.Editable; - else if (view is UITextField field) - isFirstResponder = field.Enabled; - - return !isFirstResponder ? false : - !view.Hidden - && view.Alpha != 0f; - // && !view.IsAlertViewTextField(); - // the above is in the original code but is not useful here - } } diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index fd8bc7fcddae..890a115538dd 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -711,5 +711,102 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton } return null; } + + internal static UIView? FindNextView(this UIView view, UIView superView, Type[] requestedTypes) + { + if (requestedTypes is null) + return null; + + // calculate the original CGRect parameters once here instead of multiple times later + var originalRect = view.ConvertRectToView(view.Bounds, null); + + var nextField = superView.SearchBestNextView(originalRect, null, requestedTypes); + return nextField; + } + + static UIView? SearchBestNextView(this UIView view, CGRect originalRect, UIView? currentBest, Type[] requestedTypes) + { + foreach (var child in view.Subviews) + { + var inheritsType = false; + + foreach (var t in requestedTypes) + { + if (child.GetType().IsSubclassOf(t) || child.GetType() == t) + { + inheritsType = true; + break; + } + } + + if (inheritsType && child.CanBecomeFirstResponder()) + { + if (TryFindNewBestView(originalRect, currentBest, child, out var newBest)) + currentBest = newBest; + } + + else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) + { + var newBestChild = child.SearchBestNextView(originalRect, currentBest, requestedTypes); + if (newBestChild is not null && TryFindNewBestView(originalRect, currentBest, newBestChild, out var newBest)) + currentBest = newBest; + } + } + + return currentBest; + } + + static bool TryFindNewBestView(CGRect originalRect, UIView? currentBest, UIView newView, out UIView newBest) + { + var currentBestRect = currentBest?.ConvertRectToView(currentBest.Bounds, null); + var newViewRect = newView.ConvertRectToView(newView.Bounds, null); + + var cbrValue = currentBestRect.GetValueOrDefault(); + newBest = newView; + + if (originalRect.Top < newViewRect.Top && + (currentBestRect is null || newViewRect.Top < cbrValue.Top)) + { + return true; + } + + else if (originalRect.Top == newViewRect.Top && + originalRect.Left < newViewRect.Left && + (currentBestRect is null || newViewRect.Left < cbrValue.Left)) + { + return true; + } + + return false; + } + + internal static void ChangeFocusedView(this UIView view, UIView? newView) + { + if (newView is null) + view.ResignFirstResponder(); + + else + newView.BecomeFirstResponder(); + } + + static bool CanBecomeFirstResponder(this UIView view) + { + var isFirstResponder = false; + + switch (view) + { + case UITextView tview: + isFirstResponder = tview.Editable; + break; + case UITextField field: + isFirstResponder = field.Enabled; + break; + // add in other control enabled properties here as necessary + default: + break; + } + + return isFirstResponder && !view.Hidden && view.Alpha != 0f; + } } } From a52da3ffe783f466672cf2c9fb12a83357a4a781 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Wed, 4 Jan 2023 18:02:14 -0600 Subject: [PATCH 08/12] change signatures for tests --- .../Handlers/ListView/iOS/EntryCellRenderer.cs | 2 +- src/Core/src/Platform/iOS/KeyboardAutoManager.cs | 4 ++-- .../Handlers/Entry/EntryHandlerTests.iOS.cs | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) 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 b882582cf130..19b7eeb5ebb5 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); - KeyboardAutoManager.GoToNextResponderOrResign(view, isEligibleOverride: true); + KeyboardAutoManager.GoToNextResponderOrResign(view, true); return true; } diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index 6c34ffce4ea5..bb6c25f75eb7 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -12,7 +12,7 @@ namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { - internal static void GoToNextResponderOrResign(UIView view, bool isUnchangeableReturnKey = false) + internal static void GoToNextResponderOrResign(UIView view, bool isUnchangeableReturnKey = false, UIView? customSuperView = null) { if (!view.CheckIfEligible(isUnchangeableReturnKey)) { @@ -20,7 +20,7 @@ internal static void GoToNextResponderOrResign(UIView view, bool isUnchangeableR return; } - var superview = view.GetViewController()?.View; + var superview = customSuperView ?? view.GetViewController()?.View; if (superview is null) { view.ResignFirstResponder(); diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index 12e919d43175..66666152e96f 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -107,7 +107,7 @@ public async Task NextMovesToNextEntry() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), entry1.ToPlatform().Superview); + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); Assert.True(entry2.IsFocused); }, entry1, entry2); } @@ -136,7 +136,7 @@ public async Task NextMovesPastNotEnabledEntry() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), entry1.ToPlatform().Superview); + KeyboardAutoManager.GoToNextResponderOrResign(entry1.ToPlatform(), customSuperView: entry1.ToPlatform().Superview); Assert.True(entry3.IsFocused); }, entry1, entry2, entry3); } @@ -157,7 +157,7 @@ public async Task NextMovesToEditor() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); Assert.True(editor.IsFocused); }, entry, editor); } @@ -184,7 +184,7 @@ public async Task NextMovesPastNotEnabledEditor() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); Assert.True(editor2.IsFocused); }, entry, editor1, editor2); } @@ -205,7 +205,7 @@ public async Task NextMovesToSearchBar() await NextMovesHelper(() => { - KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), entry.ToPlatform().Superview); + KeyboardAutoManager.GoToNextResponderOrResign(entry.ToPlatform(), customSuperView: entry.ToPlatform().Superview); var uISearchBar = searchBar.Handler.PlatformView as UISearchBar; Assert.True(uISearchBar.GetSearchTextField().IsFirstResponder); }, entry, searchBar); From 28285308015aea99d3c5ec3456c02c894e1838cf Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Wed, 4 Jan 2023 18:11:40 -0600 Subject: [PATCH 09/12] add third party notice --- THIRD-PARTY-NOTICES.TXT | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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. +========================================= From 83fa0eef47c3787bdad49f7b956406580d743d41 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Thu, 19 Jan 2023 03:49:51 -0600 Subject: [PATCH 10/12] Change names, support RightToLeft, loop back to beginning --- .../ListView/iOS/EntryCellRenderer.cs | 2 +- .../src/Platform/iOS/KeyboardAutoManager.cs | 13 +++-- src/Core/src/Platform/iOS/ViewExtensions.cs | 52 +++++++++++-------- 3 files changed, 37 insertions(+), 30 deletions(-) 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 19b7eeb5ebb5..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); - KeyboardAutoManager.GoToNextResponderOrResign(view, true); + KeyboardAutoManager.GoToNextResponderOrResign(view); return true; } diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index bb6c25f75eb7..a69bd9208892 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -12,29 +12,28 @@ namespace Microsoft.Maui.Platform; internal static class KeyboardAutoManager { - internal static void GoToNextResponderOrResign(UIView view, bool isUnchangeableReturnKey = false, UIView? customSuperView = null) + internal static void GoToNextResponderOrResign(UIView view, UIView? customSuperView = null) { - if (!view.CheckIfEligible(isUnchangeableReturnKey)) + if (!view.CheckIfEligible()) { view.ResignFirstResponder(); return; } - var superview = customSuperView ?? view.GetViewController()?.View; + var superview = customSuperView ?? view.FindResponder()?.View; if (superview is null) { view.ResignFirstResponder(); return; } - var nextField = view.FindNextView(superview, new Type[] { typeof(UITextView), typeof(UITextField) }); + var nextField = view.FindNextView(superview, view => { return view is UITextView || view is UITextField; }); view.ChangeFocusedView(nextField); } - static bool CheckIfEligible(this UIView view, bool isUnchangeableReturnKey) + static bool CheckIfEligible(this UIView view) { - // have isUnchangeableReturnKey flag since EntryCells do not have a public property to change ReturnKeyType - if (view is UITextField field && (field.ReturnKeyType == UIReturnKeyType.Next || isUnchangeableReturnKey)) + if (view is UITextField field && field.ReturnKeyType == UIReturnKeyType.Next) return true; else if (view is UITextView) return true; diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index 890a115538dd..813088369876 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -699,55 +699,53 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton layer.CornerRadius = stroke.CornerRadius; } - internal static UIViewController? GetViewController(this UIView view) where T : UIViewController + 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 viewController) - return viewController; + if (nextResponder is T responder) + return responder; } return null; } - internal static UIView? FindNextView(this UIView view, UIView superView, Type[] requestedTypes) - { - if (requestedTypes is null) - return null; + static bool IsRtl = false; + internal static UIView? FindNextView(this UIView view, UIView superView, Func isValidType) + { // calculate the original CGRect parameters once here instead of multiple times later var originalRect = view.ConvertRectToView(view.Bounds, null); - var nextField = superView.SearchBestNextView(originalRect, null, requestedTypes); + IsRtl = false; + var nextField = superView.FindNextView(originalRect, null, isValidType); + + // wrap around to the top if we are at the end to mirror Xamarin.Forms behavior + if (nextField is null) + nextField = superView.FindNextView(new CGRect(float.MinValue, float.MinValue, 0, 0), null, isValidType); + return nextField; } - static UIView? SearchBestNextView(this UIView view, CGRect originalRect, UIView? currentBest, Type[] requestedTypes) + static UIView? FindNextView(this UIView view, CGRect originalRect, UIView? currentBest, Func isValidType) { + IsRtl |= view.SemanticContentAttribute == UISemanticContentAttribute.ForceRightToLeft; + foreach (var child in view.Subviews) { - var inheritsType = false; - - foreach (var t in requestedTypes) - { - if (child.GetType().IsSubclassOf(t) || child.GetType() == t) - { - inheritsType = true; - break; - } - } - - if (inheritsType && child.CanBecomeFirstResponder()) + if (isValidType(child) && child.CanBecomeFirstResponder()) { if (TryFindNewBestView(originalRect, currentBest, child, out var newBest)) + { currentBest = newBest; + } } else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) { - var newBestChild = child.SearchBestNextView(originalRect, currentBest, requestedTypes); + var newBestChild = child.FindNextView(originalRect, currentBest, isValidType); if (newBestChild is not null && TryFindNewBestView(originalRect, currentBest, newBestChild, out var newBest)) currentBest = newBest; } @@ -770,6 +768,16 @@ static bool TryFindNewBestView(CGRect originalRect, UIView? currentBest, UIView return true; } + else if (IsRtl) + { + if (originalRect.Top == newViewRect.Top && + originalRect.Right > newViewRect.Right && + (currentBestRect is null || newViewRect.Right > cbrValue.Right)) + { + return true; + } + } + else if (originalRect.Top == newViewRect.Top && originalRect.Left < newViewRect.Left && (currentBestRect is null || newViewRect.Left < cbrValue.Left)) From 23bd48ebef5e4fafd6df1414c8c622755bc2b9c0 Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Thu, 19 Jan 2023 03:57:49 -0600 Subject: [PATCH 11/12] add comment for IsRtl --- src/Core/src/Platform/iOS/ViewExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index 813088369876..cd31e6dc0514 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -712,6 +712,7 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton return null; } + // static field to hold value when propagating back out the recursion static bool IsRtl = false; internal static UIView? FindNextView(this UIView view, UIView superView, Func isValidType) From 84dfaf68c3aa7293bc00381c2582b2165f9b7d5e Mon Sep 17 00:00:00 2001 From: TJ Lambert Date: Fri, 27 Jan 2023 13:45:40 -0600 Subject: [PATCH 12/12] Use logical tree ordering, add more unit tests, and create horizontalstacklayoutstub --- .../src/Platform/iOS/KeyboardAutoManager.cs | 9 +- src/Core/src/Platform/iOS/ViewExtensions.cs | 96 ++----- .../Handlers/Entry/EntryHandlerTests.iOS.cs | 238 +++++++++++++++++- .../Stubs/HorizontalStackLayoutStub.cs | 19 ++ 4 files changed, 283 insertions(+), 79 deletions(-) create mode 100644 src/Core/tests/DeviceTests/Stubs/HorizontalStackLayoutStub.cs diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index a69bd9208892..c0380f87956c 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -27,7 +27,14 @@ internal static void GoToNextResponderOrResign(UIView view, UIView? customSuperV return; } - var nextField = view.FindNextView(superview, view => { return view is UITextView || view is UITextField; }); + 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); } diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index cd31e6dc0514..1bdc07fc2914 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -712,81 +712,43 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton return null; } - // static field to hold value when propagating back out the recursion - static bool IsRtl = false; - internal static UIView? FindNextView(this UIView view, UIView superView, Func isValidType) { - // calculate the original CGRect parameters once here instead of multiple times later - var originalRect = view.ConvertRectToView(view.Bounds, null); + var passedOriginal = false; - IsRtl = false; - var nextField = superView.FindNextView(originalRect, null, isValidType); + var nextView = superView.FindNextView(view, ref passedOriginal, isValidType); - // wrap around to the top if we are at the end to mirror Xamarin.Forms behavior - if (nextField is null) - nextField = superView.FindNextView(new CGRect(float.MinValue, float.MinValue, 0, 0), null, isValidType); + // if we did not find the next view, try to find the first one + nextView ??= superView.FindNextView(null, ref passedOriginal, isValidType); - return nextField; + return nextView; } - static UIView? FindNextView(this UIView view, CGRect originalRect, UIView? currentBest, Func isValidType) + static UIView? FindNextView(this UIView view, UIView? origView, ref bool passedOriginal, Func isValidType) { - IsRtl |= view.SemanticContentAttribute == UISemanticContentAttribute.ForceRightToLeft; - foreach (var child in view.Subviews) { - if (isValidType(child) && child.CanBecomeFirstResponder()) + if (isValidType(child)) { - if (TryFindNewBestView(originalRect, currentBest, child, out var newBest)) - { - currentBest = newBest; - } - } - - else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) - { - var newBestChild = child.FindNextView(originalRect, currentBest, isValidType); - if (newBestChild is not null && TryFindNewBestView(originalRect, currentBest, newBestChild, out var newBest)) - currentBest = newBest; - } - } - - return currentBest; - } - - static bool TryFindNewBestView(CGRect originalRect, UIView? currentBest, UIView newView, out UIView newBest) - { - var currentBestRect = currentBest?.ConvertRectToView(currentBest.Bounds, null); - var newViewRect = newView.ConvertRectToView(newView.Bounds, null); + if (origView is null) + return child; - var cbrValue = currentBestRect.GetValueOrDefault(); - newBest = newView; + if (passedOriginal) + return child; - if (originalRect.Top < newViewRect.Top && - (currentBestRect is null || newViewRect.Top < cbrValue.Top)) - { - return true; - } + if (child == origView) + passedOriginal = true; + } - else if (IsRtl) - { - if (originalRect.Top == newViewRect.Top && - originalRect.Right > newViewRect.Right && - (currentBestRect is null || newViewRect.Right > cbrValue.Right)) + else if (child.Subviews.Length > 0 && !child.Hidden && child.Alpha > 0f) { - return true; + var nextLevel = child.FindNextView(origView, ref passedOriginal, isValidType); + if (nextLevel is not null) + return nextLevel; } } - else if (originalRect.Top == newViewRect.Top && - originalRect.Left < newViewRect.Left && - (currentBestRect is null || newViewRect.Left < cbrValue.Left)) - { - return true; - } - - return false; + return null; } internal static void ChangeFocusedView(this UIView view, UIView? newView) @@ -797,25 +759,5 @@ internal static void ChangeFocusedView(this UIView view, UIView? newView) else newView.BecomeFirstResponder(); } - - static bool CanBecomeFirstResponder(this UIView view) - { - var isFirstResponder = false; - - switch (view) - { - case UITextView tview: - isFirstResponder = tview.Editable; - break; - case UITextField field: - isFirstResponder = field.Enabled; - break; - // add in other control enabled properties here as necessary - default: - break; - } - - return isFirstResponder && !view.Hidden && view.Alpha != 0f; - } } } diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index 66666152e96f..eecf49a86259 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -211,6 +211,241 @@ await NextMovesHelper(() => }, 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 => @@ -218,6 +453,7 @@ async Task NextMovesHelper(Action action = null, params StubBase[] views) builder.ConfigureMauiHandlers(handler => { handler.AddHandler(); + handler.AddHandler(); handler.AddHandler(); handler.AddHandler(); handler.AddHandler(); @@ -231,7 +467,7 @@ async Task NextMovesHelper(Action action = null, params StubBase[] views) layout.Add(view); } - layout.Width = 100; + layout.Width = 300; layout.Height = 150; await InvokeOnMainThreadAsync(async () => 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); + } + } +}