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 3 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
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
140 changes: 140 additions & 0 deletions src/Core/src/Platform/iOS/KeyboardAutoManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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
internal static void GoToNextResponderOrResign(UIView view, UIView? topView = null)
{
if (!view.CheckIfEligible())
{
view.ResignFirstResponder();
return;
}

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

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<UIView> GetDeepResponderViews(UIView view)
PureWeen marked this conversation as resolved.
Show resolved Hide resolved
{
var textItems = view.GetResponderViews();

textItems.Sort(new ResponderSorter());

return textItems;
}

static List<UIView> GetResponderViews(this UIView view)
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
{
var textItems = new List<UIView>();

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)
tj-devel709 marked this conversation as resolved.
Show resolved Hide resolved
{
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)
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 UIViewController viewController)
return viewController;
}
return null;
}

class ResponderSorter : Comparer<UIView>
{
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;
}
}
}

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 @@ -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(), 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(), 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(), 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(), 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(), 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