Skip to content

Commit

Permalink
Flutter iOS Interactive Keyboard: Take Screenshot and Handle Pointer …
Browse files Browse the repository at this point in the history
…Movement (flutter#43972)

This PR address the movement aspect of the flutter interactive keyboard. It handles pointer movement while a scroll view widget is visible, and the interactive behavior is chosen for keyboardDismissBehavior. This is a desired behavior of the keyboard that has not yet been implemented. 
Design Document:
https://docs.google.com/document/d/1-T7_0mSkXzPaWxveeypIzzzAdyo-EEuP5V84161foL4/edit?pli=1

Issues Address:
flutter/flutter#57609

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
  • Loading branch information
Matt2D authored and gaaclarke committed Aug 30, 2023
1 parent 726c8ea commit bbb2bcb
Show file tree
Hide file tree
Showing 3 changed files with 318 additions and 1 deletion.
Expand Up @@ -165,4 +165,9 @@ FLUTTER_DARWIN_EXPORT
- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin NS_DESIGNATED_INITIALIZER;

@end

@interface UIView (FindFirstResponder)
@property(nonatomic, readonly) id flutterFirstResponder;
@end

#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h"

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
Expand Down Expand Up @@ -45,6 +46,8 @@
static NSString* const kSetSelectionRectsMethod = @"Scribble.setSelectionRects";
static NSString* const kStartLiveTextInputMethod = @"TextInput.startLiveTextInput";
static NSString* const kUpdateConfigMethod = @"TextInput.updateConfig";
static NSString* const kOnInteractiveKeyboardPointerMoveMethod =
@"TextInput.onPointerMoveForInteractiveKeyboard";

#pragma mark - TextInputConfiguration Field Names
static NSString* const kSecureTextEntry = @"obscureText";
Expand Down Expand Up @@ -761,6 +764,7 @@ @interface FlutterTextInputView ()
@property(nonatomic, assign) CGRect markedRect;
@property(nonatomic) BOOL isVisibleToAutofill;
@property(nonatomic, assign) BOOL accessibilityEnabled;
@property(nonatomic, assign) int textInputClient;
// The composed character that is temporarily removed by the keyboard API.
// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character
// etc)
Expand Down Expand Up @@ -2214,6 +2218,11 @@ @interface FlutterTextInputPlugin ()
@property(nonatomic, retain) FlutterTextInputView* activeView;
@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
@property(nonatomic, readonly, weak) id<FlutterViewResponder> viewResponder;

@property(nonatomic, strong) UIView* keyboardViewContainer;
@property(nonatomic, strong) UIView* keyboardView;
@property(nonatomic, strong) UIView* cachedFirstResponder;
@property(nonatomic, assign) CGRect keyboardRect;
@end

@implementation FlutterTextInputPlugin {
Expand All @@ -2222,18 +2231,29 @@ @implementation FlutterTextInputPlugin {

- (instancetype)initWithDelegate:(id<FlutterTextInputDelegate>)textInputDelegate {
self = [super init];

if (self) {
// `_textInputDelegate` is a weak reference because it should retain FlutterTextInputPlugin.
_textInputDelegate = textInputDelegate;
_autofillContext = [[NSMutableDictionary alloc] init];
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
_scribbleElements = [[NSMutableDictionary alloc] init];
_keyboardViewContainer = [[UIView alloc] init];

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardWillShow:)
name:UIKeyboardWillShowNotification
object:nil];
}

return self;
}

- (void)handleKeyboardWillShow:(NSNotification*)notification {
NSDictionary* keyboardInfo = [notification userInfo];
NSValue* keyboardFrameEnd = [keyboardInfo valueForKey:UIKeyboardFrameEndUserInfoKey];
_keyboardRect = [keyboardFrameEnd CGRectValue];
}

- (void)dealloc {
[self hideTextInput];
}
Expand Down Expand Up @@ -2295,11 +2315,67 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
} else if ([method isEqualToString:kUpdateConfigMethod]) {
[self updateConfig:args];
result(nil);
} else if ([method isEqualToString:kOnInteractiveKeyboardPointerMoveMethod]) {
CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
[self handlePointerMove:pointerY];
result(nil);
} else {
result(FlutterMethodNotImplemented);
}
}

