Skip to content

Commit 6da1bb4

Browse files
authored
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+
}

0 commit comments

Comments
 (0)