Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 14 commits into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions THIRD-PARTY-NOTICES.TXT
Original file line number Diff line number Diff line change
Expand Up @@ -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.
=========================================
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ static bool OnShouldReturn(UITextField view)
if (handler != null)
handler(realCell, EventArgs.Empty);

view.ResignFirstResponder();
KeyboardAutoManager.GoToNextResponderOrResign(view, true);
return true;
}

Expand Down
5 changes: 2 additions & 3 deletions src/Core/src/Handlers/Entry/EntryHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Maui.Graphics;
using ObjCRuntime;
using UIKit;
using Microsoft.Maui.Platform;

namespace Microsoft.Maui.Handlers
{
Expand Down Expand Up @@ -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);
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved

VirtualView?.Completed();

Expand Down
44 changes: 44 additions & 0 deletions src/Core/src/Platform/iOS/KeyboardAutoManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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, bool isUnchangeableReturnKey = false, UIView? customSuperView = null)
{
if (!view.CheckIfEligible(isUnchangeableReturnKey))
{
view.ResignFirstResponder();
return;
}

var superview = customSuperView ?? view.GetViewController<ContainerViewController>()?.View;
if (superview is null)
{
view.ResignFirstResponder();
return;
}

var nextField = view.FindNextView(superview, new Type[] { typeof(UITextView), typeof(UITextField) });
view.ChangeFocusedView(nextField);
}

static bool CheckIfEligible(this UIView view, bool isUnchangeableReturnKey)
{
// have isUnchangeableReturnKey flag since EntryCells do not have a public property to change ReturnKeyType
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
if (view is UITextField field && (field.ReturnKeyType == UIReturnKeyType.Next || isUnchangeableReturnKey))
return true;
else if (view is UITextView)
return true;

return false;
}
}
110 changes: 110 additions & 0 deletions src/Core/src/Platform/iOS/ViewExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -698,5 +698,115 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton
if (stroke.CornerRadius >= 0)
layer.CornerRadius = stroke.CornerRadius;
}

internal static UIViewController? GetViewController<T>(this UIView view) where T : UIViewController
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
{
var nextResponder = view as UIResponder;
while (nextResponder is not null)
{
nextResponder = nextResponder.NextResponder;

if (nextResponder is T viewController)
return viewController;
}
return null;
}

internal static UIView? FindNextView(this UIView view, UIView superView, Type[] requestedTypes)
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
{
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)
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
{
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 &&
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
(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;
}
}
}
158 changes: 157 additions & 1 deletion src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -88,6 +90,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(), 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);
}

async Task NextMovesHelper(Action action = null, params StubBase[] views)
{
EnsureHandlerCreated(builder =>
{
builder.ConfigureMauiHandlers(handler =>
{
handler.AddHandler<VerticalStackLayoutStub, 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 = 100;
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);
Expand Down