Skip to content

Commit 347fef3

Browse files
feat: KeyboardExtender (#982)
## 📜 Description Added `KeyboardExtender` component. ## 💡 Motivation and Context This is a part of a big research that I did over several months. This PR implements `KeyboardExtender` component. On iOS it utilizes native capabilities of the OS and on Android it uses polyfill (`KeyboardBackgroundView`+`KeyboardStickyView`+`useKeyboardAnimation` for `opacity` animation). That component aims to help developers to build various keyboard extensions which will be shown above the keyboard and visually mimic to it. The initial idea of implementation was inspired by Revolut app: <div align="center"> <video src="https://github.com/user-attachments/assets/320da57f-7ad8-41e3-931a-1be7780fc6f8"> </div> And the `KeyboardExtender` ideally fits for building such things, another example is: <div align="center"> <img src="https://github.com/user-attachments/assets/bb338e9b-b2f6-4a8a-a933-babe94a662fd" width="300"> </div> And I think developers can take inspiration and build even more advanced features (such as voice search etc.) To implement this on iOS I used [UIInputView](https://developer.apple.com/documentation/uikit/uiinputview#overview) with [keyboard](https://developer.apple.com/documentation/uikit/uiinputview/style/keyboard) style. > [!WARNING] > On iOS 26 it looks bad and the view is not getting embedded into the keyboard. See [tweet](https://x.com/ziusko/status/1932492809258381350) about it. For now let's wait for Apple fixes and if they are not going to fix it, then most likely we'll have to write separate implementation for iSO 26 to use liquid glass design. On Android I'm trying to re--create iOS effect, but it's obviously not possible to match it fully, so I'm trying to make it looking as close as possible to. For sure I can not fully hide the view and make it moving together with keyboard from the very beginning, so I decided to use `opacity` interpolation and always show it above the keyboard (using `KeyboardStickyView`). > I decided not to use parallax animation (as in `KeyboardToolbar`) here. Let's see if `opacity` + frame-in-frame motion looks better than parallax effect 👀 <hr> Last but not least - for now I decided to make this component very simple and barely customizable (I expose only `enabled` property). And it can cause certain issues, for example if you switch between QWERTY keyboard (where you don't want `KeyboardExtender` to be present) to numeric (where you'd like to see `KeyboardExtender`), then you will see how react updates everything asynchronously and for a fraction of a second you will see QWERTY-keyboard with `KeyboardExtender`. Theoretically I could introduce binding through `nativeID` and "connect" specific inputs with specific extenders, but for now let's not do an over-engineering and try to handle all cases. Let's handle everything step-by-step and when we get a bug report then we can do another round of investigation how such thing can be implemented 👀 ## 📢 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 - added new page for `KeyboardExtender`; - added new chapter in `Components Overview`; - mention `KeyboardExtender` feature in `README`; ### E2E - added new test for `KeyboardExtender`; ### JS - added `KeyboardExtender` component; - added `KeyboardExtender` spec; - added `KeyboardExtender` mock. ### iOS - expose `KeyboardExtender` view; ### C++ - added c++ bindings (shadow nodes, component descriptor, etc.) for `KeyboardExtender`; ## 🤔 How Has This Been Tested? Tested manually on: - iPhone 15 Pro (iOS 17.5, simulator) - iPhone 16 Pro (iOS 18.4, simulator) - iPhone 6s (iOS 15.5, real device) - iPhone 16 Pro (iOS 26.0, simulator) - doesn't look good there, filled bug to Apple - Medium Phone API 35 (emulator) - Pixel 7 Pro (API 36, real device) - Xiaomi Redmi Note 5 Pro (API 28, real device) ## 📸 Screenshots (if appropriate): |iOS|Android| |----|--------| |<video src="https://github.com/user-attachments/assets/a5c7c515-6b0e-4966-a74c-78e00619c137">|<video src="https://github.com/user-attachments/assets/f6119e63-ae41-4e0f-b732-e112f2e7c4e6">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent a57fa4b commit 347fef3

52 files changed

Lines changed: 1055 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ exports[`components rendering should render \`KeyboardControllerView\` 1`] = `
5454
/>
5555
`;
5656

57+
exports[`components rendering should render \`KeyboardExtenderTest\` 1`] = `
58+
<KeyboardExtender
59+
enabled={true}
60+
>
61+
<View
62+
style={
63+
{
64+
"backgroundColor": "black",
65+
"height": 20,
66+
"width": 20,
67+
}
68+
}
69+
/>
70+
</KeyboardExtender>
71+
`;
72+
5773
exports[`components rendering should render \`KeyboardProvider\` 1`] = `
5874
<KeyboardProvider
5975
statusBarTranslucent={true}

FabricExample/__tests__/components-rendering.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
KeyboardAwareScrollView,
77
KeyboardBackgroundView,
88
KeyboardControllerView,
9+
KeyboardExtender,
910
KeyboardProvider,
1011
KeyboardStickyView,
1112
KeyboardToolbar,
@@ -79,6 +80,10 @@ function KeyboardBackgroundViewTest() {
7980
return <KeyboardBackgroundView />;
8081
}
8182

83+
function KeyboardExtenderTest() {
84+
return <KeyboardExtender enabled={true}>{<EmptyView />}</KeyboardExtender>;
85+
}
86+
8287
describe("components rendering", () => {
8388
it("should render `KeyboardControllerView`", () => {
8489
expect(render(<KeyboardControllerViewTest />)).toMatchSnapshot();
@@ -111,4 +116,8 @@ describe("components rendering", () => {
111116
it("should render `KeyboardBackgroundView`", () => {
112117
expect(render(<KeyboardBackgroundViewTest />)).toMatchSnapshot();
113118
});
119+
120+
it("should render `KeyboardExtenderTest`", () => {
121+
expect(render(<KeyboardExtenderTest />)).toMatchSnapshot();
122+
});
114123
});

FabricExample/src/constants/screenNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ export enum ScreenNames {
2626
USE_KEYBOARD_STATE = "USE_KEYBOARD_STATE",
2727
LIQUID_KEYBOARD = "LIQUID_KEYBOARD",
2828
KEYBOARD_SHARED_TRANSITIONS = "KEYBOARD_SHARED_TRANSITIONS",
29+
KEYBOARD_EXTENDER = "KEYBOARD_EXTENDER",
2930
}

FabricExample/src/navigation/ExamplesStack/index.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import InteractiveKeyboard from "../../screens/Examples/InteractiveKeyboard";
1313
import InteractiveKeyboardIOS from "../../screens/Examples/InteractiveKeyboardIOS";
1414
import KeyboardAnimation from "../../screens/Examples/KeyboardAnimation";
1515
import KeyboardAvoidingViewExample from "../../screens/Examples/KeyboardAvoidingView";
16+
import KeyboardExtender from "../../screens/Examples/KeyboardExtender";
1617
import KeyboardSharedTransitionExample from "../../screens/Examples/KeyboardSharedTransitions";
1718
import UseKeyboardState from "../../screens/Examples/KeyboardStateHook";
1819
import LiquidKeyboardExample from "../../screens/Examples/LiquidKeyboard";
@@ -52,6 +53,7 @@ export type ExamplesStackParamList = {
5253
[ScreenNames.USE_KEYBOARD_STATE]: undefined;
5354
[ScreenNames.LIQUID_KEYBOARD]: undefined;
5455
[ScreenNames.KEYBOARD_SHARED_TRANSITIONS]: undefined;
56+
[ScreenNames.KEYBOARD_EXTENDER]: undefined;
5557
};
5658

5759
const Stack = createStackNavigator<ExamplesStackParamList>();
@@ -132,6 +134,10 @@ const options = {
132134
title: "Keyboard shared transitions",
133135
headerShown: false,
134136
},
137+
[ScreenNames.KEYBOARD_EXTENDER]: {
138+
title: "Keyboard Extender",
139+
headerShown: false,
140+
},
135141
};
136142

137143
const ExamplesStack = () => (
@@ -256,6 +262,11 @@ const ExamplesStack = () => (
256262
name={ScreenNames.KEYBOARD_SHARED_TRANSITIONS}
257263
options={options[ScreenNames.KEYBOARD_SHARED_TRANSITIONS]}
258264
/>
265+
<Stack.Screen
266+
component={KeyboardExtender}
267+
name={ScreenNames.KEYBOARD_EXTENDER}
268+
options={options[ScreenNames.KEYBOARD_EXTENDER]}
269+
/>
259270
</Stack.Navigator>
260271
);
261272

15.8 KB
Loading
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { useEffect, useState } from "react";
2+
import {
3+
Alert,
4+
Image,
5+
Keyboard,
6+
StyleSheet,
7+
Text,
8+
TextInput,
9+
TouchableOpacity,
10+
TouchableWithoutFeedback,
11+
} from "react-native";
12+
import { KeyboardExtender } from "react-native-keyboard-controller";
13+
import Reanimated, {
14+
useAnimatedStyle,
15+
useSharedValue,
16+
withTiming,
17+
} from "react-native-reanimated";
18+
import { SafeAreaView } from "react-native-safe-area-context";
19+
20+
export default function KeyboardExtendExample() {
21+
const [showExtend, setShowExtend] = useState(true);
22+
const opacity = useSharedValue(1);
23+
24+
useEffect(() => {
25+
opacity.set(withTiming(showExtend ? 1 : 0));
26+
}, [showExtend]);
27+
28+
const animatedStyle = useAnimatedStyle(
29+
() => ({
30+
opacity: opacity.value,
31+
}),
32+
[],
33+
);
34+
35+
return (
36+
<>
37+
<Image source={require("./background.jpg")} style={styles.background} />
38+
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
39+
<SafeAreaView edges={["top"]} style={styles.container}>
40+
<TextInput
41+
keyboardType="numeric"
42+
placeholder="Donation amount"
43+
placeholderTextColor="#5c5c5c"
44+
style={styles.input}
45+
testID="donation_amount"
46+
onFocus={() => setShowExtend(true)}
47+
/>
48+
<TextInput
49+
keyboardType="numeric"
50+
placeholder="Postal code"
51+
placeholderTextColor="#5c5c5c"
52+
style={styles.input}
53+
testID="postal_code"
54+
onFocus={() => setShowExtend(false)}
55+
/>
56+
</SafeAreaView>
57+
</TouchableWithoutFeedback>
58+
<KeyboardExtender enabled={showExtend}>
59+
<Reanimated.View style={[styles.keyboardExtend, animatedStyle]}>
60+
<TouchableOpacity
61+
testID="donation_10"
62+
onPress={() => Alert.alert("10 dollars")}
63+
>
64+
<Text style={styles.priceText}>10$</Text>
65+
</TouchableOpacity>
66+
<TouchableOpacity
67+
testID="donation_20"
68+
onPress={() => Alert.alert("20 dollars")}
69+
>
70+
<Text style={styles.priceText}>20$</Text>
71+
</TouchableOpacity>
72+
<TouchableOpacity
73+
testID="donation_50"
74+
onPress={() => Alert.alert("50 dollars")}
75+
>
76+
<Text style={styles.priceText}>50$</Text>
77+
</TouchableOpacity>
78+
</Reanimated.View>
79+
</KeyboardExtender>
80+
</>
81+
);
82+
}
83+
84+
const styles = StyleSheet.create({
85+
background: {
86+
...StyleSheet.absoluteFillObject,
87+
flex: 1,
88+
width: "100%",
89+
},
90+
container: {
91+
flex: 1,
92+
paddingHorizontal: 20,
93+
},
94+
input: {
95+
height: 40,
96+
borderWidth: 2,
97+
borderColor: "#1c1c1c",
98+
borderRadius: 8,
99+
padding: 10,
100+
fontSize: 18,
101+
marginBottom: 20,
102+
},
103+
keyboardExtend: {
104+
width: "100%",
105+
flexDirection: "row",
106+
justifyContent: "space-around",
107+
alignItems: "center",
108+
},
109+
priceText: {
110+
color: "black",
111+
fontSize: 18,
112+
fontWeight: "600",
113+
padding: 20,
114+
},
115+
});

FabricExample/src/screens/Examples/KeyboardSharedTransitions/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const KeyboardSharedTransitionExample = () => {
6464
>
6565
<ReanimatedTextInput
6666
placeholder="127.0.0.1"
67-
placeholderTextColor="#ecececec"
67+
placeholderTextColor="#ececec"
6868
style={[
6969
{
7070
width: "100%",

FabricExample/src/screens/Examples/Main/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,10 @@ export const examples: Example[] = [
147147
info: ScreenNames.KEYBOARD_SHARED_TRANSITIONS,
148148
icons: "🔄",
149149
},
150+
{
151+
title: "Keyboard Extender",
152+
testID: "keyboard_extender",
153+
info: ScreenNames.KEYBOARD_EXTENDER,
154+
icons: "🧩",
155+
},
150156
];

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A universal keyboard handling solution for React Native — lightweight, fully c
1717
- 📐 `KeyboardToolbar` with customizable _**previous**_, _**next**_, and _**done**_ buttons
1818
- 🌐 Display anything over the keyboard (without dismissing it) using `OverKeyboardView`
1919
- 🎨 Match keyboard background with `KeyboardBackgroundView`
20+
- 🧩 Extend keyboard with custom buttons/UI via `KeyboardExtender`
2021
- 📝 Easy retrieval of focused input info
2122
- 🧭 Compatible with any navigation library
2223
- ✨ More coming soon... stay tuned! 😊

android/src/main/java/com/reactnativekeyboardcontroller/views/background/Skins.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package com.reactnativekeyboardcontroller.views.background
33
import android.content.Context
44
import android.graphics.Color
55
import android.os.Build
6-
import android.util.Log
76
import androidx.annotation.ColorInt
87
import com.facebook.react.uimanager.ThemedReactContext
98
import com.reactnativekeyboardcontroller.R
109
import com.reactnativekeyboardcontroller.extensions.currentImePackage
1110
import com.reactnativekeyboardcontroller.extensions.isSystemDarkMode
11+
import com.reactnativekeyboardcontroller.log.Logger
1212

13+
private const val TAG = "Skins"
1314
private const val MAX_RGB_VALUE = 255
1415

1516
object ImePackages {
@@ -76,7 +77,7 @@ fun ThemedReactContext.getInputMethodColor(): Int {
7677
val imePackage = currentImePackage()
7778
val isDark = isSystemDarkMode()
7879

79-
Log.i("Skins", "Current IME: $imePackage")
80+
Logger.i(TAG, "Current IME: $imePackage")
8081

8182
val (lightColorRes, darkColorRes) =
8283
imeColorMap[imePackage]

0 commit comments

Comments
 (0)