diff --git a/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap b/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap index 6897ce4170..9fca9ee417 100644 --- a/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap +++ b/FabricExample/__tests__/__snapshots__/components-rendering.spec.tsx.snap @@ -133,3 +133,38 @@ exports[`components rendering should render \`OverKeyboardView\` 1`] = ` /> `; + +exports[`components rendering should render compound \`KeyboardToolbar\` 1`] = ` +[ + + + + + + + + + + + , + + + , +] +`; diff --git a/FabricExample/__tests__/components-rendering.spec.tsx b/FabricExample/__tests__/components-rendering.spec.tsx index f154e642a9..429542e4c0 100644 --- a/FabricExample/__tests__/components-rendering.spec.tsx +++ b/FabricExample/__tests__/components-rendering.spec.tsx @@ -1,6 +1,6 @@ import { render } from "@testing-library/react-native"; import React from "react"; -import { View } from "react-native"; +import { TextInput, View } from "react-native"; import { KeyboardAvoidingView, KeyboardAwareScrollView, @@ -68,6 +68,27 @@ function KeyboardToolbarTest() { return ; } +function KeyboardToolbarCompoundTest() { + return ( + <> + + + + + + + + + + + + + + + + ); +} + function OverKeyboardViewTest() { return ( @@ -109,6 +130,10 @@ describe("components rendering", () => { expect(render()).toMatchSnapshot(); }); + it("should render compound `KeyboardToolbar`", () => { + expect(render()).toMatchSnapshot(); + }); + it("should render `OverKeyboardView`", () => { expect(render()).toMatchSnapshot(); }); diff --git a/FabricExample/src/screens/Examples/Toolbar/index.tsx b/FabricExample/src/screens/Examples/Toolbar/index.tsx index 353bc13baf..51c96a4200 100644 --- a/FabricExample/src/screens/Examples/Toolbar/index.tsx +++ b/FabricExample/src/screens/Examples/Toolbar/index.tsx @@ -1,3 +1,4 @@ +import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet"; import React, { useCallback, useEffect, useState } from "react"; import { Modal, Platform, StyleSheet, Text, View } from "react-native"; import { @@ -214,6 +215,24 @@ export default function ToolbarExample({ navigation }: Props) {
+ + + + + + + + ); } @@ -245,4 +264,7 @@ const styles = StyleSheet.create({ modal: { marginTop: 32, }, + bottomSheetContent: { + flex: 1, + }, }); diff --git a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt index 22fced3ac3..d3018eac42 100644 --- a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt +++ b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt @@ -60,5 +60,6 @@ class KeyboardControllerPackage : BaseReactPackage() { OverKeyboardViewManager(), KeyboardBackgroundViewManager(), ClippingScrollViewDecoratorViewManager(), + KeyboardToolbarGroupViewManager(), ) } diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt new file mode 100644 index 0000000000..b8399e90a2 --- /dev/null +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt @@ -0,0 +1,23 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerDelegate +import com.facebook.react.viewmanagers.KeyboardToolbarGroupViewManagerInterface +import com.reactnativekeyboardcontroller.managers.KeyboardToolbarGroupViewManagerImpl +import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup + +class KeyboardToolbarGroupViewManager : + ViewGroupManager(), + KeyboardToolbarGroupViewManagerInterface { + private val manager = KeyboardToolbarGroupViewManagerImpl() + private val mDelegate = KeyboardToolbarGroupViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = mDelegate + + override fun getName(): String = KeyboardToolbarGroupViewManagerImpl.NAME + + override fun createViewInstance(context: ThemedReactContext): KeyboardToolbarGroupReactViewGroup = + manager.createViewInstance(context) +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt index 1e7be518b0..4de762dae0 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputObserver.kt @@ -120,7 +120,8 @@ class FocusedInputObserver( selectionSubscription = newFocus.addOnSelectionChangedListener(selectionListener) FocusedInputHolder.set(newFocus) - val allInputFields = ViewHierarchyNavigator.getAllInputFields(context?.rootView) + val groupAncestor = ViewHierarchyNavigator.findGroupAncestor(newFocus) + val allInputFields = ViewHierarchyNavigator.getAllInputFields(groupAncestor ?: context?.rootView) val currentIndex = allInputFields.indexOf(newFocus) context.emitEvent( diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt new file mode 100644 index 0000000000..1dd6b5ea8e --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardToolbarGroupViewManagerImpl.kt @@ -0,0 +1,13 @@ +package com.reactnativekeyboardcontroller.managers + +import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup + +class KeyboardToolbarGroupViewManagerImpl { + fun createViewInstance(reactContext: ThemedReactContext): KeyboardToolbarGroupReactViewGroup = + KeyboardToolbarGroupReactViewGroup(reactContext) + + companion object { + const val NAME = "KeyboardToolbarGroupView" + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt b/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt index 8a0d6ec4ee..13d43717dd 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigator.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import android.widget.EditText import com.facebook.react.bridge.UiThreadUtil import com.reactnativekeyboardcontroller.extensions.focus +import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup object ViewHierarchyNavigator { fun setFocusTo( @@ -25,19 +26,40 @@ object ViewHierarchyNavigator { fun findEditTexts(view: View?) { if (isValidTextInput(view)) { editTexts.add(view as EditText) - } else if (view is ViewGroup) { + } else if (view is ViewGroup && view !is KeyboardToolbarGroupReactViewGroup) { for (i in 0 until view.childCount) { findEditTexts(view.getChildAt(i)) } } } - // Start the search with the provided viewGroup - findEditTexts(viewGroup) + // If the root is a group itself, search within it (for group-scoped queries) + if (viewGroup is KeyboardToolbarGroupReactViewGroup) { + for (i in 0 until viewGroup.childCount) { + findEditTexts(viewGroup.getChildAt(i)) + } + } else { + findEditTexts(viewGroup) + } return editTexts } + /** + * Finds the closest [KeyboardToolbarGroupReactViewGroup] ancestor of the given view. + * Returns null if the view is not inside any group. + */ + fun findGroupAncestor(view: View?): KeyboardToolbarGroupReactViewGroup? { + var current = view?.parent + while (current != null) { + if (current is KeyboardToolbarGroupReactViewGroup) { + return current + } + current = current.parent + } + return null + } + private fun findNextEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, 1) private fun findPreviousEditText(currentFocus: View): EditText? = findEditTextInDirection(currentFocus, -1) @@ -64,6 +86,11 @@ object ViewHierarchyNavigator { i += direction } + // Don't navigate outside the group boundary + if (parentViewGroup is KeyboardToolbarGroupReactViewGroup) { + return null + } + // Recurse to the parent's parent if no sibling EditText is found return findEditTextInDirection(parentViewGroup, direction) } @@ -91,7 +118,7 @@ object ViewHierarchyNavigator { if (isValidTextInput(child)) { result = child as EditText - } else if (child is ViewGroup) { + } else if (child is ViewGroup && child !is KeyboardToolbarGroupReactViewGroup) { // If the child is a ViewGroup, check its children recursively result = findEditTextInHierarchy(child, direction) } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt new file mode 100644 index 0000000000..09b1b23ef5 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardToolbarGroupReactViewGroup.kt @@ -0,0 +1,13 @@ +package com.reactnativekeyboardcontroller.views + +import android.annotation.SuppressLint +import android.content.Context +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.view.ReactViewGroup + +@SuppressLint("ViewConstructor") +class KeyboardToolbarGroupReactViewGroup : ReactViewGroup { + constructor(reactContext: ThemedReactContext) : super(reactContext) + internal constructor(context: Context) : super(context) + // semantic view used in KeyboardToolbar traverse algorithm +} diff --git a/android/src/main/jni/RNKC.h b/android/src/main/jni/RNKC.h index 9916c77dc1..c099b0ab8f 100644 --- a/android/src/main/jni/RNKC.h +++ b/android/src/main/jni/RNKC.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt new file mode 100644 index 0000000000..074f8c1791 --- /dev/null +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardToolbarGroupViewManager.kt @@ -0,0 +1,15 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.view.ReactViewManager +import com.reactnativekeyboardcontroller.managers.KeyboardToolbarGroupViewManagerImpl +import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup + +class KeyboardToolbarGroupViewManager : ReactViewManager() { + private val manager = KeyboardToolbarGroupViewManagerImpl() + + override fun getName(): String = KeyboardToolbarGroupViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): KeyboardToolbarGroupReactViewGroup = + manager.createViewInstance(reactContext) +} diff --git a/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt b/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt new file mode 100644 index 0000000000..939af4c7e5 --- /dev/null +++ b/android/src/test/java/com/reactnativekeyboardcontroller/traversal/ViewHierarchyNavigatorGroupTest.kt @@ -0,0 +1,157 @@ +package com.reactnativekeyboardcontroller.traversal + +import android.content.Context +import android.widget.EditText +import android.widget.LinearLayout +import androidx.test.core.app.ApplicationProvider +import com.reactnativekeyboardcontroller.extensions.focus +import com.reactnativekeyboardcontroller.views.KeyboardToolbarGroupReactViewGroup +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLooper + +@RunWith(RobolectricTestRunner::class) +class ViewHierarchyNavigatorGroupTest { + private lateinit var layout: LinearLayout + private lateinit var editText1: EditText + private lateinit var editText2: EditText + private lateinit var groupEditText1: EditText + private lateinit var groupEditText2: EditText + private lateinit var groupEditText3: EditText + private lateinit var editText3: EditText + private lateinit var editText4: EditText + private lateinit var group: KeyboardToolbarGroupReactViewGroup + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + + editText1 = EditText(context).apply { id = 1 } + editText2 = EditText(context).apply { id = 2 } + groupEditText1 = EditText(context).apply { id = 3 } + groupEditText2 = EditText(context).apply { id = 4 } + groupEditText3 = EditText(context).apply { id = 5 } + editText3 = EditText(context).apply { id = 6 } + editText4 = EditText(context).apply { id = 7 } + + group = + KeyboardToolbarGroupReactViewGroup(context).apply { + addView(groupEditText1) + addView(groupEditText2) + addView(groupEditText3) + } + + // Layout: editText1, editText2, [group: gET1, gET2, gET3], editText3, editText4 + layout = + LinearLayout(context).apply { + addView(editText1) + addView(editText2) + addView(group) + addView(editText3) + addView(editText4) + } + } + + @Test + fun `getAllInputFields should not include inputs inside a group`() { + val editTexts = ViewHierarchyNavigator.getAllInputFields(layout) + + // Only editText1, editText2, editText3, editText4 (group inputs excluded) + assertTrue(editTexts.size == 4) + } + + @Test + fun `getAllInputFields with group as root should return only group inputs`() { + val editTexts = ViewHierarchyNavigator.getAllInputFields(group) + + assertTrue(editTexts.size == 3) + } + + @Test + fun `setFocusTo 'next' inside group should stay within group`() { + groupEditText1.focus() + + ViewHierarchyNavigator.setFocusTo("next", groupEditText1) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + assertTrue(groupEditText2.hasFocus()) + } + + @Test + fun `setFocusTo 'prev' inside group should stay within group`() { + groupEditText3.focus() + + ViewHierarchyNavigator.setFocusTo("prev", groupEditText3) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + assertTrue(groupEditText2.hasFocus()) + } + + @Test + fun `setFocusTo 'next' at last group input should not leave group`() { + groupEditText3.focus() + + ViewHierarchyNavigator.setFocusTo("next", groupEditText3) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + // Should stay on last group input, not move to editText3 + assertTrue(groupEditText3.hasFocus()) + } + + @Test + fun `setFocusTo 'prev' at first group input should not leave group`() { + groupEditText1.focus() + + ViewHierarchyNavigator.setFocusTo("prev", groupEditText1) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + // Should stay on first group input, not move to editText2 + assertTrue(groupEditText1.hasFocus()) + } + + @Test + fun `setFocusTo 'next' outside group should skip group inputs`() { + editText2.focus() + + ViewHierarchyNavigator.setFocusTo("next", editText2) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + // Should skip group and go to editText3 + assertTrue(editText3.hasFocus()) + } + + @Test + fun `setFocusTo 'prev' outside group should skip group inputs`() { + editText3.focus() + + ViewHierarchyNavigator.setFocusTo("prev", editText3) + + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + + // Should skip group and go to editText2 + assertTrue(editText2.hasFocus()) + } + + @Test + fun `findGroupAncestor should return group for inputs inside group`() { + val ancestor = ViewHierarchyNavigator.findGroupAncestor(groupEditText1) + + assertTrue(ancestor === group) + } + + @Test + fun `findGroupAncestor should return null for inputs outside group`() { + val ancestor = ViewHierarchyNavigator.findGroupAncestor(editText1) + + assertNull(ancestor) + } +} diff --git a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt index cd576d51ea..4e61039c9b 100644 --- a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt +++ b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt @@ -60,5 +60,7 @@ class KeyboardControllerPackage : TurboReactPackage() { KeyboardGestureAreaViewManager(), OverKeyboardViewManager(), KeyboardBackgroundViewManager(), + ClippingScrollViewDecoratorViewManager(), + KeyboardToolbarGroupViewManager(), ) } diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h new file mode 100644 index 0000000000..36605c4b32 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewComponentDescriptor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "RNKCKeyboardToolbarGroupViewShadowNode.h" + +#include +#include +#include + +namespace facebook::react { +class KeyboardToolbarGroupViewComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + void adopt(ShadowNode &shadowNode) const override { + react_native_assert(dynamic_cast(&shadowNode)); + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp new file mode 100644 index 0000000000..876bff3cc7 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.cpp @@ -0,0 +1,7 @@ +#include "RNKCKeyboardToolbarGroupViewShadowNode.h" + +namespace facebook::react { + +extern const char KeyboardToolbarGroupViewComponentName[] = "KeyboardToolbarGroupView"; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h new file mode 100644 index 0000000000..2db75c9f83 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewShadowNode.h @@ -0,0 +1,23 @@ +#pragma once + +#include "RNKCKeyboardToolbarGroupViewState.h" + +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char KeyboardToolbarGroupViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +using KeyboardToolbarGroupViewShadowNode = ConcreteViewShadowNode< + KeyboardToolbarGroupViewComponentName, + KeyboardToolbarGroupViewProps, + KeyboardToolbarGroupViewEventEmitter, + KeyboardToolbarGroupViewState>; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h new file mode 100644 index 0000000000..a69d6a6208 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCKeyboardToolbarGroupViewState.h @@ -0,0 +1,21 @@ +#pragma once + +#ifdef ANDROID +#include +#endif + +namespace facebook::react { + +class KeyboardToolbarGroupViewState { + public: + KeyboardToolbarGroupViewState() = default; + +#ifdef ANDROID + KeyboardToolbarGroupViewState(KeyboardToolbarGroupViewState const &previousState, folly::dynamic data) {} + folly::dynamic getDynamic() const { + return {}; + } +#endif +}; + +} // namespace facebook::react diff --git a/docs/docs/api/components/keyboard-toolbar/index.mdx b/docs/docs/api/components/keyboard-toolbar/index.mdx index bf0d023949..8d9f2cb3cc 100644 --- a/docs/docs/api/components/keyboard-toolbar/index.mdx +++ b/docs/docs/api/components/keyboard-toolbar/index.mdx @@ -332,6 +332,26 @@ The property that allows to specify custom text for `Done` button. ``` +### `` + +This component defines a group of inputs that form an isolated navigation region. When the user presses the prev/next arrows on `KeyboardToolbar`, the focus will only cycle through inputs **within the same group**. Inputs outside the group are not reachable from within, and inputs inside the group are not reachable from outside. + +This is useful for scenarios like bottom sheets, tab views, or any UI where a subset of inputs should have independent navigation. + +```tsx + + + + + + + +``` + +:::info Toolbar button state +When a grouped input is focused, the toolbar's prev/next buttons reflect the position within that group (not the global list of inputs). For example, the "prev" button will be disabled when focused on the first input of a group. +::: + ## Props ### [`View Props`](https://reactnative.dev/docs/view#props) diff --git a/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap b/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap index 13e0011b0c..542d694b5f 100644 --- a/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap +++ b/example/__tests__/__snapshots__/components-rendering.spec.tsx.snap @@ -142,3 +142,38 @@ exports[`components rendering should render \`OverKeyboardView\` 1`] = ` /> `; + +exports[`components rendering should render compound \`KeyboardToolbar\` 1`] = ` +[ + + + + + + + + + + + , + + + , +] +`; diff --git a/example/__tests__/components-rendering.spec.tsx b/example/__tests__/components-rendering.spec.tsx index f154e642a9..429542e4c0 100644 --- a/example/__tests__/components-rendering.spec.tsx +++ b/example/__tests__/components-rendering.spec.tsx @@ -1,6 +1,6 @@ import { render } from "@testing-library/react-native"; import React from "react"; -import { View } from "react-native"; +import { TextInput, View } from "react-native"; import { KeyboardAvoidingView, KeyboardAwareScrollView, @@ -68,6 +68,27 @@ function KeyboardToolbarTest() { return ; } +function KeyboardToolbarCompoundTest() { + return ( + <> + + + + + + + + + + + + + + + + ); +} + function OverKeyboardViewTest() { return ( @@ -109,6 +130,10 @@ describe("components rendering", () => { expect(render()).toMatchSnapshot(); }); + it("should render compound `KeyboardToolbar`", () => { + expect(render()).toMatchSnapshot(); + }); + it("should render `OverKeyboardView`", () => { expect(render()).toMatchSnapshot(); }); diff --git a/example/src/screens/Examples/Toolbar/index.tsx b/example/src/screens/Examples/Toolbar/index.tsx index 353bc13baf..51c96a4200 100644 --- a/example/src/screens/Examples/Toolbar/index.tsx +++ b/example/src/screens/Examples/Toolbar/index.tsx @@ -1,3 +1,4 @@ +import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet"; import React, { useCallback, useEffect, useState } from "react"; import { Modal, Platform, StyleSheet, Text, View } from "react-native"; import { @@ -214,6 +215,24 @@ export default function ToolbarExample({ navigation }: Props) { + + + + + + + + ); } @@ -245,4 +264,7 @@ const styles = StyleSheet.create({ modal: { marginTop: 32, }, + bottomSheetContent: { + flex: 1, + }, }); diff --git a/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj b/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj index 3c9bae28b7..c80a0bd66d 100644 --- a/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj +++ b/ios/KeyboardControllerNative/KeyboardControllerNative.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0837001E2CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; }; 0837001F2CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; }; 083700202CE8CA4F00D67BBF /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0837001D2CE8CA4F00D67BBF /* TextInput.swift */; }; + 083C81EB2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */; }; 0850CCBA2CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */; }; 0850CCC22CB49F74000C0F8D /* SpringAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */; }; 0850CCC32CB49F74000C0F8D /* SpringAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */; }; @@ -76,6 +77,7 @@ 081006AB2C36906800578E07 /* UIScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UIScrollView.swift; path = ../../extensions/UIScrollView.swift; sourceTree = ""; }; 0828F07A2D4BFFDC005D4701 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIView.swift; path = ../extensions/UIView.swift; sourceTree = SOURCE_ROOT; }; 0837001D2CE8CA4F00D67BBF /* TextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TextInput.swift; path = ../protocols/TextInput.swift; sourceTree = SOURCE_ROOT; }; + 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardToolbarGroupTests.swift; sourceTree = ""; }; 0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpringAnimationPerformanceTest.swift; sourceTree = ""; }; 0850CCBF2CB49F74000C0F8D /* SpringAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SpringAnimation.swift; path = ../../animations/SpringAnimation.swift; sourceTree = ""; }; 0850CCC02CB49F74000C0F8D /* KeyboardAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = KeyboardAnimation.swift; path = ../../animations/KeyboardAnimation.swift; sourceTree = ""; }; @@ -177,6 +179,7 @@ 0873ED652BB6B7390004F3A4 /* KeyboardControllerNativeTests */ = { isa = PBXGroup; children = ( + 083C81EA2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift */, 0873ED662BB6B7390004F3A4 /* KeyboardControllerNativeTests.swift */, 0850CCB92CB49ECC000C0F8D /* SpringAnimationPerformanceTest.swift */, 08833D512CB56DB9007D4380 /* TimingAnimationPerformanceTest.swift */, @@ -358,6 +361,7 @@ 081006AD2C36906900578E07 /* UIScrollView.swift in Sources */, 0850CCCD2CB4A096000C0F8D /* UIUtils.swift in Sources */, 08833D522CB56DB9007D4380 /* TimingAnimationPerformanceTest.swift in Sources */, + 083C81EB2F6035A5005EDD4C /* KeyboardToolbarGroupTests.swift in Sources */, 083700202CE8CA4F00D67BBF /* TextInput.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift b/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift new file mode 100644 index 0000000000..7b629a693f --- /dev/null +++ b/ios/KeyboardControllerNative/KeyboardControllerNativeTests/KeyboardToolbarGroupTests.swift @@ -0,0 +1,168 @@ +// +// KeyboardToolbarGroupTests.swift +// KeyboardControllerNativeTests +// +// Created by Kiryl Ziusko on 10/03/2026. +// + +@testable import KeyboardControllerNative +import XCTest + +/// A mock group view whose type name matches what ViewHierarchyNavigator checks. +class KeyboardToolbarGroupView: UIView {} + +final class KeyboardToolbarGroupTests: XCTestCase { + var rootView: UIView! + var groupView: KeyboardToolbarGroupView! + var editText1: TestableInput! + var editText2: TestableInput! + var groupEditText1: TestableInput! + var groupEditText2: TestableInput! + var groupEditText3: TestableInput! + var editText3: TestableInput! + var editText4: TestableInput! + + override func setUpWithError() throws { + super.setUp() + + rootView = UIView() + groupView = KeyboardToolbarGroupView() + + editText1 = TestableTextField() + editText1.tag = 1 + editText2 = TestableTextView() + editText2.tag = 2 + groupEditText1 = TestableTextField() + groupEditText1.tag = 3 + groupEditText2 = TestableTextView() + groupEditText2.tag = 4 + groupEditText3 = TestableTextField() + groupEditText3.tag = 5 + editText3 = TestableTextView() + editText3.tag = 6 + editText4 = TestableTextField() + editText4.tag = 7 + + groupView.addSubview(groupEditText1) + groupView.addSubview(groupEditText2) + groupView.addSubview(groupEditText3) + + // Layout: editText1, editText2, [group: gET1, gET2, gET3], editText3, editText4 + rootView.addSubview(editText1) + rootView.addSubview(editText2) + rootView.addSubview(groupView) + rootView.addSubview(editText3) + rootView.addSubview(editText4) + } + + // MARK: - getAllInputFields + + func testGetAllInputFieldsExcludesGroupInputs() { + let allFields = ViewHierarchyNavigator.getAllInputFields(root: rootView) + + // Only editText1, editText2, editText3, editText4 (group inputs excluded) + XCTAssertEqual(allFields.count, 4) + } + + func testGetAllInputFieldsWithGroupRootReturnsOnlyGroupInputs() { + let groupFields = ViewHierarchyNavigator.getAllInputFields(root: groupView) + + XCTAssertEqual(groupFields.count, 3) + } + + // MARK: - Navigation within group + + func testNextInsideGroupStaysWithinGroup() { + FocusedInputHolder.shared.set(groupEditText1 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "next") + + waitForFocusChange(to: groupEditText2) + } + + func testPrevInsideGroupStaysWithinGroup() { + FocusedInputHolder.shared.set(groupEditText3 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "prev") + + waitForFocusChange(to: groupEditText2) + } + + // MARK: - Group boundary: cannot leave + + func testNextAtLastGroupInputDoesNotLeaveGroup() { + FocusedInputHolder.shared.set(groupEditText3 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "next") + + // Should NOT move to editText3 — should stay at groupEditText3 + let expectation = XCTestExpectation(description: "Wait for main queue") + DispatchQueue.main.async { + if let input = self.editText3 as? TestableInput { + XCTAssertFalse( + input.becomeFirstResponderCalled, + "Should not have moved focus to editText3 outside group" + ) + } else { + XCTFail("editText3 is not a TestableInput") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 10.0) + } + + func testPrevAtFirstGroupInputDoesNotLeaveGroup() { + FocusedInputHolder.shared.set(groupEditText1 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "prev") + + // Should NOT move to editText2 — should stay at groupEditText1 + let expectation = XCTestExpectation(description: "Wait for main queue") + DispatchQueue.main.async { + if let input = self.editText2 as? TestableInput { + XCTAssertFalse( + input.becomeFirstResponderCalled, + "Should not have moved focus to editText2 outside group" + ) + } else { + XCTFail("editText2 is not a TestableInput") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 10.0) + } + + // MARK: - Navigation outside group skips group inputs + + func testNextOutsideGroupSkipsGroupInputs() { + FocusedInputHolder.shared.set(editText2 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "next") + + // Should skip group and go to editText3 + waitForFocusChange(to: editText3) + } + + func testPrevOutsideGroupSkipsGroupInputs() { + FocusedInputHolder.shared.set(editText3 as? TextInput) + + ViewHierarchyNavigator.setFocusTo(direction: "prev") + + // Should skip group and go to editText2 + waitForFocusChange(to: editText2) + } + + // MARK: - findGroupAncestor + + func testFindGroupAncestorReturnsGroupForInputsInsideGroup() { + let ancestor = ViewHierarchyNavigator.findGroupAncestor(groupEditText1) + + XCTAssertTrue(ancestor === groupView) + } + + func testFindGroupAncestorReturnsNilForInputsOutsideGroup() { + let ancestor = ViewHierarchyNavigator.findGroupAncestor(editText1) + + XCTAssertNil(ancestor) + } +} diff --git a/ios/observers/FocusedInputObserver.swift b/ios/observers/FocusedInputObserver.swift index 1f01ebd7ef..74ede28b97 100644 --- a/ios/observers/FocusedInputObserver.swift +++ b/ios/observers/FocusedInputObserver.swift @@ -151,7 +151,8 @@ public class FocusedInputObserver: NSObject { FocusedInputHolder.shared.set(currentResponder as? TextInput) - let allInputFields = ViewHierarchyNavigator.getAllInputFields() + let groupAncestor = ViewHierarchyNavigator.findGroupAncestor(currentResponder as? UIView) + let allInputFields = ViewHierarchyNavigator.getAllInputFields(root: groupAncestor) let currentIndex = allInputFields.firstIndex(where: { $0 == currentResponder }) ?? -1 onFocusDidSet([ diff --git a/ios/traversal/ViewHierarchyNavigator.swift b/ios/traversal/ViewHierarchyNavigator.swift index 8af9f74af9..29164a8576 100644 --- a/ios/traversal/ViewHierarchyNavigator.swift +++ b/ios/traversal/ViewHierarchyNavigator.swift @@ -11,6 +11,8 @@ import UIKit @objc(ViewHierarchyNavigator) public class ViewHierarchyNavigator: NSObject { + private static let groupViewTypeName = "KeyboardToolbarGroupView" + @objc public static func setFocusTo(direction: String) { DispatchQueue.main.async { if direction == "current" { @@ -30,30 +32,67 @@ public class ViewHierarchyNavigator: NSObject { } public static func getAllInputFields() -> [TextInput] { + return getAllInputFields(root: nil) + } + + public static func getAllInputFields(root: UIView?) -> [TextInput] { var textInputs = [TextInput]() - guard let rootView = UIApplication.topViewController()?.view else { + let rootView: UIView? + if let root = root { + rootView = root + } else { + rootView = UIApplication.topViewController()?.view + } + + guard let rootView = rootView else { return [] } + let isGroupRoot = isGroupView(rootView) + /// Helper function to recursively search for TextInput views func findTextInputs(in view: UIView?) { guard let view = view else { return } if let textInput = isValidTextInput(view) { textInputs.append(textInput) - } else { + } else if !isGroupView(view) { for subview in view.subviews { findTextInputs(in: subview) } } } - findTextInputs(in: rootView) + if isGroupRoot { + // When root is a group, search its children directly + for subview in rootView.subviews { + findTextInputs(in: subview) + } + } else { + findTextInputs(in: rootView) + } return textInputs } + /// Finds the closest KeyboardToolbarGroupView ancestor of the given view. + /// Returns nil if the view is not inside any group. + public static func findGroupAncestor(_ view: UIView?) -> UIView? { + var current = view?.superview + while let parent = current { + if isGroupView(parent) { + return parent + } + current = parent.superview + } + return nil + } + + private static func isGroupView(_ view: UIView) -> Bool { + return String(describing: type(of: view)) == groupViewTypeName + } + private static func findTextInputInDirection(currentFocus: UIView, direction: String) -> TextInput? { // Find the parent view group guard let parentViewGroup = currentFocus.superview else { @@ -81,6 +120,11 @@ public class ViewHierarchyNavigator: NSObject { } } + // Don't navigate outside the group boundary + if isGroupView(parentViewGroup) { + return nil + } + // If no next or previous sibling was found in the parent, recurse to the parent's parent return findTextInputInDirection(currentFocus: parentViewGroup, direction: direction) } @@ -91,6 +135,8 @@ public class ViewHierarchyNavigator: NSObject { return validTextInput } + guard !isGroupView(view) else { return nil } + // Determine the iteration order based on the direction let subviews = direction == "next" ? view.subviews : view.subviews.reversed() diff --git a/ios/views/KeyboardToolbarGroupViewManager.h b/ios/views/KeyboardToolbarGroupViewManager.h new file mode 100644 index 0000000000..a2d478936d --- /dev/null +++ b/ios/views/KeyboardToolbarGroupViewManager.h @@ -0,0 +1,28 @@ +// +// KeyboardToolbarGroupViewManager.h +// KeyboardController +// +// Created by Kiryl Ziusko on 26/12/2024. +// + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#else +#import +#endif +#import +#import + +@interface KeyboardToolbarGroupViewManager : RCTViewManager +@end + +@interface KeyboardToolbarGroupView : +#ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +#else + UIView + +- (instancetype)initWithBridge:(RCTBridge *)bridge; + +#endif +@end diff --git a/ios/views/KeyboardToolbarGroupViewManager.mm b/ios/views/KeyboardToolbarGroupViewManager.mm new file mode 100644 index 0000000000..083a2fcb0c --- /dev/null +++ b/ios/views/KeyboardToolbarGroupViewManager.mm @@ -0,0 +1,88 @@ +// +// KeyboardToolbarGroupViewManager.mm +// react-native-keyboard-controller +// +// Created by Kiryl Ziusko on 26/12/2024. +// + +#import "KeyboardToolbarGroupViewManager.h" + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" +#endif + +#import + +#ifdef RCT_NEW_ARCH_ENABLED +using namespace facebook::react; +#endif + +// MARK: Manager +@implementation KeyboardToolbarGroupViewManager + +RCT_EXPORT_MODULE(KeyboardToolbarGroupViewManager) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +#ifndef RCT_NEW_ARCH_ENABLED +- (UIView *)view +{ + return [[KeyboardToolbarGroupView alloc] initWithBridge:self.bridge]; +} +#endif + +@end + +// MARK: View +#ifdef RCT_NEW_ARCH_ENABLED +@interface KeyboardToolbarGroupView () +@end +#endif + +@implementation KeyboardToolbarGroupView { +} + +#ifdef RCT_NEW_ARCH_ENABLED ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} +#endif + +// Needed because of this: https://github.com/facebook/react-native/pull/37274 ++ (void)load +{ + [super load]; +} + +// MARK: Constructor +#ifdef RCT_NEW_ARCH_ENABLED +- (instancetype)init +{ + self = [super init]; + return self; +} +#else +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + self = [super init]; + return self; +} +#endif + +#ifdef RCT_NEW_ARCH_ENABLED +Class KeyboardToolbarGroupViewCls(void) +{ + return KeyboardToolbarGroupView.class; +} +#endif + +@end diff --git a/jest/index.js b/jest/index.js index 7ca5a79610..17e7a2c6df 100644 --- a/jest/index.js +++ b/jest/index.js @@ -122,7 +122,14 @@ const mock = { KeyboardStickyView: View, KeyboardAvoidingView: View, KeyboardAwareScrollView: ScrollView, - KeyboardToolbar: View, + KeyboardToolbar: Object.assign(View, { + Background: "KeyboardToolbar.Background", + Content: "KeyboardToolbar.Content", + Prev: "KeyboardToolbar.Prev", + Next: "KeyboardToolbar.Next", + Done: "KeyboardToolbar.Done", + Group: "KeyboardToolbar.Group", + }), KeyboardChatScrollView: ScrollView, // themes DefaultKeyboardToolbarTheme, diff --git a/package.json b/package.json index 97a4f2c626..2648f520fe 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,8 @@ "OverKeyboardView": "OverKeyboardView", "KeyboardBackgroundView": "KeyboardBackgroundView", "KeyboardExtender": "KeyboardExtender", - "ClippingScrollViewDecoratorView": "ClippingScrollViewDecoratorView" + "ClippingScrollViewDecoratorView": "ClippingScrollViewDecoratorView", + "KeyboardToolbarGroupView": "KeyboardToolbarGroupView" } } }, diff --git a/react-native.config.js b/react-native.config.js index fb3c79b6e3..33228da090 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -8,6 +8,7 @@ module.exports = { "OverKeyboardViewComponentDescriptor", "KeyboardBackgroundViewComponentDescriptor", "ClippingScrollViewDecoratorViewComponentDescriptor", + "KeyboardToolbarGroupViewComponentDescriptor", ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt", }, diff --git a/src/bindings.native.ts b/src/bindings.native.ts index 9489aa9834..8f29cc10d2 100644 --- a/src/bindings.native.ts +++ b/src/bindings.native.ts @@ -8,6 +8,7 @@ import type { KeyboardEventsModule, KeyboardExtenderProps, KeyboardGestureAreaProps, + KeyboardToolbarGroupViewProps, OverKeyboardViewProps, WindowDimensionsEventsModule, } from "./types"; @@ -73,3 +74,5 @@ export const RCTKeyboardExtender: React.FC = : ({ children }: KeyboardExtenderProps) => children; export const ClippingScrollView: React.FC = require("./specs/ClippingScrollViewDecoratorViewNativeComponent").default; +export const RCTKeyboardToolbarGroupView: React.FC = + require("./specs/KeyboardToolbarGroupViewNativeComponent").default; diff --git a/src/bindings.ts b/src/bindings.ts index 65f8c56f2d..96e40ac91b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -9,6 +9,7 @@ import type { KeyboardEventsModule, KeyboardExtenderProps, KeyboardGestureAreaProps, + KeyboardToolbarGroupViewProps, OverKeyboardViewProps, WindowDimensionsEventsModule, } from "./types"; @@ -86,9 +87,20 @@ export const KeyboardBackgroundView = export const RCTKeyboardExtender = View as unknown as React.FC; /** - * A decorator that will clip the content of the `ScrollView`. It helps to simulate `contentInset` behavior on Android. + * A decorator that will clip the content of the `ScrollView`. It helps to simulate `contentInset` behavior on Android * Supports only `bottom` property (`paddingBottom` is not supported property of `ScrollView.style`). * Using this component we can modify bottom inset without having a fake view. + * + * On iOS we use swizzling to apply runtime patches to fix some broken internal methods. + * Ideally this component shouldn't exist and all its fixes/polyfills must be added directly to react-native and + * we will port features/fixes back to upstream, but at the moment we use this view to + * deliver desired functionality regardless of react-native version used. */ export const ClippingScrollView = View as unknown as React.FC; +/** + * A View that defines a group of `TextInput`s. + * Used in toolbar navigation to assure that you can navigate only between inputs withing the same group. + */ +export const RCTKeyboardToolbarGroupView = + View as unknown as React.FC; diff --git a/src/components/KeyboardToolbar/index.tsx b/src/components/KeyboardToolbar/index.tsx index bc2a807fe7..8ec18c31c7 100644 --- a/src/components/KeyboardToolbar/index.tsx +++ b/src/components/KeyboardToolbar/index.tsx @@ -1,7 +1,10 @@ import React, { useEffect, useMemo, useState } from "react"; import { StyleSheet, View } from "react-native"; -import { FocusedInputEvents } from "../../bindings"; +import { + FocusedInputEvents, + RCTKeyboardToolbarGroupView, +} from "../../bindings"; import { useKeyboardState } from "../../hooks"; import KeyboardStickyView from "../KeyboardStickyView"; @@ -41,6 +44,7 @@ const KeyboardToolbar: React.FC & { Prev: typeof Prev; Next: typeof Next; Done: typeof Done; + Group: typeof RCTKeyboardToolbarGroupView; } = (props) => { const { children, @@ -227,6 +231,7 @@ KeyboardToolbar.Content = Content; KeyboardToolbar.Prev = Prev; KeyboardToolbar.Next = Next; KeyboardToolbar.Done = Done; +KeyboardToolbar.Group = RCTKeyboardToolbarGroupView; export { colors as DefaultKeyboardToolbarTheme, KeyboardToolbarProps }; export default KeyboardToolbar; diff --git a/src/specs/KeyboardToolbarGroupViewNativeComponent.ts b/src/specs/KeyboardToolbarGroupViewNativeComponent.ts new file mode 100644 index 0000000000..b1116c1ae9 --- /dev/null +++ b/src/specs/KeyboardToolbarGroupViewNativeComponent.ts @@ -0,0 +1,10 @@ +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; + +import type { HostComponent } from "react-native"; +import type { ViewProps } from "react-native/Libraries/Components/View/ViewPropTypes"; + +export interface NativeProps extends ViewProps {} + +export default codegenNativeComponent("KeyboardToolbarGroupView", { + interfaceOnly: true, +}) as HostComponent; diff --git a/src/types/views.ts b/src/types/views.ts index c13446587a..e1da3bd28f 100644 --- a/src/types/views.ts +++ b/src/types/views.ts @@ -49,6 +49,7 @@ export type KeyboardExtenderProps = PropsWithChildren<{ /** Controls whether this `KeyboardExtender` instance should take an effect. Default is `true`. */ enabled?: boolean; }>; +export type KeyboardToolbarGroupViewProps = PropsWithChildren; export type ClippingScrollViewProps = PropsWithChildren< ViewProps & { /** An additional space that gets applied to the bottom of the `ScrollView` (inside a scrollable content). Default is `0`. */