- (void)handlePointerMove:(CGFloat)pointerY {
// View must be loaded at this point.
UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
double screenHeight = screen.bounds.size.height;
double keyboardHeight = _keyboardRect.size.height;
if (screenHeight - keyboardHeight <= pointerY) {
// If the pointer is within the bounds of the keyboard.
if (_keyboardView.superview == nil) {
// If no screenshot has been taken.
[self takeKeyboardScreenshotAndDisplay];
[self hideKeyboardWithoutAnimation];
} else {
[self setKeyboardContainerHeight:pointerY];
}
} else {
if (_keyboardView.superview != nil) {
// Keeps keyboard at proper height.
_keyboardViewContainer.frame = _keyboardRect;
}
}
}

- (void)setKeyboardContainerHeight:(CGFloat)pointerY {
CGRect frameRect = _keyboardRect;
frameRect.origin.y = pointerY;
_keyboardViewContainer.frame = frameRect;
}

- (void)hideKeyboardWithoutAnimation {
[UIView setAnimationsEnabled:NO];
_cachedFirstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
[_cachedFirstResponder resignFirstResponder];
[UIView setAnimationsEnabled:YES];
}

- (void)takeKeyboardScreenshotAndDisplay {
// View must be loaded at this point
UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
UIView* keyboardSnap = [screen snapshotViewAfterScreenUpdates:YES];
keyboardSnap = [keyboardSnap resizableSnapshotViewFromRect:_keyboardRect
afterScreenUpdates:YES
withCapInsets:UIEdgeInsetsZero];
_keyboardView = keyboardSnap;
[_keyboardViewContainer addSubview:_keyboardView];
if (_keyboardViewContainer.superview == nil) {
[UIApplication.sharedApplication.delegate.window.rootViewController.view
addSubview:_keyboardViewContainer];
}
_keyboardViewContainer.layer.zPosition = NSIntegerMax;
_keyboardViewContainer.frame = _keyboardRect;
}

- (void)setEditableSizeAndTransform:(NSDictionary*)dictionary {
[_activeView setEditableTransform:dictionary[@"transform"]];
if ([_activeView isScribbleAvailable]) {
Expand Down Expand Up @@ -2764,3 +2840,21 @@ - (BOOL)handlePress:(nonnull FlutterUIPressProxy*)press API_AVAILABLE(ios(13.4))
return NO;
}
@end

/**
* Recursively searches the UIView's subviews to locate the First Responder
*/
@implementation UIView (FindFirstResponder)
- (id)flutterFirstResponder {
if (self.isFirstResponder) {
return self;
}
for (UIView* subView in self.subviews) {
UIView* firstResponder = subView.flutterFirstResponder;
if (firstResponder) {
return firstResponder;
}
}
return nil;
}
@end
Expand Up @@ -60,6 +60,9 @@ @interface FlutterSecureTextInputView : FlutterTextInputView

@interface FlutterTextInputPlugin ()
@property(nonatomic, assign) FlutterTextInputView* activeView;
@property(nonatomic, readonly) UIView* keyboardViewContainer;
@property(nonatomic, readonly) UIView* keyboardView;
@property(nonatomic, readonly) CGRect keyboardRect;
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;

Expand Down Expand Up @@ -2425,4 +2428,219 @@ - (void)testSetPlatformViewClient {
XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
}

- (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];

[inputView setTextInputClient:123];
[inputView reloadInputViews];
[inputView becomeFirstResponder];
XCTAssert(inputView.isFirstResponder);

CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
[NSNotificationCenter.defaultCenter
postNotificationName:UIKeyboardWillShowNotification
object:nil
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
FlutterMethodCall* onPointerMoveCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(500)}];
[textInputPlugin handleMethodCall:onPointerMoveCall
result:^(id _Nullable result){
}];
XCTAssertFalse(inputView.isFirstResponder);
}

- (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
UIScene* scene = scenes.anyObject;
XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
UIWindowScene* windowScene = (UIWindowScene*)scene;
XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
UIWindow* window = windowScene.windows[0];
[window addSubview:viewController.view];

[viewController loadView];

FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];

