Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class KeyboardControllerPackage : BaseReactPackage() {
KeyboardControllerViewManager(),
KeyboardGestureAreaViewManager(),
OverKeyboardViewManager(),
CustomKeyboardViewManager(),
KeyboardBackgroundViewManager(),
ClippingScrollViewDecoratorViewManager(),
KeyboardToolbarGroupViewManager(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CustomKeyboardHostView>(),
CustomKeyboardViewManagerInterface<CustomKeyboardHostView> {
private val manager = CustomKeyboardViewManagerImpl()
private val mDelegate = CustomKeyboardViewManagerDelegate(this)

override fun getDelegate(): ViewManagerDelegate<CustomKeyboardHostView> = 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<out LayoutShadowNode> = 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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading