Skip to content

Commit 4a32305

Browse files
authored
feat: KeyboardToolbar.Group (#881)
## 📜 Description Added `KeyboardToolbar.Group` component. ## 💡 Motivation and Context This PR adds new `KeyboardToolbar.Group` component and corresponding native view. The idea is that with new component we can define a **group** of **related inputs** so that other inputs will not be scanned. The idea is similar to https://github.com/hackiftekhar/IQKeyboardManager/wiki/Manual-Management#show-previousnext-arrow-buttons-for-textfield-which-are-not-direct-disblings Closes #470 ## 📢 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 - add API reference how to use new API; ### JS - declare `KeyboardToolbarGroupView` component (codegen); - updated mocks file; - added snapshot testing for rendering compound `KeyboardToolbar`; ### iOS - update traversal algorithm to take `KeyboardToolbarGroupView` into consideration; - added unit tests to cover new functionality; ### Android - update traversal algorithm to take `KeyboardToolbarGroupView` into consideration; - added unit tests to cover new functionality; ## 🤔 How Has This Been Tested? Tested manually on: - Pixel 7 Pro (API 36, real device) - iPhone 17 Pro (iOS 26.2, simulator) ## 📸 Screenshots (if appropriate): |Android|iOS| |--------|----| |<video src="https://github.com/user-attachments/assets/05cd4c1f-06bc-4ac2-8ccb-540103f1199c">|<video src="https://github.com/user-attachments/assets/1a399979-bc64-4ecd-b5e9-a83760248075">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 6872736 commit 4a32305

35 files changed

Lines changed: 898 additions & 15 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,38 @@ exports[`components rendering should render \`OverKeyboardView\` 1`] = `
133133
/>
134134
</OverKeyboardView>
135135
`;
136+
137+
exports[`components rendering should render compound \`KeyboardToolbar\` 1`] = `
138+
[
139+
<View>
140+
<KeyboardToolbar.Background>
141+
<View
142+
style={
143+
{
144+
"backgroundColor": "black",
145+
"height": 20,
146+
"width": 20,
147+
}
148+
}
149+
/>
150+
</KeyboardToolbar.Background>
151+
<KeyboardToolbar.Content>
152+
<View
153+
style={
154+
{
155+
"backgroundColor": "black",
156+
"height": 20,
157+
"width": 20,
158+
}
159+
}
160+
/>
161+
</KeyboardToolbar.Content>
162+
<KeyboardToolbar.Prev />
163+
<KeyboardToolbar.Next />
164+
<KeyboardToolbar.Done />
165+
</View>,
166+
<KeyboardToolbar.Group>
167+
<TextInput />
168+
</KeyboardToolbar.Group>,
169+
]
170+
`;

FabricExample/__tests__/components-rendering.spec.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render } from "@testing-library/react-native";
22
import React from "react";
3-
import { View } from "react-native";
3+
import { TextInput, View } from "react-native";
44
import {
55
KeyboardAvoidingView,
66
KeyboardAwareScrollView,
@@ -68,6 +68,27 @@ function KeyboardToolbarTest() {
6868
return <KeyboardToolbar content={content} />;
6969
}
7070

71+
function KeyboardToolbarCompoundTest() {
72+
return (
73+
<>
74+
<KeyboardToolbar>
75+
<KeyboardToolbar.Background>
76+
<EmptyView />
77+
</KeyboardToolbar.Background>
78+
<KeyboardToolbar.Content>
79+
<EmptyView />
80+
</KeyboardToolbar.Content>
81+
<KeyboardToolbar.Prev />
82+
<KeyboardToolbar.Next />
83+
<KeyboardToolbar.Done />
84+
</KeyboardToolbar>
85+
<KeyboardToolbar.Group>
86+
<TextInput />
87+
</KeyboardToolbar.Group>
88+
</>
89+
);
90+
}
91+
7192
function OverKeyboardViewTest() {
7293
return (
7394
<OverKeyboardView visible={true}>
@@ -109,6 +130,10 @@ describe("components rendering", () => {
109130
expect(render(<KeyboardToolbarTest />)).toMatchSnapshot();
110131
});
111132

133+
it("should render compound `KeyboardToolbar`", () => {
134+
expect(render(<KeyboardToolbarCompoundTest />)).toMatchSnapshot();
135+
});
136+
112137
it("should render `OverKeyboardView`", () => {
113138
expect(render(<OverKeyboardViewTest />)).toMatchSnapshot();
114139
});

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
12
import React, { useCallback, useEffect, useState } from "react";
23
import { Modal, Platform, StyleSheet, Text, View } from "react-native";
34
import {
@@ -214,6 +215,24 @@ export default function ToolbarExample({ navigation }: Props) {
214215
<Form />
215216
</View>
216217
</Modal>
218+
<BottomSheet index={-1}>
219+
<BottomSheetView style={styles.bottomSheetContent}>
220+
<KeyboardToolbar.Group>
221+
<TextInput
222+
keyboardType="default"
223+
placeholder="Group input 1"
224+
testID="TextInput#14"
225+
title="Group Input 1"
226+
/>
227+
<TextInput
228+
keyboardType="default"
229+
placeholder="Group input 2"
230+
testID="TextInput#15"
231+
title="Group Input 2"
232+
/>
233+
</KeyboardToolbar.Group>
234+
</BottomSheetView>
235+
</BottomSheet>
217236
</>
218237
);
219238
}
@@ -245,4 +264,7 @@ const styles = StyleSheet.create({
245264
modal: {
246265
marginTop: 32,
247266
},
267+
bottomSheetContent: {
268+
flex: 1,
269+
},
248270
});

android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,6 @@ class KeyboardControllerPackage : BaseReactPackage() {
6060
OverKeyboardViewManager(),
6161
KeyboardBackgroundViewManager(),
6262
ClippingScrollViewDecoratorViewManager(),
63+
KeyboardToolbarGroupViewManager(),
6364
)
6465
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.reactnativekeyboardcontroller
2+
3+
import com.facebook.react.uimanager.ThemedReactContext
4+
import com.facebook.react.uimanager.ViewGroupManager
5+
import com.facebook.react.uimanager.ViewManagerDelegate
6+
import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerDelegate
7+
import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerInterface
8+
import com.reactnativekeyboardcontroller.managers.KeyboardToolbarGroupViewManagerImpl
9+
import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
10+
11+
class KeyboardToolbarGroupViewManager :
12+
ViewGroupManager<KeyboardToolbarGroupReactViewGroup>(),
13+
KeyboardToolbarGroupViewManagerInterface<KeyboardToolbarGroupReactViewGroup> {
14+
private val manager = KeyboardToolbarGroupViewManagerImpl()
15+
private val mDelegate = KeyboardToolbarGroupViewManagerDelegate(this)
16+
17+
override fun getDelegate(): ViewManagerDelegate<KeyboardToolbarGroupReactViewGroup> = mDelegate
18+
19+
override fun getName(): String = KeyboardToolbarGroupViewManagerImpl.NAME
20+
21+
override fun createViewInstance(context: ThemedReactContext): KeyboardToolbarGroupReactViewGroup =
22+
manager.createViewInstance(context)
23+
}

android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ class FocusedInputObserver(
120120
selectionSubscription = newFocus.addOnSelectionChangedListener(selectionListener)
121121
FocusedInputHolder.set(newFocus)
122122

123-
val allInputFields = ViewHierarchyNavigator.getAllInputFields(context?.rootView)
123+
val groupAncestor = ViewHierarchyNavigator.findGroupAncestor(newFocus)
124+
val allInputFields = ViewHierarchyNavigator.getAllInputFields(groupAncestor ?: context?.rootView)
124125
val currentIndex = allInputFields.indexOf(newFocus)
125126

126127
context.emitEvent(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.reactnativekeyboardcontroller.managers
2+
3+
import com.facebook.react.uimanager.ThemedReactContext
4+
import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
5+
6+
class KeyboardToolbarGroupViewManagerImpl {
7+
fun createViewInstance(reactContext: ThemedReactContext): KeyboardToolbarGroupReactViewGroup =
8+
KeyboardToolbarGroupReactViewGroup(reactContext)
9+
10+
companion object {
11+
const val NAME = "KeyboardToolbarGroupView"
12+
}
13+
}

android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.view.ViewGroup
55
import android.widget.EditText
66
import com.facebook.react.bridge.UiThreadUtil
77
import com.reactnativekeyboardcontroller.extensions.focus
8+
import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup
89

910
object ViewHierarchyNavigator {
1011
fun setFocusTo(
@@ -25,19 +26,40 @@ object ViewHierarchyNavigator {
2526
fun findEditTexts(view: View?) {
2627
if (isValidTextInput(view)) {
2728
editTexts.add(view as EditText)
28-
} else if (view is ViewGroup) {
29+
} else if (view is ViewGroup && view !is KeyboardToolbarGroupReactViewGroup) {
2930
for (i in 0 until view.childCount) {
3031
findEditTexts(view.getChildAt(i))
3132
}
3233
}
3334
}
3435

35-
// Start the search with the provided viewGroup
36-
findEditTexts(viewGroup)
36+
// If the root is a group itself, search within it (for group-scoped queries)
37+
if (viewGroup is KeyboardToolbarGroupReactViewGroup) {
38+
for (i in 0 until viewGroup.childCount) {
39+
findEditTexts(viewGroup.getChildAt(i))
40+
}
41+
} else {
42+
findEditTexts(viewGroup)
43+
}
3744

3845
return editTexts
3946
}
4047

48+
/**
49+
* Finds the closest [KeyboardToolbarGroupReactViewGroup] ancestor of the given view.
50+
* Returns null if the view is not inside any group.
51+
*/
52+
fun findGroupAncestor(view: View?): KeyboardToolbarGroupReactViewGroup? {
53+
var current = view?.parent
54+
while (current != null) {
55+
if (current is KeyboardToolbarGroupReactViewGroup) {
56+
return current
57+
}
58+
current = current.parent
59+
}
60+
return null
61+
}
62+
4163
private fun findNextEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, 1)
4264

4365
private fun findPreviousEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, -1)
@@ -64,6 +86,11 @@ object ViewHierarchyNavigator {
6486
i += direction
6587
}
6688

89+
// Don't navigate outside the group boundary
90+
if (parentViewGroup is KeyboardToolbarGroupReactViewGroup) {
91+
return null
92+
}
93+
6794
// Recurse to the parent's parent if no sibling EditText is found
6895
return findEditTextInDirection(parentViewGroup, direction)
6996
}
@@ -91,7 +118,7 @@ object ViewHierarchyNavigator {
91118

92119
if (isValidTextInput(child)) {
93120
result = child as EditText
94-
} else if (child is ViewGroup) {
121+
} else if (child is ViewGroup && child !is KeyboardToolbarGroupReactViewGroup) {
95122
// If the child is a ViewGroup, check its children recursively
96123
result = findEditTextInHierarchy(child, direction)
97124
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.reactnativekeyboardcontroller.views
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import com.facebook.react.uimanager.ThemedReactContext
6+
import com.facebook.react.views.view.ReactViewGroup
7+
8+
@SuppressLint("ViewConstructor")
9+
class KeyboardToolbarGroupReactViewGroup : ReactViewGroup {
10+
constructor(reactContext: ThemedReactContext) : super(reactContext)
11+
internal constructor(context: Context) : super(context)
12+
// semantic view used in KeyboardToolbar traverse algorithm
13+
}

android/src/main/jni/RNKC.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <react/renderer/components/RNKC/RNKCOverKeyboardViewComponentDescriptor.h>
1616
#include <react/renderer/components/RNKC/RNKCKeyboardBackgroundViewComponentDescriptor.h>
1717
#include <react/renderer/components/RNKC/RNKCClippingScrollViewDecoratorViewComponentDescriptor.h>
18+
#include <react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h>
1819

1920
#include <memory>
2021
#include <string>

0 commit comments

Comments
 (0)