diff --git a/FabricExample/src/constants/screenNames.ts b/FabricExample/src/constants/screenNames.ts index d271f5a0c9..e725a664c6 100644 --- a/FabricExample/src/constants/screenNames.ts +++ b/FabricExample/src/constants/screenNames.ts @@ -7,4 +7,5 @@ export enum ScreenNames { EXAMPLES_STACK = 'EXAMPLES_STACK', EXAMPLES = 'EXAMPLES', NON_UI_PROPS = 'NON_UI_PROPS', + INTERACTIVE_KEYBOARD = 'INTERACTIVE_KEYBOARD', } diff --git a/FabricExample/src/navigation/ExamplesStack/index.tsx b/FabricExample/src/navigation/ExamplesStack/index.tsx index 746c21ab4f..226f1df741 100644 --- a/FabricExample/src/navigation/ExamplesStack/index.tsx +++ b/FabricExample/src/navigation/ExamplesStack/index.tsx @@ -9,6 +9,7 @@ import Events from '../../screens/Examples/Events'; import AwareScrollView from '../../screens/Examples/AwareScrollView'; import StatusBar from '../../screens/Examples/StatusBar'; import NonUIProps from '../../screens/Examples/NonUIProps'; +import InteractiveKeyboard from '../../screens/Examples/InteractiveKeyboard'; export type ExamplesStackParamList = { [ScreenNames.ANIMATED_EXAMPLE]: undefined; @@ -17,6 +18,7 @@ export type ExamplesStackParamList = { [ScreenNames.AWARE_SCROLL_VIEW]: undefined; [ScreenNames.STATUS_BAR]: undefined; [ScreenNames.NON_UI_PROPS]: undefined; + [ScreenNames.INTERACTIVE_KEYBOARD]: undefined; }; const Stack = createStackNavigator(); @@ -41,6 +43,9 @@ const options = { [ScreenNames.NON_UI_PROPS]: { title: 'Non UI Props', }, + [ScreenNames.INTERACTIVE_KEYBOARD]: { + title: 'Interactive keyboard', + }, }; const ExamplesStack = () => ( @@ -75,6 +80,11 @@ const ExamplesStack = () => ( component={NonUIProps} options={options[ScreenNames.NON_UI_PROPS]} /> + ); diff --git a/FabricExample/src/screens/Examples/InteractiveKeyboard/index.tsx b/FabricExample/src/screens/Examples/InteractiveKeyboard/index.tsx new file mode 100644 index 0000000000..154afaa2da --- /dev/null +++ b/FabricExample/src/screens/Examples/InteractiveKeyboard/index.tsx @@ -0,0 +1,108 @@ +import { StackScreenProps } from '@react-navigation/stack'; +import React, { useEffect, useState } from 'react'; +import { Text, TextInput, View } from 'react-native'; +import { + KeyboardGestureArea, + useKeyboardHandler, +} from 'react-native-keyboard-controller'; +import Reanimated, { + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; + +import Message from '../../../components/Message'; +import { history } from '../../../components/Message/data'; +import { ExamplesStackParamList } from '../../../navigation/ExamplesStack'; +import styles from './styles'; + +const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput); + +const useKeyboardAnimation = () => { + const progress = useSharedValue(0); + const height = useSharedValue(0); + useKeyboardHandler({ + onMove: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + onInteractive: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + }); + + return { height, progress }; +}; + +type Props = StackScreenProps; + +function InteractiveKeyboard({ navigation }: Props) { + const [interpolator, setInterpolator] = useState<'ios' | 'linear'>('linear'); + const { height } = useKeyboardAnimation(); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + setInterpolator(interpolator === 'ios' ? 'linear' : 'ios') + } + > + {interpolator} + + ), + }); + }, [interpolator]); + + const scrollViewStyle = useAnimatedStyle( + () => ({ + transform: [{ translateY: -height.value }, ...styles.inverted.transform], + }), + [] + ); + const textInputStyle = useAnimatedStyle( + () => ({ + height: 50, + width: '100%', + backgroundColor: '#BCBCBC', + transform: [{ translateY: -height.value }], + }), + [] + ); + const fakeView = useAnimatedStyle( + () => ({ + height: height.value, + }), + [] + ); + + return ( + + + + + + {history.map((message, index) => ( + + ))} + + + + + + ); +} + +export default InteractiveKeyboard; diff --git a/FabricExample/src/screens/Examples/InteractiveKeyboard/styles.ts b/FabricExample/src/screens/Examples/InteractiveKeyboard/styles.ts new file mode 100644 index 0000000000..5a43d0fd83 --- /dev/null +++ b/FabricExample/src/screens/Examples/InteractiveKeyboard/styles.ts @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + justifyContent: 'flex-end', + flex: 1, + }, + header: { + color: 'black', + marginRight: 12, + }, + inverted: { + transform: [ + { + rotate: '180deg', + }, + ], + }, + content: { + flex: 1, + }, +}); diff --git a/FabricExample/src/screens/Examples/Main/constants.ts b/FabricExample/src/screens/Examples/Main/constants.ts index b2cebdc218..e5e13a556f 100644 --- a/FabricExample/src/screens/Examples/Main/constants.ts +++ b/FabricExample/src/screens/Examples/Main/constants.ts @@ -24,4 +24,9 @@ export const examples: Example[] = [ info: ScreenNames.NON_UI_PROPS, icons: '🚀', }, + { + title: 'Interactive keyboard (WIP)', + info: ScreenNames.INTERACTIVE_KEYBOARD, + icons: '👆📱', + }, ]; diff --git a/README.md b/README.md index c4384a05e0..d5fecb24b4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Keyboard manager which works in identical way on both iOS and Android. - missing `keyboardWillShow` / `keyboardWillHide` events are available on Android 😍 - module for changing soft input mode on Android 🤔 - reanimated support 🚀 -- interactive keyboard dismissing (planned) 👆📱 +- interactive keyboard dismissing (WIP) 👆📱 - and more is coming... Stay tuned! 😊 ## Installation diff --git a/android/build.gradle b/android/build.gradle index 86d9992a0a..72deb3109d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -161,4 +161,5 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.5.0-beta03' + implementation 'androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03' } diff --git a/android/gradle.properties b/android/gradle.properties index 5c3fc55cfa..a4f97b4f14 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ KeyboardController_kotlinVersion=1.6.21 -KeyboardController_compileSdkVersion=29 -KeyboardController_targetSdkVersion=29 +KeyboardController_compileSdkVersion=33 +KeyboardController_targetSdkVersion=33 android.useAndroidX=true diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt new file mode 100644 index 0000000000..893daeae8f --- /dev/null +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt @@ -0,0 +1,47 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.KeyboardGestureAreaManagerDelegate +import com.facebook.react.viewmanagers.KeyboardGestureAreaManagerInterface +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.react.views.view.ReactViewManager +import com.reactnativekeyboardcontroller.managers.KeyboardGestureAreaViewManagerImpl +import com.reactnativekeyboardcontroller.views.KeyboardGestureAreaReactViewGroup + +class KeyboardGestureAreaViewManager(mReactContext: ReactApplicationContext) : + ReactViewManager(), + KeyboardGestureAreaManagerInterface { + private val manager = KeyboardGestureAreaViewManagerImpl(mReactContext) + private val mDelegate = KeyboardGestureAreaManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate { + return mDelegate + } + + override fun getName(): String = KeyboardGestureAreaViewManagerImpl.NAME + + override fun createViewInstance(context: ThemedReactContext): KeyboardGestureAreaReactViewGroup { + return manager.createViewInstance(context) + } + + @ReactProp(name = "interpolator") + override fun setInterpolator(view: ReactViewGroup, value: String?) { + manager.setInterpolator(view as KeyboardGestureAreaReactViewGroup, value ?: "linear") + } + + @ReactProp(name = "allowToShowKeyboardFromHiddenStateBySwipeUp") + override fun setAllowToShowKeyboardFromHiddenStateBySwipeUp( + view: ReactViewGroup, + value: Boolean, + ) { + manager.setScrollKeyboardOnScreenWhenNotVisible(view as KeyboardGestureAreaReactViewGroup, value) + } + + @ReactProp(name = "allowToDragKeyboardFromShownStateBySwipes") + override fun setAllowToDragKeyboardFromShownStateBySwipes(view: ReactViewGroup?, value: Boolean) { + manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value) + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/InteractiveKeyboardProvider.kt b/android/src/main/java/com/reactnativekeyboardcontroller/InteractiveKeyboardProvider.kt new file mode 100644 index 0000000000..e7ba6f337e --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/InteractiveKeyboardProvider.kt @@ -0,0 +1,6 @@ +package com.reactnativekeyboardcontroller + +object InteractiveKeyboardProvider { + var shown = false + var isInteractive = false +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt index 05c0878aa1..276417d244 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt @@ -63,7 +63,7 @@ class KeyboardAnimationCallback( // having such check allows us not to dispatch unnecessary incorrect events // the condition will be executed only when keyboard is opened and changes its size // (for example it happens when user changes keyboard type from 'text' to 'emoji' input - if (isKeyboardVisible && isKeyboardVisible() && !isTransitioning && Build.VERSION.SDK_INT >= 30) { + if (isKeyboardVisible && isKeyboardVisible() && !isTransitioning && Build.VERSION.SDK_INT >= 30 && !InteractiveKeyboardProvider.isInteractive) { val keyboardHeight = getCurrentKeyboardHeight() this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight)) @@ -133,9 +133,10 @@ class KeyboardAnimationCallback( } catch (e: ArithmeticException) { // do nothing, send progress as 0 } - Log.i(TAG, "DiffY: $diffY $height $progress") + Log.i(TAG, "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive}") - this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", height, progress)) + val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove" + this.sendEventToJS(KeyboardTransitionEvent(view.id, event, height, progress)) return insets } @@ -144,7 +145,19 @@ class KeyboardAnimationCallback( super.onEnd(animation) isTransitioning = false - this.persistentKeyboardHeight = getCurrentKeyboardHeight() + // if keyboard becomes shown after interactive animation completion + // getCurrentKeyboardHeight() will be `0` and isKeyboardVisible will be `false` + // it's not correct behavior, so we are handling it here + val isKeyboardShown = InteractiveKeyboardProvider.shown + if (!isKeyboardShown) { + this.persistentKeyboardHeight = getCurrentKeyboardHeight() + } else { + // if keyboard is shown after interactions and the animation has finished + // then we need to reset the state + InteractiveKeyboardProvider.shown = false + } + val isKeyboardVisible = isKeyboardVisible || isKeyboardShown + this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(this.persistentKeyboardHeight)) this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", this.persistentKeyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0)) } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationController.kt b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationController.kt new file mode 100644 index 0000000000..57672e32cf --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationController.kt @@ -0,0 +1,394 @@ +package com.reactnativekeyboardcontroller + +import android.os.CancellationSignal +import android.view.View +import android.view.animation.LinearInterpolator +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationControlListenerCompat +import androidx.core.view.WindowInsetsAnimationControllerCompat +import androidx.core.view.WindowInsetsCompat +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce +import androidx.dynamicanimation.animation.springAnimationOf +import androidx.dynamicanimation.animation.withSpringForceProperties +import kotlin.math.roundToInt + +internal class KeyboardAnimationController { + private var insetsAnimationController: WindowInsetsAnimationControllerCompat? = null + private var pendingRequestCancellationSignal: CancellationSignal? = null + private var pendingRequestOnReady: ((WindowInsetsAnimationControllerCompat) -> Unit)? = null + + /* To take control of the an WindowInsetsAnimation, we need to pass in a listener to + controlWindowInsetsAnimation() in startControlRequest(). The listener created here + keeps track of the current WindowInsetsAnimationController and resets our state. */ + private val animationControlListener by lazy { + object : WindowInsetsAnimationControlListenerCompat { + /** + * Once the request is ready, call our [onRequestReady] function + */ + override fun onReady( + controller: WindowInsetsAnimationControllerCompat, + types: Int, + ) = onRequestReady(controller) + + /** + * If the request is finished, we should reset our internal state + */ + override fun onFinished(controller: WindowInsetsAnimationControllerCompat) { + reset() + } + + /** + * If the request is cancelled, we should reset our internal state + */ + override fun onCancelled(controller: WindowInsetsAnimationControllerCompat?) { + reset() + } + } + } + + /** + * True if the IME was shown at the start of the current animation. + */ + private var isImeShownAtStart = false + + private var currentSpringAnimation: SpringAnimation? = null + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController]. This should + * be called once the view is in a position to take control over the position of the IME. + * + * @param view The view which is triggering this request + * @param onRequestReady optional listener which will be called when the request is ready and + * the animation can proceed + */ + fun startControlRequest( + view: View, + onRequestReady: ((WindowInsetsAnimationControllerCompat) -> Unit)? = null, + ) { + check(!isInsetAnimationInProgress()) { + "Animation in progress. Can not start a new request to controlWindowInsetsAnimation()" + } + + // Keep track of the IME insets, and the IME visibility, at the start of the request + isImeShownAtStart = ViewCompat.getRootWindowInsets(view) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true + + // Create a cancellation signal, which we pass to controlWindowInsetsAnimation() below + pendingRequestCancellationSignal = CancellationSignal() + // Keep reference to the onReady callback + pendingRequestOnReady = onRequestReady + + // update our state manager + InteractiveKeyboardProvider.isInteractive = true + + // Finally we make a controlWindowInsetsAnimation() request: + ViewCompat.getWindowInsetsController(view)?.controlWindowInsetsAnimation( + // We're only catering for IME animations in this listener + WindowInsetsCompat.Type.ime(), + // Animation duration. This is not used by the system, and is only passed to any + // WindowInsetsAnimation.Callback set on views. We pass in -1 to indicate that we're + // not starting a finite animation, and that this is completely controlled by + // the user's touch. + -1, + // The time interpolator used in calculating the animation progress. The fraction value + // we passed into setInsetsAndAlpha() which be passed into this interpolator before + // being used by the system to inset the IME. LinearInterpolator is a good type + // to use for scrolling gestures. + linearInterpolator, + // A cancellation signal, which allows us to cancel the request to control + pendingRequestCancellationSignal, + // The WindowInsetsAnimationControlListener + animationControlListener, + ) + } + + /** + * Start a control request to the [view]s [android.view.WindowInsetsController], similar to + * [startControlRequest], but immediately fling to a finish using [velocityY] once ready. + * + * This function is useful for fire-and-forget operations to animate the IME. + * + * @param view The view which is triggering this request + * @param velocityY the velocity of the touch gesture which caused this call + */ + fun startAndFling(view: View, velocityY: Float) = startControlRequest(view) { + animateToFinish(velocityY) + } + + /** + * Update the inset position of the IME by the given [dy] value. This value will be coerced + * into the hidden and shown inset values. + * + * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the amount of [dy] consumed by the inset animation, in pixels + */ + fun insetBy(dy: Int): Int { + val controller = insetsAnimationController + ?: throw IllegalStateException( + "Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true", + ) + + InteractiveKeyboardProvider.isInteractive = true + // Call updateInsetTo() with the new inset value + return insetTo(controller.currentInsets.bottom - dy) + } + + /** + * Update the inset position of the IME to be the given [inset] value. This value will be + * coerced into the hidden and shown inset values. + * + * This function should only be called if [isInsetAnimationInProgress] returns true. + * + * @return the distance moved by the inset animation, in pixels + */ + fun insetTo(inset: Int): Int { + val controller = insetsAnimationController + ?: throw IllegalStateException( + "Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true", + ) + + val hiddenBottom = controller.hiddenStateInsets.bottom + val shownBottom = controller.shownStateInsets.bottom + val startBottom = if (isImeShownAtStart) shownBottom else hiddenBottom + val endBottom = if (isImeShownAtStart) hiddenBottom else shownBottom + + // We coerce the given inset within the limits of the hidden and shown insets + val coercedBottom = inset.coerceIn(hiddenBottom, shownBottom) + + val consumedDy = controller.currentInsets.bottom - coercedBottom + + // Finally update the insets in the WindowInsetsAnimationController using + // setInsetsAndAlpha(). + controller.setInsetsAndAlpha( + // Here we update the animating insets. This is what controls where the IME is displayed. + // It is also passed through to views via their WindowInsetsAnimation.Callback. + Insets.of(0, 0, 0, coercedBottom), + // This controls the alpha value. We don't want to alter the alpha so use 1f + 1f, + // Finally we calculate the animation progress fraction. This value is passed through + // to any WindowInsetsAnimation.Callbacks, but it is not used by the system. + (coercedBottom - startBottom) / (endBottom - startBottom).toFloat(), + ) + + return consumedDy + } + + /** + * Return `true` if an inset animation is in progress. + */ + fun isInsetAnimationInProgress(): Boolean { + return insetsAnimationController != null + } + + /** + * Return `true` if an inset animation is currently finishing. + */ + fun isInsetAnimationFinishing(): Boolean { + return currentSpringAnimation != null + } + + /** + * Return `true` if a request to control an inset animation is in progress. + */ + fun isInsetAnimationRequestPending(): Boolean { + return pendingRequestCancellationSignal != null + } + + /** + * Cancel the current [WindowInsetsAnimationControllerCompat]. We immediately finish + * the animation, reverting back to the state at the start of the gesture. + */ + fun cancel() { + insetsAnimationController?.finish(isImeShownAtStart) + pendingRequestCancellationSignal?.cancel() + + // Cancel the current spring animation + currentSpringAnimation?.cancel() + + reset() + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat] immediately. + */ + fun finish() { + val controller = insetsAnimationController + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + pendingRequestCancellationSignal?.cancel() + return + } + + val current = controller.currentInsets.bottom + val shown = controller.shownStateInsets.bottom + val hidden = controller.hiddenStateInsets.bottom + + when (current) { + // The current inset matches either the shown/hidden inset, finish() immediately + shown -> { + InteractiveKeyboardProvider.shown = true + controller.finish(true) + } + hidden -> { + InteractiveKeyboardProvider.shown = false + controller.finish(false) + } + else -> { + // Otherwise, we'll look at the current position... + if (controller.currentFraction >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we snap to the toggled state + InteractiveKeyboardProvider.shown = !isImeShownAtStart + controller.finish(!isImeShownAtStart) + } else { + // ...otherwise, we snap back to the original visibility + InteractiveKeyboardProvider.shown = isImeShownAtStart + controller.finish(isImeShownAtStart) + } + } + } + } + + /** + * Finish the current [WindowInsetsAnimationControllerCompat]. We finish the animation, + * animating to the end state if necessary. + * + * @param velocityY the velocity of the touch gesture which caused this call to [animateToFinish]. + * Can be `null` if velocity is not available. + */ + fun animateToFinish(velocityY: Float? = null) { + val controller = insetsAnimationController + + if (controller == null) { + // If we don't currently have a controller, cancel any pending request and return + pendingRequestCancellationSignal?.cancel() + return + } + + InteractiveKeyboardProvider.isInteractive = false + + val current = controller.currentInsets.bottom + val shown = controller.shownStateInsets.bottom + val hidden = controller.hiddenStateInsets.bottom + + when { + // If we have a velocity, we can use it's direction to determine + // the visibility. Upwards == visible + velocityY != null -> animateImeToVisibility( + visible = velocityY < 0, + velocityY = velocityY, + ) + // The current inset matches either the shown/hidden inset, finish() immediately + current == shown -> controller.finish(true) + current == hidden -> controller.finish(false) + else -> { + // Otherwise, we'll look at the current position... + if (controller.currentFraction >= SCROLL_THRESHOLD) { + // If the IME is past the 'threshold' we animate it to the toggled state + animateImeToVisibility(!isImeShownAtStart) + } else { + // ...otherwise, we animate it back to the original visibility + animateImeToVisibility(isImeShownAtStart) + } + } + } + } + + fun getCurrentKeyboardHeight(): Int { + val controller = insetsAnimationController + ?: throw IllegalStateException( + "Current WindowInsetsAnimationController is null." + + "This should only be called if isAnimationInProgress() returns true", + ) + + return controller.currentInsets.bottom + } + + private fun onRequestReady(controller: WindowInsetsAnimationControllerCompat) { + // The request is ready, so clear out the pending cancellation signal + pendingRequestCancellationSignal = null + // Store the current WindowInsetsAnimationController + insetsAnimationController = controller + + // Call any pending callback + pendingRequestOnReady?.invoke(controller) + pendingRequestOnReady = null + } + + /** + * Resets all of our internal state. + */ + private fun reset() { + // Clear all of our internal state + insetsAnimationController = null + pendingRequestCancellationSignal = null + + isImeShownAtStart = false + + currentSpringAnimation?.cancel() + currentSpringAnimation = null + + pendingRequestOnReady = null + } + + /** + * Animate the IME to a given visibility. + * + * @param visible `true` to animate the IME to it's fully shown state, `false` to it's + * fully hidden state. + * @param velocityY the velocity of the touch gesture which caused this call. Can be `null` + * if velocity is not available. + */ + private fun animateImeToVisibility( + visible: Boolean, + velocityY: Float? = null, + ) { + val controller = insetsAnimationController + ?: throw IllegalStateException("Controller should not be null") + + currentSpringAnimation = springAnimationOf( + setter = { + insetTo(it.roundToInt()) + }, + getter = { controller.currentInsets.bottom.toFloat() }, + finalPosition = when { + visible -> controller.shownStateInsets.bottom.toFloat() + else -> controller.hiddenStateInsets.bottom.toFloat() + }, + ).withSpringForceProperties { + // Tweak the damping value, to remove any bounciness. + dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY + // The stiffness value controls the strength of the spring animation, which + // controls the speed. Medium (the default) is a good value, but feel free to + // play around with this value. + stiffness = SpringForce.STIFFNESS_MEDIUM + }.apply { + if (velocityY != null) { + setStartVelocity(velocityY) + } + addEndListener { anim, _, _, _ -> + if (anim == currentSpringAnimation) { + currentSpringAnimation = null + } + // Once the animation has ended, finish the controller + finish() + } + }.also { it.start() } + } +} + +/** + * Scroll threshold for determining whether to animating to the end state, or to the start state. + * Currently 15% of the total swipe distance distance + */ +private const val SCROLL_THRESHOLD = 0.15f + +/** + * A LinearInterpolator instance we can re-use across listeners. + */ +private val linearInterpolator = LinearInterpolator() diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt index 27382f301d..969d083c6c 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerPackage.kt @@ -54,6 +54,6 @@ class KeyboardControllerPackage : TurboReactPackage() { } override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(KeyboardControllerViewManager(reactContext)) + return listOf(KeyboardControllerViewManager(reactContext), KeyboardGestureAreaViewManager(reactContext)) } } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt index c4cb5c239d..168f4263d6 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt @@ -1,7 +1,9 @@ package com.reactnativekeyboardcontroller.extensions +import android.graphics.Rect import android.os.Build import android.view.View +import androidx.annotation.RequiresApi /** * Call this everytime when using [ViewCompat.setOnApplyWindowInsetsListener] @@ -27,3 +29,22 @@ fun View.requestApplyInsetsWhenAttached() { }) } } + +private val tmpIntArr = IntArray(2) + +/** + * Function which updates the given [rect] with this view's position and bounds in its window. + */ +@RequiresApi(Build.VERSION_CODES.KITKAT) +fun View.copyBoundsInWindow(rect: Rect) { + if (isLaidOut && isAttachedToWindow) { + rect.set(0, 0, width, height) + getLocationInWindow(tmpIntArr) + rect.offset(tmpIntArr[0], tmpIntArr[1]) + } else { + throw IllegalArgumentException( + "Can not copy bounds as view is not laid out" + + " or attached to window", + ) + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/Interpolator.kt b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/Interpolator.kt new file mode 100644 index 0000000000..7e6a1740fb --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/Interpolator.kt @@ -0,0 +1,14 @@ +package com.reactnativekeyboardcontroller.interpolators + +interface Interpolator { + /** + * A function that allows you to control the layout of the keyboard + * depending on the position of the finger on the screen. + * + * @param dy the distance that the finger has moved relative to the previous location. + * @param absoluteFingerPosition current position of the finger. + * @param keyboardPosition current keyboard position. + * @return the distance the keyboard should be moved from its current location. + */ + fun interpolate(dy: Int, absoluteFingerPosition: Int, keyboardPosition: Int): Int +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/IosInterpolator.kt b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/IosInterpolator.kt new file mode 100644 index 0000000000..b2a7eb7369 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/IosInterpolator.kt @@ -0,0 +1,18 @@ +package com.reactnativekeyboardcontroller.interpolators + +class IosInterpolator : Interpolator { + override fun interpolate( + dy: Int, + absoluteFingerPosition: Int, + keyboardPosition: Int, + ): Int { + if ( + absoluteFingerPosition <= keyboardPosition || // user overscrolled keyboard + dy <= 0 // user scrolls up + ) { + return dy + } + + return 0 + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/LinearInterpolator.kt b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/LinearInterpolator.kt new file mode 100644 index 0000000000..79364f1654 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/interpolators/LinearInterpolator.kt @@ -0,0 +1,11 @@ +package com.reactnativekeyboardcontroller.interpolators + +class LinearInterpolator : Interpolator { + override fun interpolate( + dy: Int, + absoluteFingerPosition: Int, + keyboardPosition: Int, + ): Int { + return dy + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt index ea85f1d43b..2fe61b60cb 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt @@ -74,6 +74,8 @@ class KeyboardControllerViewManagerImpl(private val mReactContext: ReactApplicat MapBuilder.of("registrationName", "onKeyboardMoveStart"), "topKeyboardMoveEnd", MapBuilder.of("registrationName", "onKeyboardMoveEnd"), + "topKeyboardMoveInteractive", + MapBuilder.of("registrationName", "onKeyboardMoveInteractive"), ) return map diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardGestureAreaViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardGestureAreaViewManagerImpl.kt new file mode 100644 index 0000000000..d36789e4a3 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardGestureAreaViewManagerImpl.kt @@ -0,0 +1,27 @@ +package com.reactnativekeyboardcontroller.managers + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativekeyboardcontroller.views.KeyboardGestureAreaReactViewGroup + +class KeyboardGestureAreaViewManagerImpl(mReactContext: ReactApplicationContext) { + fun createViewInstance(reactContext: ThemedReactContext): KeyboardGestureAreaReactViewGroup { + return KeyboardGestureAreaReactViewGroup(reactContext) + } + + fun setInterpolator(view: KeyboardGestureAreaReactViewGroup, interpolator: String) { + view.setInterpolator(interpolator) + } + + fun setScrollKeyboardOffScreenWhenVisible(view: KeyboardGestureAreaReactViewGroup, value: Boolean) { + view.setScrollKeyboardOffScreenWhenVisible(value) + } + + fun setScrollKeyboardOnScreenWhenNotVisible(view: KeyboardGestureAreaReactViewGroup, value: Boolean) { + view.setScrollKeyboardOnScreenWhenNotVisible(value) + } + + companion object { + const val NAME = "KeyboardGestureArea" + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardGestureAreaReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardGestureAreaReactViewGroup.kt new file mode 100644 index 0000000000..8b5018045e --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/KeyboardGestureAreaReactViewGroup.kt @@ -0,0 +1,192 @@ +package com.reactnativekeyboardcontroller.views + +import android.annotation.SuppressLint +import android.graphics.Rect +import android.os.Build +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.view.ReactViewGroup +import com.reactnativekeyboardcontroller.KeyboardAnimationController +import com.reactnativekeyboardcontroller.extensions.copyBoundsInWindow +import com.reactnativekeyboardcontroller.interpolators.Interpolator +import com.reactnativekeyboardcontroller.interpolators.IosInterpolator +import com.reactnativekeyboardcontroller.interpolators.LinearInterpolator +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +val interpolators = mapOf( + "linear" to LinearInterpolator(), + "ios" to IosInterpolator(), +) + +@SuppressLint("ViewConstructor") +class KeyboardGestureAreaReactViewGroup(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) { + // internal state management + private var isHandling = false + private var lastTouchX = 0f + private var lastTouchY = 0f + private var lastWindowY = 0 + + // react props + private var interpolator: Interpolator = LinearInterpolator() + private var scrollKeyboardOnScreenWhenNotVisible = false + private var scrollKeyboardOffScreenWhenVisible = true + + private val bounds = Rect() + + private val controller = KeyboardAnimationController() + + private var velocityTracker: VelocityTracker? = null + + @RequiresApi(Build.VERSION_CODES.R) + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (velocityTracker == null) { + // Obtain a VelocityTracker if we don't have one yet + velocityTracker = VelocityTracker.obtain() + } + + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + velocityTracker?.addMovement(event) + + lastTouchX = event.x + lastTouchY = event.y + + this.copyBoundsInWindow(bounds) + lastWindowY = bounds.top + } + MotionEvent.ACTION_MOVE -> { + // Since the view is likely to be translated/moved as the WindowInsetsAnimation + // progresses, we need to make sure we account for that change in our touch + // handling. We do that by keeping track of the view's Y position in the window, + // and detecting the difference between the current bounds. + this.copyBoundsInWindow(bounds) + val windowOffsetY = bounds.top - lastWindowY + + // We then make a copy of the MotionEvent, and offset it with the calculated + // windowOffsetY. We can then pass it to the VelocityTracker. + val velocityTrackerEvent = MotionEvent.obtain(event) + velocityTrackerEvent.offsetLocation(0f, windowOffsetY.toFloat()) + velocityTracker?.addMovement(velocityTrackerEvent) + + val dx = velocityTrackerEvent.x - lastTouchX + val dy = velocityTrackerEvent.y - lastTouchY + + if (!isHandling) { + // If we're not currently handling the touch gesture, lets check if we should + // start handling, by seeing if the gesture is majorly vertical, and + // larger than the touch slop + isHandling = dy.absoluteValue > dx.absoluteValue && + dy.absoluteValue >= ViewConfiguration.get(this.context).scaledTouchSlop + } + + if (isHandling) { + if (controller.isInsetAnimationInProgress()) { + // If we currently have control, we can update the IME insets to 'scroll' + // the IME in + val moveBy = this.interpolator.interpolate(dy.roundToInt(), this.getWindowHeight() - event.rawY.toInt(), controller.getCurrentKeyboardHeight()) + + if (moveBy != 0) { + controller.insetBy(moveBy) + } + } else if ( + !controller.isInsetAnimationRequestPending() && + shouldStartRequest( + dy = dy, + imeVisible = ViewCompat.getRootWindowInsets(this) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true, + ) + ) { + // If we don't currently have control (and a request isn't pending), + // the IME is not shown, the user is scrolling up, and the view can't + // scroll up any more (i.e. over-scrolling), we can start to control + // the IME insets + controller.startControlRequest(this) + } + + // Lastly we record the event X, Y, and view's Y window position, for the + // next touch event + lastTouchY = event.y + lastTouchX = event.x + lastWindowY = bounds.top + } + } + MotionEvent.ACTION_UP -> { + velocityTracker?.addMovement(event) + + // Calculate the current velocityY, over 500 milliseconds + velocityTracker?.computeCurrentVelocity(500) + val velocityY = velocityTracker?.yVelocity + + // If we received a ACTION_UP event, end any current WindowInsetsAnimation passing + // in the calculated Y velocity + controller.animateToFinish(velocityY) + + // Reset our touch handling state + reset() + } + MotionEvent.ACTION_CANCEL -> { + // If we received a ACTION_CANCEL event, cancel any current WindowInsetsAnimation + controller.cancel() + // Reset our touch handling state + reset() + } + } + + return super.dispatchTouchEvent(event) + } + + fun setInterpolator(interpolator: String) { + this.interpolator = interpolators[interpolator] ?: LinearInterpolator() + } + + fun setScrollKeyboardOnScreenWhenNotVisible(scrollImeOnScreenWhenNotVisible: Boolean) { + this.scrollKeyboardOnScreenWhenNotVisible = scrollImeOnScreenWhenNotVisible + } + + fun setScrollKeyboardOffScreenWhenVisible(scrollImeOffScreenWhenVisible: Boolean) { + this.scrollKeyboardOffScreenWhenVisible = scrollImeOffScreenWhenVisible + } + + /** + * Resets all of our internal state. + */ + private fun reset() { + // Clear all of our internal state + isHandling = false + lastTouchX = 0f + lastTouchY = 0f + lastWindowY = 0 + bounds.setEmpty() + + velocityTracker?.recycle() + velocityTracker = null + } + + /** + * Returns true if the given [dy], [IME visibility][imeVisible], and constructor options + * support a IME animation request. + */ + private fun shouldStartRequest(dy: Float, imeVisible: Boolean) = when { + // If the user is scroll up, return true if scrollImeOnScreenWhenNotVisible is true, and + // the IME is not currently visible + dy < 0 -> !imeVisible && scrollKeyboardOnScreenWhenNotVisible + // If the user is scroll down, start the request if scrollImeOffScreenWhenVisible is true, + // and the IME is currently visible + dy > 0 -> imeVisible && scrollKeyboardOffScreenWhenVisible + // Otherwise, return false + else -> false + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getWindowHeight(): Int { + val metrics = reactContext.currentActivity?.windowManager?.currentWindowMetrics + + return metrics?.bounds?.height() ?: 0 + } +} diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt new file mode 100644 index 0000000000..429f509dc7 --- /dev/null +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt @@ -0,0 +1,33 @@ +package com.reactnativekeyboardcontroller + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.views.view.ReactViewManager +import com.reactnativekeyboardcontroller.managers.KeyboardGestureAreaViewManagerImpl +import com.reactnativekeyboardcontroller.views.KeyboardGestureAreaReactViewGroup + +class KeyboardGestureAreaViewManager(mReactContext: ReactApplicationContext) : ReactViewManager() { + private val manager = KeyboardGestureAreaViewManagerImpl(mReactContext) + + override fun getName(): String = KeyboardGestureAreaViewManagerImpl.NAME + + override fun createViewInstance(reactContext: ThemedReactContext): KeyboardGestureAreaReactViewGroup { + return manager.createViewInstance(reactContext) + } + + @ReactProp(name = "interpolator") + fun setInterpolator(view: KeyboardGestureAreaReactViewGroup, interpolator: String) { + manager.setInterpolator(view, interpolator) + } + + @ReactProp(name = "allowToShowKeyboardFromHiddenStateBySwipeUp") + fun setScrollKeyboardOnScreenWhenNotVisible(view: KeyboardGestureAreaReactViewGroup, value: Boolean) { + manager.setScrollKeyboardOnScreenWhenNotVisible(view, value) + } + + @ReactProp(name = "allowToDragKeyboardFromShownStateBySwipes") + fun setScrollKeyboardOffScreenWhenVisible(view: KeyboardGestureAreaReactViewGroup, value: Boolean) { + manager.setScrollKeyboardOffScreenWhenVisible(view, value) + } +} diff --git a/example/src/constants/screenNames.ts b/example/src/constants/screenNames.ts index bdcb61a66a..9899ab98a6 100644 --- a/example/src/constants/screenNames.ts +++ b/example/src/constants/screenNames.ts @@ -8,4 +8,5 @@ export enum ScreenNames { EXAMPLES_STACK = 'EXAMPLES_STACK', EXAMPLES = 'EXAMPLES', NON_UI_PROPS = 'NON_UI_PROPS', + INTERACTIVE_KEYBOARD = 'INTERACTIVE_KEYBOARD', } diff --git a/example/src/navigation/ExamplesStack/index.tsx b/example/src/navigation/ExamplesStack/index.tsx index 6ccfa82998..7bbf28b963 100644 --- a/example/src/navigation/ExamplesStack/index.tsx +++ b/example/src/navigation/ExamplesStack/index.tsx @@ -10,6 +10,7 @@ import AwareScrollView from '../../screens/Examples/AwareScrollView'; import StatusBar from '../../screens/Examples/StatusBar'; import LottieAnimation from '../../screens/Examples/Lottie'; import NonUIProps from '../../screens/Examples/NonUIProps'; +import InteractiveKeyboard from '../../screens/Examples/InteractiveKeyboard'; export type ExamplesStackParamList = { [ScreenNames.ANIMATED_EXAMPLE]: undefined; @@ -19,6 +20,7 @@ export type ExamplesStackParamList = { [ScreenNames.STATUS_BAR]: undefined; [ScreenNames.LOTTIE]: undefined; [ScreenNames.NON_UI_PROPS]: undefined; + [ScreenNames.INTERACTIVE_KEYBOARD]: undefined; }; const Stack = createStackNavigator(); @@ -46,6 +48,9 @@ const options = { [ScreenNames.NON_UI_PROPS]: { title: 'Non UI Props', }, + [ScreenNames.INTERACTIVE_KEYBOARD]: { + title: 'Interactive keyboard', + }, }; const ExamplesStack = () => ( @@ -85,6 +90,11 @@ const ExamplesStack = () => ( component={NonUIProps} options={options[ScreenNames.NON_UI_PROPS]} /> + ); diff --git a/example/src/screens/Examples/InteractiveKeyboard/index.tsx b/example/src/screens/Examples/InteractiveKeyboard/index.tsx new file mode 100644 index 0000000000..154afaa2da --- /dev/null +++ b/example/src/screens/Examples/InteractiveKeyboard/index.tsx @@ -0,0 +1,108 @@ +import { StackScreenProps } from '@react-navigation/stack'; +import React, { useEffect, useState } from 'react'; +import { Text, TextInput, View } from 'react-native'; +import { + KeyboardGestureArea, + useKeyboardHandler, +} from 'react-native-keyboard-controller'; +import Reanimated, { + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; + +import Message from '../../../components/Message'; +import { history } from '../../../components/Message/data'; +import { ExamplesStackParamList } from '../../../navigation/ExamplesStack'; +import styles from './styles'; + +const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput); + +const useKeyboardAnimation = () => { + const progress = useSharedValue(0); + const height = useSharedValue(0); + useKeyboardHandler({ + onMove: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + onInteractive: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + }); + + return { height, progress }; +}; + +type Props = StackScreenProps; + +function InteractiveKeyboard({ navigation }: Props) { + const [interpolator, setInterpolator] = useState<'ios' | 'linear'>('linear'); + const { height } = useKeyboardAnimation(); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + setInterpolator(interpolator === 'ios' ? 'linear' : 'ios') + } + > + {interpolator} + + ), + }); + }, [interpolator]); + + const scrollViewStyle = useAnimatedStyle( + () => ({ + transform: [{ translateY: -height.value }, ...styles.inverted.transform], + }), + [] + ); + const textInputStyle = useAnimatedStyle( + () => ({ + height: 50, + width: '100%', + backgroundColor: '#BCBCBC', + transform: [{ translateY: -height.value }], + }), + [] + ); + const fakeView = useAnimatedStyle( + () => ({ + height: height.value, + }), + [] + ); + + return ( + + + + + + {history.map((message, index) => ( + + ))} + + + + + + ); +} + +export default InteractiveKeyboard; diff --git a/example/src/screens/Examples/InteractiveKeyboard/styles.ts b/example/src/screens/Examples/InteractiveKeyboard/styles.ts new file mode 100644 index 0000000000..5a43d0fd83 --- /dev/null +++ b/example/src/screens/Examples/InteractiveKeyboard/styles.ts @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + justifyContent: 'flex-end', + flex: 1, + }, + header: { + color: 'black', + marginRight: 12, + }, + inverted: { + transform: [ + { + rotate: '180deg', + }, + ], + }, + content: { + flex: 1, + }, +}); diff --git a/example/src/screens/Examples/Main/constants.ts b/example/src/screens/Examples/Main/constants.ts index 539bfb449f..47e9d6f8ab 100644 --- a/example/src/screens/Examples/Main/constants.ts +++ b/example/src/screens/Examples/Main/constants.ts @@ -29,4 +29,9 @@ export const examples: Example[] = [ info: ScreenNames.NON_UI_PROPS, icons: '🚀', }, + { + title: 'Interactive keyboard (WIP)', + info: ScreenNames.INTERACTIVE_KEYBOARD, + icons: '👆📱', + }, ]; diff --git a/jest/index.js b/jest/index.js index c8a30eaaef..edd699b2d7 100644 --- a/jest/index.js +++ b/jest/index.js @@ -28,6 +28,7 @@ const mock = { }, // views KeyboardControllerView: 'KeyboardControllerView', + KeyboardGestureArea: 'KeyboardGestureArea', // providers KeyboardProvider: 'KeyboardProvider', }; diff --git a/src/animated.tsx b/src/animated.tsx index a50700f981..a035dc865b 100644 --- a/src/animated.tsx +++ b/src/animated.tsx @@ -130,6 +130,13 @@ export const KeyboardProvider = ({ broadcast('onEnd', event); }, + onKeyboardMoveInteractive: (event: NativeEvent) => { + 'worklet'; + + // only android for now, since iOS implementation is missing + updateSharedValues(event, ['android']); + broadcast('onInteractive', event); + }, }, [] ); @@ -140,6 +147,7 @@ export const KeyboardProvider = ({ onKeyboardMoveReanimated={handler} onKeyboardMoveStart={Platform.OS === 'ios' ? onKeyboardMove : undefined} onKeyboardMove={Platform.OS === 'android' ? onKeyboardMove : undefined} + onKeyboardMoveInteractive={onKeyboardMove} navigationBarTranslucent={navigationBarTranslucent} statusBarTranslucent={statusBarTranslucent} style={styles.container} diff --git a/src/internal.ts b/src/internal.ts index 162edf2690..9c143f4172 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -11,6 +11,7 @@ export function useAnimatedKeyboardHandler< onKeyboardMoveStart?: (e: NativeEvent, context: TContext) => void; onKeyboardMove?: (e: NativeEvent, context: TContext) => void; onKeyboardMoveEnd?: (e: NativeEvent, context: TContext) => void; + onKeyboardMoveInteractive?: (e: NativeEvent, context: TContext) => void; }, dependencies?: ReadonlyArray ) { @@ -19,8 +20,12 @@ export function useAnimatedKeyboardHandler< return useEvent( (event: EventWithName) => { 'worklet'; - const { onKeyboardMoveStart, onKeyboardMove, onKeyboardMoveEnd } = - handlers; + const { + onKeyboardMoveStart, + onKeyboardMove, + onKeyboardMoveEnd, + onKeyboardMoveInteractive, + } = handlers; if ( onKeyboardMoveStart && @@ -36,8 +41,20 @@ export function useAnimatedKeyboardHandler< if (onKeyboardMoveEnd && event.eventName.endsWith('onKeyboardMoveEnd')) { onKeyboardMoveEnd(event, context); } + + if ( + onKeyboardMoveInteractive && + event.eventName.endsWith('onKeyboardMoveInteractive') + ) { + onKeyboardMoveInteractive(event, context); + } }, - ['onKeyboardMoveStart', 'onKeyboardMove', 'onKeyboardMoveEnd'], + [ + 'onKeyboardMoveStart', + 'onKeyboardMove', + 'onKeyboardMoveEnd', + 'onKeyboardMoveInteractive', + ], doDependenciesDiffer ); } diff --git a/src/native.ts b/src/native.ts index 3c7f9a07ca..ffe22dee90 100644 --- a/src/native.ts +++ b/src/native.ts @@ -5,6 +5,7 @@ import type { KeyboardControllerModule, KeyboardControllerProps, KeyboardEventData, + KeyboardGestureAreaProps, } from './types'; const LINKING_ERROR = @@ -56,3 +57,7 @@ export const KeyboardEvents = { }; export const KeyboardControllerView: React.FC = require('./specs/KeyboardControllerViewNativeComponent').default; +export const KeyboardGestureArea: React.FC = + Platform.OS === 'android' && Platform.Version >= 30 + ? require('./specs/KeyboardGestureAreaNativeComponent').default + : ({ children }: KeyboardGestureAreaProps) => children; diff --git a/src/specs/KeyboardControllerViewNativeComponent.ts b/src/specs/KeyboardControllerViewNativeComponent.ts index b6e0ec010a..61a38974e7 100644 --- a/src/specs/KeyboardControllerViewNativeComponent.ts +++ b/src/specs/KeyboardControllerViewNativeComponent.ts @@ -16,9 +16,10 @@ export interface NativeProps extends ViewProps { statusBarTranslucent?: boolean; navigationBarTranslucent?: boolean; // callbacks - onKeyboardMove?: DirectEventHandler; onKeyboardMoveStart?: DirectEventHandler; + onKeyboardMove?: DirectEventHandler; onKeyboardMoveEnd?: DirectEventHandler; + onKeyboardMoveInteractive?: DirectEventHandler; } export default codegenNativeComponent( diff --git a/src/specs/KeyboardGestureAreaNativeComponent.ts b/src/specs/KeyboardGestureAreaNativeComponent.ts new file mode 100644 index 0000000000..36422d6ac7 --- /dev/null +++ b/src/specs/KeyboardGestureAreaNativeComponent.ts @@ -0,0 +1,14 @@ +import type { HostComponent } from 'react-native'; +import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes'; +import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +export interface NativeProps extends ViewProps { + interpolator?: WithDefault<'linear' | 'ios', 'linear'>; + allowToShowKeyboardFromHiddenStateBySwipeUp?: boolean; + allowToDragKeyboardFromShownStateBySwipes?: boolean; +} + +export default codegenNativeComponent('KeyboardGestureArea', { + excludedPlatforms: ['iOS'], +}) as HostComponent; diff --git a/src/types.ts b/src/types.ts index 4ec945e160..e69fbf0496 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,9 @@ export type KeyboardControllerProps = { onKeyboardMoveEnd?: ( e: NativeSyntheticEvent> ) => void; + onKeyboardMoveInteractive?: ( + e: NativeSyntheticEvent> + ) => void; // fake prop used to activate reanimated bindings onKeyboardMoveReanimated?: ( e: NativeSyntheticEvent> @@ -30,6 +33,21 @@ export type KeyboardControllerProps = { navigationBarTranslucent?: boolean; } & ViewProps; +export type KeyboardGestureAreaProps = { + interpolator: 'ios' | 'linear'; + /** + * Whether to allow to show a keyboard from dismissed state by swipe up. + * Default to `false`. + */ + allowToShowKeyboardFromHiddenStateBySwipeUp?: boolean; + /** + * Whether to allow to control a keyboard by gestures. The strategy how + * it should be controlled is determined by `interpolator` property. + * Defaults to `true`. + */ + allowToDragKeyboardFromShownStateBySwipes?: boolean; +} & ViewProps; + export type KeyboardControllerModule = { // android only setDefaultMode: () => void; @@ -53,9 +71,10 @@ export type KeyboardEventData = { // package types export type Handlers = Record; -export type KeyboardHandler = { - onStart?: (e: NativeEvent) => void; - onMove?: (e: NativeEvent) => void; - onEnd?: (e: NativeEvent) => void; -}; +export type KeyboardHandler = Partial<{ + onStart: (e: NativeEvent) => void; + onMove: (e: NativeEvent) => void; + onEnd: (e: NativeEvent) => void; + onInteractive: (e: NativeEvent) => void; +}>; export type KeyboardHandlers = Handlers;