From a105cf98f1fed057c0fec876bca4b41126d0a6d5 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Tue, 23 Aug 2022 11:11:06 +0400 Subject: [PATCH] [RNKC-054] - detect keyboard size changes (#58) * [RNKC-054] - detect keyboard size changes * [RNKC-054] - don't consume insets * [RNKC-054] - set callback for applying additional paddings after setting WindowInsetsAnimationCallback (otherwise insets may be ignored and main view can go under navigation bar) * [RNKC-054] - fixed event example and subscribed to correct views * [RNKC-054] - update fabric example * [RNKC-054] - update documentation * [RNKC-054] - update class name * [RNKC-054] - use own view rather than decorView --- .../src/screens/Examples/Events/index.tsx | 8 +- ...llback.kt => KeyboardAnimationCallback.kt} | 167 ++++++++++-------- .../KeyboardControllerViewManager.kt | 45 +++-- example/src/screens/Examples/Events/index.tsx | 8 +- 4 files changed, 124 insertions(+), 104 deletions(-) rename android/src/main/java/com/reactnativekeyboardcontroller/{TranslateDeferringInsetsAnimationCallback.kt => KeyboardAnimationCallback.kt} (51%) diff --git a/FabricExample/src/screens/Examples/Events/index.tsx b/FabricExample/src/screens/Examples/Events/index.tsx index 60a5a69aa2..5ac5c2af00 100644 --- a/FabricExample/src/screens/Examples/Events/index.tsx +++ b/FabricExample/src/screens/Examples/Events/index.tsx @@ -21,11 +21,11 @@ function EventsListener() { text2: `📲 Height: ${e.height}`, }); }); - const shown = KeyboardEvents.addListener('keyboardDidShow', () => { + const shown = KeyboardEvents.addListener('keyboardDidShow', (e) => { Toast.show({ type: 'success', text1: '⌨️ Keyboard is shown', - text2: '👋', + text2: `👋 Height: ${e.height}`, }); }); const hide = KeyboardEvents.addListener('keyboardWillHide', (e) => { @@ -35,11 +35,11 @@ function EventsListener() { text2: `📲 Height: ${e.height}`, }); }); - const hid = KeyboardEvents.addListener('keyboardDidHide', () => { + const hid = KeyboardEvents.addListener('keyboardDidHide', (e) => { Toast.show({ type: 'error', text1: '⌨️ Keyboard is hidden', - text2: '🤐', + text2: `🤐 Height: ${e.height}`, }); }); diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/TranslateDeferringInsetsAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt similarity index 51% rename from android/src/main/java/com/reactnativekeyboardcontroller/TranslateDeferringInsetsAnimationCallback.kt rename to android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt index 083cb19451..679ddffa48 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/TranslateDeferringInsetsAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt @@ -1,24 +1,10 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package com.reactnativekeyboardcontroller import android.content.Context import android.util.Log +import android.view.View import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat @@ -27,9 +13,9 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.events.Event import com.facebook.react.views.view.ReactViewGroup import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent -import java.util.* fun toDp(px: Float, context: Context?): Int { if (context == null) { @@ -39,32 +25,17 @@ fun toDp(px: Float, context: Context?): Int { return (px / context.resources.displayMetrics.density).toInt() } -/** - * A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any - * inset animations of the given inset type. - * - * This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of - * certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in - * [deferredInsetTypes]. The values passed into this constructor should match those which - * the [RootViewDeferringInsetsCallback] is created with. - * - * @param view the view to translate from it's start to end state - * @param persistentInsetTypes the bitmask of any inset types which were handled as part of the - * layout - * @param deferredInsetTypes the bitmask of insets types which should be deferred until after - * any [WindowInsetsAnimationCompat]s have ended - * @param dispatchMode The dispatch mode for this callback. - * See [WindowInsetsAnimationCompat.Callback.getDispatchMode]. - */ -class TranslateDeferringInsetsAnimationCallback( +class KeyboardAnimationCallback( val view: ReactViewGroup, val persistentInsetTypes: Int, val deferredInsetTypes: Int, dispatchMode: Int = DISPATCH_MODE_STOP, - val context: ReactApplicationContext? -) : WindowInsetsAnimationCompat.Callback(dispatchMode) { - private val TAG = TranslateDeferringInsetsAnimationCallback::class.qualifiedName + val context: ReactApplicationContext?, + val onApplyWindowInsetsListener: OnApplyWindowInsetsListener +) : WindowInsetsAnimationCompat.Callback(dispatchMode), OnApplyWindowInsetsListener { + private val TAG = KeyboardAnimationCallback::class.qualifiedName private var persistentKeyboardHeight = 0 + private var isKeyboardVisible = false init { require(persistentInsetTypes and deferredInsetTypes == 0) { @@ -73,54 +44,69 @@ class TranslateDeferringInsetsAnimationCallback( } } + /** + * This method is called everytime when keyboard appears or hides (*) + * and the call happens before `onStart` (in `onStart` we update `this.isKeyboardVisible` field) + * + * *in fact it's getting called much more times, but here for the simplicity we are talking only about keyboard + */ + override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat): WindowInsetsCompat { + // when keyboard appears values will be (false && true) + // when keyboard disappears values will be (true && false) + // 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()) { + val keyboardHeight = getCurrentKeyboardHeight() + /** + * By default it's up to OS whether to animate keyboard changes or not. + * For example my Xiaomi Redmi Note 5 Pro (Android 9) applies layout animation + * whereas Pixel 3 (Android 12) is not applying layout animation and view changes + * its position instantly. We stick to the default behavior and rely on it. + * Though if we decide to animate always (any animation looks better than instant transition) + * we can use the code below: + * + *
+       *   val animation = ValueAnimator.ofInt(-this.persistentKeyboardHeight, -keyboardHeight)
+       *
+       *   animation.addUpdateListener { animator ->
+       *     val toValue = animator.animatedValue as Int
+       *     this.sendEventToJS(KeyboardTransitionEvent(view.id, toValue, 1.0))
+       *   }
+       *   animation.setDuration(250).startDelay = 0
+       *   animation.start()
+       * 
+ * + * But for now let's rely on OS preferences. + */ + this.sendEventToJS(KeyboardTransitionEvent(view.id, -keyboardHeight, 1.0)) + this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight)) + + this.persistentKeyboardHeight = keyboardHeight + } + + return onApplyWindowInsetsListener.onApplyWindowInsets(v, insets) + } + override fun onStart( animation: WindowInsetsAnimationCompat, bounds: WindowInsetsAnimationCompat.BoundsCompat ): WindowInsetsAnimationCompat.BoundsCompat { + isKeyboardVisible = isKeyboardVisible() val keyboardHeight = getCurrentKeyboardHeight() - val isKeyboardVisible = isKeyboardVisible() if (isKeyboardVisible) { // do not update it on hide, since back progress will be invalid this.persistentKeyboardHeight = keyboardHeight } - val params: WritableMap = Arguments.createMap() - params.putInt("height", keyboardHeight) - context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", params) + this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight)) - Log.i(TAG, "KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow") Log.i(TAG, "HEIGHT:: $keyboardHeight") return super.onStart(animation, bounds) } - override fun onEnd(animation: WindowInsetsAnimationCompat) { - super.onEnd(animation) - - val isKeyboardVisible = isKeyboardVisible() - - val params: WritableMap = Arguments.createMap() - context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", params) - - Log.i(TAG, "KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow") - } - - private fun isKeyboardVisible(): Boolean { - val insets = ViewCompat.getRootWindowInsets(view) - - return insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false - } - - private fun getCurrentKeyboardHeight(): Int { - val insets = ViewCompat.getRootWindowInsets(view) - val keyboardHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 - val navigationBar = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 - - // on hide it will be negative value, so we are using max function - return Math.max(toDp((keyboardHeight - navigationBar).toFloat(), context), 0) - } - override fun onProgress( insets: WindowInsetsCompat, runningAnimations: List @@ -148,11 +134,50 @@ class TranslateDeferringInsetsAnimationCallback( } Log.i(TAG, "DiffY: $diffY $height") + this.sendEventToJS(KeyboardTransitionEvent(view.id, height, progress)) + + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + super.onEnd(animation) + + this.persistentKeyboardHeight = getCurrentKeyboardHeight() + this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(this.persistentKeyboardHeight)) + } + + private fun isKeyboardVisible(): Boolean { + val insets = ViewCompat.getRootWindowInsets(view) + + return insets?.isVisible(WindowInsetsCompat.Type.ime()) ?: false + } + + private fun getCurrentKeyboardHeight(): Int { + val insets = ViewCompat.getRootWindowInsets(view) + val keyboardHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + val navigationBar = insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 + + // on hide it will be negative value, so we are using max function + return Math.max(toDp((keyboardHeight - navigationBar).toFloat(), context), 0) + } + + private fun sendEventToJS(event: Event<*>) { context ?.getNativeModule(UIManagerModule::class.java) ?.eventDispatcher - ?.dispatchEvent(KeyboardTransitionEvent(view.id, height, progress)) + ?.dispatchEvent(event) + } - return insets + private fun emitEvent(event: String, params: WritableMap) { + context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(event, params) + + Log.i(TAG, event) + } + + private fun getEventParams(height: Int): WritableMap { + val params: WritableMap = Arguments.createMap() + params.putInt("height", height) + + return params } } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt index 380ad5fe4b..f86be7a7aa 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt @@ -29,34 +29,29 @@ class KeyboardControllerViewManager(reactContext: ReactApplicationContext) : Rea return view } - val window = activity.window - val decorView = window.decorView - - ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> - val content = - mReactContext.currentActivity?.window?.decorView?.rootView?.findViewById( - R.id.action_bar_root + val callback = KeyboardAnimationCallback( + view = view, + persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), + deferredInsetTypes = WindowInsetsCompat.Type.ime(), + // We explicitly allow dispatch to continue down to binding.messageHolder's + // child views, so that step 2.5 below receives the call + dispatchMode = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE, + context = mReactContext, + onApplyWindowInsetsListener = { v, insets -> + val content = + mReactContext.currentActivity?.window?.decorView?.rootView?.findViewById( + R.id.action_bar_root + ) + content?.setPadding( + 0, if (this.isStatusBarTranslucent) 0 else insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0, 0, + insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 ) - content?.setPadding( - 0, if (this.isStatusBarTranslucent) 0 else insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0, 0, - insets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 - ) - - insets - } - ViewCompat.setWindowInsetsAnimationCallback( - decorView, - TranslateDeferringInsetsAnimationCallback( - view = view, - persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), - deferredInsetTypes = WindowInsetsCompat.Type.ime(), - // We explicitly allow dispatch to continue down to binding.messageHolder's - // child views, so that step 2.5 below receives the call - dispatchMode = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE, - context = mReactContext - ) + insets + } ) + ViewCompat.setWindowInsetsAnimationCallback(view, callback) + ViewCompat.setOnApplyWindowInsetsListener(view, callback) return view } diff --git a/example/src/screens/Examples/Events/index.tsx b/example/src/screens/Examples/Events/index.tsx index 58c17a9d55..bf20f79acf 100644 --- a/example/src/screens/Examples/Events/index.tsx +++ b/example/src/screens/Examples/Events/index.tsx @@ -21,11 +21,11 @@ function EventsListener() { text2: `📲 Height: ${e.height}`, }); }); - const shown = KeyboardEvents.addListener('keyboardDidShow', () => { + const shown = KeyboardEvents.addListener('keyboardDidShow', (e) => { Toast.show({ type: 'success', text1: '⌨️ Keyboard is shown', - text2: '👋', + text2: `👋 Height: ${e.height}`, }); }); const hide = KeyboardEvents.addListener('keyboardWillHide', (e) => { @@ -35,11 +35,11 @@ function EventsListener() { text2: `📲 Height: ${e.height}`, }); }); - const hid = KeyboardEvents.addListener('keyboardDidHide', () => { + const hid = KeyboardEvents.addListener('keyboardDidHide', (e) => { Toast.show({ type: 'error', text1: '⌨️ Keyboard is hidden', - text2: '🤐', + text2: `🤐 Height: ${e.height}`, }); });