[iOS] Fix Entry Next Keyboard Button Finds Next TextField (#11914)

* Enable the Next button on keyboard to find next text field - ios

* Use the most top superview or allow user to specify

* Add message for IQKeyboard and stop the upward search as the ContainerViewController

* add ThirdPartyNotice.txt from Android repo

* remove the ThirdPartyNotices.txt file for now

* address Shane comments and use more efficient search for next field

* make the search modular and more generic to fit inside ViewExtensions.cs

* change signatures for tests

* add third party notice

* Change names, support RightToLeft, loop back to beginning

* add comment for IsRtl

* Use logical tree ordering, add more unit tests, and create horizontalstacklayoutstub

---------

Co-authored-by: TJ Lambert <tjlambert@microsoft.com>
This commit is contained in:
TJ Lambert 2023-02-06 14:52:44 -06:00 коммит произвёл GitHub
Родитель 605a70e813
Коммит 384137241d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 554 добавлений и 5 удалений

Просмотреть файл

@ -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.
=========================================

Просмотреть файл

@ -183,7 +183,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility
if (handler != null)
handler(realCell, EventArgs.Empty);
view.ResignFirstResponder();
KeyboardAutoManager.GoToNextResponderOrResign(view);
return true;
}

Просмотреть файл

@ -3,6 +3,7 @@ using Foundation;
using Microsoft.Maui.Graphics;
using ObjCRuntime;
using UIKit;
using Microsoft.Maui.Platform;
namespace Microsoft.Maui.Handlers
{
@ -122,9 +123,7 @@ namespace Microsoft.Maui.Handlers
protected virtual bool OnShouldReturn(UITextField view)
{
view.ResignFirstResponder();
// TODO: Focus next View
KeyboardAutoManager.GoToNextResponderOrResign(view);
VirtualView?.Completed();

Просмотреть файл

@ -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<ContainerViewController>()?.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;
}
}

Просмотреть файл

@ -721,5 +721,66 @@ namespace Microsoft.Maui.Platform
if (stroke.CornerRadius >= 0)
layer.CornerRadius = stroke.CornerRadius;
}
internal static T? FindResponder<T>(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<UIView, bool> 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<UIView, bool> 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();
}
}
}

Просмотреть файл

@ -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 @@ namespace Microsoft.Maui.DeviceTests
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<VerticalStackLayoutStub, LayoutHandler>();
handler.AddHandler<HorizontalStackLayoutStub, LayoutHandler>();
handler.AddHandler<EntryStub, EntryHandler>();
handler.AddHandler<EditorStub, EditorHandler>();
handler.AddHandler<SearchBarStub, SearchBarHandler>();
});
});
var layout = new VerticalStackLayoutStub();
foreach (var view in views)
{
layout.Add(view);
}
layout.Width = 300;
layout.Height = 150;
await InvokeOnMainThreadAsync(async () =>
{
var contentViewHandler = CreateHandler<LayoutHandler>(layout);
await contentViewHandler.PlatformView.AttachAndRun(() =>
{
action?.Invoke();
});
});
}
double GetNativeCharacterSpacing(EntryHandler entryHandler)
{
var entry = GetNativeEntry(entryHandler);

Просмотреть файл

@ -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);
}
}
}