[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:
Родитель
605a70e813
Коммит
384137241d
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче