Skip to content

Commit 98df8d8

Browse files
authored
fix: switch between inputs with KeyboardGestureArea on iOS (#938)
## 📜 Description Fixed a problem with autoclosing keyboard if you switch between inputs + `KeyboardGestureArea` has active `offset`. ## 💡 Motivation and Context The key problem here is that in previous implementation `resignFirstResponder` call gets delayed, so first of all we call `becomeFirstResponder`, then we call `resignFirstResponder`, but it's getting delayed, so new input get focus and then we call `resignFirstResponder` and keyboard gets closed. First fix that I did was saving pending request in `resignFirstResponder` - I found out that it's getting called two times, and I thought if we call it second time and we have pending request -> we can immediately remove focus and cancel previous task. It works on simulator, but not on a real device - on a real device keyboard becomes hidden and then becomes visible. To fix all problems I decided to intercept `becomeFirstResponder` event, and save new focused input. If `resignFirstResponder` is getting called and we have a pending request, then we should remove `inputAccessoryView` immediately + don't add any delays. In its turn `becomeFirstResponder` should store new focus request only for short amount of time - otherwise, if we store it permanently we can not distinguish a case "focus switch" vs "dismiss a keyboard", so current algorithm looks like: - we have `resignFistResponder` and in the past frame we had `becomeFirstResponder` request -> we switch focus, so do a cleanup immediately; - we have `resignFistResponder` and in the past frame we didn't have `becomeFirstResponder` request -> remove `inputAccessoryView` and close the keyboard (similar to how it was working before). ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### iOS - swizzle `becomeFirstResponder`; - store current first responder for one frame; - check in `resignFirstResponder` presence of current first responder and if we have it -> do a cleanup immediately. ## 🤔 How Has This Been Tested? Tested manually on iPhone 11 (iOS 17.4), with following code added before `ScrollView`: ```tsx <AnimatedTextInput multiline nativeID="chat-input" style={{width: "100%", height: 50, backgroundColor: "blue"}} testID="chat.input" /> ``` ## 📸 Screenshots (if appropriate): https://github.com/user-attachments/assets/ff7d54d9-452b-432c-b587-2660132c3deb ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 49898cd commit 98df8d8

File tree

2 files changed

+58
-10
lines changed

2 files changed

+58
-10
lines changed

ios/swizzling/UIResponderSwizzle.swift

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,64 @@
88
import Foundation
99
import UIKit
1010

11+
private var pendingBecomeResponder: TextInput?
1112
private var originalResignFirstResponder: IMP?
13+
private var originalBecomeFirstResponder: IMP?
1214

1315
@objc
1416
extension UIResponder {
17+
public static func swizzleBecomeFirstResponder() {
18+
let originalBecomeSelector = #selector(becomeFirstResponder)
19+
guard
20+
let originalBecomeMethod = class_getInstanceMethod(UIResponder.self, originalBecomeSelector)
21+
else {
22+
return
23+
}
24+
25+
originalBecomeFirstResponder = method_getImplementation(originalBecomeMethod)
26+
27+
let swizzledBecomeImplementation: @convention(block) (UIResponder) -> Bool = { (self) in
28+
pendingBecomeResponder = self as? TextInput
29+
30+
DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) {
31+
pendingBecomeResponder = nil
32+
}
33+
34+
return self.callOriginalBecomeFirstResponder(originalBecomeSelector)
35+
}
36+
37+
let implementation = imp_implementationWithBlock(swizzledBecomeImplementation)
38+
method_setImplementation(originalBecomeMethod, implementation)
39+
}
40+
1541
public static func swizzleResignFirstResponder() {
16-
let originalSelector = #selector(resignFirstResponder)
42+
let originalResignSelector = #selector(resignFirstResponder)
1743

18-
guard let originalMethod = class_getInstanceMethod(UIResponder.self, originalSelector) else {
44+
guard
45+
let originalResignMethod = class_getInstanceMethod(UIResponder.self, originalResignSelector)
46+
else {
1947
return
2048
}
2149

22-
originalResignFirstResponder = method_getImplementation(originalMethod)
50+
originalResignFirstResponder = method_getImplementation(originalResignMethod)
2351

24-
let swizzledImplementation: @convention(block) (UIResponder) -> Bool = { (self) in
52+
let swizzledResignImplementation: @convention(block) (UIResponder) -> Bool = { (self) in
2553
// Check the type of responder
2654
if let textField = self as? TextInput {
2755
// check inputAccessoryView and call original method immediately if not InvisibleInputAccessoryView
2856
if !(textField.inputAccessoryView is InvisibleInputAccessoryView) {
29-
return self.callOriginalResignFirstResponder(originalSelector)
57+
return self.callOriginalResignFirstResponder(originalResignSelector)
58+
} else if pendingBecomeResponder != nil {
59+
// if we already have a new focus request
60+
pendingBecomeResponder = nil
61+
KeyboardAreaExtender.shared.hide()
62+
(self as? TextInput)?.inputAccessoryView = nil
63+
KeyboardAreaExtender.shared.remove()
64+
return self.callOriginalResignFirstResponder(originalResignSelector)
3065
}
3166
} else {
3267
// If casting to TextInput fails
33-
return self.callOriginalResignFirstResponder(originalSelector)
68+
return self.callOriginalResignFirstResponder(originalResignSelector)
3469
}
3570

3671
KeyboardAreaExtender.shared.hide()
@@ -39,22 +74,34 @@ extension UIResponder {
3974
DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) {
4075
(self as? TextInput)?.inputAccessoryView = nil
4176
KeyboardAreaExtender.shared.remove()
42-
_ = self.callOriginalResignFirstResponder(originalSelector)
77+
_ = self.callOriginalResignFirstResponder(originalResignSelector)
4378
}
4479

4580
// We need to return a value immediately, even though the actual action is delayed
4681
return false
4782
}
4883

49-
let implementation = imp_implementationWithBlock(swizzledImplementation)
50-
method_setImplementation(originalMethod, implementation)
84+
let implementation = imp_implementationWithBlock(swizzledResignImplementation)
85+
method_setImplementation(originalResignMethod, implementation)
5186
}
5287

5388
private func callOriginalResignFirstResponder(_ selector: Selector) -> Bool {
5489
guard let originalResignFirstResponder = originalResignFirstResponder else { return false }
5590
typealias Function = @convention(c) (AnyObject, Selector) -> Bool
56-
let castOriginalResignFirstResponder = unsafeBitCast(originalResignFirstResponder, to: Function.self)
91+
let castOriginalResignFirstResponder = unsafeBitCast(
92+
originalResignFirstResponder, to: Function.self
93+
)
5794
let result = castOriginalResignFirstResponder(self, selector)
5895
return result
5996
}
97+
98+
private func callOriginalBecomeFirstResponder(_ selector: Selector) -> Bool {
99+
guard let originalBecomeFirstResponder = originalBecomeFirstResponder else { return false }
100+
typealias Function = @convention(c) (AnyObject, Selector) -> Bool
101+
let castOriginalBecomeFirstResponder = unsafeBitCast(
102+
originalBecomeFirstResponder, to: Function.self
103+
)
104+
let result = castOriginalBecomeFirstResponder(self, selector)
105+
return result
106+
}
60107
}

ios/views/KeyboardGestureAreaManager.mm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ + (void)load
7575
[super load];
7676

7777
[UIResponder swizzleResignFirstResponder];
78+
[UIResponder swizzleBecomeFirstResponder];
7879
}
7980

8081
// MARK: Constructor

0 commit comments

Comments
 (0)