Skip to content

Commit 6da1bb4

Browse files
authoredJan 15, 2025··
feat: keyboard offset on iOS (#727)
## 📜 Description Added an ability to specify `offset` for interactive keyboard dismissal on iOS. ## 💡 Motivation and Context In this PR I'm exposing `KeyboardGestureArea` on iOS and adding two props for that: `offset` and `textInputNativeID`. This PR is a re-thinking concept of how we work with `inputAccessoryView` on iOS. To make long text short - default `InputAccessoryView` comes with many restrictions, such as not growing `TextInput`, unability to specify position on the screen, weird animations on unmount, complexity with managing `SafeArea` insets, etc. We already have `KeyboardStickyView` that don't have all that problems, but if you interactively dismiss a keyboard then interactive dismissal starts from a top border of the keyboard (not the input). Taking a step back and utilising `inputAccessoryView` (moving a view from RN hierarchy directly into `inputAccessoryView`) is possible, but comes with a previous set of challenges. In this PR I decided to think about different concepts between iOS/Android and how to make a solution that will work everywhere identically. And the idea is to create an invisible/non-interactable instance of `inputAccessoryView`, that will simply extend the keyboard area (but keyboard-controller will know about that offset and will automatically exclude it from final keyboard dimensions, so you can use everything as you used before). Schematically all process can be shown on a diagram below: ![image](https://github.com/user-attachments/assets/06f85e15-9347-4569-b6a6-06018d61231f) However new approach comes with its own set of challenges. Mainly they come from the fact how keyboard dismissal works on iOS, and in simple words: - when you perform `Keyboard.dismiss()`/press enter then whole combination (keyboard + inputAccessoryView) is treated as a single keyboard and entire element gets hidden in a single animation. - when you perform interactive dismissal, then we have two fold animation - first we dismiss a keyboard, and in second stage we dismiss `inputAccessoryView`. From all the description above it's clear, that we want to ignore `inputAccessoryView` animations or exclude its height from the animation (when its animated as a single element). To solve the first problem (when keyboard dismissed as a single element) we need to remove `inputAccessoryView` and only then perform an animation. Otherwise if we use default hooks `useKeyboardAnimation`/`useReanimatedKeyboardAnimation` that rely on layout animation, then we will see unsynchronized animation (because for example actual keyboard height is 250 + 50, but in JS we give only value of 250, so we will animate from 250 to 0, though actual animation will be from 300 to 0). To fix that I had to swizzle into `resignFirstResponder`. In this method we see, if we have `InvisibleAccessoryView`, then we postpone a keyboard dismissal and remove current `inputAccessoryView`. In this case we will dismiss a keyboard without `inputAccessoryView`, so it will work as it works before. The second main challenge was a time when to remove `inputAccessoryView` during interactive keyboard dismissal. The initial idea was to remove it as soon as dismiss gesture begins. However I rejected this idea in d11afd6 mainly because it was causing a lot of issues (such as ghost animation when keyboard is fully dismissed). When we remove that code it removes additional complexity and we remove `inputAccessoryView` when we call `resignFirstResponder` (happens when keyboard gets dismissed, i. e. first phase passed). In this case it works more predictable. Last but not least - it's wort to note, that the idea with invisible `inputAccessoryView` is not new in iOS community, and some even native projects are utilizing it: https://github.com/iAmrMohamed/AMKeyboardFrameTracker Closes #250 ## 📢 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 --> ### Docs - mention that `KeyboardGestureArea` is not Android specific anymore; - add new `textInputNativeID` description + show how to use it. ### JS - don't exclude `iOS` for `KeyboardGestureArea` in codegen; - expose new `textInputNativeID` property for `KeyboardGestureArea`; - applied patch in fabric example app facebook/react-native#48339 - make `interpolator` optional (will be `ios` on `iOS` and `linear` on `Android`) - make growing/multiline `TextInput` in interactive iOS keyboard example; ### iOS - expose `KeyboardGestureArea` on iOS as well - added `InvisibleInputAccessoryView` class; - added `KeyboardEventsIgnorer` class; - added `KeyboardAreaExtender` class; - added `KeyboardOffsetProvider` class; - added `KeyboardEventsIgnorer` class; - added `UIResponderSwizzle` class; - added `shouldIgnoreKeyboardEvents` event to `Notification`; - added `nativeID` extension to `UIResponder` (and it's mock for a native project); ### Android - added no-op setters for `textInputNativeId`; ## 🤔 How Has This Been Tested? Tested locally on: - iPhone 6s (iOS 15.8, real device); - iPhone 11 (iOS 18.0, iOS 18.1, real device) - iPhone 16 Pro (iOS 18.0, simulator) - iPhone 15 Pro (iOS 17.5, simulator) - iPhone 14 Pro (iOS 16.5, simulator) ## 📸 Screenshots (if appropriate): https://github.com/user-attachments/assets/097d76e1-4f79-4a27-89b7-43a479b6b32b ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 6e7c2db commit 6da1bb4

File tree

21 files changed

+653
-41
lines changed

21 files changed

+653
-41
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
diff --git a/node_modules/react-native/.DS_Store b/node_modules/react-native/.DS_Store
2+
new file mode 100644
3+
index 0000000..597365c
4+
Binary files /dev/null and b/node_modules/react-native/.DS_Store differ
5+
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
6+
index e74500f..c2d4515 100644
7+
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
8+
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
9+
@@ -68,6 +68,8 @@ @implementation RCTTextInputComponentView {
10+
* later comparison insensitive to them.
11+
*/
12+
NSDictionary<NSAttributedStringKey, id> *_originalTypingAttributes;
13+
+
14+
+ BOOL _hasInputAccessoryView;
15+
}
16+
17+
#pragma mark - UIView overrides
18+
@@ -590,10 +592,12 @@ - (void)setDefaultInputAccessoryView
19+
keyboardType == UIKeyboardTypeDecimalPad || keyboardType == UIKeyboardTypeASCIICapableNumberPad) &&
20+
containsKeyType;
21+
22+
- if ((_backedTextInputView.inputAccessoryView != nil) == shouldHaveInputAccessoryView) {
23+
+ if (_hasInputAccessoryView == shouldHaveInputAccessoryView) {
24+
return;
25+
}
26+
27+
+ _hasInputAccessoryView = shouldHaveInputAccessoryView;
28+
+
29+
if (shouldHaveInputAccessoryView) {
30+
NSString *buttonLabel = [self returnKeyTypeToString:returnKeyType];
31+

‎FabricExample/src/screens/Examples/InteractiveKeyboardIOS/index.tsx

+30-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import React, { useCallback, useRef } from "react";
2-
import { TextInput, View } from "react-native";
3-
import { useKeyboardHandler } from "react-native-keyboard-controller";
1+
import React, { useCallback, useRef, useState } from "react";
2+
import { TextInput } from "react-native";
3+
import {
4+
KeyboardGestureArea,
5+
useKeyboardHandler,
6+
} from "react-native-keyboard-controller";
47
import Reanimated, {
58
useAnimatedProps,
69
useAnimatedScrollHandler,
@@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data";
1316

1417
import styles from "./styles";
1518

19+
import type { LayoutChangeEvent } from "react-native";
20+
1621
const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);
1722

1823
const useKeyboardAnimation = () => {
@@ -86,6 +91,12 @@ const contentContainerStyle = {
8691
function InteractiveKeyboard() {
8792
const ref = useRef<Reanimated.ScrollView>(null);
8893
const { height, onScroll, inset, offset } = useKeyboardAnimation();
94+
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
95+
const [text, setText] = useState("");
96+
97+
const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
98+
setInputHeight(e.nativeEvent.layout.height);
99+
}, []);
89100

90101
const scrollToBottom = useCallback(() => {
91102
ref.current?.scrollToEnd({ animated: false });
@@ -94,7 +105,7 @@ function InteractiveKeyboard() {
94105
const textInputStyle = useAnimatedStyle(
95106
() => ({
96107
position: "absolute",
97-
height: TEXT_INPUT_HEIGHT,
108+
minHeight: TEXT_INPUT_HEIGHT,
98109
width: "100%",
99110
backgroundColor: "#BCBCBC",
100111
transform: [{ translateY: -height.value }],
@@ -113,7 +124,11 @@ function InteractiveKeyboard() {
113124
}));
114125

115126
return (
116-
<View style={styles.container}>
127+
<KeyboardGestureArea
128+
offset={inputHeight}
129+
style={styles.container}
130+
textInputNativeID="chat-input"
131+
>
117132
<Reanimated.ScrollView
118133
ref={ref}
119134
// simulation of `automaticallyAdjustKeyboardInsets` behavior on RN < 0.73
@@ -130,8 +145,16 @@ function InteractiveKeyboard() {
130145
<Message key={index} {...message} />
131146
))}
132147
</Reanimated.ScrollView>
133-
<AnimatedTextInput style={textInputStyle} testID="chat.input" />
134-
</View>
148+
<AnimatedTextInput
149+
multiline
150+
nativeID="chat-input"
151+
style={textInputStyle}
152+
testID="chat.input"
153+
value={text}
154+
onChangeText={setText}
155+
onLayout={onInputLayoutChanged}
156+
/>
157+
</KeyboardGestureArea>
135158
);
136159
}
137160

‎android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt

+8
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ class KeyboardGestureAreaViewManager(
5656
) {
5757
manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value)
5858
}
59+
60+
@ReactProp(name = "textInputNativeID")
61+
override fun setTextInputNativeID(
62+
view: ReactViewGroup,
63+
value: String?,
64+
) {
65+
// no-op
66+
}
5967
}

‎android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt

+9
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,13 @@ class KeyboardGestureAreaViewManager(
4848
) {
4949
manager.setScrollKeyboardOffScreenWhenVisible(view, value)
5050
}
51+
52+
@Suppress("detekt:UnusedParameter")
53+
@ReactProp(name = "textInputNativeID")
54+
fun setTextInputNativeID(
55+
view: KeyboardGestureAreaReactViewGroup,
56+
value: String,
57+
) {
58+
// no-op
59+
}
5160
}

‎docs/docs/api/keyboard-gesture-area.md

+13-10
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ keywords:
1010
]
1111
---
1212

