Skip to content

Commit

Permalink
feat: keyboard offset on iOS (#727)
Browse files Browse the repository at this point in the history
## 📜 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
  • Loading branch information
kirillzyusko authored Jan 15, 2025
1 parent 6e7c2db commit 6da1bb4
Show file tree
Hide file tree
Showing 21 changed files with 653 additions and 41 deletions.
31 changes: 31 additions & 0 deletions FabricExample/patches/react-native+0.76.2.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
diff --git a/node_modules/react-native/.DS_Store b/node_modules/react-native/.DS_Store
new file mode 100644
index 0000000..597365c
Binary files /dev/null and b/node_modules/react-native/.DS_Store differ
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
index e74500f..c2d4515 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
@@ -68,6 +68,8 @@ @implementation RCTTextInputComponentView {
* later comparison insensitive to them.
*/
NSDictionary<NSAttributedStringKey, id> *_originalTypingAttributes;
+
+ BOOL _hasInputAccessoryView;
}

#pragma mark - UIView overrides
@@ -590,10 +592,12 @@ - (void)setDefaultInputAccessoryView
keyboardType == UIKeyboardTypeDecimalPad || keyboardType == UIKeyboardTypeASCIICapableNumberPad) &&
containsKeyType;

- if ((_backedTextInputView.inputAccessoryView != nil) == shouldHaveInputAccessoryView) {
+ if (_hasInputAccessoryView == shouldHaveInputAccessoryView) {
return;
}

+ _hasInputAccessoryView = shouldHaveInputAccessoryView;
+
if (shouldHaveInputAccessoryView) {
NSString *buttonLabel = [self returnKeyTypeToString:returnKeyType];

Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useCallback, useRef } from "react";
import { TextInput, View } from "react-native";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import React, { useCallback, useRef, useState } from "react";
import { TextInput } from "react-native";
import {
KeyboardGestureArea,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import Reanimated, {
useAnimatedProps,
useAnimatedScrollHandler,
Expand All @@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data";

import styles from "./styles";

import type { LayoutChangeEvent } from "react-native";

const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);

const useKeyboardAnimation = () => {
Expand Down Expand Up @@ -86,6 +91,12 @@ const contentContainerStyle = {
function InteractiveKeyboard() {
const ref = useRef<Reanimated.ScrollView>(null);
const { height, onScroll, inset, offset } = useKeyboardAnimation();
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
const [text, setText] = useState("");

const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
setInputHeight(e.nativeEvent.layout.height);
}, []);

const scrollToBottom = useCallback(() => {
ref.current?.scrollToEnd({ animated: false });
Expand All @@ -94,7 +105,7 @@ function InteractiveKeyboard() {
const textInputStyle = useAnimatedStyle(
() => ({
position: "absolute",
height: TEXT_INPUT_HEIGHT,
minHeight: TEXT_INPUT_HEIGHT,
width: "100%",
backgroundColor: "#BCBCBC",
transform: [{ translateY: -height.value }],
Expand All @@ -113,7 +124,11 @@ function InteractiveKeyboard() {
}));

return (
<View style={styles.container}>
<KeyboardGestureArea
offset={inputHeight}
style={styles.container}
textInputNativeID="chat-input"
>
<Reanimated.ScrollView
ref={ref}
// simulation of `automaticallyAdjustKeyboardInsets` behavior on RN < 0.73
Expand All @@ -130,8 +145,16 @@ function InteractiveKeyboard() {
<Message key={index} {...message} />
))}
</Reanimated.ScrollView>
<AnimatedTextInput style={textInputStyle} testID="chat.input" />
</View>
<AnimatedTextInput
multiline
nativeID="chat-input"
style={textInputStyle}
testID="chat.input"
value={text}
onChangeText={setText}
onLayout={onInputLayoutChanged}
/>
</KeyboardGestureArea>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,12 @@ class KeyboardGestureAreaViewManager(
) {
manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value)
}

@ReactProp(name = "textInputNativeID")
override fun setTextInputNativeID(
view: ReactViewGroup,
value: String?,
) {
// no-op
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ class KeyboardGestureAreaViewManager(
) {
manager.setScrollKeyboardOffScreenWhenVisible(view, value)
}

@Suppress("detekt:UnusedParameter")
@ReactProp(name = "textInputNativeID")
fun setTextInputNativeID(
view: KeyboardGestureAreaReactViewGroup,
value: String,
) {
// no-op
}
}
23 changes: 13 additions & 10 deletions docs/docs/api/keyboard-gesture-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ keywords:
]
---

<!-- prettier-ignore-start -->
<!-- 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 -->
<!-- markdownlint-disable-next-line MD025 -->
# KeyboardGestureArea <div className="label android"></div>
<!-- prettier-ignore-end -->

`KeyboardGestureArea` allows you to define a region on the screen, where gestures will control the keyboard position.

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

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

### `interpolator`
### `interpolator` <div className="label android"></div>

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

- **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.
- **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.

### `showOnSwipeUp`
### `showOnSwipeUp` <div className="label android"></div>

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.

### `enableSwipeToDismiss`
### `enableSwipeToDismiss` <div className="label android"></div>

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.

### `textInputNativeID` <div className="label ios"></div>

A corresponding `nativeID` value from the corresponding `TextInput`.

## Example

```tsx
<KeyboardGestureArea interpolator="ios" offset={50}>
<KeyboardGestureArea
interpolator="ios"
offset={50}
textInputNativeID="composer"
>
<ScrollView>
{/* The other UI components of application in your tree */}
</ScrollView>
<TextInput nativeID="composer" />
</KeyboardGestureArea>
```
37 changes: 30 additions & 7 deletions example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useCallback, useRef } from "react";
import { TextInput, View } from "react-native";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import React, { useCallback, useRef, useState } from "react";
import { TextInput } from "react-native";
import {
KeyboardGestureArea,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import Reanimated, {
useAnimatedProps,
useAnimatedScrollHandler,
Expand All @@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data";

import styles from "./styles";

import type { LayoutChangeEvent } from "react-native";

const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);

const useKeyboardAnimation = () => {
Expand Down Expand Up @@ -86,6 +91,12 @@ const contentContainerStyle = {
function InteractiveKeyboard() {
const ref = useRef<Reanimated.ScrollView>(null);
const { height, onScroll, inset, offset } = useKeyboardAnimation();
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);
const [text, setText] = useState("");

const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
setInputHeight(e.nativeEvent.layout.height);
}, []);

const scrollToBottom = useCallback(() => {
ref.current?.scrollToEnd({ animated: false });
Expand All @@ -94,7 +105,7 @@ function InteractiveKeyboard() {
const textInputStyle = useAnimatedStyle(
() => ({
position: "absolute",
height: TEXT_INPUT_HEIGHT,
minHeight: TEXT_INPUT_HEIGHT,
width: "100%",
backgroundColor: "#BCBCBC",
transform: [{ translateY: -height.value }],
Expand All @@ -113,7 +124,11 @@ function InteractiveKeyboard() {
}));

return (
<View style={styles.container}>
<KeyboardGestureArea
offset={inputHeight}
style={styles.container}
textInputNativeID="chat-input"
>
<Reanimated.ScrollView
ref={ref}
// simulation of `automaticallyAdjustKeyboardInsets` behavior on RN < 0.73
Expand All @@ -130,8 +145,16 @@ function InteractiveKeyboard() {
<Message key={index} {...message} />
))}
</Reanimated.ScrollView>
<AnimatedTextInput style={textInputStyle} testID="chat.input" />
</View>
<AnimatedTextInput
multiline
nativeID="chat-input"
style={textInputStyle}
testID="chat.input"
value={text}
onChangeText={setText}
onLayout={onInputLayoutChanged}
/>
</KeyboardGestureArea>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public extension UIView {
var reactTag: NSNumber {
return tag as NSNumber
}

var nativeID: String {
return accessibilityIdentifier ?? ""
}
}
4 changes: 4 additions & 0 deletions ios/extensions/Notification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ extension Notification {
return (duration, keyboardFrame)
}
}

extension Notification.Name {
static let shouldIgnoreKeyboardEvents = Notification.Name("shouldIgnoreKeyboardEvents")
}
10 changes: 10 additions & 0 deletions ios/extensions/UIResponder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ public extension Optional where Wrapped == UIResponder {
return (self as? UIView)?.superview?.reactTag ?? -1
#endif
}

var nativeID: String? {
guard let superview = (self as? UIView)?.superview else { return nil }

#if KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED
return (superview as NSObject).value(forKey: "nativeId") as? String
#else
return (superview as? UIView)?.nativeID
#endif
}
}

public extension Optional where Wrapped: UIResponder {
Expand Down
59 changes: 59 additions & 0 deletions ios/interactive/InvisibleInputAccessoryView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// InvisibleInputAccessoryView.swift
// Pods
//
// Created by Kiryl Ziusko on 01/11/2024.
//

import Foundation
import UIKit

public class InvisibleInputAccessoryView: UIView {
var isShown = true

override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}

public convenience init(height: CGFloat) {
self.init(frame: CGRect(x: 0, y: 0, width: 0, height: height))
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}

override public func point(inside _: CGPoint, with _: UIEvent?) -> Bool {
// Return false to allow touch events to pass through
return false
}

public func updateHeight(to newHeight: CGFloat) {
frame = CGRect(x: 0, y: 0, width: 0, height: newHeight)

// Invalidate intrinsic content size to trigger a layout update
invalidateIntrinsicContentSize()
layoutIfNeeded()
}

public func hide() {
guard isShown else { return }
isShown = false
updateHeight(to: 0.0)
superview?.layoutIfNeeded()
}

override public var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: frame.height)
}

private func setupView() {
isUserInteractionEnabled = false
// for debug purposes
// backgroundColor = UIColor.red.withAlphaComponent(0.2)
backgroundColor = .clear
autoresizingMask = .flexibleHeight
}
}
Loading

0 comments on commit 6da1bb4

Please sign in to comment.