From 4c1aa30485287be6667f136677581ccdcf4ba4e9 Mon Sep 17 00:00:00 2001 From: VladyslavMartynov10 Date: Mon, 13 Jul 2026 21:07:29 +0300 Subject: [PATCH] feat: custom keyboard support --- .../KeyboardControllerPackage.kt | 1 + .../CustomKeyboardViewManager.kt | 49 +++ .../listeners/KeyboardAnimationCallback.kt | 97 ++++- .../managers/CustomKeyboardViewManagerImpl.kt | 20 + .../CustomKeyboardHostShadowNode.kt | 24 ++ .../customkeyboard/CustomKeyboardViewGroup.kt | 397 ++++++++++++++++++ android/src/main/jni/RNKC.h | 1 + .../CustomKeyboardViewManager.kt | 30 ++ .../KeyboardControllerPackage.kt | 1 + ...NKCCustomKeyboardViewComponentDescriptor.h | 40 ++ .../RNKC/RNKCCustomKeyboardViewShadowNode.cpp | 14 + .../RNKC/RNKCCustomKeyboardViewShadowNode.h | 30 ++ .../RNKC/RNKCCustomKeyboardViewState.h | 39 ++ example/ios/Podfile.lock | 136 +++--- example/src/constants/screenNames.ts | 1 + .../src/navigation/ExamplesStack/index.tsx | 7 + .../src/navigation/ExamplesStack/options.ts | 3 + .../screens/Examples/CustomKeyboard/index.tsx | 155 +++++++ .../src/screens/Examples/Main/constants.ts | 6 + ios/views/CustomKeyboardContainerView.swift | 62 +++ ios/views/CustomKeyboardViewManager.h | 28 ++ ios/views/CustomKeyboardViewManager.mm | 341 +++++++++++++++ package.json | 1 + src/bindings.native.ts | 3 + src/bindings.ts | 7 + src/index.ts | 2 +- .../CustomKeyboardViewNativeComponent.ts | 12 + src/types/views.ts | 4 + src/views/CustomKeyboard/index.android.tsx | 31 ++ src/views/CustomKeyboard/index.ios.tsx | 31 ++ src/views/CustomKeyboard/index.tsx | 6 + src/views/index.ts | 1 + 32 files changed, 1510 insertions(+), 70 deletions(-) create mode 100644 android/src/fabric/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt create mode 100644 android/src/main/java/com/reactnativekeyboardcontroller/managers/CustomKeyboardViewManagerImpl.kt create mode 100644 android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardHostShadowNode.kt create mode 100644 android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardViewGroup.kt create mode 100644 android/src/paper/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt create mode 100644 common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.h create mode 100644 common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewState.h create mode 100644 example/src/screens/Examples/CustomKeyboard/index.tsx create mode 100644 ios/views/CustomKeyboardContainerView.swift create mode 100644 ios/views/CustomKeyboardViewManager.h create mode 100644 ios/views/CustomKeyboardViewManager.mm create mode 100644 src/specs/CustomKeyboardViewNativeComponent.ts create mode 100644 src/views/CustomKeyboard/index.android.tsx create mode 100644 src/views/CustomKeyboard/index.ios.tsx create mode 100644 src/views/CustomKeyboard/index.tsx diff --git a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt index d3018eac42..55e665216e 100644 --- a/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt +++ b/android/src/base/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt @@ -58,6 +58,7 @@ class KeyboardControllerPackage : BaseReactPackage() { KeyboardControllerViewManager(), KeyboardGestureAreaViewManager(), OverKeyboardViewManager(), + CustomKeyboardViewManager(), KeyboardBackgroundViewManager(), ClippingScrollViewDecoratorViewManager(), KeyboardToolbarGroupViewManager(), diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt new file mode 100644 index 0000000000..1e861ac038 --- /dev/null +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt @@ -0,0 +1,49 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.uimanager.LayoutShadowNode +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.CustomKeyboardViewManagerDelegate +import com.facebook.react.viewmanagers.CustomKeyboardViewManagerInterface +import com.reactnativekeyboardcontroller.managers.CustomKeyboardViewManagerImpl +import com.reactnativekeyboardcontroller.views.customkeyboard.CustomKeyboardHostShadowNode +import com.reactnativekeyboardcontroller.views.customkeyboard.CustomKeyboardHostView + +class CustomKeyboardViewManager : + ViewGroupManager(), + CustomKeyboardViewManagerInterface { + private val manager = CustomKeyboardViewManagerImpl() + private val mDelegate = CustomKeyboardViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = mDelegate + + override fun getName(): String = CustomKeyboardViewManagerImpl.NAME + + override fun createViewInstance(context: ThemedReactContext): CustomKeyboardHostView = + manager.createViewInstance(context) + + override fun createShadowNodeInstance(): LayoutShadowNode = CustomKeyboardHostShadowNode() + + override fun getShadowNodeClass(): Class = CustomKeyboardHostShadowNode::class.java + + override fun updateState( + view: CustomKeyboardHostView, + props: ReactStylesDiffMap, + stateWrapper: StateWrapper, + ): Any? { + view.stateWrapper = stateWrapper + return null + } + + @ReactProp(name = "enabled") + override fun setEnabled( + view: CustomKeyboardHostView, + value: Boolean, + ) { + manager.setEnabled(view, value) + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt index dc4e1d0491..20032b125a 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt @@ -1,7 +1,11 @@ package com.reactnativekeyboardcontroller.listeners +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator import android.view.View import android.view.ViewTreeObserver.OnGlobalFocusChangeListener +import android.view.animation.DecelerateInterpolator import android.widget.EditText import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener @@ -75,6 +79,7 @@ class KeyboardAnimationCallback( private val isKeyboardInteractive: Boolean get() = duration == -1 override var isSuspended: Boolean = false + private var syntheticAnimator: ValueAnimator? = null // listeners private val focusListener = @@ -83,7 +88,9 @@ class KeyboardAnimationCallback( viewTagFocused = newFocus.id // keyboard is visible and focus has been changed - if (this.isKeyboardVisible && oldFocus !== null) { + // (when suspended a custom keyboard drives its own event lifecycle, + // so instant IME-height events would only cause UI jumps) + if (this.isKeyboardVisible && oldFocus !== null && !isSuspended) { // imitate iOS behavior and send two instant start/end events containing an info about new tag // 1. onStart/onMove/onEnd can be still dispatched after, if keyboard change size (numeric -> alphabetic type) // 2. event should be send only when keyboard is visible, since this event arrives earlier -> `tag` will be @@ -415,6 +422,94 @@ class KeyboardAnimationCallback( } } + /** + * Emits a full animated keyboard transition (will event + Start + per-frame Move + settled + * state) that is not backed by a real IME animation. Used by custom keyboards (panels) to + * mimic the system keyboard appearance/disappearance. + */ + fun animateSyntheticTransition( + height: Double, + isVisible: Boolean, + durationMs: Int, + ) { + val runningAnimator = syntheticAnimator + val fromHeight = + if (runningAnimator?.isRunning == true) { + (runningAnimator.animatedValue as Float).toDouble() + } else { + prevKeyboardHeight + } + runningAnimator?.cancel() + syntheticAnimator = null + + if (fromHeight == height) { + syncKeyboardPosition(height, isVisible) + return + } + + duration = durationMs + isTransitioning = true + pendingStartEvent = null + + context.emitEvent( + "KeyboardController::" + if (isVisible) "keyboardWillShow" else "keyboardWillHide", + getEventParams(height), + ) + context.dispatchEvent( + eventPropagationView.id, + KeyboardTransitionEvent( + surfaceId, + eventPropagationView.id, + KeyboardTransitionEvent.Start, + height, + if (isVisible) 1.0 else 0.0, + durationMs, + viewTagFocused, + ), + ) + + val denominator = maxOf(height, fromHeight) + val animator = ValueAnimator.ofFloat(fromHeight.toFloat(), height.toFloat()) + animator.duration = durationMs.toLong() + animator.interpolator = DecelerateInterpolator() + animator.addUpdateListener { animation -> + val animatedHeight = (animation.animatedValue as Float).toDouble() + val progress = if (denominator == 0.0) 0.0 else animatedHeight / denominator + + context.dispatchEvent( + eventPropagationView.id, + KeyboardTransitionEvent( + surfaceId, + eventPropagationView.id, + KeyboardTransitionEvent.Move, + animatedHeight, + progress, + durationMs, + viewTagFocused, + ), + ) + } + animator.addListener( + object : AnimatorListenerAdapter() { + private var isCancelled = false + + override fun onAnimationCancel(animation: Animator) { + isCancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (isCancelled) { + return + } + syntheticAnimator = null + syncKeyboardPosition(height, isVisible) + } + }, + ) + syntheticAnimator = animator + animator.start() + } + fun destroy() { pendingStartEvent = null view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/CustomKeyboardViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/CustomKeyboardViewManagerImpl.kt new file mode 100644 index 0000000000..f5ef4083a0 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/CustomKeyboardViewManagerImpl.kt @@ -0,0 +1,20 @@ +package com.reactnativekeyboardcontroller.managers + +import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativekeyboardcontroller.views.customkeyboard.CustomKeyboardHostView + +class CustomKeyboardViewManagerImpl { + fun createViewInstance(reactContext: ThemedReactContext): CustomKeyboardHostView = + CustomKeyboardHostView(reactContext) + + fun setEnabled( + view: CustomKeyboardHostView, + value: Boolean, + ) { + view.active = value + } + + companion object { + const val NAME = "CustomKeyboardView" + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardHostShadowNode.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardHostShadowNode.kt new file mode 100644 index 0000000000..53e1bd0c73 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardHostShadowNode.kt @@ -0,0 +1,24 @@ +package com.reactnativekeyboardcontroller.views.customkeyboard + +import com.facebook.react.uimanager.LayoutShadowNode +import com.facebook.react.uimanager.ReactShadowNodeImpl +import com.facebook.yoga.YogaPositionType +import com.reactnativekeyboardcontroller.extensions.getDisplaySize + +internal class CustomKeyboardHostShadowNode : LayoutShadowNode() { + init { + // Maybe we should do it directly in Component itself with style: absolute, instead YogaPositionSet + setPositionType(YogaPositionType.ABSOLUTE) + } + + override fun addChildAt( + child: ReactShadowNodeImpl, + i: Int, + ) { + super.addChildAt(child, i) + + @Suppress("UsePropertyAccessSyntax") + val displaySize = getThemedContext().getDisplaySize() + child.setStyleWidth(displaySize.x.toFloat()) + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardViewGroup.kt new file mode 100644 index 0000000000..35d8792ef2 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/customkeyboard/CustomKeyboardViewGroup.kt @@ -0,0 +1,397 @@ +package com.reactnativekeyboardcontroller.views.customkeyboard + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.graphics.PixelFormat +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.view.animation.DecelerateInterpolator +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.view.ReactViewGroup +import com.reactnativekeyboardcontroller.extensions.dp +import com.reactnativekeyboardcontroller.log.Logger +import com.reactnativekeyboardcontroller.traversal.FocusedInputHolder +import com.reactnativekeyboardcontroller.views.EdgeToEdgeViewRegistry +import com.reactnativekeyboardcontroller.views.background.getInputMethodColor +import com.reactnativekeyboardcontroller.views.overlay.JSPointerDispatcherCompat +import com.reactnativekeyboardcontroller.views.overlay.RootViewCompat +import java.lang.ref.WeakReference + +private val TAG = CustomKeyboardHostView::class.qualifiedName +private const val TRANSITION_DURATION_MS = 250L + +@SuppressLint("ViewConstructor") +class CustomKeyboardHostView( + private val reactContext: ThemedReactContext, +) : ReactViewGroup(reactContext) { + private val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, this.id) + private var windowManager: WindowManager = reactContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private var hostView: CustomKeyboardRootViewGroup = CustomKeyboardRootViewGroup(reactContext) + private var takenOverInput: WeakReference = WeakReference(null) + private var isHidingPanel = false + + var active: Boolean = false + set(value) { + field = value + + if (value) { + FocusedInputHolder.get()?.let { takeOver(it) } + } else { + restoreSystemKeyboard() + } + } + + var stateWrapper: StateWrapper? + get() = hostView.stateWrapper + set(stateWrapper) { + hostView.stateWrapper = stateWrapper + } + + private val focusListener = + ViewTreeObserver.OnGlobalFocusChangeListener { _, newFocus -> + if (newFocus is EditText) { + if (active) { + takeOver(newFocus) + } else { + hidePanel() + } + } else if (newFocus == null) { + hidePanel() + } + } + + private val contentLayoutListener = + View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (bottom - top != oldBottom - oldTop) { + onContentHeightMayHaveChanged() + } + } + + init { + hostView.eventDispatcher = dispatcher + } + + // region life cycles + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + viewTreeObserver.addOnGlobalFocusChangeListener(focusListener) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) + restoreSystemKeyboard(showIme = false) + } + + override fun addView( + child: View?, + index: Int, + ) { + UiThreadUtil.assertOnUiThread() + child?.addOnLayoutChangeListener(contentLayoutListener) + hostView.addView(child, index) + } + + override fun getChildCount(): Int = hostView.childCount + + override fun getChildAt(index: Int): View? = hostView.getChildAt(index) + + override fun removeView(child: View?) { + UiThreadUtil.assertOnUiThread() + + if (child != null) { + child.removeOnLayoutChangeListener(contentLayoutListener) + hostView.removeView(child) + } + } + + override fun removeViewAt(index: Int) { + UiThreadUtil.assertOnUiThread() + val child = getChildAt(index) + child?.removeOnLayoutChangeListener(contentLayoutListener) + hostView.removeView(child) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + + } + + + override fun addChildrenForAccessibility(outChildren: ArrayList) { + + } + + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = false + + + + private fun takeOver(input: EditText) { + val previousInput = takenOverInput.get() + if (previousInput != null && previousInput !== input) { + previousInput.showSoftInputOnFocus = true + } + + + input.showSoftInputOnFocus = false + takenOverInput = WeakReference(input) + + suspendImeEvents(true) + imm()?.hideSoftInputFromWindow(input.windowToken, 0) + + showPanel() + } + + private fun restoreSystemKeyboard(showIme: Boolean = true) { + val input = takenOverInput.get() + takenOverInput = WeakReference(null) + input?.showSoftInputOnFocus = true + + hidePanel { + if (showIme && input != null && input.isFocused) { + imm()?.showSoftInput(input, 0) + } + } + } + + private fun imm(): InputMethodManager? = + reactContext.currentActivity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + + private fun contentHeight(): Int = hostView.getChildAt(0)?.height ?: 0 + + private fun onContentHeightMayHaveChanged() { + if (hostView.isAttached) { + windowManager.updateViewLayout(hostView, createLayoutParams()) + emitKeyboardTransition() + } + } + + private fun createLayoutParams(): WindowManager.LayoutParams { + val layoutParams = + WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + contentHeight().coerceAtLeast(1), + WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT, + ) + layoutParams.gravity = Gravity.BOTTOM + + return layoutParams + } + + private fun showPanel() { + if (hostView.isAttached) { + if (isHidingPanel) { + isHidingPanel = false + hostView.animate().cancel() + hostView + .animate() + .translationY(0f) + .setDuration(TRANSITION_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + keyboardEventsCallback()?.animateSyntheticTransition( + contentHeight().toFloat().dp, + true, + TRANSITION_DURATION_MS.toInt(), + ) + } + return + } + + val height = contentHeight() + + hostView.translationY = height.toFloat() + hostView.visibility = View.VISIBLE + + try { + windowManager.addView(hostView, createLayoutParams()) + } catch ( + @Suppress("detekt:TooGenericExceptionCaught") e: RuntimeException, + ) { + Logger.w(TAG, "Can not show custom keyboard", e) + return + } + + hostView + .animate() + .translationY(0f) + .setDuration(TRANSITION_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + + keyboardEventsCallback()?.animateSyntheticTransition( + height.toFloat().dp, + true, + TRANSITION_DURATION_MS.toInt(), + ) + } + + private fun hidePanel(onHidden: (() -> Unit)? = null) { + if (!hostView.isAttached) { + onHidden?.invoke() + return + } + + isHidingPanel = true + keyboardEventsCallback()?.animateSyntheticTransition(0.0, false, TRANSITION_DURATION_MS.toInt()) + + hostView + .animate() + .translationY(contentHeight().toFloat()) + .setDuration(TRANSITION_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + if (!isHidingPanel) { + return@withEndAction + } + isHidingPanel = false + if (hostView.isAttached) { + hostView.visibility = View.INVISIBLE + windowManager.removeView(hostView) + } + suspendImeEvents(false) + onHidden?.invoke() + }.start() + } + + private fun suspendImeEvents(suspended: Boolean) { + keyboardEventsCallback()?.suspend(suspended) + } + + private fun keyboardEventsCallback() = EdgeToEdgeViewRegistry.get()?.callback + + private fun emitKeyboardTransition() { + val isShown = hostView.isAttached + val height = if (isShown) contentHeight().toFloat().dp else 0.0 + + keyboardEventsCallback()?.syncKeyboardPosition(height, isShown) + } +} + +@SuppressLint("ViewConstructor") +class CustomKeyboardRootViewGroup( + private val reactContext: ThemedReactContext, +) : ReactViewGroup(reactContext), + RootViewCompat { + private val jsTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this) + private var jsPointerDispatcher: JSPointerDispatcherCompat? = null + internal var eventDispatcher: EventDispatcher? = null + internal var stateWrapper: StateWrapper? = null + internal var isAttached = false + + init { + if (ReactFeatureFlags.dispatchPointerEvents) { + jsPointerDispatcher = JSPointerDispatcherCompat(this) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + isAttached = true + setBackgroundColor(reactContext.getInputMethodColor()) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + isAttached = false + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setBackgroundColor(reactContext.getInputMethodColor()) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { eventDispatcher -> + try { + jsTouchDispatcher.handleTouchEvent(event, eventDispatcher) + jsPointerDispatcher?.handleMotionEventCompat(event, eventDispatcher, true) + } catch ( + @Suppress("detekt:TooGenericExceptionCaught") e: RuntimeException, + ) { + Logger.w(TAG, "Can not handle touch event", e) + } + } + return super.onInterceptTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { eventDispatcher -> + try { + jsTouchDispatcher.handleTouchEvent(event, eventDispatcher) + jsPointerDispatcher?.handleMotionEventCompat(event, eventDispatcher, false) + } catch ( + @Suppress("detekt:TooGenericExceptionCaught") e: RuntimeException, + ) { + Logger.w(TAG, "Can not handle touch event", e) + } + } + super.onTouchEvent(event) + + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { + jsPointerDispatcher?.handleMotionEventCompat(event, it, true) + } + return super.onInterceptHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { + jsPointerDispatcher?.handleMotionEventCompat(event, it, false) + } + return super.onHoverEvent(event) + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + } + + + override fun onChildStartedNativeGesture( + childView: View?, + ev: MotionEvent, + ) { + eventDispatcher?.let { eventDispatcher -> + jsTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher) + jsPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher) + } + } + + override fun onChildEndedNativeGesture( + childView: View, + ev: MotionEvent, + ) { + eventDispatcher?.let { jsTouchDispatcher.onChildEndedNativeGesture(ev, it) } + jsPointerDispatcher?.onChildEndedNativeGesture() + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } +} diff --git a/android/src/main/jni/RNKC.h b/android/src/main/jni/RNKC.h index c099b0ab8f..d7a677825a 100644 --- a/android/src/main/jni/RNKC.h +++ b/android/src/main/jni/RNKC.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt new file mode 100644 index 0000000000..e1d59dc0c9 --- /dev/null +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/CustomKeyboardViewManager.kt @@ -0,0 +1,30 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.uimanager.LayoutShadowNode +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.reactnativekeyboardcontroller.managers.CustomKeyboardViewManagerImpl +import com.reactnativekeyboardcontroller.views.customkeyboard.CustomKeyboardHostShadowNode +import com.reactnativekeyboardcontroller.views.customkeyboard.CustomKeyboardHostView + +class CustomKeyboardViewManager : ViewGroupManager() { + private val manager = CustomKeyboardViewManagerImpl() + + override fun getName(): String = CustomKeyboardViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): CustomKeyboardHostView = + manager.createViewInstance(reactContext) + + override fun createShadowNodeInstance(): LayoutShadowNode = CustomKeyboardHostShadowNode() + + override fun getShadowNodeClass(): Class = CustomKeyboardHostShadowNode::class.java + + @ReactProp(name = "enabled") + fun setEnabled( + view: CustomKeyboardHostView, + value: Boolean, + ) { + manager.setEnabled(view, value) + } +} diff --git a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt index 4e61039c9b..d93c158a85 100644 --- a/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt +++ b/android/src/turbo/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt @@ -59,6 +59,7 @@ class KeyboardControllerPackage : TurboReactPackage() { KeyboardControllerViewManager(), KeyboardGestureAreaViewManager(), OverKeyboardViewManager(), + CustomKeyboardViewManager(), KeyboardBackgroundViewManager(), ClippingScrollViewDecoratorViewManager(), KeyboardToolbarGroupViewManager(), diff --git a/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewComponentDescriptor.h b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewComponentDescriptor.h new file mode 100644 index 0000000000..40c629af64 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewComponentDescriptor.h @@ -0,0 +1,40 @@ +// +// RNKCCustomKeyboardViewComponentDescriptor.h +// Pods +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +#pragma once + +#include "RNKCCustomKeyboardViewShadowNode.h" + +#include +#include +#include + +namespace facebook::react { +class CustomKeyboardViewComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + void adopt(ShadowNode &shadowNode) const override { + react_native_assert(dynamic_cast(&shadowNode)); + + auto &layoutableShadowNode = static_cast(shadowNode); + auto &stateData = + static_cast(*shadowNode.getState()) + .getData(); + + + layoutableShadowNode.setPositionType(YGPositionTypeAbsolute); + + if (stateData.containerWidth > 0) { + layoutableShadowNode.setSize(Size{stateData.containerWidth, 0}); + } + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.cpp b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.cpp new file mode 100644 index 0000000000..c61908af39 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.cpp @@ -0,0 +1,14 @@ +// +// RNKCCustomKeyboardViewShadowNode.cpp +// Pods +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +#include "RNKCCustomKeyboardViewShadowNode.h" + +namespace facebook::react { + +extern const char CustomKeyboardViewComponentName[] = "CustomKeyboardView"; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.h b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.h new file mode 100644 index 0000000000..6ed792b6c1 --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewShadowNode.h @@ -0,0 +1,30 @@ +// +// RNKCCustomKeyboardViewShadowNode.h +// Pods +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +#pragma once + +#include "RNKCCustomKeyboardViewState.h" + +#include +#include +#include +#include + +namespace facebook::react { + +JSI_EXPORT extern const char CustomKeyboardViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +using CustomKeyboardViewShadowNode = ConcreteViewShadowNode< + CustomKeyboardViewComponentName, + CustomKeyboardViewProps, + CustomKeyboardViewEventEmitter, + CustomKeyboardViewState>; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewState.h b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewState.h new file mode 100644 index 0000000000..a7322bdb6c --- /dev/null +++ b/common/cpp/react/renderer/components/RNKC/RNKCCustomKeyboardViewState.h @@ -0,0 +1,39 @@ +// +// RNKCCustomKeyboardViewState.h +// Pods +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +#pragma once + +#include +#include + +#ifdef ANDROID +#include +#endif + +#include + +namespace facebook::react { + +class CustomKeyboardViewState final { + public: + using Shared = std::shared_ptr; + + CustomKeyboardViewState() = default; + explicit CustomKeyboardViewState(Float width) : containerWidth(width) {} + + Float containerWidth{0}; + +#ifdef ANDROID + CustomKeyboardViewState(CustomKeyboardViewState const &previousState, folly::dynamic data) + : containerWidth(static_cast(data["containerWidth"].getDouble())) {} + folly::dynamic getDynamic() const { + return folly::dynamic::object("containerWidth", containerWidth); + } +#endif +}; + +} // namespace facebook::react diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7c0c1d96a3..d47e49389d 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2972,85 +2972,85 @@ SPEC CHECKSUMS: hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394 InputMask: 71d291dc54d2deaeac6512afb6ec2304228c0bb7 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 - lottie-react-native: 38ad382c9420a1a079c7e6958a11e26c3183d943 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + lottie-react-native: ef74dd931e7b75fc09c1e4bfe33c23f6228cacdd + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585 RCTRequired: 58719f5124f9267b5f9649c08bf23d9aea845b23 RCTTypeSafety: 4aefa8328ab1f86da273f08517f1f6b343f6c2cc React: 2073376f47c71b7e9a0af7535986a77522ce1049 React-callinvoker: 751b6f2c83347a0486391c3f266f291f0f53b27e - React-Core: 7195661f0b48e7ea46c3360ccb575288a20c932c - React-CoreModules: 14f0054ab46000dd3b816d6528af3bd600d82073 - React-cxxreact: 7f602425c63096c398dac13cd7a300efd7c281ae + React-Core: dff5d29973349b11dd6631c9498456d75f846d5e + React-CoreModules: c0ae04452e4c5d30e06f8e94692a49107657f537 + React-cxxreact: 376fd672c95dfb64ad5cc246e6a1e9edb78dec4c React-debug: 7b56a0a7da432353287d2eedac727903e35278f5 - React-defaultsnativemodule: 695d8a0b40f735edb3c4031e0f049e567fdac47a - React-domnativemodule: 6d66c1f61f277d008d98cae650ce2c025b89d3b9 - React-Fabric: 997d4115d688f483cb409a1290171bff3c93dab4 - React-FabricComponents: 8167e5e363ca3a3fe394d8afee355e4072bea1db - React-FabricImage: f8f9f2c97657116702acc670e3f4357bc842bed3 - React-featureflags: dfb4d0d527d55dd968231370f6832b9197ee653d - React-featureflagsnativemodule: c63cfd8fe95cd98f12ebb37daa919c4544810a45 - React-graphics: fd795f1c2a1133a08dde31725b20949edd545dca - React-hermes: 0a167bbb02c242664745e82154578c64e90a88e5 - React-idlecallbacksnativemodule: 1798c6aa33ddc7c2e9fa3c3d67729728639889e9 - React-ImageManager: c498ee6945dffacc82bfa175aa3264212f27c70b - React-jserrorhandler: 216951fea62fc26c600f4c96f0dc4fd53d1e7a9b - React-jsi: 9c27d27d3007b73c702ad3fd5a6166557c741020 - React-jsiexecutor: 2b24f4ed4026344a27f717bf947a434cbbeeff7a - React-jsinspector: 02394b059c48805780f7d977366317a24168d00e - React-jsinspectorcdp: f4b6d5c5c9db05ef44d082716714f90cfeed96bb - React-jsinspectornetwork: e7c77d01b5f0664e24c0bec1aea27d5e3d7fb746 - React-jsinspectortracing: aaa96a4e53abb88dc6d47da3b5744c710652fef9 - React-jsitooling: 226e5f4147c7b6f1ae1954a8406ffa713f3da828 - React-jsitracing: 8a2fbeaa9c53c3f0b23904ccffefc890eae48d71 - React-logger: 1767babce2d28c3251039ce05556714a2c8c6ded - React-Mapbuffer: 33f678ee25b6c0ee2b01b1ecec08e3e02424cefe - React-microtasksnativemodule: 44b44a4d3cd6ffb85d928abf741acdc26722de2e - react-native-blur: 2181432139e3b470cad492f7729cb640cee60045 - react-native-keyboard-controller: cdd4a3a4ce772a139d23b2d383d165e04414f375 - react-native-safe-area-context: aac2745e96999c8633d2f6119e4e39b499c2ac8b - react-native-skia: 841224c153394b64410d94ff87f7a7a5af44a95e - react-native-text-input-mask: 22ca8eeef84d42a896f79428f7d175a5eb8b1c4e - react-native-webview: a742b11276a0443e856cc5dc3e21a3fdf3351cdf - React-NativeModulesApple: b5d18bc109c45c9a1c6b71664991b5cc3adc4e48 + React-defaultsnativemodule: 393b81aaa6211408f50a6ef00a277847256dd881 + React-domnativemodule: 5fb5829baa7a7a0f217019cbad1eb226d94f7062 + React-Fabric: a17c4ae35503673b57b91c2d1388429e7cbee452 + React-FabricComponents: a76572ddeba78ebe4ec58615291e9db4a55cd46a + React-FabricImage: d806eb2695d7ef355ec28d1a21f5a14ac26b1cae + React-featureflags: 1690ec3c453920b6308e23a4e24eb9c3632f9c75 + React-featureflagsnativemodule: 7b7e8483fc671c5a33aefd699b7c7a3c0bdfdfec + React-graphics: ea146ee799dc816524a3a0922fc7be0b5a52dcc1 + React-hermes: fcbdc45ecf38259fe3b12642bd0757c52270a107 + React-idlecallbacksnativemodule: a353f9162eaa7ad787e68aba9f52a1cfa8154098 + React-ImageManager: ec5cf55ce9cc81719eb5f1f51d23d04db851c86c + React-jserrorhandler: 594c593f3d60f527be081e2cace7710c2bd9f524 + React-jsi: 59ec3190dd364cca86a58869e7755477d2468948 + React-jsiexecutor: b87d78a2e8dd7a6f56e9cdac038da45de98c944f + React-jsinspector: b9204adf1af622c98e78af96ec1bca615c2ce2bd + React-jsinspectorcdp: 4a356fa69e412d35d3a38c44d4a6cc555c5931e8 + React-jsinspectornetwork: 7820056773178f321cbf18689e1ffcd38276a878 + React-jsinspectortracing: b341c5ef6e031a33e0bd462d67fd397e8e9cd612 + React-jsitooling: 401655e05cb966b0081225c5201d90734a567cb9 + React-jsitracing: 67eff6dea0cb58a1e7bd8b49243012d88c0f511e + React-logger: a3cb5b29c32b8e447b5a96919340e89334062b48 + React-Mapbuffer: 9d2434a42701d6144ca18f0ca1c4507808ca7696 + React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b + react-native-blur: f3a7d1ca6aa959eb611168e1ce4ca5481359bcff + react-native-keyboard-controller: 602d8e65bff94e2b7b85cb9fe0ce8cce19187d6b + react-native-safe-area-context: 2243039f43d10cb1ea30ec5ac57fc6d1448413f4 + react-native-skia: 18dc52b16c59522468698a361bcbaf9824124afc + react-native-text-input-mask: aa3030769aea6abeffbe1f18876454b734cc8051 + react-native-webview: 9cebbe24b05f426c783a9a81372ae1f21f2adae9 + React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3 React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d - React-perflogger: a03d913e3205b00aee4128082abe42fd45ce0c98 - React-performancetimeline: 9b5986cc15afafb9bf246d7dd55bdd138df94451 + React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510 + React-performancetimeline: 9041c53efa07f537164dcfe7670a36642352f4c2 React-RCTActionSheet: 42195ae666e6d79b4af2346770f765b7c29435b9 - React-RCTAnimation: 5c10527683128c56ff2c09297fb080f7c35bd293 - React-RCTAppDelegate: 36d71b04a7ba1143fa783ce4840a04ebd9379d73 - React-RCTBlob: 6e3757bdd7dce6fd9788c0dd675fd6b6c432db9d - React-RCTFabric: 093b280be70e5c9f871830c6a628f53bf2c8038b - React-RCTFBReactNativeSpec: 59f4ad68294512b75a8b213dd219df70d3d17fc5 - React-RCTImage: a3482fe1ae562d1bab08b42d4670a7c9a21813cd - React-RCTLinking: d82b9adb141aef9d2b38d446b837ae7017ab60aa - React-RCTNetwork: fa9350dd99354c5695964f589bd4790bdd4f6a85 - React-RCTRuntime: 50868a908922c3a331f9d0249c934638e067a890 - React-RCTSettings: b7f4a03f44dba1d3a4dc6770843547b203ca9129 - React-RCTText: 91dc597a5f6b27fd1048bb287c41ea05eeca9333 - React-RCTVibration: 27b09ddf74bddfa30a58d20e48f885ea6ed6c9d9 + React-RCTAnimation: fa103ccc3503b1ed8dedca7e62e7823937748843 + React-RCTAppDelegate: 2ee875077ee5b5a6e48aa2700fc3c18c6d118612 + React-RCTBlob: 0fa9530c255644db095f2c4fd8d89738d9d9ecc0 + React-RCTFabric: 4b4123f8e0b919e298cc41ea17c7fad9446dc8c7 + React-RCTFBReactNativeSpec: 50be51842148dd53ea44673a4787ebb90dbdfe4f + React-RCTImage: ba824e61ce2e920a239a65d130b83c3a1d426dff + React-RCTLinking: d2dc199c37e71e6f505d9eca3e5c33be930014d4 + React-RCTNetwork: 87137d4b9bd77e5068f854dd5c1f30d4b072faf6 + React-RCTRuntime: c8578e980313bdb4eed18622f2fb2568612b79e8 + React-RCTSettings: 71f5c7fd7b5f4e725a4e2114a4b4373d0e46048f + React-RCTText: b94d4699b49285bee22b8ebf768924d607eccee3 + React-RCTVibration: 6e3993c4f6c36a3899059f9a9ead560ddaf5a7d7 React-rendererconsistency: b4785e5ed837dc7c242bbc5fdd464b33ef5bfae7 - React-renderercss: cef3f26df2ddec558ce3c0790fc574b4fb62ce67 - React-rendererdebug: e68433ae67738caeb672a6c8cc993e9276b298a9 - React-RuntimeApple: dc1d4709bf847bc695dbe6e8aaf3e22ef25aef02 - React-RuntimeCore: ca3473c8b6578693fa3bad4d44240098d49d6723 - React-runtimeexecutor: 0db3ca0b09cd72489cef3a3729349b3c2cf13320 - React-RuntimeHermes: f92cabaf97ef2546a74360eddfc1c74a34cb9ff8 - React-runtimescheduler: 06aea75069e0d556a75d258bfc89eb0ebd5d557e - React-timing: 1a90df9a04d8e7fd165ff7fa0918b9595c776373 - React-utils: 92115441fb55ce01ded4abfb5e9336a74cd93e9c - ReactAppDependencyProvider: b20fba6c3d091a393925890009999472c8f94d95 - ReactCodegen: 58dc2eb138a27145826ad7d5568610159dfcadee - ReactCommon: 00df7b9f859c9d02181844255bb89a8bca544374 - RNCMaskedView: 7d91ca73421e3a6563757c9831c3abb3f3702ad0 - RNFlashList: aa7f4d103a58c6f5fadfdb01b160721bb00c7bff - RNGestureHandler: 4e01eefc427d3af22b877fdb4f7bc826132daab6 - RNReactNativeHapticFeedback: c56813c4217519c6bff2f640a7d48f07c30902c2 - RNReanimated: f1d171f6d4a8bf0ec2b8b18bdc09be608fab2f9b - RNScreens: 9ae2656a55e9466dc04da3ba196d7932a6bd8146 + React-renderercss: e6fb0ba387b389c595ffa86b8b628716d31f58dc + React-rendererdebug: 60a03de5c7ea59bf2d39791eb43c4c0f5d8b24e3 + React-RuntimeApple: 3df6788cd9b938bb8cb28298d80b5fbd98a4d852 + React-RuntimeCore: fad8adb4172c414c00ff6980250caf35601a0f5d + React-runtimeexecutor: d2db7e72d97751855ea0bf5273d2ac84e5ea390c + React-RuntimeHermes: 04faa4cf9a285136a6d73738787fe36020170613 + React-runtimescheduler: f6a1c9555e7131b4a8b64cce01489ad0405f6e8d + React-timing: 1e6a8acb66e2b7ac9d418956617fd1fdb19322fd + React-utils: 52bbb03f130319ef82e4c3bc7a85eaacdb1fec87 + ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3 + ReactCodegen: 1d05923ad119796be9db37830d5e5dc76586aa00 + ReactCommon: 394c6b92765cf6d211c2c3f7f6bc601dffb316a6 + RNCMaskedView: 84430d0d76eff7473af742c3c2298ed23cc4a296 + RNFlashList: 3ff55e40f74f7cd92cb5bc7486390b8c5c9648b6 + RNGestureHandler: 12a436b5074378be95468a57b62c165a1e24cfc9 + RNReactNativeHapticFeedback: b9a2e47c9b5b83e94a86a20f072c708f412a2105 + RNReanimated: ccdec16aeb589101ff6a835125ba2e3a6a95637c + RNScreens: 35525ebfe219c8709da0d26aebbc9a5e02e1077b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d PODFILE CHECKSUM: 9daeb2f305d208764d5e2abd51a7380cefa2c594 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/src/constants/screenNames.ts b/example/src/constants/screenNames.ts index eabc864c31..fa8d65f5dd 100644 --- a/example/src/constants/screenNames.ts +++ b/example/src/constants/screenNames.ts @@ -32,4 +32,5 @@ export enum ScreenNames { AI_LEGEND_LIST_CHAT = "AI_LEGEND_LIST_CHAT", KEYBOARD_EFFECTS = "KEYBOARD_EFFECTS", AI_KEYBOARD = "AI_KEYBOARD", + CUSTOM_KEYBOARD = "CUSTOM_KEYBOARD", } diff --git a/example/src/navigation/ExamplesStack/index.tsx b/example/src/navigation/ExamplesStack/index.tsx index 27939deb04..21aeb0a1b6 100644 --- a/example/src/navigation/ExamplesStack/index.tsx +++ b/example/src/navigation/ExamplesStack/index.tsx @@ -7,6 +7,7 @@ import AILegendListChat from "../../screens/Examples/AILegendListChat"; import AwareScrollView from "../../screens/Examples/AwareScrollView"; import AwareScrollViewStickyFooter from "../../screens/Examples/AwareScrollViewStickyFooter"; import CloseScreen from "../../screens/Examples/Close"; +import CustomKeyboardExample from "../../screens/Examples/CustomKeyboard"; import EnabledDisabled from "../../screens/Examples/EnabledDisabled"; import Events from "../../screens/Examples/Events"; import FocusedInputHandlers from "../../screens/Examples/FocusedInputHandlers"; @@ -66,6 +67,7 @@ export type ExamplesStackParamList = { [ScreenNames.AI_LEGEND_LIST_CHAT]: undefined; [ScreenNames.KEYBOARD_EFFECTS]: undefined; [ScreenNames.AI_KEYBOARD]: undefined; + [ScreenNames.CUSTOM_KEYBOARD]: undefined; }; const Stack = createStackNavigator(); @@ -222,6 +224,11 @@ const ExamplesStack = () => ( name={ScreenNames.AI_KEYBOARD} options={options[ScreenNames.AI_KEYBOARD]} /> + ); diff --git a/example/src/navigation/ExamplesStack/options.ts b/example/src/navigation/ExamplesStack/options.ts index e35e3f0b8f..5b1c4c0b3e 100644 --- a/example/src/navigation/ExamplesStack/options.ts +++ b/example/src/navigation/ExamplesStack/options.ts @@ -101,4 +101,7 @@ export const options = { title: "AI Keyboard", headerShown: false, }, + [ScreenNames.CUSTOM_KEYBOARD]: { + title: "Custom Keyboard", + }, }; diff --git a/example/src/screens/Examples/CustomKeyboard/index.tsx b/example/src/screens/Examples/CustomKeyboard/index.tsx new file mode 100644 index 0000000000..2a5e0af0ce --- /dev/null +++ b/example/src/screens/Examples/CustomKeyboard/index.tsx @@ -0,0 +1,155 @@ +import React, { useState } from "react"; +import { + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { + CustomKeyboard, + KeyboardAwareScrollView, +} from "react-native-keyboard-controller"; + +const EMOJIS = [ + "๐Ÿ˜€", + "๐Ÿ˜‚", + "๐Ÿ˜", + "๐Ÿค”", + "๐Ÿ˜Ž", + "๐Ÿฅณ", + "๐Ÿ˜ด", + "๐Ÿคฏ", + "๐Ÿ‘", + "๐Ÿ‘Ž", + "๐Ÿ‘", + "๐Ÿ™", + "๐Ÿ’ช", + "๐Ÿ”ฅ", + "โœจ", + "๐ŸŽ‰", + "โค๏ธ", + "๐Ÿ’™", + "๐Ÿ’š", + "๐Ÿ’›", + "๐Ÿงก", + "๐Ÿ’œ", + "๐Ÿ–ค", + "๐Ÿค", + "๐Ÿถ", + "๐Ÿฑ", + "๐ŸฆŠ", + "๐Ÿผ", + "๐Ÿฆ„", + "๐Ÿธ", + "๐Ÿ™", + "๐Ÿฆ‹", +]; + +const FIELD_COUNT = 10; +const FIELDS = Array.from({ length: FIELD_COUNT }, (_, i) => `field_${i + 1}`); + +export default function CustomKeyboardExample() { + const [values, setValues] = useState>({}); + const [focusedField, setFocusedField] = useState(FIELDS[0]); + + const append = (emoji: string) => + setValues((prev) => ({ + ...prev, + [focusedField]: (prev[focusedField] ?? "") + emoji, + })); + const backspace = () => + setValues((prev) => ({ + ...prev, + [focusedField]: [...(prev[focusedField] ?? "")].slice(0, -1).join(""), + })); + + return ( + <> + + {FIELDS.map((field, index) => ( + setFocusedField(field)} + /> + ))} + + + + + {EMOJIS.map((emoji) => ( + append(emoji)} + > + {emoji} + + ))} + + + โŒซ + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingHorizontal: 20, + paddingVertical: 20, + gap: 40, + flexGrow: 1, + }, + input: { + height: 40, + borderWidth: 2, + borderColor: "#1c1c1c", + borderRadius: 8, + padding: 10, + fontSize: 18, + }, + keyboard: { + height: 300, + paddingHorizontal: 8, + paddingTop: 12, + }, + emojiGrid: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "space-around", + }, + emojiKey: { + width: "12.5%", + alignItems: "center", + paddingVertical: 8, + }, + emoji: { + fontSize: 28, + }, + backspace: { + alignSelf: "flex-end", + paddingHorizontal: 24, + paddingVertical: 8, + }, + backspaceText: { + fontSize: 28, + }, +}); diff --git a/example/src/screens/Examples/Main/constants.ts b/example/src/screens/Examples/Main/constants.ts index 6a74087dec..f639ee4024 100644 --- a/example/src/screens/Examples/Main/constants.ts +++ b/example/src/screens/Examples/Main/constants.ts @@ -183,4 +183,10 @@ export const examples: Example[] = [ info: ScreenNames.AI_KEYBOARD, icons: "๐Ÿ”ฎ", }, + { + title: "Custom Keyboard", + testID: "custom_keyboard", + info: ScreenNames.CUSTOM_KEYBOARD, + icons: "โŒจ๏ธ", + }, ]; diff --git a/ios/views/CustomKeyboardContainerView.swift b/ios/views/CustomKeyboardContainerView.swift new file mode 100644 index 0000000000..c8022073cc --- /dev/null +++ b/ios/views/CustomKeyboardContainerView.swift @@ -0,0 +1,62 @@ +// +// CustomKeyboardContainerView.swift +// Pods +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +import UIKit + +@objc +public class CustomKeyboardContainerView: NSObject { + @objc public static func create(frame: CGRect, contentView: UIView) -> UIView { + return ContainerView(frame: frame, contentView: contentView) + } +} + +private class ContainerView: UIInputView { + var contentView: UIView! + + init(frame: CGRect, contentView: UIView) { + super.init(frame: frame, inputViewStyle: .keyboard) + self.contentView = contentView + + allowsSelfSizing = true + autoresizingMask = [.flexibleHeight] + + addSubview(contentView) + contentView.frame = bounds + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func calculateDesiredHeight() -> CGFloat { + guard let firstSubview = contentView.subviews.first else { return 0 } + return firstSubview.frame.height + } + + override func layoutSubviews() { + super.layoutSubviews() + + let desiredHeight = calculateDesiredHeight() + + if abs(frame.height - desiredHeight) > 0.001 { + frame.size.height = desiredHeight + + contentView.frame = bounds + + invalidateIntrinsicContentSize() + setNeedsLayout() + UIResponder.current?.reloadInputViews() + } else { + contentView.frame = bounds + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: frame.height) + } +} diff --git a/ios/views/CustomKeyboardViewManager.h b/ios/views/CustomKeyboardViewManager.h new file mode 100644 index 0000000000..5464c06a09 --- /dev/null +++ b/ios/views/CustomKeyboardViewManager.h @@ -0,0 +1,28 @@ +// +// CustomKeyboardViewManager.h +// KeyboardController +// +// Created by Vladyslav Martynov on 11/07/2026. +// +#ifdef RCT_NEW_ARCH_ENABLED +#import +#else +#import +#endif +#import +#import + +@interface CustomKeyboardViewManager : RCTViewManager +@end + +@interface CustomKeyboardView : +#ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +#else + UIView + +- (instancetype)initWithBridge:(RCTBridge *)bridge; + +#endif +@property (nonatomic, assign) BOOL enabled; +@end diff --git a/ios/views/CustomKeyboardViewManager.mm b/ios/views/CustomKeyboardViewManager.mm new file mode 100644 index 0000000000..6f442f8695 --- /dev/null +++ b/ios/views/CustomKeyboardViewManager.mm @@ -0,0 +1,341 @@ +// +// CustomKeyboardViewManager.mm +// react-native-keyboard-controller +// +// Created by Vladyslav Martynov on 11/07/2026. +// + +#import "CustomKeyboardViewManager.h" + +#if __has_include("react_native_keyboard_controller-Swift.h") +#import "react_native_keyboard_controller-Swift.h" +#else +#import +#endif + +#ifdef RCT_NEW_ARCH_ENABLED +#import + +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" +#endif + +#import +#import +#import + +// MARK: Shadow view (old architecture) +// The children are re-parented into the keyboard window natively, so the host must +// not occupy space in the layout flow, while the child still needs to be laid out +// against the keyboard width (the height is content-driven). On the new architecture +// the same is done by `CustomKeyboardViewComponentDescriptor::adopt`. +@interface CustomKeyboardShadowView : RCTShadowView +@end + +@implementation CustomKeyboardShadowView + +- (instancetype)init +{ + if (self = [super init]) { + self.position = YGPositionTypeAbsolute; + } + return self; +} + +- (void)insertReactSubview:(RCTShadowView *)subview atIndex:(NSInteger)atIndex +{ + [super insertReactSubview:subview atIndex:atIndex]; + subview.width = (YGValue){static_cast(UIScreen.mainScreen.bounds.size.width), YGUnitPoint}; +} + +@end + +#ifdef RCT_NEW_ARCH_ENABLED +using namespace facebook::react; +#endif + +// MARK: Manager +@implementation CustomKeyboardViewManager + +RCT_EXPORT_MODULE(CustomKeyboardViewManager) + +// Expose the `enabled` prop to React Native +RCT_EXPORT_VIEW_PROPERTY(enabled, BOOL) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +#ifndef RCT_NEW_ARCH_ENABLED +- (UIView *)view +{ + return [[CustomKeyboardView alloc] initWithBridge:self.bridge]; +} + +- (RCTShadowView *)shadowView +{ + return [CustomKeyboardShadowView new]; +} +#endif + +@end + +// MARK: View +#ifdef RCT_NEW_ARCH_ENABLED +@interface CustomKeyboardView () +@end +#endif + +@implementation CustomKeyboardView { + UIView *_contentView; + UIView *_sharedInputView; + __weak UIView *_attachedInput; +#ifdef RCT_NEW_ARCH_ENABLED + RCTSurfaceTouchHandler *_touchHandler; + CustomKeyboardViewShadowNode::ConcreteState::Shared _state; +#else + RCTTouchHandler *_touchHandler; +#endif +} + +#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]; +} + +#ifdef RCT_NEW_ARCH_ENABLED +// MARK: state +- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + + auto width = static_cast(UIScreen.mainScreen.bounds.size.width); + if (_state && _state->getData().containerWidth != width) { + _state->updateState(CustomKeyboardViewState(width)); + } +} +#endif + +// MARK: Constructor +#ifdef RCT_NEW_ARCH_ENABLED +- (instancetype)init +{ + if (self = [super init]) { + _touchHandler = [RCTSurfaceTouchHandler new]; + _contentView = [[UIView alloc] initWithFrame:CGRectZero]; + } +#else +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + self = [super initWithFrame:CGRectZero]; + if (self) { + _touchHandler = [[RCTTouchHandler alloc] initWithBridge:bridge]; + _contentView = [[UIView alloc] initWithFrame:CGRectZero]; + } +#endif + [_touchHandler attachToView:_contentView]; + [self setupObservers]; + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +// MARK: Listeners +- (void)setupObservers +{ + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleTextInputDidBeginEditing:) + name:UITextFieldTextDidBeginEditingNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleTextInputDidBeginEditing:) + name:UITextViewTextDidBeginEditingNotification + object:nil]; +} + +- (void)handleTextInputDidBeginEditing:(NSNotification *)notification +{ + if (self.window == nil) { + return; + } + + if (self.enabled) { + [self attachToTextInput:(UIView *)notification.object]; + } else { + [self detachInputView]; + } +} + +- (void)attachToTextInput:(UIView *)textInput +{ + if ([textInput isKindOfClass:[UITextField class]]) { + [self attachInputViewTo:(UITextField *)textInput]; + } else if ([textInput isKindOfClass:[UITextView class]]) { + [self attachInputViewTo:(UITextView *)textInput]; + } +} + +- (void)createSharedInputView +{ + CGFloat contentHeight = + _contentView.subviews.count > 0 ? _contentView.subviews[0].frame.size.height : 0; + _sharedInputView = [CustomKeyboardContainerView + createWithFrame:CGRectMake(0, 0, UIScreen.mainScreen.bounds.size.width, contentHeight) + contentView:_contentView]; +} + +- (void)attachInputViewTo:(UIView *)input +{ + if (!_sharedInputView) { + [self createSharedInputView]; + } + + if (_attachedInput != nil && _attachedInput != input) { + [self clearInputViewFrom:_attachedInput]; + } + + if ([input isKindOfClass:[UITextField class]]) { + ((UITextField *)input).inputView = _sharedInputView; + } else if ([input isKindOfClass:[UITextView class]]) { + ((UITextView *)input).inputView = _sharedInputView; + } + _attachedInput = input; + + [input reloadInputViews]; +} + +- (void)clearInputViewFrom:(UIView *)textInput +{ + if ([textInput isKindOfClass:[UITextField class]] && + ((UITextField *)textInput).inputView == _sharedInputView) { + ((UITextField *)textInput).inputView = nil; + } else if ( + [textInput isKindOfClass:[UITextView class]] && + ((UITextView *)textInput).inputView == _sharedInputView) { + ((UITextView *)textInput).inputView = nil; + } +} + +- (void)detachInputView +{ + UIView *textInput = _attachedInput; + if (textInput != nil) { + [self clearInputViewFrom:textInput]; + [textInput reloadInputViews]; + _attachedInput = nil; + } +} + +// MARK: touch handling +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + return nil; +} + +// MARK: props updater +#ifdef RCT_NEW_ARCH_ENABLED +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &newViewProps = *std::static_pointer_cast(props); + + if (newViewProps.enabled != self.enabled) { + [self updateEnabledState:newViewProps.enabled]; + } + + [super updateProps:props oldProps:oldProps]; +} +#else +- (void)setEnabled:(BOOL)enabled +{ + _enabled = enabled; + + [self updateEnabledState:enabled]; +} +#endif + +- (void)updateEnabledState:(BOOL)enabled +{ + _enabled = enabled; + + if (!enabled) { + [self detachInputView]; + } else { + UIResponder *firstResponder = [UIResponder current]; + if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) { + [self attachToTextInput:(UIView *)firstResponder]; + } + } +} + +// MARK: child management +#ifdef RCT_NEW_ARCH_ENABLED +- (void)mountChildComponentView:(UIView *)childComponentView + index:(NSInteger)index +{ + [_contentView insertSubview:childComponentView atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView + index:(NSInteger)index +{ + [childComponentView removeFromSuperview]; +} +#else +- (void)addSubview:(UIView *)view +{ + [_contentView addSubview:view]; +} +#endif + +- (void)layoutSubviews +{ + [_sharedInputView layoutSubviews]; +} + +// MARK: lifecycle cleanup +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + + if (newWindow == nil) { + [self detachInputView]; + } +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + + [self detachInputView]; + _enabled = NO; + _sharedInputView = nil; + _state = nullptr; +} +#endif + +#ifdef RCT_NEW_ARCH_ENABLED +Class CustomKeyboardViewCls(void) +{ + return CustomKeyboardView.class; +} +#endif + +@end diff --git a/package.json b/package.json index 52d395bf04..ed1a146511 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "OverKeyboardView": "OverKeyboardView", "KeyboardBackgroundView": "KeyboardBackgroundView", "KeyboardExtender": "KeyboardExtender", + "CustomKeyboardView": "CustomKeyboardView", "ClippingScrollViewDecoratorView": "ClippingScrollViewDecoratorView", "KeyboardToolbarGroupView": "KeyboardToolbarGroupView" } diff --git a/src/bindings.native.ts b/src/bindings.native.ts index 8f29cc10d2..191b767a07 100644 --- a/src/bindings.native.ts +++ b/src/bindings.native.ts @@ -1,6 +1,7 @@ import { NativeEventEmitter, Platform } from "react-native"; import type { + CustomKeyboardProps, FocusedInputEventsModule, KeyboardBackgroundViewProps, KeyboardControllerNativeModule, @@ -72,6 +73,8 @@ export const RCTKeyboardExtender: React.FC = Platform.OS === "ios" ? require("./specs/KeyboardExtenderNativeComponent").default : ({ children }: KeyboardExtenderProps) => children; +export const RCTCustomKeyboardView: React.FC = + require("./specs/CustomKeyboardViewNativeComponent").default; export const ClippingScrollView: React.FC = require("./specs/ClippingScrollViewDecoratorViewNativeComponent").default; export const RCTKeyboardToolbarGroupView: React.FC = diff --git a/src/bindings.ts b/src/bindings.ts index 90a163f02c..dc9da4e37a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2,6 +2,7 @@ import { View } from "react-native"; import type { ClippingScrollViewProps, + CustomKeyboardProps, FocusedInputEventsModule, KeyboardBackgroundViewProps, KeyboardControllerNativeModule, @@ -90,6 +91,12 @@ export const KeyboardBackgroundView = */ export const RCTKeyboardExtender = View as unknown as React.FC; +/** + * A container that replaces the system keyboard with its children + * whenever a text input becomes focused. + */ +export const RCTCustomKeyboardView = + View as unknown as React.FC; /** * 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`). diff --git a/src/index.ts b/src/index.ts index 48b58f9f76..8270dc920d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,4 +27,4 @@ export type { KeyboardToolbarProps, KeyboardEffectsProps, } from "./components"; -export { OverKeyboardView, KeyboardExtender } from "./views"; +export { OverKeyboardView, KeyboardExtender, CustomKeyboard } from "./views"; diff --git a/src/specs/CustomKeyboardViewNativeComponent.ts b/src/specs/CustomKeyboardViewNativeComponent.ts new file mode 100644 index 0000000000..288c8f69e2 --- /dev/null +++ b/src/specs/CustomKeyboardViewNativeComponent.ts @@ -0,0 +1,12 @@ +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 { + enabled?: boolean; +} + +export default codegenNativeComponent("CustomKeyboardView", { + interfaceOnly: true, +}) as HostComponent; diff --git a/src/types/views.ts b/src/types/views.ts index e1da3bd28f..da9cd29ab5 100644 --- a/src/types/views.ts +++ b/src/types/views.ts @@ -49,6 +49,10 @@ export type KeyboardExtenderProps = PropsWithChildren<{ /** Controls whether this `KeyboardExtender` instance should take an effect. Default is `true`. */ enabled?: boolean; }>; +export type CustomKeyboardProps = PropsWithChildren<{ + /** Controls whether this `CustomKeyboard` instance should replace the system keyboard. Default is `true`. */ + enabled?: boolean; +}>; export type KeyboardToolbarGroupViewProps = PropsWithChildren; export type ClippingScrollViewProps = PropsWithChildren< ViewProps & { diff --git a/src/views/CustomKeyboard/index.android.tsx b/src/views/CustomKeyboard/index.android.tsx new file mode 100644 index 0000000000..48f720bdbd --- /dev/null +++ b/src/views/CustomKeyboard/index.android.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { View } from "react-native"; + +import { RCTCustomKeyboardView } from "../../bindings"; + +import type { CustomKeyboardProps } from "../../types"; +import type { PropsWithChildren } from "react"; + +/** + * A component that replaces the system keyboard with its children whenever + * a text input becomes focused. + * @param props - Component props. + * @returns A view component that renders in place of the system keyboard. + * @example + * ```tsx + * + * + * + * ``` + */ +const CustomKeyboard = (props: PropsWithChildren) => { + const { children, enabled = true } = props; + + return ( + + {children} + + ); +}; + +export default CustomKeyboard; diff --git a/src/views/CustomKeyboard/index.ios.tsx b/src/views/CustomKeyboard/index.ios.tsx new file mode 100644 index 0000000000..48f720bdbd --- /dev/null +++ b/src/views/CustomKeyboard/index.ios.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { View } from "react-native"; + +import { RCTCustomKeyboardView } from "../../bindings"; + +import type { CustomKeyboardProps } from "../../types"; +import type { PropsWithChildren } from "react"; + +/** + * A component that replaces the system keyboard with its children whenever + * a text input becomes focused. + * @param props - Component props. + * @returns A view component that renders in place of the system keyboard. + * @example + * ```tsx + * + * + * + * ``` + */ +const CustomKeyboard = (props: PropsWithChildren) => { + const { children, enabled = true } = props; + + return ( + + {children} + + ); +}; + +export default CustomKeyboard; diff --git a/src/views/CustomKeyboard/index.tsx b/src/views/CustomKeyboard/index.tsx new file mode 100644 index 0000000000..92ee8cb1bc --- /dev/null +++ b/src/views/CustomKeyboard/index.tsx @@ -0,0 +1,6 @@ +/** + * For now null is a fallback for web + */ +const CustomKeyboard = () => null; + +export default CustomKeyboard; diff --git a/src/views/index.ts b/src/views/index.ts index e2b0279396..79d08c1808 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -1,2 +1,3 @@ export { default as OverKeyboardView } from "./OverKeyboardView"; export { default as KeyboardExtender } from "./KeyboardExtender"; +export { default as CustomKeyboard } from "./CustomKeyboard";