13-
<!-- prettier-ignore-start -->
14-
<!-- we explicitly specify title and h1 because we add badge to h1 and we don't want this element to go to table of contents -->
15-
<!-- markdownlint-disable-next-line MD025 -->
16-
# KeyboardGestureArea <div className="label android"></div>
17-
<!-- prettier-ignore-end -->
18-
1913
`KeyboardGestureArea` allows you to define a region on the screen, where gestures will control the keyboard position.
2014

2115
:::info Platform availability
@@ -28,27 +22,36 @@ This component is available only for Android >= 11. For iOS and Android < 11 it
2822

2923
Extra distance to the keyboard. Default is `0`.
3024

31-
### `interpolator`
25+
### `interpolator` <div className="label android"></div>
3226

3327
String with possible values `linear` and `ios`:
3428

3529
- **ios** - interactive keyboard dismissing will work as in iOS: swipes in non-keyboard area will not affect keyboard positioning, but if your swipe touches keyboard - keyboard will follow finger position.
3630
- **linear** - gestures inside the component will linearly affect the position of the keyboard, i.e. if the user swipes down by 20 pixels, then the keyboard will also be moved down by 20 pixels, even if the gesture was not made over the keyboard area.
3731

38-
### `showOnSwipeUp`
32+
### `showOnSwipeUp` <div className="label android"></div>
3933

