Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit a41e685

Browse files
committed
Keyboard Screenshot Pointer Move
1 parent 977bf02 commit a41e685

File tree

3 files changed

+272
-1
lines changed

3 files changed

+272
-1
lines changed

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,9 @@ FLUTTER_DARWIN_EXPORT
164164
- (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin NS_DESIGNATED_INITIALIZER;
165165

166166
@end
167+
168+
@interface UIView (FindFirstResponder)
169+
- (id)flt_firstResponder;
170+
@end
171+
167172
#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
static NSString* const kSetSelectionRectsMethod = @"Scribble.setSelectionRects";
4646
static NSString* const kStartLiveTextInputMethod = @"TextInput.startLiveTextInput";
4747
static NSString* const kUpdateConfigMethod = @"TextInput.updateConfig";
48+
static NSString* const kOnInteractiveKeyboardPointerMoveMethod =
49+
@"TextInput.onPointerMoveForInteractiveKeyboard";
4850

4951
#pragma mark - TextInputConfiguration Field Names
5052
static NSString* const kSecureTextEntry = @"obscureText";
@@ -761,6 +763,7 @@ @interface FlutterTextInputView ()
761763
@property(nonatomic, assign) CGRect markedRect;
762764
@property(nonatomic) BOOL isVisibleToAutofill;
763765
@property(nonatomic, assign) BOOL accessibilityEnabled;
766+
@property(nonatomic, assign) int textInputClient;
764767
// The composed character that is temporarily removed by the keyboard API.
765768
// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character
766769
// etc)
@@ -2191,6 +2194,10 @@ @interface FlutterTextInputPlugin ()
21912194
@property(nonatomic, retain) FlutterTextInputView* activeView;
21922195
@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
21932196
@property(nonatomic, readonly, weak) id<FlutterViewResponder> viewResponder;
2197+
2198+
@property(nonatomic, strong) UIView* keyboardViewContainer;
2199+
@property(nonatomic, strong) UIView* firstResponder;
2200+
@property(nonatomic, assign) CGRect keyboardRect;
21942201
@end
21952202

21962203
@implementation FlutterTextInputPlugin {
@@ -2199,18 +2206,29 @@ @implementation FlutterTextInputPlugin {
21992206

22002207
- (instancetype)initWithDelegate:(id<FlutterTextInputDelegate>)textInputDelegate {
22012208
self = [super init];
2202-
22032209
if (self) {
22042210
// `_textInputDelegate` is a weak reference because it should retain FlutterTextInputPlugin.
22052211
_textInputDelegate = textInputDelegate;
22062212
_autofillContext = [[NSMutableDictionary alloc] init];
22072213
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
22082214
_scribbleElements = [[NSMutableDictionary alloc] init];
2215+
_keyboardViewContainer = [[UIView alloc] init];
2216+
2217+
[[NSNotificationCenter defaultCenter] addObserver:self
2218+
selector:@selector(handleKeyboardWillShow:)
2219+
name:UIKeyboardWillShowNotification
2220+
object:nil];
22092221
}
22102222

22112223
return self;
22122224
}
22132225

2226+
- (void)handleKeyboardWillShow:(NSNotification*)notification {
2227+
NSDictionary* keyboardInfo = [notification userInfo];
2228+
NSValue* keyboardFrameEnd = [keyboardInfo valueForKey:UIKeyboardFrameEndUserInfoKey];
2229+
_keyboardRect = [keyboardFrameEnd CGRectValue];
2230+
}
2231+
22142232
- (void)dealloc {
22152233
[self hideTextInput];
22162234
}
@@ -2272,11 +2290,57 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
22722290
} else if ([method isEqualToString:kUpdateConfigMethod]) {
22732291
[self updateConfig:args];
22742292
result(nil);
2293+
} else if ([method isEqualToString:kOnInteractiveKeyboardPointerMoveMethod]) {
2294+
CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
2295+
[self handlePointerMove:pointerY];
2296+
result(nil);
22752297
} else {
22762298
result(FlutterMethodNotImplemented);
22772299
}
22782300
}
22792301

2302+
- (void)handlePointerMove:(CGFloat)pointerY {
2303+
double screenHeight = [UIScreen mainScreen].bounds.size.height;
2304+
double keyboardHeight = _keyboardRect.size.height;
2305+
// Determines if pointer goes overtop of the keyboard
2306+
if (screenHeight - keyboardHeight <= pointerY) {
2307+
// If no screenshot has been taken
2308+
if (_keyboardViewContainer.subviews.count == 0) {
2309+
[self takeScreenshot];
2310+
[self hideKeyboardWithoutAnimation];
2311+
} else {
2312+
CGRect frameRect = _keyboardRect;
2313+
frameRect.origin.y = pointerY;
2314+
_keyboardViewContainer.frame = frameRect;
2315+
}
2316+
} else {
2317+
// If pointer is not overtop of the original keyboard keep screenshot in place.
2318+
if (_keyboardViewContainer.subviews.count != 0) {
2319+
_keyboardViewContainer.frame = _keyboardRect;
2320+
}
2321+
}
2322+
}
2323+
2324+
- (void)hideKeyboardWithoutAnimation {
2325+
[UIView setAnimationsEnabled:NO];
2326+
_firstResponder = UIApplication.sharedApplication.keyWindow.flt_firstResponder;
2327+
[_firstResponder resignFirstResponder];
2328+
[UIView setAnimationsEnabled:YES];
2329+
}
2330+
2331+
- (void)takeScreenshot {
2332+
UIView* keyboardSnap = [[UIScreen mainScreen] snapshotViewAfterScreenUpdates:YES];
2333+
keyboardSnap = [keyboardSnap resizableSnapshotViewFromRect:_keyboardRect
2334+
afterScreenUpdates:YES
2335+
withCapInsets:UIEdgeInsetsZero];
2336+
[_keyboardViewContainer addSubview:keyboardSnap];
2337+
if (_keyboardViewContainer.superview == nil) {
2338+
[UIApplication.sharedApplication.delegate.window.rootViewController.view
2339+
addSubview:_keyboardViewContainer];
2340+
}
2341+
_keyboardViewContainer.frame = _keyboardRect;
2342+
}
2343+
22802344
- (void)setEditableSizeAndTransform:(NSDictionary*)dictionary {
22812345
[_activeView setEditableTransform:dictionary[@"transform"]];
22822346
if ([_activeView isScribbleAvailable]) {
@@ -2741,3 +2805,21 @@ - (BOOL)handlePress:(nonnull FlutterUIPressProxy*)press API_AVAILABLE(ios(13.4))
27412805
return NO;
27422806
}
27432807
@end
2808+
2809+
/**
2810+
* Recursively searches the UIView's subviews to locate the First Responder
2811+
*/
2812+
@implementation UIView (FindFirstResponder)
2813+
- (id)flt_firstResponder {
2814+
if (self.isFirstResponder) {
2815+
return self;
2816+
}
2817+
for (UIView* subView in self.subviews) {
2818+
UIView* firstResponder = subView.flt_firstResponder;
2819+
if (firstResponder) {
2820+
return firstResponder;
2821+
}
2822+
}
2823+
return nil;
2824+
}
2825+
@end

shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ @interface FlutterSecureTextInputView : FlutterTextInputView
6060

6161
@interface FlutterTextInputPlugin ()
6262
@property(nonatomic, assign) FlutterTextInputView* activeView;
63+
@property(nonatomic, assign) UIView* keyboardViewContainer;
64+
@property(nonatomic, assign) CGRect keyboardRect;
6365
@property(nonatomic, readonly)
6466
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
6567

@@ -2315,4 +2317,186 @@ - (void)testSetPlatformViewClient {
23152317
XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
23162318
}
23172319

2320+
- (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
2321+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2322+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2323+
2324+
[inputView setTextInputClient:123];
2325+
[inputView reloadInputViews];
2326+
[inputView becomeFirstResponder];
2327+
XCTAssert(inputView.isFirstResponder);
2328+
2329+
CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2330+
[NSNotificationCenter.defaultCenter
2331+
postNotificationName:UIKeyboardWillShowNotification
2332+
object:nil
2333+
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2334+
FlutterMethodCall* onPointerMoveCall =
2335+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2336+
arguments:@{@"pointerY" : @(500)}];
2337+
[textInputPlugin handleMethodCall:onPointerMoveCall
2338+
result:^(id _Nullable result){
2339+
}];
2340+
XCTAssertFalse(inputView.isFirstResponder);
2341+
}
2342+
2343+
- (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
2344+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2345+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2346+
2347+
[inputView setTextInputClient:123];
2348+
[inputView reloadInputViews];
2349+
[inputView becomeFirstResponder];
2350+
if (textInputPlugin.keyboardViewContainer.subviews.count != 0) {
2351+
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2352+
[subView removeFromSuperview];
2353+
}
2354+
}
2355+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 0);
2356+
CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2357+
[NSNotificationCenter.defaultCenter
2358+
postNotificationName:UIKeyboardWillShowNotification
2359+
object:nil
2360+
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2361+
FlutterMethodCall* onPointerMoveCall =
2362+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2363+
arguments:@{@"pointerY" : @(510)}];
2364+
[textInputPlugin handleMethodCall:onPointerMoveCall
2365+
result:^(id _Nullable result){
2366+
}];
2367+
XCTAssertFalse(textInputPlugin.keyboardViewContainer.subviews.count == 0);
2368+
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2369+
[subView removeFromSuperview];
2370+
}
2371+
}
2372+
2373+
- (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
2374+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2375+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2376+
2377+
[inputView setTextInputClient:123];
2378+
[inputView reloadInputViews];
2379+
[inputView becomeFirstResponder];
2380+
2381+
CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2382+
[NSNotificationCenter.defaultCenter
2383+
postNotificationName:UIKeyboardWillShowNotification
2384+
object:nil
2385+
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2386+
FlutterMethodCall* onPointerMoveCall =
2387+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2388+
arguments:@{@"pointerY" : @(510)}];
2389+
[textInputPlugin handleMethodCall:onPointerMoveCall
2390+
result:^(id _Nullable result){
2391+
}];
2392+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 1);
2393+
2394+
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
2395+
2396+
FlutterMethodCall* onPointerMoveCallMove =
2397+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2398+
arguments:@{@"pointerY" : @(600)}];
2399+
[textInputPlugin handleMethodCall:onPointerMoveCallMove
2400+
result:^(id _Nullable result){
2401+
}];
2402+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 1);
2403+
CGFloat newHeight = 600;
2404+
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, newHeight);
2405+
2406+
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2407+
[subView removeFromSuperview];
2408+
}
2409+
}
2410+
2411+
- (void)testInteractiveKeyboardScreenshotWillBeMovedUpAfterUserScroll {
2412+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2413+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2414+
2415+
[inputView setTextInputClient:123];
2416+
[inputView reloadInputViews];
2417+
[inputView becomeFirstResponder];
2418+
2419+
CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
2420+
[NSNotificationCenter.defaultCenter
2421+
postNotificationName:UIKeyboardWillShowNotification
2422+
object:nil
2423+
userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
2424+
FlutterMethodCall* onPointerMoveCall =
2425+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2426+
arguments:@{@"pointerY" : @(500)}];
2427+
[textInputPlugin handleMethodCall:onPointerMoveCall
2428+
result:^(id _Nullable result){
2429+
}];
2430+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 1);
2431+
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
2432+
2433+
FlutterMethodCall* onPointerMoveCallMove =
2434+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2435+
arguments:@{@"pointerY" : @(600)}];
2436+
[textInputPlugin handleMethodCall:onPointerMoveCallMove
2437+
result:^(id _Nullable result){
2438+
}];
2439+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 1);
2440+
CGFloat newHeight = 600;
2441+
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, newHeight);
2442+
2443+
FlutterMethodCall* onPointerMoveCallBackUp =
2444+
[FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
2445+
arguments:@{@"pointerY" : @(10)}];
2446+
[textInputPlugin handleMethodCall:onPointerMoveCallBackUp
2447+
result:^(id _Nullable result){
2448+
}];
2449+
XCTAssert(textInputPlugin.keyboardViewContainer.subviews.count == 1);
2450+
XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
2451+
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
2452+
[subView removeFromSuperview];
2453+
}
2454+
}
2455+
2456+
- (void)testInteractiveKeyboardFindFirstResponderRecursive {
2457+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2458+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2459+
[inputView setTextInputClient:123];
2460+
[inputView reloadInputViews];
2461+
[inputView becomeFirstResponder];
2462+
2463+
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flt_firstResponder;
2464+
XCTAssertEqualObjects(inputView, firstResponder);
2465+
}
2466+
2467+
- (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
2468+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2469+
FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2470+
FlutterTextInputView* otherSubInputView =
2471+
[[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2472+
FlutterTextInputView* subFirstResponderInputView =
2473+
[[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2474+
[subInputView addSubview:subFirstResponderInputView];
2475+
[inputView addSubview:subInputView];
2476+
[inputView addSubview:otherSubInputView];
2477+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2478+
[inputView setTextInputClient:123];
2479+
[inputView reloadInputViews];
2480+
[subInputView setTextInputClient:123];
2481+
[subInputView reloadInputViews];
2482+
[otherSubInputView setTextInputClient:123];
2483+
[otherSubInputView reloadInputViews];
2484+
[subFirstResponderInputView setTextInputClient:123];
2485+
[subFirstResponderInputView reloadInputViews];
2486+
[subFirstResponderInputView becomeFirstResponder];
2487+
2488+
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flt_firstResponder;
2489+
XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
2490+
}
2491+
2492+
- (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
2493+
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2494+
[UIApplication.sharedApplication.keyWindow addSubview:inputView];
2495+
[inputView setTextInputClient:123];
2496+
[inputView reloadInputViews];
2497+
2498+
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flt_firstResponder;
2499+
XCTAssertNil(firstResponder);
2500+
}
2501+
23182502
@end

0 commit comments

Comments
 (0)