[inputView setTextInputClient:123];
[inputView reloadInputViews];
[inputView becomeFirstResponder];

if (textInputPlugin.keyboardView.superview != nil) {
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
}
XCTAssert(textInputPlugin.keyboardView.superview == nil);
CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
[NSNotificationCenter.defaultCenter
postNotificationName:UIKeyboardWillShowNotification
object:nil
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
FlutterMethodCall* onPointerMoveCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(510)}];
[textInputPlugin handleMethodCall:onPointerMoveCall
result:^(id _Nullable result){
}];
XCTAssertFalse(textInputPlugin.keyboardView.superview == nil);
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
}

- (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
UIScene* scene = scenes.anyObject;
XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
UIWindowScene* windowScene = (UIWindowScene*)scene;
XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
UIWindow* window = windowScene.windows[0];
[window addSubview:viewController.view];

[viewController loadView];

FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];

[inputView setTextInputClient:123];
[inputView reloadInputViews];
[inputView becomeFirstResponder];

CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
[NSNotificationCenter.defaultCenter
postNotificationName:UIKeyboardWillShowNotification
object:nil
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
FlutterMethodCall* onPointerMoveCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(510)}];
[textInputPlugin handleMethodCall:onPointerMoveCall
result:^(id _Nullable result){
}];
XCTAssert(textInputPlugin.keyboardView.superview != nil);

XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);

FlutterMethodCall* onPointerMoveCallMove =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(600)}];
[textInputPlugin handleMethodCall:onPointerMoveCallMove
result:^(id _Nullable result){
}];
XCTAssert(textInputPlugin.keyboardView.superview != nil);

XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);

for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
}

- (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll {
NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
UIScene* scene = scenes.anyObject;
XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
UIWindowScene* windowScene = (UIWindowScene*)scene;
XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
UIWindow* window = windowScene.windows[0];
[window addSubview:viewController.view];

[viewController loadView];

FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];

[inputView setTextInputClient:123];
[inputView reloadInputViews];
[inputView becomeFirstResponder];

CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
[NSNotificationCenter.defaultCenter
postNotificationName:UIKeyboardWillShowNotification
object:nil
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
FlutterMethodCall* onPointerMoveCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(500)}];
[textInputPlugin handleMethodCall:onPointerMoveCall
result:^(id _Nullable result){
}];
XCTAssert(textInputPlugin.keyboardView.superview != nil);
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);

FlutterMethodCall* onPointerMoveCallMove =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(600)}];
[textInputPlugin handleMethodCall:onPointerMoveCallMove
result:^(id _Nullable result){
}];
XCTAssert(textInputPlugin.keyboardView.superview != nil);
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);

FlutterMethodCall* onPointerMoveCallBackUp =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
arguments:@{@"pointerY" : @(10)}];
[textInputPlugin handleMethodCall:onPointerMoveCallBackUp
result:^(id _Nullable result){
}];
XCTAssert(textInputPlugin.keyboardView.superview != nil);
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
}

- (void)testInteractiveKeyboardFindFirstResponderRecursive {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
[inputView setTextInputClient:123];
[inputView reloadInputViews];
[inputView becomeFirstResponder];

UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertEqualObjects(inputView, firstResponder);
}

- (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
FlutterTextInputView* otherSubInputView =
[[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
FlutterTextInputView* subFirstResponderInputView =
[[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[subInputView addSubview:subFirstResponderInputView];
[inputView addSubview:subInputView];
[inputView addSubview:otherSubInputView];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
[inputView setTextInputClient:123];
[inputView reloadInputViews];
[subInputView setTextInputClient:123];
[subInputView reloadInputViews];
[otherSubInputView setTextInputClient:123];
[otherSubInputView reloadInputViews];
[subFirstResponderInputView setTextInputClient:123];
[subFirstResponderInputView reloadInputViews];
[subFirstResponderInputView becomeFirstResponder];

UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
}

- (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
[inputView setTextInputClient:123];
[inputView reloadInputViews];

UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertNil(firstResponder);
}

@end

0 comments on commit bbb2bcb

Please sign in to comment.