4034
A boolean prop which allows to customize interactive keyboard behavior. If set to `true` then it allows to show keyboard (if it's already closed) by swipe up gesture. `false` by default.
4135

42-
### `enableSwipeToDismiss`
36+
### `enableSwipeToDismiss` <div className="label android"></div>
4337

4438
A boolean prop which allows to customize interactive keyboard behavior. If set to `false`, then any gesture will not affect keyboard position if the keyboard is shown. `true` by default.
4539

40+
### `textInputNativeID` <div className="label ios"></div>
41+
42+
A corresponding `nativeID` value from the corresponding `TextInput`.
43+
4644
## Example
4745

4846
```tsx
49-
<KeyboardGestureArea interpolator="ios" offset={50}>
47+
<KeyboardGestureArea
48+
interpolator="ios"
49+
offset={50}
50+
textInputNativeID="composer"
51+
>
5052
<ScrollView>
5153
{/* The other UI components of application in your tree */}
5254
</ScrollView>
55+
<TextInput nativeID="composer" />
5356
</KeyboardGestureArea>
5457
```

‎example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx

+30-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import React, { useCallback, useRef } from "react";
2-
import { TextInput, View } from "react-native";
3-
import { useKeyboardHandler } from "react-native-keyboard-controller";
1+
import React, { useCallback, useRef, useState } from "react";
2+
import { TextInput } from "react-native";
3+
import {
4+
KeyboardGestureArea,
5+
useKeyboardHandler,
6+
} from "react-native-keyboard-controller";
47
import Reanimated, {
58
useAnimatedProps,
69
useAnimatedScrollHandler,
@@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data";
1316

1417
import styles from "./styles";
1518

19+
import type { LayoutChangeEvent } from "react-native";
20+
1621
const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);
1722

1823
const useKeyboardAnimation = () => {
@@ -86,6 +91,12 @@ const contentContainerStyle = {
8691
function InteractiveKeyboard() {
8792
const ref = useRef<Reanimated.ScrollView>(null);
8893
const { height, onScroll, inset, offset } = useKeyboardAnimation();
94+
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
95+
const [text, setText] = useState("");
96+
97+
const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
98+
setInputHeight(e.nativeEvent.layout.height);
99+
}, []);
89100

90101
const scrollToBottom = useCallback(() => {
91102
ref.current?.scrollToEnd({ animated: false });
@@ -94,7 +105,7 @@ function InteractiveKeyboard() {
94105
const textInputStyle = useAnimatedStyle(
95106
() => ({
96107
position: "absolute",
97-
height: TEXT_INPUT_HEIGHT,
108+
minHeight: TEXT_INPUT_HEIGHT,
98109
width: "100%",
99110
backgroundColor: "#BCBCBC",
100111
transform: [{ translateY: -height.value }],
@@ -113,7 +124,11 @@ function InteractiveKeyboard() {
113124
}));
114125

115126
return (
116-
<View style={styles.container}>
127+
<KeyboardGestureArea
128+
offset={inputHeight}
129+
style={styles.container}
130+
textInputNativeID="chat-input"
131+
>
117132
<Reanimated.ScrollView
118133
ref={ref}
119134
// simulation of `automaticallyAdjustKeyboardInsets` behavior on RN < 0.73
@@ -130,8 +145,16 @@ function InteractiveKeyboard() {
130145
<Message key={index} {...message} />
131146
))}
132147
</Reanimated.ScrollView>
133-
<AnimatedTextInput style={textInputStyle} testID="chat.input" />
134-
</View>
148+
<AnimatedTextInput
149+
multiline
150+
nativeID="chat-input"
151+
style={textInputStyle}
152+
testID="chat.input"
153+
value={text}
154+
onChangeText={setText}
155+
onLayout={onInputLayoutChanged}
156+
/>
157+
</KeyboardGestureArea>
135158
);
136159
}
137160

‎ios/KeyboardControllerNative/KeyboardControllerNative/Extension+UIView.swift

+4
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ public extension UIView {
1212
var reactTag: NSNumber {
1313
return tag as NSNumber
1414
}
15+
16+
var nativeID: String {
17+
return accessibilityIdentifier ?? ""
18+
}
1519
}

‎ios/extensions/Notification.swift

+4
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ extension Notification {
1515
return (duration, keyboardFrame)
1616
}
1717
}
18+
19+
extension Notification.Name {
20+
static let shouldIgnoreKeyboardEvents = Notification.Name("shouldIgnoreKeyboardEvents")
21+
}

‎ios/extensions/UIResponder.swift

+10
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ public extension Optional where Wrapped == UIResponder {
4444
return (self as? UIView)?.superview?.reactTag ?? -1
4545
#endif
4646
}
47+
48+
var nativeID: String? {
49+
guard let superview = (self as? UIView)?.superview else { return nil }
50+
51+
#if KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED
52+
return (superview as NSObject).value(forKey: "nativeId") as? String
53+
#else
54+
return (superview as? UIView)?.nativeID
55+
#endif
56+
}
4757
}
4858

4959
public extension Optional where Wrapped: UIResponder {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// InvisibleInputAccessoryView.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 01/11/2024.
6+
//
7+
8+
import Foundation
9+
import UIKit
10+
11+
public class InvisibleInputAccessoryView: UIView {
12+
var isShown = true
13+
14+
override init(frame: CGRect) {
15+
super.init(frame: frame)
16+
setupView()
17+
}
18+
19+
public convenience init(height: CGFloat) {
20+
self.init(frame: CGRect(x: 0, y: 0, width: 0, height: height))
21+
}
22+
23+
required init?(coder aDecoder: NSCoder) {
24+
super.init(coder: aDecoder)
25+
setupView()
26+
}
27+
28+
override public func point(inside _: CGPoint, with _: UIEvent?) -> Bool {
29+
// Return false to allow touch events to pass through
30+
return false
31+
}
32+
33+
public func updateHeight(to newHeight: CGFloat) {
34+
frame = CGRect(x: 0, y: 0, width: 0, height: newHeight)
35+
36+
// Invalidate intrinsic content size to trigger a layout update
37+
invalidateIntrinsicContentSize()
38+
layoutIfNeeded()
39+
}
40+
41+
public func hide() {
42+
guard isShown else { return }
43+
isShown = false
44+
updateHeight(to: 0.0)
45+
superview?.layoutIfNeeded()
46+
}
47+
48+
override public var intrinsicContentSize: CGSize {
49+
return CGSize(width: UIView.noIntrinsicMetric, height: frame.height)
50+
}
51+
52+
private func setupView() {
53+
isUserInteractionEnabled = false
54+
// for debug purposes
55+
// backgroundColor = UIColor.red.withAlphaComponent(0.2)
56+
backgroundColor = .clear
57+
autoresizingMask = .flexibleHeight
58+
}
59+
}
+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// KeyboardAreaExtender.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 02/11/2024.
6+
//
7+
8+
class KeyboardAreaExtender: NSObject {
9+
private var currentInputAccessoryView: InvisibleInputAccessoryView?
10+
11+
@objc public static let shared = KeyboardAreaExtender()
12+
13+
override private init() {
14+
super.init()
15+
NotificationCenter.default.addObserver(
16+
self,
17+
selector: #selector(keyboardDidAppear),
18+
name: UIResponder.keyboardDidShowNotification,
19+
object: nil
20+
)
21+
}
22+
23+
deinit {
24+
NotificationCenter.default.removeObserver(self)
25+
}
26+
27+
public var offset: CGFloat {
28+
return currentInputAccessoryView?.frame.height ?? 0
29+
}
30+
31+
public func hide() {
32+
if isVisible {
33+
NotificationCenter.default.post(
34+
name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true]
35+
)
36+
currentInputAccessoryView?.hide()
37+
}
38+
}
39+
40+
public func remove() {
41+
currentInputAccessoryView = nil
42+
}
43+
44+
public func updateHeight(to newHeight: CGFloat, for nativeID: String) {
45+
if UIResponder.current.nativeID == nativeID {
46+
currentInputAccessoryView?.updateHeight(to: newHeight)
47+
}
48+
}
49+
50+
private var isVisible: Bool {
51+
return currentInputAccessoryView?.isShown ?? false
52+
}
53+
54+
@objc private func keyboardDidAppear(_: Notification) {
55+
let responder = UIResponder.current
56+
if let activeTextInput = responder as? TextInput,
57+
let offset = KeyboardOffsetProvider.shared.getOffset(
58+
forTextInputNativeID: responder.nativeID),
59+
responder?.inputAccessoryView == nil
60+
{
61+
currentInputAccessoryView = InvisibleInputAccessoryView(height: CGFloat(truncating: offset))
62+
63+
// we need to send this event before we actually attach the IAV
64+
// since on some OS versions (iOS 15 for example), `reloadInputViews`
65+
// will trigger `keyboardDidAppear` listener immediately
66+
NotificationCenter.default.post(
67+
name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true]
68+
)
69+
70+
activeTextInput.inputAccessoryView = currentInputAccessoryView
71+
activeTextInput.reloadInputViews()
72+
}
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// KeyboardOffsetProvider.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 01/11/2024.
6+
//
7+
8+
import Foundation
9+
10+
@objc(KeyboardOffsetProvider)
11+
public class KeyboardOffsetProvider: NSObject {
12+
@objc public static let shared = KeyboardOffsetProvider()
13+
14+
override private init() {}
15+
16+
private var offsetMap: [String: (offset: NSNumber, tag: NSNumber)] = [:]
17+
18+
@objc public func setOffset(forTextInputNativeID nativeID: String, offset: NSNumber, withTag tag: NSNumber) {
19+
KeyboardAreaExtender.shared.updateHeight(to: CGFloat(offset), for: nativeID)
20+
offsetMap[nativeID] = (offset: offset, tag: tag)
21+
}
22+
23+
@objc public func getOffset(forTextInputNativeID nativeID: String?) -> NSNumber? {
24+
guard let unwrappedNativeID = nativeID, let pair = offsetMap[unwrappedNativeID] else { return nil }
25+
return pair.offset
26+
}
27+
28+
@objc public func hasOffset(forTextInputNativeID nativeID: String?) -> Bool {
29+
guard let unwrappedNativeID = nativeID else { return false }
30+
return offsetMap[unwrappedNativeID] != nil
31+
}
32+
33+
@objc public func removeOffset(forTextInputNativeID nativeID: String, withTag tag: NSNumber) {
34+
// Ensure the tag matches before removing the entry
35+
if let currentPair = offsetMap[nativeID], currentPair.tag == tag {
36+
offsetMap.removeValue(forKey: nativeID)
37+
}
38+
}
39+
}
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// KeyboardEventsIgnorer.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 24/11/2024.
6+
//
7+
8+
import Foundation
9+
10+
@objc(KeyboardEventsIgnorer)
11+
public class KeyboardEventsIgnorer: NSObject {
12+
@objc public static let shared = KeyboardEventsIgnorer()
13+
14+
var shouldIgnoreKeyboardEvents = false
15+
16+
public var shouldIgnore: Bool {
17+
return shouldIgnoreKeyboardEvents
18+
}
19+
20+
override init() {
21+
super.init()
22+
NotificationCenter.default.addObserver(
23+
self,
24+
selector: #selector(handleIgnoreKeyboardEventsNotification),
25+
name: .shouldIgnoreKeyboardEvents,
26+
object: nil
27+
)
28+
}
29+
30+
@objc private func handleIgnoreKeyboardEventsNotification(_ notification: Notification) {
31+
if let userInfo = notification.userInfo, let value = userInfo["ignore"] as? Bool {
32+
shouldIgnoreKeyboardEvents = value
33+
}
34+
}
35+
36+
deinit {
37+
NotificationCenter.default.removeObserver(self)
38+
}
39+
}

‎ios/observers/KeyboardMovementObserver.swift

+26-12
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ public class KeyboardMovementObserver: NSObject {
3636
private var hasKVObserver = false
3737
private var isMounted = false
3838
// state variables
39-
private var keyboardHeight: CGFloat = 0.0
39+
private var _keyboardHeight: CGFloat = 0.0
40+
private var keyboardHeight: CGFloat {
41+
get { _keyboardHeight - KeyboardAreaExtender.shared.offset }
42+
set { _keyboardHeight = newValue }
43+
}
44+
4045
private var duration = 0
4146
private var tag: NSNumber = -1
4247
private var animation: KeyboardAnimation?
@@ -121,7 +126,7 @@ public class KeyboardMovementObserver: NSObject {
121126
}
122127
// if keyboard height is not equal to its bounds - we can ignore
123128
// values, since they'll be invalid and will cause UI jumps
124-
if keyboardView?.bounds.size.height != keyboardHeight {
129+
if floor(keyboardView?.bounds.size.height ?? 0) != floor(_keyboardHeight) {
125130
return
126131
}
127132

@@ -131,11 +136,12 @@ public class KeyboardMovementObserver: NSObject {
131136
let keyboardFrameY = changeValue.cgPointValue.y
132137
let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0
133138
let keyboardPosition = keyboardWindowH - keyboardFrameY
139+
134140
let position = CGFloat.interpolate(
135-
inputRange: [keyboardHeight / 2, -keyboardHeight / 2],
136-
outputRange: [keyboardHeight, 0],
141+
inputRange: [_keyboardHeight / 2, -_keyboardHeight / 2],
142+
outputRange: [_keyboardHeight, 0],
137143
currentValue: keyboardPosition
138-
)
144+
) - KeyboardAreaExtender.shared.offset
139145

140146
if position == 0 {
141147
// it will be triggered before `keyboardWillDisappear` and
@@ -163,6 +169,8 @@ public class KeyboardMovementObserver: NSObject {
163169
}
164170

165171
@objc func keyboardWillAppear(_ notification: Notification) {
172+
guard !KeyboardEventsIgnorer.shared.shouldIgnore else { return }
173+
166174
let (duration, frame) = notification.keyboardMetaData()
167175
if let keyboardFrame = frame {
168176
tag = UIResponder.current.reactViewTag
@@ -172,16 +180,16 @@ public class KeyboardMovementObserver: NSObject {
172180
didShowDeadline = Date.currentTimeStamp + Int64(duration)
173181

174182
onRequestAnimation()
175-
onEvent("onKeyboardMoveStart", Float(keyboardHeight) as NSNumber, 1, duration as NSNumber, tag)
176-
onNotify("KeyboardController::keyboardWillShow", buildEventParams(keyboardHeight, duration, tag))
183+
onEvent("onKeyboardMoveStart", Float(self.keyboardHeight) as NSNumber, 1, duration as NSNumber, tag)
184+
onNotify("KeyboardController::keyboardWillShow", buildEventParams(self.keyboardHeight, duration, tag))
177185

178186
setupKeyboardWatcher()
179-
initializeAnimation(fromValue: prevKeyboardPosition, toValue: keyboardHeight)
187+
initializeAnimation(fromValue: prevKeyboardPosition, toValue: self.keyboardHeight)
180188
}
181189
}
182190

183191
@objc func keyboardWillDisappear(_ notification: Notification) {
184-
let (duration, _) = notification.keyboardMetaData()
192+
let (duration, keyboardFrame) = notification.keyboardMetaData()
185193
tag = UIResponder.current.reactViewTag
186194
self.duration = duration
187195

@@ -202,9 +210,15 @@ public class KeyboardMovementObserver: NSObject {
202210
let keyboardHeight = keyboardFrame.cgRectValue.size.height
203211
tag = UIResponder.current.reactViewTag
204212
self.keyboardHeight = keyboardHeight
213+
214+
guard !KeyboardEventsIgnorer.shared.shouldIgnore else {
215+
KeyboardEventsIgnorer.shared.shouldIgnoreKeyboardEvents = false
216+
return
217+
}
218+
205219
// if the event is caught in between it's highly likely that it could be a "resize" event
206220
// so we just read actual keyboard frame value in this case
207-
let height = timestamp >= didShowDeadline ? keyboardHeight : position
221+
let height = timestamp >= didShowDeadline ? self.keyboardHeight : position - KeyboardAreaExtender.shared.offset
208222
// always limit progress to the maximum possible value
209223
let progress = min(height / self.keyboardHeight, 1.0)
210224

@@ -219,7 +233,7 @@ public class KeyboardMovementObserver: NSObject {
219233
}
220234

221235
@objc func keyboardDidDisappear(_ notification: Notification) {
222-
let (duration, _) = notification.keyboardMetaData()
236+
let (duration, keyboardFrame) = notification.keyboardMetaData()
223237
tag = UIResponder.current.reactViewTag
224238

225239
onCancelAnimation()
@@ -267,7 +281,7 @@ public class KeyboardMovementObserver: NSObject {
267281
}
268282

269283
let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.frameTransitionInWindow
270-
var keyboardPosition = visibleKeyboardHeight
284+
var keyboardPosition = visibleKeyboardHeight - KeyboardAreaExtender.shared.offset
271285

272286
if keyboardPosition == prevKeyboardPosition || keyboardFrameY == 0 {
273287
return

‎ios/protocols/TextInput.swift

+2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import UIKit
1111

1212
public protocol TextInput: UIView {
1313
// default common methods/properties
14+
var inputAccessoryView: UIView? { get set }
1415
var inputView: UIView? { get set }
1516
var keyboardType: UIKeyboardType { get }
1617
var keyboardAppearance: UIKeyboardAppearance { get }
18+
// custom methods/properties
1719
func focus()
1820
}
1921

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// UIResponderSwizzle.swift
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 01/11/2024.
6+
//
7+
8+
import Foundation
9+
import UIKit
10+
11+
private var originalResignFirstResponder: IMP?
12+
13+
@objc
14+
extension UIResponder {
15+
public static func swizzleResignFirstResponder() {
16+
let originalSelector = #selector(resignFirstResponder)
17+
18+
guard let originalMethod = class_getInstanceMethod(UIResponder.self, originalSelector) else {
19+
return
20+
}
21+
22+
originalResignFirstResponder = method_getImplementation(originalMethod)
23+
24+
let swizzledImplementation: @convention(block) (UIResponder) -> Bool = { (self) in
25+
// Check the type of responder
26+
if let textField = self as? TextInput {
27+
// check inputAccessoryView and call original method immediately if not InvisibleInputAccessoryView
28+
if !(textField.inputAccessoryView is InvisibleInputAccessoryView) {
29+
return self.callOriginalResignFirstResponder(originalSelector)
30+
}
31+
} else {
32+
// If casting to TextInput fails
33+
return self.callOriginalResignFirstResponder(originalSelector)
34+
}
35+
36+
KeyboardAreaExtender.shared.hide()
37+
38+
// Postpone execution of the original resignFirstResponder
39+
DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) {
40+
(self as? TextInput)?.inputAccessoryView = nil
41+
KeyboardAreaExtender.shared.remove()
42+
_ = self.callOriginalResignFirstResponder(originalSelector)
43+
}
44+
45+
// We need to return a value immediately, even though the actual action is delayed
46+
return false
47+
}
48+
49+
let implementation = imp_implementationWithBlock(swizzledImplementation)
50+
method_setImplementation(originalMethod, implementation)
51+
}
52+
53+
private func callOriginalResignFirstResponder(_ selector: Selector) -> Bool {
54+
guard let originalResignFirstResponder = originalResignFirstResponder else { return false }
55+
typealias Function = @convention(c) (AnyObject, Selector) -> Bool
56+
let castOriginalResignFirstResponder = unsafeBitCast(originalResignFirstResponder, to: Function.self)
57+
let result = castOriginalResignFirstResponder(self, selector)
58+
return result
59+
}
60+
}
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// KeyboardGestureAreaManager.h
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 01/11/2024.
6+
//
7+
8+
#ifdef RCT_NEW_ARCH_ENABLED
9+
#import <React/RCTViewComponentView.h>
10+
#else
11+
#import <React/RCTBridge.h>
12+
#endif
13+
#import <React/RCTViewManager.h>
14+
#import <UIKit/UIKit.h>
15+
16+
@interface KeyboardGestureAreaManager : RCTViewManager
17+
@end
18+
19+
@interface KeyboardGestureArea :
20+
#ifdef RCT_NEW_ARCH_ENABLED
21+
RCTViewComponentView
22+
#else
23+
UIView
24+
25+
- (instancetype)initWithBridge:(RCTBridge *)bridge;
26+
27+
#endif
28+
29+
@property (nonatomic, strong) NSNumber *offset;
30+
@property (nonatomic, strong) NSString *textInputNativeID;
31+
@end
+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//
2+
// KeyboardGestureAreaManager.mm
3+
// Pods
4+
//
5+
// Created by Kiryl Ziusko on 01/11/2024.
6+
//
7+
8+
#import "KeyboardGestureAreaManager.h"
9+
10+
#if __has_include("react_native_keyboard_controller-Swift.h")
11+
#import "react_native_keyboard_controller-Swift.h"
12+
#else
13+
#import <react_native_keyboard_controller/react_native_keyboard_controller-Swift.h>
14+
#endif
15+
16+
#ifdef RCT_NEW_ARCH_ENABLED
17+
#import <react/renderer/components/reactnativekeyboardcontroller/ComponentDescriptors.h>
18+
#import <react/renderer/components/reactnativekeyboardcontroller/EventEmitters.h>
19+
#import <react/renderer/components/reactnativekeyboardcontroller/Props.h>
20+
#import <react/renderer/components/reactnativekeyboardcontroller/RCTComponentViewHelpers.h>
21+
22+
#import "RCTFabricComponentsPlugins.h"
23+
#endif
24+
25+
#import <UIKit/UIKit.h>
26+
27+
#ifdef RCT_NEW_ARCH_ENABLED
28+
using namespace facebook::react;
29+
#endif
30+
31+
// MARK: Manager
32+
@implementation KeyboardGestureAreaManager
33+
34+
RCT_EXPORT_MODULE(KeyboardGestureAreaManager)
35+
36+
// Expose props to React Native
37+
RCT_EXPORT_VIEW_PROPERTY(textInputNativeID, NSString *)
38+
RCT_EXPORT_VIEW_PROPERTY(offset, NSNumber *)
39+
40+
+ (BOOL)requiresMainQueueSetup
41+
{
42+
return NO;
43+
}
44+
45+
#ifndef RCT_NEW_ARCH_ENABLED
46+
- (UIView *)view
47+
{
48+
return [[KeyboardGestureArea alloc] initWithBridge:self.bridge];
49+
}
50+
#endif
51+
52+
@end
53+
54+
// MARK: View
55+
#ifdef RCT_NEW_ARCH_ENABLED
56+
@interface KeyboardGestureArea () <RCTKeyboardGestureAreaViewProtocol>
57+
#else
58+
@interface KeyboardGestureArea ()
59+
#endif
60+
@end
61+
62+
@implementation KeyboardGestureArea {
63+
}
64+
65+
#ifdef RCT_NEW_ARCH_ENABLED
66+
+ (ComponentDescriptorProvider)componentDescriptorProvider
67+
{
68+
return concreteComponentDescriptorProvider<KeyboardGestureAreaComponentDescriptor>();
69+
}
70+
#endif
71+
72+
// Needed because of this: https://github.com/facebook/react-native/pull/37274
73+
+ (void)load
74+
{
75+
[super load];
76+
77+
[UIResponder swizzleResignFirstResponder];
78+
}
79+
80+
// MARK: Constructor
81+
#ifdef RCT_NEW_ARCH_ENABLED
82+
- (instancetype)init
83+
{
84+
if (self = [super init]) {
85+
}
86+
return self;
87+
}
88+
#else
89+
- (instancetype)initWithBridge:(RCTBridge *)bridge
90+
{
91+
self = [super initWithFrame:CGRectZero];
92+
if (self) {
93+
}
94+
95+
return self;
96+
}
97+
#endif
98+
99+
// MARK: lifecycle methods
100+
- (void)didMoveToSuperview
101+
{
102+
if (self.superview == nil) {
103+
// unmounted
104+
[[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:_textInputNativeID
105+
withTag:self.reactTag];
106+
}
107+
}
108+
109+
// MARK: props updater
110+
#ifdef RCT_NEW_ARCH_ENABLED
111+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
112+
{
113+
const auto &newViewProps = *std::static_pointer_cast<const KeyboardGestureAreaProps>(props);
114+
const KeyboardGestureAreaProps *oldViewPropsPtr =
115+
oldProps ? std::static_pointer_cast<const KeyboardGestureAreaProps>(oldProps).get() : nullptr;
116+
NSString *newTextInputNativeID = !newViewProps.textInputNativeID.empty()
117+
? [NSString stringWithUTF8String:newViewProps.textInputNativeID.c_str()]
118+
: nil;
119+
NSString *oldTextInputNativeID = (oldViewPropsPtr && !oldViewPropsPtr->textInputNativeID.empty())
120+
? [NSString stringWithUTF8String:oldViewPropsPtr->textInputNativeID.c_str()]
121+
: nil;
122+
NSNumber *tag = [NSNumber numberWithInteger:self.tag];
123+
NSNumber *newOffset = @(newViewProps.offset);
124+
125+
if (newTextInputNativeID != oldTextInputNativeID) {
126+
if (oldTextInputNativeID) {
127+
[[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:oldTextInputNativeID
128+
withTag:tag];
129+
}
130+
if (newTextInputNativeID) {
131+
[[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:newTextInputNativeID
132+
offset:newOffset
133+
withTag:tag];
134+
}
135+
} else if (!oldViewPropsPtr || newViewProps.offset != oldViewPropsPtr->offset) {
136+
if (newTextInputNativeID) {
137+
[[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:newTextInputNativeID
138+
withTag:tag];
139+
[[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:newTextInputNativeID
140+
offset:newOffset
141+
withTag:tag];
142+
}
143+
}
144+
145+
[super updateProps:props oldProps:oldProps];
146+
}
147+
#else
148+
- (void)setOffset:(NSNumber *)offset
149+
{
150+
[[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:_textInputNativeID
151+
offset:offset
152+
withTag:self.reactTag];
153+
_offset = offset;
154+
}
155+
156+
- (void)setTextInputNativeID:(NSString *)textInputNativeID
157+
{
158+
[[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:_textInputNativeID
159+
withTag:self.reactTag];
160+
[[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:textInputNativeID
161+
offset:_offset
162+
withTag:self.reactTag];
163+
_textInputNativeID = textInputNativeID;
164+
}
165+
#endif
166+
167+
#ifdef RCT_NEW_ARCH_ENABLED
168+
Class<RCTComponentViewProtocol> KeyboardGestureAreaCls(void)
169+
{
170+
return KeyboardGestureArea.class;
171+
}
172+
#endif
173+
174+
@end

‎src/bindings.native.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const WindowDimensionsEvents: WindowDimensionsEventsModule = {
5555
export const KeyboardControllerView: React.FC<KeyboardControllerProps> =
5656
require("./specs/KeyboardControllerViewNativeComponent").default;
5757
export const KeyboardGestureArea: React.FC<KeyboardGestureAreaProps> =
58-
Platform.OS === "android" && Platform.Version >= 30
58+
(Platform.OS === "android" && Platform.Version >= 30) || Platform.OS === "ios"
5959
? require("./specs/KeyboardGestureAreaNativeComponent").default
6060
: ({ children }: KeyboardGestureAreaProps) => children;
6161
export const RCTOverKeyboardView: React.FC<OverKeyboardViewProps> =

‎src/specs/KeyboardGestureAreaNativeComponent.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ export interface NativeProps extends ViewProps {
1212
showOnSwipeUp?: boolean;
1313
enableSwipeToDismiss?: boolean;
1414
offset?: Double;
15+
textInputNativeID?: string;
1516
}
1617

17-
export default codegenNativeComponent<NativeProps>("KeyboardGestureArea", {
18-
excludedPlatforms: ["iOS"],
19-
}) as HostComponent<NativeProps>;
18+
export default codegenNativeComponent<NativeProps>(
19+
"KeyboardGestureArea",
20+
) as HostComponent<NativeProps>;

‎src/types.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export type KeyboardControllerProps = {
9393
} & ViewProps;
9494

9595
export type KeyboardGestureAreaProps = {
96-
interpolator: "ios" | "linear";
96+
interpolator?: "ios" | "linear";
9797
/**
9898
* Whether to allow to show a keyboard from dismissed state by swipe up.
9999
* Default to `false`.
@@ -109,6 +109,10 @@ export type KeyboardGestureAreaProps = {
109109
* Extra distance to the keyboard.
110110
*/
111111
offset?: number;
112+
/**
113+
* A corresponding `nativeID` value from the corresponding `TextInput`.
114+
*/
115+
textInputNativeID?: string;
112116
} & ViewProps;
113117
export type OverKeyboardViewProps = PropsWithChildren<{
114118
visible: boolean;

0 commit comments

Comments
 (0)
Please sign in to comment.